Skip to content

Commit 7dec118

Browse files
authored
obs(selfhost): add Cloudflare D1 size/row-count probe, metrics, alerts, panel (#4153)
Advances #3810 -- the dedup/retention fix (dedupeSignalSnapshots) already shipped in #3857; this covers the remaining, deliberately-deferred half: observability for the ~10GB D1 storage cap that incident hit. Adds an opt-in Cloudflare Management API probe (src/selfhost/d1-size-probe.ts, gated on CLOUDFLARE_D1_MONITOR_ACCOUNT_ID/DATABASE_ID/API_TOKEN -- absent on almost every self-host install, which runs its own SQLite/Postgres backend) that polls D1's file size and RETENTION_POLICY's monitored tables' row counts every 15 minutes from the self-host process, publishing gittensory_d1_database_size_bytes, gittensory_d1_table_row_count{table=...}, and gittensory_signal_snapshots_rows_per_key (scoped to the latest-only-dedup signal types dedupeSignalSnapshots converges to ~1 row per key, so a climbing ratio means that job has stopped running or its allowlist regressed). Size and each table's row count are fetched independently and a failure in one never blanks the other or the previous good reading, recorded via gittensory_d1_probe_errors_total. Adds four Prometheus alerts (D1 size warn ~70% / critical ~90% of the ~10GB cap, the signal_snapshots dedup-regression ratio, and probe-failure) and a "Cloudflare D1 (Central Cloud)" row on the self-host Grafana dashboard.
1 parent 274d6f3 commit 7dec118

9 files changed

Lines changed: 866 additions & 2 deletions

File tree

apps/gittensory-ui/src/lib/selfhost-env-reference.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
6565
name: "CLAUDE_AI_TIMEOUT_MS",
6666
firstReference: "src/selfhost/ai.ts",
6767
},
68+
{
69+
name: "CLOUDFLARE_D1_MONITOR_ACCOUNT_ID",
70+
firstReference: "src/selfhost/d1-size-probe.ts",
71+
},
72+
{
73+
name: "CLOUDFLARE_D1_MONITOR_API_TOKEN",
74+
firstReference: "src/selfhost/d1-size-probe.ts",
75+
},
76+
{
77+
name: "CLOUDFLARE_D1_MONITOR_DATABASE_ID",
78+
firstReference: "src/selfhost/d1-size-probe.ts",
79+
},
6880
{
6981
name: "CODEX_AI_EFFORT",
7082
firstReference: "src/selfhost/ai.ts",
@@ -409,6 +421,9 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
409421
"| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts` |",
410422
"| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts` |",
411423
"| `CLAUDE_AI_TIMEOUT_MS` | `src/selfhost/ai.ts` |",
424+
"| `CLOUDFLARE_D1_MONITOR_ACCOUNT_ID` | `src/selfhost/d1-size-probe.ts` |",
425+
"| `CLOUDFLARE_D1_MONITOR_API_TOKEN` | `src/selfhost/d1-size-probe.ts` |",
426+
"| `CLOUDFLARE_D1_MONITOR_DATABASE_ID` | `src/selfhost/d1-size-probe.ts` |",
412427
"| `CODEX_AI_EFFORT` | `src/selfhost/ai.ts` |",
413428
"| `CODEX_AI_FIRST_OUTPUT_TIMEOUT_MS` | `src/selfhost/ai.ts` |",
414429
"| `CODEX_AI_MODEL` | `src/selfhost/ai.ts` |",

grafana/dashboards/gittensory.json

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3182,6 +3182,159 @@
31823182
"refId": "A"
31833183
}
31843184
]
3185+
},
3186+
{
3187+
"collapsed": false,
3188+
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 222 },
3189+
"id": 200,
3190+
"title": "Cloudflare D1 (Central Cloud, #3810)",
3191+
"type": "row"
3192+
},
3193+
{
3194+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3195+
"fieldConfig": {
3196+
"defaults": {
3197+
"color": { "mode": "thresholds" },
3198+
"thresholds": {
3199+
"mode": "absolute",
3200+
"steps": [
3201+
{ "color": "green", "value": null },
3202+
{ "color": "yellow", "value": 7000000000 },
3203+
{ "color": "red", "value": 9000000000 }
3204+
]
3205+
},
3206+
"unit": "decbytes"
3207+
}
3208+
},
3209+
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 223 },
3210+
"id": 201,
3211+
"options": {
3212+
"colorMode": "background",
3213+
"graphMode": "area",
3214+
"justifyMode": "auto",
3215+
"orientation": "auto",
3216+
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
3217+
"textMode": "auto"
3218+
},
3219+
"title": "D1 Database Size (-1 = probe disabled/no sample yet)",
3220+
"description": "Cloudflare D1 file size for the monitored database, from the opt-in Management API probe (src/selfhost/d1-size-probe.ts, CLOUDFLARE_D1_MONITOR_* env vars). -1 means the probe is disabled or has never completed a successful sample. D1's known per-database cap is ~10GB (#3810).",
3221+
"type": "stat",
3222+
"targets": [
3223+
{
3224+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3225+
"expr": "gittensory_d1_database_size_bytes",
3226+
"legendFormat": "size",
3227+
"refId": "A"
3228+
}
3229+
]
3230+
},
3231+
{
3232+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3233+
"fieldConfig": {
3234+
"defaults": {
3235+
"color": { "mode": "thresholds" },
3236+
"thresholds": {
3237+
"mode": "absolute",
3238+
"steps": [
3239+
{ "color": "green", "value": null },
3240+
{ "color": "yellow", "value": 10 },
3241+
{ "color": "red", "value": 50 }
3242+
]
3243+
},
3244+
"unit": "short"
3245+
}
3246+
},
3247+
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 223 },
3248+
"id": 202,
3249+
"options": {
3250+
"colorMode": "background",
3251+
"graphMode": "area",
3252+
"justifyMode": "auto",
3253+
"orientation": "auto",
3254+
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
3255+
"textMode": "auto"
3256+
},
3257+
"title": "signal_snapshots Rows per Dedup Key (-1 = unavailable)",
3258+
"description": "signal_snapshots row count divided by its distinct (signal_type, target_key) count, scoped to the latest-only-dedup signal types dedupeSignalSnapshots (src/db/retention.ts) converges to ~1 row per key. A climbing value means the daily dedup job has stopped running or its allowlist regressed -- the 2026-07-06 incident ratio was ~157 (342243 rows / 2183 keys).",
3259+
"type": "stat",
3260+
"targets": [
3261+
{
3262+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3263+
"expr": "gittensory_signal_snapshots_rows_per_key",
3264+
"legendFormat": "rows/key",
3265+
"refId": "A"
3266+
}
3267+
]
3268+
},
3269+
{
3270+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3271+
"fieldConfig": {
3272+
"defaults": {
3273+
"color": { "mode": "thresholds" },
3274+
"thresholds": {
3275+
"mode": "absolute",
3276+
"steps": [
3277+
{ "color": "green", "value": null },
3278+
{ "color": "red", "value": 1 }
3279+
]
3280+
},
3281+
"unit": "short"
3282+
}
3283+
},
3284+
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 223 },
3285+
"id": 203,
3286+
"options": {
3287+
"colorMode": "background",
3288+
"graphMode": "none",
3289+
"justifyMode": "auto",
3290+
"orientation": "auto",
3291+
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
3292+
"textMode": "auto"
3293+
},
3294+
"title": "D1 Probe Errors (total)",
3295+
"description": "Cloudflare Management API probe failures (src/selfhost/d1-size-probe.ts), labeled by part (database_info/table_row_count). Climbing while the size/row-count panels stay flat means the PROBE is broken (bad/expired token, wrong account or database id), not that the database stopped growing.",
3296+
"type": "stat",
3297+
"targets": [
3298+
{
3299+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3300+
"expr": "sum(gittensory_d1_probe_errors_total) or vector(0)",
3301+
"legendFormat": "errors",
3302+
"refId": "A"
3303+
}
3304+
]
3305+
},
3306+
{
3307+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3308+
"fieldConfig": {
3309+
"defaults": {
3310+
"color": { "mode": "palette-classic" },
3311+
"custom": { "lineWidth": 2, "fillOpacity": 10 },
3312+
"unit": "short"
3313+
}
3314+
},
3315+
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 227 },
3316+
"id": 204,
3317+
"options": {
3318+
"legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" },
3319+
"tooltip": { "mode": "multi", "sort": "desc" }
3320+
},
3321+
"title": "D1 Database Size & Monitored Table Row Counts",
3322+
"description": "Trend view of the same D1 size/row-count probe as the stat panels above -- database bytes on the left axis scale, monitored-table row counts (RETENTION_POLICY's tables, src/db/retention.ts) labeled by table.",
3323+
"type": "timeseries",
3324+
"targets": [
3325+
{
3326+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3327+
"expr": "gittensory_d1_database_size_bytes",
3328+
"legendFormat": "database bytes",
3329+
"refId": "A"
3330+
},
3331+
{
3332+
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
3333+
"expr": "gittensory_d1_table_row_count",
3334+
"legendFormat": "{{table}} rows",
3335+
"refId": "B"
3336+
}
3337+
]
31853338
}
31863339
],
31873340
"refresh": "30s",
@@ -3206,5 +3359,5 @@
32063359
"timezone": "browser",
32073360
"title": "Gittensory Self-Host",
32083361
"uid": "gittensory-selfhost",
3209-
"version": 9
3362+
"version": 10
32103363
}

