From 28a9bdf6c729e31fb5a1908590421b25484b0b8e Mon Sep 17 00:00:00 2001 From: Phil Connor Date: Tue, 26 May 2026 22:51:51 +0200 Subject: [PATCH] =?UTF-8?q?Add=20flarum-exporter.sh=20v1.0=20=E2=80=94=20F?= =?UTF-8?q?larum=20forum=20metrics=20for=20Prometheus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flarum-exporter.sh | 386 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100755 flarum-exporter.sh diff --git a/flarum-exporter.sh b/flarum-exporter.sh new file mode 100755 index 0000000..6fb64cc --- /dev/null +++ b/flarum-exporter.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +# +# Flarum Prometheus Metrics Exporter +# +# Prometheus exporter for Flarum forum software. +# Uses MariaDB/MySQL to query the Flarum database directly for +# user counts, discussion/post stats, tag breakdowns, engagement +# metrics (likes, flags), and overall forum health. +# +# Usage: +# FLARUM_DB_PASS="xxx" ./flarum-exporter.sh +# FLARUM_DB_PASS="xxx" ./flarum-exporter.sh --textfile +# FLARUM_DB_PASS="xxx" ./flarum-exporter.sh --http +# +# Parameters: +# --textfile Write to textfile collector directory +# --http Start a simple HTTP server on port 9199 +# --help Show usage +# +# Environment: +# FLARUM_DB_HOST Database host (default: localhost) +# FLARUM_DB_PORT Database port (default: 3306) +# FLARUM_DB_NAME Database name (default: flarum) +# FLARUM_DB_USER Database user (default: flarum) +# FLARUM_DB_PASS Database password (required) +# TEXTFILE_DIR Textfile collector directory (default: /var/lib/node_exporter/textfile_collector) +# +# Author: Phil Connor +# Contact: contact@mylinux.work +# Website: https://mylinux.work +# License: MIT +# Version: 1.0 +# +# Metrics Exported: +# Core: +# - flarum_up +# - flarum_exporter_info{version} +# +# Users: +# - flarum_users_total +# - flarum_users_email_confirmed +# - flarum_users_suspended +# - flarum_users_active_1d +# - flarum_users_active_7d +# - flarum_users_active_30d +# +# Content: +# - flarum_discussions_total +# - flarum_discussions_open +# - flarum_discussions_locked +# - flarum_discussions_sticky +# - flarum_posts_total +# - flarum_comments_total +# +# Tags: +# - flarum_tags_total +# - flarum_discussions_per_tag{tag} +# +# Engagement: +# - flarum_likes_total +# - flarum_flags_total +# +# Exporter: +# - flarum_exporter_duration_seconds +# - flarum_exporter_last_run_timestamp + +set -euo pipefail + +# --- Configuration --- +readonly VERSION="1.0" +readonly SCRIPT_NAME="$(basename "$0")" +FLARUM_DB_HOST="${FLARUM_DB_HOST:-localhost}" +FLARUM_DB_PORT="${FLARUM_DB_PORT:-3306}" +FLARUM_DB_NAME="${FLARUM_DB_NAME:-flarum}" +FLARUM_DB_USER="${FLARUM_DB_USER:-flarum}" +FLARUM_DB_PASS="${FLARUM_DB_PASS:-}" +TEXTFILE_DIR="${TEXTFILE_DIR:-/var/lib/node_exporter/textfile_collector}" +TEXTFILE_MODE=false +HTTP_MODE=false +readonly HTTP_PORT=9199 +OUTPUT="" +START_TIME="" + +# --- Functions --- + +usage() { + cat </dev/null; then + missing+=("$cmd") + fi + done + if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: Missing required commands: ${missing[*]}" >&2 + echo "Install with: apt install ${missing[*]} OR dnf install ${missing[*]}" >&2 + exit 1 + fi +} + +validate_config() { + if [[ -z "$FLARUM_DB_PASS" ]]; then + echo "ERROR: FLARUM_DB_PASS environment variable is required" >&2 + exit 1 + fi +} + +db_query() { + local query="$1" + mysql -h "$FLARUM_DB_HOST" -P "$FLARUM_DB_PORT" -u "$FLARUM_DB_USER" \ + -p"$FLARUM_DB_PASS" "$FLARUM_DB_NAME" -N -B -e "$query" 2>/dev/null || echo "" +} + +sanitize_label() { + local value="$1" + echo "$value" | sed 's/[^a-zA-Z0-9_\/.-]/_/g' +} + +add_metric() { + local name="$1" + local type="$2" + local help="$3" + local value="$4" + local labels="${5:-}" + + if [[ -n "$labels" ]]; then + OUTPUT+="# HELP ${name} ${help} +# TYPE ${name} ${type} +${name}{${labels}} ${value} +" + else + OUTPUT+="# HELP ${name} ${help} +# TYPE ${name} ${type} +${name} ${value} +" + fi +} + +add_metric_value() { + local name="$1" + local value="$2" + local labels="${3:-}" + + if [[ -n "$labels" ]]; then + OUTPUT+="${name}{${labels}} ${value} +" + else + OUTPUT+="${name} ${value} +" + fi +} + +collect_health() { + local result + result=$(db_query "SELECT 1;") + + if [[ -z "$result" || "$result" != "1" ]]; then + add_metric "flarum_up" "gauge" "Flarum database reachability (1=up, 0=down)" "0" + return 1 + fi + + add_metric "flarum_up" "gauge" "Flarum database reachability (1=up, 0=down)" "1" + return 0 +} + +collect_users() { + local total + total=$(db_query "SELECT COUNT(*) FROM users;") + add_metric "flarum_users_total" "gauge" "Total number of registered users" "${total:-0}" + + local confirmed + confirmed=$(db_query "SELECT COUNT(*) FROM users WHERE is_email_confirmed = 1;") + add_metric "flarum_users_email_confirmed" "gauge" "Users with confirmed email" "${confirmed:-0}" + + local suspended + suspended=$(db_query "SELECT COUNT(*) FROM users WHERE suspended_until IS NOT NULL AND suspended_until > NOW();") + add_metric "flarum_users_suspended" "gauge" "Currently suspended users" "${suspended:-0}" + + local active_1d + active_1d=$(db_query "SELECT COUNT(*) FROM users WHERE last_seen_at >= NOW() - INTERVAL 1 DAY;") + add_metric "flarum_users_active_1d" "gauge" "Users active in the past 24 hours" "${active_1d:-0}" + + local active_7d + active_7d=$(db_query "SELECT COUNT(*) FROM users WHERE last_seen_at >= NOW() - INTERVAL 7 DAY;") + add_metric "flarum_users_active_7d" "gauge" "Users active in the past 7 days" "${active_7d:-0}" + + local active_30d + active_30d=$(db_query "SELECT COUNT(*) FROM users WHERE last_seen_at >= NOW() - INTERVAL 30 DAY;") + add_metric "flarum_users_active_30d" "gauge" "Users active in the past 30 days" "${active_30d:-0}" +} + +collect_content() { + local discussions_total + discussions_total=$(db_query "SELECT COUNT(*) FROM discussions;") + add_metric "flarum_discussions_total" "gauge" "Total number of discussions" "${discussions_total:-0}" + + local discussions_open + discussions_open=$(db_query "SELECT COUNT(*) FROM discussions WHERE hidden_at IS NULL;") + add_metric "flarum_discussions_open" "gauge" "Open discussions (not hidden/deleted)" "${discussions_open:-0}" + + local discussions_locked + discussions_locked=$(db_query "SELECT COUNT(*) FROM discussions WHERE is_locked = 1;") + add_metric "flarum_discussions_locked" "gauge" "Locked discussions" "${discussions_locked:-0}" + + local discussions_sticky + discussions_sticky=$(db_query "SELECT COUNT(*) FROM discussions WHERE is_sticky = 1;") + add_metric "flarum_discussions_sticky" "gauge" "Sticky discussions" "${discussions_sticky:-0}" + + local posts_total + posts_total=$(db_query "SELECT COUNT(*) FROM posts;") + add_metric "flarum_posts_total" "gauge" "Total number of posts" "${posts_total:-0}" + + local comments_total + comments_total=$(db_query "SELECT COALESCE(SUM(comment_count), 0) FROM discussions;") + add_metric "flarum_comments_total" "gauge" "Total comment count across all discussions" "${comments_total:-0}" +} + +collect_tags() { + local tags_total + tags_total=$(db_query "SELECT COUNT(*) FROM tags;") + add_metric "flarum_tags_total" "gauge" "Total number of tags" "${tags_total:-0}" + + # Per-tag discussion counts + local tag_data + tag_data=$(db_query "SELECT t.name, COUNT(dt.discussion_id) FROM tags t LEFT JOIN discussion_tag dt ON t.id = dt.tag_id GROUP BY t.id, t.name;") + + if [[ -n "$tag_data" ]]; then + OUTPUT+="# HELP flarum_discussions_per_tag Number of discussions per tag +# TYPE flarum_discussions_per_tag gauge +" + while IFS=$'\t' read -r tag_name count; do + if [[ -n "$tag_name" ]]; then + local safe_tag + safe_tag=$(sanitize_label "$tag_name") + add_metric_value "flarum_discussions_per_tag" "${count:-0}" "tag=\"${safe_tag}\"" + fi + done <<< "$tag_data" + fi +} + +collect_engagement() { + local likes + likes=$(db_query "SELECT COUNT(*) FROM post_likes;" 2>/dev/null) + if [[ -n "$likes" ]]; then + add_metric "flarum_likes_total" "gauge" "Total number of likes" "${likes:-0}" + fi + + local flags + flags=$(db_query "SELECT COUNT(*) FROM flags;" 2>/dev/null) + if [[ -n "$flags" ]]; then + add_metric "flarum_flags_total" "gauge" "Total number of flags" "${flags:-0}" + fi +} + +write_output() { + if [[ "$TEXTFILE_MODE" == true ]]; then + local output_file="${TEXTFILE_DIR}/flarum.prom" + local temp_file + temp_file=$(mktemp "${TEXTFILE_DIR}/flarum.prom.XXXXXX") + + mkdir -p "$TEXTFILE_DIR" + echo "$OUTPUT" > "$temp_file" + mv "$temp_file" "$output_file" + else + echo "$OUTPUT" + fi +} + +serve_http() { + # Check for netcat + local nc_cmd="" + if command -v ncat &>/dev/null; then + nc_cmd="ncat" + elif command -v nc &>/dev/null; then + nc_cmd="nc" + else + echo "ERROR: Neither ncat nor nc found. Install nmap-ncat or netcat." >&2 + exit 1 + fi + + echo "# Flarum exporter listening on port ${HTTP_PORT}..." + + while true; do + { + # Read the HTTP request + local request="" + read -r request || true + + # Collect fresh metrics + OUTPUT="" + START_TIME=$(date +%s%N) + + add_metric "flarum_exporter_info" "gauge" "Exporter version information" "1" "version=\"${VERSION}\"" + + if collect_health; then + collect_users + collect_content + collect_tags + collect_engagement + fi + + local end_time duration + end_time=$(date +%s%N) + duration=$(echo "scale=2; ($end_time - $START_TIME) / 1000000000" | bc 2>/dev/null || echo "0") + add_metric "flarum_exporter_duration_seconds" "gauge" "Time to generate all metrics" "$duration" + add_metric "flarum_exporter_last_run_timestamp" "gauge" "Unix timestamp of last successful run" "$(date +%s)" + + # Serve response + local body="$OUTPUT" + local content_length=${#body} + printf "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" "$content_length" "$body" + } | "$nc_cmd" -l -p "$HTTP_PORT" 2>/dev/null || "$nc_cmd" -l "$HTTP_PORT" 2>/dev/null || true + done +} + +# --- Main --- + +main() { + # Parse arguments + for arg in "$@"; do + case "$arg" in + --textfile) TEXTFILE_MODE=true ;; + --http) HTTP_MODE=true ;; + --help|-h) usage ;; + *) echo "Unknown option: $arg" >&2; usage ;; + esac + done + + check_dependencies + validate_config + + if [[ "$HTTP_MODE" == true ]]; then + serve_http + exit 0 + fi + + START_TIME=$(date +%s%N) + + # Exporter info + add_metric "flarum_exporter_info" "gauge" "Exporter version information" "1" "version=\"${VERSION}\"" + + # Collect metrics + if collect_health; then + collect_users + collect_content + collect_tags + collect_engagement + fi + + # Exporter performance + local end_time duration + end_time=$(date +%s%N) + duration=$(echo "scale=2; ($end_time - $START_TIME) / 1000000000" | bc 2>/dev/null || echo "0") + add_metric "flarum_exporter_duration_seconds" "gauge" "Time to generate all metrics" "$duration" + add_metric "flarum_exporter_last_run_timestamp" "gauge" "Unix timestamp of last successful run" "$(date +%s)" + + write_output +} + +main "$@"