diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx index b1ac9f8404..5c469dacec 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx @@ -78,12 +78,22 @@ review_context_fetch_failed`} The observability profile starts Prometheus, Alertmanager, Loki, Promtail, and Grafana with dashboards for infra, review activity, and AI usage.

+

+ Postgres installs also expose database internals through the bundled Postgres exporter: + connection pressure, lock waits, long transactions, deadlocks, database/table growth, dead + tuples, autovacuum activity, and backup freshness. Backup freshness appears when the{" "} + backup profile is active. +

When OpenTelemetry and Sentry are enabled, job audit logs and Sentry events include trace_id/span_id fields so an operator can jump from a failed job or issue to the matching trace in Grafana or Tempo.

- +

Sentry tracing

@@ -129,6 +139,10 @@ review_context_fetch_failed`}

  • Webhook deliveries are recent and have 2xx responses.
  • AI usage matches expected review volume and model/effort choices.
  • REES and RAG failures are visible and bounded.
  • +
  • + Postgres connections, lock waits, slow transactions, dead tuples, and table growth are + stable. +
  • Backups are recent and restore-tested.
  • diff --git a/docker-compose.yml b/docker-compose.yml index cebc8d1d1f..00801edb2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,6 +162,22 @@ services: DEFAULT_POOL_SIZE: "20" AUTH_TYPE: md5 + # Postgres internals exporter. Starts with the Postgres profiles so Prometheus can scrape it when + # observability is enabled, without starting against a missing DB on SQLite-only installs. + postgres-exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.20.0 + restart: unless-stopped + profiles: ["postgres", "pgbouncer"] + depends_on: + postgres: + condition: service_healthy + environment: + DATA_SOURCE_URI: "postgres:5432/gittensory?sslmode=disable" + DATA_SOURCE_USER: gittensory + DATA_SOURCE_PASS: ${POSTGRES_PASSWORD:-CHANGEME} + expose: + - "9187" + # ── Qdrant (--profile qdrant) ───────────────────────────────────────────── # Dedicated vector database for RAG — replaces the built-in sqlite-vec / pgvector when # QDRANT_URL=http://qdrant:6333 is set. Scales to millions of vectors with ANN search. @@ -528,6 +544,26 @@ services: command: - "apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1 && while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done" + # Read-only backup freshness exporter. It serves file-age metrics from the retained backup volume so + # Prometheus can alert on missing or stale database backups without Grafana reading the live app DB. + backup-exporter: + image: alpine:3.20 + restart: unless-stopped + profiles: ["backup"] + volumes: + - gittensory-backups:/backups:ro + - ./scripts/backup-metrics.sh:/backup-metrics.sh:ro + expose: + - "9101" + command: + - /bin/sh + - -c + - "sh /backup-metrics.sh" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9101/metrics | grep -q '^gittensory_backup_latest_timestamp_seconds'"] + interval: 30s + retries: 5 + volumes: gittensory-data: gittensory-pg: diff --git a/grafana/dashboards/gittensory.json b/grafana/dashboards/gittensory.json index 9aa83989f2..039f85abab 100644 --- a/grafana/dashboards/gittensory.json +++ b/grafana/dashboards/gittensory.json @@ -1623,6 +1623,501 @@ ], "title": "Queue Rate-Limit Deferrals", "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 107 + }, + "id": 118, + "title": "Postgres & Backups", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 108 + }, + "id": 119, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "pg_up or vector(0)", + "legendFormat": "up", + "refId": "A" + } + ], + "title": "Postgres Exporter Up", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 120 } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 108 + }, + "id": 120, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(pg_stat_activity_count{datname=\"gittensory\"}) or vector(0)", + "legendFormat": "connections", + "refId": "A" + } + ], + "title": "Postgres Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "decbytes" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 108 + }, + "id": 121, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "pg_database_size_bytes{datname=\"gittensory\"} or vector(0)", + "legendFormat": "size", + "refId": "A" + } + ], + "title": "Postgres DB Size", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 86400 }, + { "color": "red", "value": 93600 } + ] + }, + "unit": "s" + } + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 108 + }, + "id": 122, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "(time() - max(gittensory_backup_latest_timestamp_seconds{target=~\"postgres|sqlite\"})) and max(gittensory_backup_latest_timestamp_seconds{target=~\"postgres|sqlite\"}) > 0", + "legendFormat": "age", + "refId": "A" + } + ], + "title": "Latest DB Backup Age", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 112 + }, + "id": 123, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum by (state) (pg_stat_activity_count{datname=\"gittensory\"}) or vector(0)", + "legendFormat": "{{state}}", + "refId": "A" + } + ], + "title": "Postgres Connections by State", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 112 + }, + "id": 124, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(pg_stat_activity_count{datname=\"gittensory\", wait_event_type=\"Lock\"}) or vector(0)", + "legendFormat": "lock waits", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "max(pg_stat_activity_max_tx_duration{datname=\"gittensory\"}) or vector(0)", + "legendFormat": "max transaction seconds", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "rate(pg_stat_database_deadlocks{datname=\"gittensory\"}[5m]) or vector(0)", + "legendFormat": "deadlocks/s", + "refId": "C" + } + ], + "title": "Postgres Locks & Slow Transactions", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 120 + }, + "id": 125, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "pg_database_size_bytes{datname=\"gittensory\"} or vector(0)", + "legendFormat": "database bytes", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "topk(10, pg_stat_user_tables_n_live_tup{datname=\"gittensory\"}) or vector(0)", + "legendFormat": "{{relname}} live rows", + "refId": "B" + } + ], + "title": "Postgres Size & Table Growth", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 120 + }, + "id": 126, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "topk(10, pg_stat_user_tables_n_dead_tup{datname=\"gittensory\"}) or vector(0)", + "legendFormat": "{{relname}} dead rows", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum by (relname) (increase(pg_stat_user_tables_autovacuum_count{datname=\"gittensory\"}[1h])) or vector(0)", + "legendFormat": "{{relname}} autovacuum/hour", + "refId": "B" + } + ], + "title": "Dead Tuples / Autovacuum", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 128 + }, + "id": 127, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "(time() - gittensory_backup_latest_timestamp_seconds{target=~\"postgres|sqlite|qdrant\"}) and gittensory_backup_latest_timestamp_seconds{target=~\"postgres|sqlite|qdrant\"} > 0", + "legendFormat": "{{target}} age seconds", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "gittensory_backup_files{target=~\"postgres|sqlite|qdrant\"} or vector(0)", + "legendFormat": "{{target}} files", + "refId": "B" + } + ], + "title": "Backup Freshness", + "type": "timeseries" } ], "refresh": "30s", diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml index 25b85de73d..6608cdd059 100644 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -23,6 +23,20 @@ scrape_configs: scrape_interval: 15s scrape_timeout: 10s + # Postgres internals from prometheus-community/postgres_exporter. The exporter service starts only with + # the Postgres profiles; SQLite-only installs show this target as absent/down without firing alerts. + - job_name: postgres + static_configs: + - targets: ["postgres-exporter:9187"] + scrape_interval: 30s + + # Backup freshness from the read-only backup-exporter sidecar. The metrics exist only when the backup + # profile is active, which keeps backup alerts opt-in with the backup feature. + - job_name: gittensory-backup + static_configs: + - targets: ["backup-exporter:9101"] + scrape_interval: 60s + # Claude Code usage telemetry, re-exposed by the OTEL collector's Prometheus exporter (--profile # observability + CLAUDE_CODE_ENABLE_TELEMETRY=1). An absent collector just shows the target as down. - job_name: claude-code-otel diff --git a/prometheus/rules/alerts.yml b/prometheus/rules/alerts.yml index 212300cf70..20c85902f4 100644 --- a/prometheus/rules/alerts.yml +++ b/prometheus/rules/alerts.yml @@ -130,6 +130,120 @@ groups: description: "GitHub rate-limit deferrals exceeded 0.05/s for 10m. The queue is protecting the bucket but throughput is GitHub-budget bound." runbook: "Use gittensory_jobs_rate_limit_* labels (kind/key_scope/job_type) to identify the stuck class. Prefer reducing duplicate webhook/regate work or adding cache coverage before raising worker concurrency." + # ── Postgres database + backup freshness ─────────────────────────────────── + - name: gittensory-postgres + rules: + - alert: GittensoryPostgresConnectionPressure + # Postgres exporter is present only for Postgres-backed installs. Fire when the app DB + # consumes more than 80% of max_connections for 10m; use PgBouncer or lower app concurrency. + expr: | + ( + sum(pg_stat_activity_count{datname="gittensory"}) + / + max(pg_settings_max_connections) + ) > 0.80 + for: 10m + labels: + severity: warning + annotations: + summary: "Postgres connection usage is above 80%" + description: "The gittensory database is using {{ $value | humanizePercentage }} of max_connections for over 10m." + runbook: "Enable the pgbouncer profile or reduce self-host worker/app concurrency. Check the Postgres Connections panel by state before increasing max_connections." + + - alert: GittensoryPostgresLockWaits + # Wait-event labels are emitted by the postgres_exporter stat_activity collector. Any sustained + # lock wait means a write path is blocked and queue processing can appear stuck. + expr: sum(pg_stat_activity_count{datname="gittensory", wait_event_type="Lock"}) > 0 + for: 5m + labels: + severity: warning + annotations: + summary: "Postgres sessions are waiting on locks" + description: "{{ $value | printf \"%.0f\" }} gittensory database session(s) have waited on Postgres locks for over 5m." + runbook: "Inspect the Postgres Locks and Slow Transactions panels. Look for long transactions, migration jobs, or stuck writers before restarting workers." + + - alert: GittensoryPostgresSlowTransaction + # Long active transactions are the self-host proxy for slow/problem queries without requiring + # pg_stat_statements or superuser-only setup. + expr: max(pg_stat_activity_max_tx_duration{datname="gittensory"}) > 120 + for: 5m + labels: + severity: warning + annotations: + summary: "Postgres transaction has been active for over 2m" + description: "The longest active gittensory database transaction is {{ $value | printf \"%.0f\" }}s old." + runbook: "Check app logs around DB-heavy jobs and review whether queue workers are saturating Postgres. A single long transaction can block cleanup or migrations." + + - alert: GittensoryPostgresDeadlocks + # Deadlocks should be effectively zero; even a low rate points at competing write paths. + expr: rate(pg_stat_database_deadlocks{datname="gittensory"}[5m]) > 0 + for: 2m + labels: + severity: warning + annotations: + summary: "Postgres deadlocks detected" + description: "Postgres deadlocks are occurring for the gittensory database." + runbook: "Correlate with deploys and queue write paths. Repeated deadlocks usually mean a transaction ordering bug or too much parallel write pressure." + + - alert: GittensoryPostgresDatabaseGrowingFast + # Growth over a 6h window smooths normal small-table churn while catching runaway queue/audit growth. + # 262144 bytes/s is roughly 5.4 GiB over 6h. + expr: deriv(pg_database_size_bytes{datname="gittensory"}[6h]) > 262144 + for: 30m + labels: + severity: warning + annotations: + summary: "Postgres database is growing quickly" + description: "The gittensory database is growing at about {{ $value | humanize }} bytes/s over a 6h trend window." + runbook: "Open the Postgres Table Growth panel and inspect queue/audit/event tables first. Confirm retention and backup volume capacity before raising traffic." + + - alert: GittensoryPostgresDeadTuplesHigh + # Autovacuum pressure: high dead tuple ratio and enough absolute dead rows to avoid tiny-table noise. + expr: | + ( + sum(pg_stat_user_tables_n_dead_tup{datname="gittensory"}) + / + clamp_min( + sum(pg_stat_user_tables_n_live_tup{datname="gittensory"} + pg_stat_user_tables_n_dead_tup{datname="gittensory"}), + 1 + ) + ) > 0.20 + and + sum(pg_stat_user_tables_n_dead_tup{datname="gittensory"}) > 10000 + for: 30m + labels: + severity: warning + annotations: + summary: "Postgres dead tuples are high" + description: "More than 20% of table tuples are dead and at least 10k dead tuples remain for over 30m." + runbook: "Check the Dead Tuples / Autovacuum panel. If autovacuum counts are flat while dead tuples rise, reduce write pressure or tune autovacuum for the largest tables." + + - alert: GittensoryBackupMissing + # Fires only when the backup-exporter service is running (backup profile enabled) and no retained + # DB backup exists yet. Qdrant backups are best-effort and not included in this DB backup alert. + expr: gittensory_backup_files{target=~"postgres|sqlite"} == 0 + for: 2h + labels: + severity: warning + annotations: + summary: "No retained gittensory database backup found" + description: "The backup profile is exposing metrics, but {{ $labels.target }} has no retained backup file after 2h." + runbook: "Run `docker compose --profile backup run --rm backup sh /backup.sh` and inspect the gittensory-backups volume. For Postgres, confirm DATABASE_URL is available to the backup service." + + - alert: GittensoryBackupStale + # Default backup loop is daily. 26h allows one missed scrape/restart window before warning. + expr: | + (time() - gittensory_backup_latest_timestamp_seconds{target=~"postgres|sqlite"} > 93600) + and + gittensory_backup_latest_timestamp_seconds{target=~"postgres|sqlite"} > 0 + for: 30m + labels: + severity: warning + annotations: + summary: "gittensory database backup is stale" + description: "The newest retained {{ $labels.target }} backup is older than 26h." + runbook: "Check `docker compose --profile backup logs backup` and run the backup command manually. Pair this with the restore verification drill before deleting old backups." + # ── Qdrant vector backend ────────────────────────────────────────────────── - name: gittensory-qdrant rules: diff --git a/scripts/backup-metrics.sh b/scripts/backup-metrics.sh new file mode 100644 index 0000000000..ad5fb6d621 --- /dev/null +++ b/scripts/backup-metrics.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Expose retained backup freshness from the gittensory-backups volume in Prometheus text format. +# Intended for the backup-exporter compose sidecar; it never touches the live database. +set -eu + +ROOT=${BACKUP_ROOT:-/backups} +OUT=${BACKUP_METRICS_DIR:-/metrics} +FILE=${BACKUP_METRICS_FILE:-$OUT/metrics} +INTERVAL=${BACKUP_METRICS_INTERVAL_SECONDS:-30} +PORT=${BACKUP_METRICS_PORT:-9101} + +case "$INTERVAL" in + ''|*[!0-9]*) INTERVAL=30 ;; +esac + +latest_timestamp() { + dir=$1 + if [ ! -d "$dir" ]; then + echo 0 + return + fi + newest=$( + find "$dir" -type f ! -name '*.tmp' -exec sh -c ' + for path do + stat -c "%Y" "$path" 2>/dev/null || stat -f "%m" "$path" 2>/dev/null || true + done + ' sh {} + 2>/dev/null | sort -nr | head -1 || true + ) + case "$newest" in + ''|*[!0-9]*) echo 0 ;; + *) echo "$newest" ;; + esac +} + +file_count() { + dir=$1 + if [ ! -d "$dir" ]; then + echo 0 + return + fi + find "$dir" -type f ! -name '*.tmp' 2>/dev/null | wc -l | tr -d ' ' +} + +write_metrics() { + mkdir -p "$OUT" + tmp="$FILE.tmp" + { + echo "# HELP gittensory_backup_latest_timestamp_seconds Unix timestamp of the newest retained self-host backup file by target." + echo "# TYPE gittensory_backup_latest_timestamp_seconds gauge" + for target in postgres sqlite qdrant; do + echo "gittensory_backup_latest_timestamp_seconds{target=\"$target\"} $(latest_timestamp "$ROOT/$target")" + done + echo "# HELP gittensory_backup_files Retained self-host backup files by target." + echo "# TYPE gittensory_backup_files gauge" + for target in postgres sqlite qdrant; do + echo "gittensory_backup_files{target=\"$target\"} $(file_count "$ROOT/$target")" + done + } > "$tmp" + mv "$tmp" "$FILE" +} + +if [ "${BACKUP_METRICS_ONCE:-}" = "1" ]; then + write_metrics + exit 0 +fi + +write_metrics +httpd -f -p "$PORT" -h "$OUT" & +server=$! +trap 'kill "$server" 2>/dev/null || true' INT TERM EXIT + +while true; do + write_metrics + sleep "$INTERVAL" +done diff --git a/test/unit/selfhost-backup-metrics-script.test.ts b/test/unit/selfhost-backup-metrics-script.test.ts new file mode 100644 index 0000000000..794375e2be --- /dev/null +++ b/test/unit/selfhost-backup-metrics-script.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const tmpRoots: string[] = []; + +function tmpRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "gittensory-backup-metrics-")); + tmpRoots.push(dir); + return dir; +} + +function writeBackup(root: string, target: "postgres" | "sqlite" | "qdrant", name: string, timestamp: string): number { + const dir = join(root, target); + mkdirSync(dir, { recursive: true }); + const file = join(dir, name); + writeFileSync(file, `${target} backup\n`); + const when = new Date(timestamp); + utimesSync(file, when, when); + return Math.floor(when.getTime() / 1000); +} + +function runExporterOnce(root: string): string { + const out = join(root, "metrics"); + execFileSync("sh", ["scripts/backup-metrics.sh"], { + cwd: process.cwd(), + env: { + ...process.env, + BACKUP_ROOT: root, + BACKUP_METRICS_DIR: out, + BACKUP_METRICS_ONCE: "1", + }, + }); + return readFileSync(join(out, "metrics"), "utf8"); +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) rmSync(dir, { force: true, recursive: true }); +}); + +describe("backup-metrics.sh", () => { + it("exports newest retained backup timestamps and file counts by target", () => { + const root = tmpRoot(); + writeBackup(root, "postgres", "gittensory-older.dump", "2026-07-01T01:00:00Z"); + const newestPostgres = writeBackup(root, "postgres", "gittensory-newer.dump", "2026-07-01T02:00:00Z"); + const sqlite = writeBackup(root, "sqlite", "gittensory.sqlite.gz", "2026-07-01T03:00:00Z"); + const qdrant = writeBackup(root, "qdrant", "snapshot", "2026-07-01T04:00:00Z"); + + const metrics = runExporterOnce(root); + + expect(metrics).toContain(`gittensory_backup_latest_timestamp_seconds{target="postgres"} ${newestPostgres}`); + expect(metrics).toContain(`gittensory_backup_latest_timestamp_seconds{target="sqlite"} ${sqlite}`); + expect(metrics).toContain(`gittensory_backup_latest_timestamp_seconds{target="qdrant"} ${qdrant}`); + expect(metrics).toContain('gittensory_backup_files{target="postgres"} 2'); + expect(metrics).toContain('gittensory_backup_files{target="sqlite"} 1'); + expect(metrics).toContain('gittensory_backup_files{target="qdrant"} 1'); + }); + + it("exports zeroes for missing backup directories instead of failing the scrape", () => { + const root = tmpRoot(); + + const metrics = runExporterOnce(root); + + expect(metrics).toContain('gittensory_backup_latest_timestamp_seconds{target="postgres"} 0'); + expect(metrics).toContain('gittensory_backup_latest_timestamp_seconds{target="sqlite"} 0'); + expect(metrics).toContain('gittensory_backup_latest_timestamp_seconds{target="qdrant"} 0'); + expect(metrics).toContain('gittensory_backup_files{target="postgres"} 0'); + expect(metrics).toContain('gittensory_backup_files{target="sqlite"} 0'); + expect(metrics).toContain('gittensory_backup_files{target="qdrant"} 0'); + }); +}); diff --git a/test/unit/selfhost-grafana-dashboard.test.ts b/test/unit/selfhost-grafana-dashboard.test.ts index cd3b1830db..6f58b78021 100644 --- a/test/unit/selfhost-grafana-dashboard.test.ts +++ b/test/unit/selfhost-grafana-dashboard.test.ts @@ -13,6 +13,7 @@ type DashboardTarget = { type DashboardPanel = { id?: number; + title?: string; targets?: DashboardTarget[]; }; @@ -106,6 +107,45 @@ describe("Gittensory Self-Host Grafana dashboard", () => { expect(alerts).toContain("sum by (kind, key_scope, job_type) (rate(gittensory_jobs_rate_limit_admission_deferred_total[5m])) > 0.05"); expect(alerts).toContain("sum by (kind, key_scope, job_type) (rate(gittensory_jobs_rate_limit_budget_deferred_total[5m])) > 0.05"); }); + + it("surfaces Postgres internals and backup freshness panels", () => { + const dashboard = readDashboard(selfhostDashboardPath); + const targets = dashboard.panels.flatMap((panel) => panel.targets ?? []); + const titles = dashboard.panels.map((panel) => panel.title); + + expect(titles).toEqual(expect.arrayContaining(["Postgres & Backups", "Postgres Connections by State", "Postgres Locks & Slow Transactions", "Postgres Size & Table Growth", "Dead Tuples / Autovacuum", "Backup Freshness"])); + expect(targets.some((target) => target.expr === 'pg_up or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'sum(pg_stat_activity_count{datname="gittensory"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'sum by (state) (pg_stat_activity_count{datname="gittensory"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'sum(pg_stat_activity_count{datname="gittensory", wait_event_type="Lock"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'max(pg_stat_activity_max_tx_duration{datname="gittensory"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'pg_database_size_bytes{datname="gittensory"} or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'topk(10, pg_stat_user_tables_n_live_tup{datname="gittensory"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'topk(10, pg_stat_user_tables_n_dead_tup{datname="gittensory"}) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'sum by (relname) (increase(pg_stat_user_tables_autovacuum_count{datname="gittensory"}[1h])) or vector(0)')).toBe(true); + expect(targets.some((target) => target.expr === 'gittensory_backup_files{target=~"postgres|sqlite|qdrant"} or vector(0)')).toBe(true); + }); + + it("ships Postgres and backup alerts for the same dashboarded failure modes", () => { + const alerts = readFileSync(selfhostAlertsPath, "utf8"); + + expect(alerts).toContain("alert: GittensoryPostgresConnectionPressure"); + expect(alerts).toContain('sum(pg_stat_activity_count{datname="gittensory"})'); + expect(alerts).toContain("alert: GittensoryPostgresLockWaits"); + expect(alerts).toContain('pg_stat_activity_count{datname="gittensory", wait_event_type="Lock"}'); + expect(alerts).toContain("alert: GittensoryPostgresSlowTransaction"); + expect(alerts).toContain('pg_stat_activity_max_tx_duration{datname="gittensory"}'); + expect(alerts).toContain("alert: GittensoryPostgresDeadlocks"); + expect(alerts).toContain('pg_stat_database_deadlocks{datname="gittensory"}'); + expect(alerts).toContain("alert: GittensoryPostgresDatabaseGrowingFast"); + expect(alerts).toContain('deriv(pg_database_size_bytes{datname="gittensory"}[6h]) > 262144'); + expect(alerts).toContain("alert: GittensoryPostgresDeadTuplesHigh"); + expect(alerts).toContain('pg_stat_user_tables_n_dead_tup{datname="gittensory"}'); + expect(alerts).toContain("alert: GittensoryBackupMissing"); + expect(alerts).toContain('gittensory_backup_files{target=~"postgres|sqlite"} == 0'); + expect(alerts).toContain("alert: GittensoryBackupStale"); + expect(alerts).toContain('time() - gittensory_backup_latest_timestamp_seconds{target=~"postgres|sqlite"} > 93600'); + }); }); describe("maintainer Reviews & PRs Grafana dashboard", () => { diff --git a/test/unit/selfhost-observability-config.test.ts b/test/unit/selfhost-observability-config.test.ts index c720732e31..1fa78d5d8f 100644 --- a/test/unit/selfhost-observability-config.test.ts +++ b/test/unit/selfhost-observability-config.test.ts @@ -88,4 +88,47 @@ describe("self-host observability trace config", () => { expect(script).toContain("gittensory-selfhost-smoke"); expect(script).toContain("selfhost.observability.smoke"); }); + + it("wires Postgres and backup exporters without starting them on SQLite-only observability", () => { + const compose = record(readYaml("docker-compose.yml")); + const services = record(compose.services); + const postgresExporter = record(services["postgres-exporter"]); + const backupExporter = record(services["backup-exporter"]); + const prometheus = record(readYaml("prometheus/prometheus.yml")); + const scrapeConfigs = prometheus.scrape_configs as Array>; + + expect(postgresExporter.image).toBe("quay.io/prometheuscommunity/postgres-exporter:v0.20.0"); + expect(postgresExporter.profiles).toEqual(["postgres", "pgbouncer"]); + expect(postgresExporter.depends_on?.postgres).toEqual({ condition: "service_healthy" }); + expect(postgresExporter.environment).toMatchObject({ + DATA_SOURCE_URI: "postgres:5432/gittensory?sslmode=disable", + DATA_SOURCE_USER: "gittensory", + DATA_SOURCE_PASS: "${POSTGRES_PASSWORD:-CHANGEME}", + }); + + expect(backupExporter.profiles).toEqual(["backup"]); + expect(backupExporter.volumes).toEqual( + expect.arrayContaining([ + "gittensory-backups:/backups:ro", + "./scripts/backup-metrics.sh:/backup-metrics.sh:ro", + ]), + ); + expect(backupExporter.healthcheck?.test).toEqual([ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:9101/metrics | grep -q '^gittensory_backup_latest_timestamp_seconds'", + ]); + + expect(scrapeConfigs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + job_name: "postgres", + static_configs: [{ targets: ["postgres-exporter:9187"] }], + }), + expect.objectContaining({ + job_name: "gittensory-backup", + static_configs: [{ targets: ["backup-exporter:9101"] }], + }), + ]), + ); + }); });