prometheus/rules/alerts.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,66 @@ groups:
552552
summary: "gittensory host clock skew is CRITICAL ({{ $value | printf \"%.0f\" }}s) -- GitHub App auth is likely failing"
553553
description: "Clock skew has exceeded 120s (sustained 2m), well past the point GitHub App JWT auth (\"Bad credentials\") is expected to start failing fleet-wide."
554554
runbook: "Same as GittensoryClockSkewWarning, but treat as urgent: fix NTP sync immediately (chronyc sources, chronyc makestep, restart chrony if every source stays at Reach: 0). Check for github_app_jwt_rejected logs to confirm auth impact."
555+
556+
# ── Cloudflare D1 (central cloud) size + signal_snapshots dedup regression (#3810) ────
557+
# gittensory_d1_* metrics come from the OPT-IN Cloudflare Management API probe (src/selfhost/
558+
# d1-size-probe.ts, CLOUDFLARE_D1_MONITOR_* env vars) -- absent/disabled reads -1 on every gauge below,
559+
# comfortably under every threshold here, so these rules never fire on an install that hasn't configured
560+
# the probe (most self-host installs run their own SQLite/Postgres backend and have nothing to monitor).
561+
- name: gittensory-d1-storage
562+
rules:
563+
- alert: GittensoryD1DatabaseSizeWarning
564+
# 7e9 bytes is ~70% of D1's known ~10GB per-database cap that was hit on 2026-07-06 (#3810), which
565+
# caused real D1 writes (including an Orb relay registration handshake) to start failing/timing out.
566+
expr: gittensory_d1_database_size_bytes > 7000000000
567+
for: 30m
568+
labels:
569+
severity: warning
570+
annotations:
571+
summary: "Cloudflare D1 database size is approaching its cap"
572+
description: "The monitored D1 database is {{ $value | humanize }}B, over 70% of the ~10GB per-database cap (sustained 30m)."
573+
runbook: "Check the D1 panel's per-table row counts (Cloudflare D1 (Central Cloud) row) and confirm the daily signal_snapshots dedup job (dedupeSignalSnapshots, wired into prune-retention) is actually running. See #3810 for the 2026-07-06 incident this guards against."
574+
575+
- alert: GittensoryD1DatabaseSizeCritical
576+
# 9e9 bytes is ~90% of the ~10GB cap -- D1 writes started failing fleet-wide the last time the
577+
# database actually hit the cap, so this is meant to fire well before that happens again.
578+
expr: gittensory_d1_database_size_bytes > 9000000000
579+
for: 10m
580+
labels:
581+
severity: critical
582+
annotations:
583+
summary: "Cloudflare D1 database size is CRITICAL -- writes may start failing soon"
584+
description: "The monitored D1 database is {{ $value | humanize }}B, over 90% of the ~10GB per-database cap (sustained 10m). D1 writes failed fleet-wide the last time this cap was hit (2026-07-06)."
585+
runbook: "Immediate: verify the signal_snapshots dedup job is running (POST /v1/internal/retention/preview, or check audit_events for its record) and identify + trim/archive any other unbounded table from the row-count panel. Contact Cloudflare to raise the account storage limit if cleanup alone doesn't recover enough headroom."
586+
587+
- alert: GittensorySignalSnapshotsDedupRegression
588+
# gittensory_signal_snapshots_rows_per_key is rows-per-distinct-key scoped ONLY to the four
589+
# latest-only-dedup signal types dedupeSignalSnapshots (src/db/retention.ts) actually converges to
590+
# ~1 row per key -- NOT the whole signal_snapshots table, which intentionally keeps bounded
591+
# multi-row history for other signal types (queue-health, contributor-decision-pack, ...). Healthy
592+
# steady-state stays a small multiple of 1 (rows can accumulate for up to a day between the daily
593+
# dedup run); 10 is a wide margin above that, while the actual 2026-07-06 incident ratio (342243
594+
# rows / 2183 keys) was ~157 -- so a value anywhere near double digits means the dedup job has
595+
# stopped running, started erroring, or its allowlist regressed.
596+
expr: gittensory_signal_snapshots_rows_per_key > 10
597+
for: 30m
598+
labels:
599+
severity: warning
600+
annotations:
601+
summary: "signal_snapshots dedup-by-key ratio is climbing"
602+
description: "signal_snapshots has {{ $value | printf \"%.1f\" }} rows per distinct dedup key (sustained 30m) -- the daily dedupeSignalSnapshots job may not be running."
603+
runbook: "Confirm the prune-retention cron (03:00 UTC daily) is completing (audit_events around that time) and that dedupeSignalSnapshots isn't throwing. See #3810 and src/db/retention.ts."
604+
605+
- alert: GittensoryD1ProbeFailing
606+
# The probe itself (a Cloudflare Management API call) can fail independently of the database it
607+
# monitors -- an expired/rotated API token, a wrong account/database id, or a Cloudflare API outage.
608+
# Without this, an operator would see a flat/stale D1 panel and could mistake "the probe broke" for
609+
# "the database stopped growing".
610+
expr: increase(gittensory_d1_probe_errors_total[1h]) > 3
611+
for: 15m
612+
labels:
613+
severity: warning
614+
annotations:
615+
summary: "the Cloudflare D1 size/row-count probe is failing"
616+
description: "{{ $value | printf \"%.0f\" }} D1 Management API probe failure(s) over the last 1h (sustained 15m, part={{ $labels.part }}). The size/row-count gauges below may be stale."
617+
runbook: "Check CLOUDFLARE_D1_MONITOR_API_TOKEN is still valid and CLOUDFLARE_D1_MONITOR_ACCOUNT_ID/DATABASE_ID are correct. Tail logs for level=error event=d1_size_probe_error."

