#!/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 "$@"