src/db/retention.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ export async function pruneExpiredRecords(
9494

9595
export type SignalSnapshotDedupeResult = { signalType: string; deleted: number };
9696

97-
const LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES = [
97+
/** Exported so the D1 size/row-count observability probe (#3810, src/selfhost/d1-size-probe.ts) can scope its
98+
* signal_snapshots "rows per dedup key" ratio to exactly the population this dedup job converges to ~1 row
99+
* per key -- NOT the whole table, which intentionally keeps bounded multi-row history for other signal
100+
* types (queue-health, contributor-decision-pack, ...). Single source of truth: if this list changes, the
101+
* probe's ratio scope changes with it automatically. */
102+
export const LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES = [
98103
"repo-culture-profile",
99104
"repo-doc-refresh-attempt",
100105
"repo-focus-manifest",

src/env.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,19 @@ declare global {
379379
* flagged (see the same-author guard in buildCollisionReport). Default OFF — unset/false leaves every
380380
* PullRequestRecord's changedFiles unset, byte-identical to today. See src/signals/engine.ts prItem. */
381381
GITTENSORY_OPEN_PR_FILE_COLLISION?: string;
382+
/** D1 size/row-count observability probe (#3810): the Cloudflare account id that owns the D1 database to
383+
* monitor. Presence of this AND the two vars below IS the enablement switch (see isD1SizeProbeEnabled,
384+
* src/selfhost/d1-size-probe.ts) -- unset/blank ⇒ the probe never runs, byte-identical to today. Most
385+
* self-host operators run their own SQLite/Postgres backend and have no Cloudflare D1 to watch; this is
386+
* for whichever deployment owns a real D1 worth monitoring (including gittensory's own central cloud
387+
* database, the one that hit its ~10GB cap on 2026-07-06). */
388+
CLOUDFLARE_D1_MONITOR_ACCOUNT_ID?: string;
389+
/** The D1 database id (uuid) to monitor. See CLOUDFLARE_D1_MONITOR_ACCOUNT_ID. */
390+
CLOUDFLARE_D1_MONITOR_DATABASE_ID?: string;
391+
/** A Cloudflare API token with read access to D1 for the account above (a scoped, read-only custom
392+
* token is sufficient — this probe never writes). A secret — never commit a real value. See
393+
* CLOUDFLARE_D1_MONITOR_ACCOUNT_ID. */
394+
CLOUDFLARE_D1_MONITOR_API_TOKEN?: string;
382395
}
383396
}
384397

0 commit comments

Comments
 (0)