Health
At-a-glance cluster health dashboard with automated headless health-sweep alerts via webhook and Cloudflare Cron.
Get a single status grid of cluster health across replication, merges, errors, disks, load, and parts — plus a headless sweep endpoint that fires webhook alerts on a schedule.
Prop
Type
What it does
The Health page aggregates checks across multiple system tables into a single status grid. Each check has a severity level (ok / warning / critical) and a human-readable summary. Operators use this page as a starting point for incident triage.
Checks cover:
- Replication lag — replicas falling behind, readonly tables
- Merge backlog — active merge count and slow merges
- Error rate — recent errors from
system.errors - Disk usage — free space across all disks
- Query load — running query count and memory pressure
- Part health — excessive part counts
In addition to the UI, chmonitor exposes a headless health-sweep endpoint (GET /api/cron/health-sweep) that runs the same checks and dispatches webhook alerts. This is designed to be called on a schedule (e.g., Cloudflare Cron every 5 minutes) without a browser.
Pages
| Page | Route | What it shows | System tables |
|---|---|---|---|
| Health | /health | Status grid with per-check severity and details | system.metrics, system.asynchronous_metrics, system.replicas, system.merges, system.errors, system.disks, system.replication_queue, system.processes, system.query_log, system.parts |
Using it
Open /health for the status grid — each check reports ok / warning / critical with a summary. Start incident triage here, then drill into the linked feature sections for detail.
To run the same checks without a browser, call the health-sweep endpoint (see below) on a schedule.
Permissions & access
CHM_FEATURE_HEALTH_ACCESS=authenticatedCHM_FEATURE_HEALTH_ENABLED=false[features.health]
enabled = true
access = "authenticated"Configuration
Health-sweep cron alerting
The health-sweep endpoint runs checks over all configured hosts and sends a webhook notification when a check meets or exceeds the minimum severity.
| Variable | Default | Description |
|---|---|---|
CRON_SECRET | (unset = endpoint disabled) | Required. Guards GET /api/cron/health-sweep and GET /api/cron/retention-prune. Pass as Authorization: Bearer <secret> (or ?secret=<secret>). When it is unset/empty the endpoints fail closed and return HTTP 503 — they do not run. Note that retention-prune is destructive (it deletes conversation rows past each plan's retention window), which is why these routes refuse to run unauthenticated. |
CHM_HEALTH_SWEEP_ENABLED | (unset = enabled iff CRON_SECRET is set) | Gates the scheduled (headless) sweep fired by the Cloudflare Cron trigger. Set true to force-enable, false to pause scheduled sweeps without removing the cron. When unset it fails closed to the CRON_SECRET posture. Does not affect manual curl hits (those only need a valid CRON_SECRET). Truthy values: 1/true/yes/on. |
HEALTH_ALERT_ENABLED | false | Set to true to enable webhook dispatch. |
HEALTH_ALERT_WEBHOOK_URL | (required if enabled) | Incoming webhook URL. The channel is auto-detected from the URL: Slack (hooks.slack.com) and Discord (discord.com/api/webhooks) get channel-native rich bodies; Microsoft Teams (*.webhook.office.com, or a Workflows *.logic.azure.com URL) gets an Adaptive Card; Google Chat (chat.googleapis.com) gets a cardsV2 card; any other URL receives the plain {"text": "...", "content": "..."} wrapper. See Alerting to Slack and Discord for setup examples. |
HEALTH_ALERT_HEALTHCHECKS_URL | (unset = healthchecks disabled) | healthchecks.io ping URL. The sweep GETs it on each alert (bare URL) and appends /fail on recovery. Self-hosted or cloud healthchecks.io both work. Overridable from the Server delivery channels UI (see callout below). |
HEALTH_ALERT_MIN_SEVERITY | warning | Global default minimum severity that triggers a notification. Values: warning or critical. Per-channel and per-route overrides below take precedence. |
HEALTH_ALERT_<CHANNEL>_MIN_SEVERITY | (inherit global) | Per-channel severity floor that beats the global default for one channel only (<CHANNEL> ∈ WEBHOOK, EMAIL, OPSGENIE, PAGERDUTY, TELEGRAM, NTFY, PUSHOVER). Example: HEALTH_ALERT_WEBHOOK_MIN_SEVERITY=warning with a critical global sends warnings to the webhook only. Values: warning or critical. Twilio keeps its own HEALTH_ALERT_TWILIO_MIN_SEVERITY. |
HEALTH_ALERT_<CHANNEL>_ENABLED | (inherit) | Set false to silence one channel entirely without unsetting its config (<CHANNEL> as above). HEALTH_ALERT_WEBHOOK_ENABLED=false disables the webhook channel — routes + the legacy global URL — distinct from the master HEALTH_ALERT_ENABLED. |
HEALTH_ALERT_TELEGRAM_BOT_TOKEN | (unset = Telegram disabled) | Telegram Bot API token. Server-only secret — never exposed to the browser. Set together with HEALTH_ALERT_TELEGRAM_CHAT_ID to enable the global Telegram channel; each finding is sent to the chat via the Bot API sendMessage endpoint. Per-rule/per-host Telegram routes (Routing tab) override this fallback. |
HEALTH_ALERT_TELEGRAM_CHAT_ID | (unset = Telegram disabled) | Target Telegram chat id (e.g. -1001234567890, or @channelname). Both this and HEALTH_ALERT_TELEGRAM_BOT_TOKEN must be set for the global Telegram channel to fire. |
HEALTH_ALERT_NTFY_URL | (unset = ntfy disabled) | Full ntfy topic URL (e.g. https://ntfy.sh/my-topic, or a self-hosted server). Each finding is published with Title/Priority/Tags headers + a plain-text body (severity → priority: critical 5/urgent, warning 4/high, recovery 3/default). Per-rule/per-host ntfy routes (Routing tab) override this fallback. |
HEALTH_ALERT_NTFY_TOKEN | (unset = no auth) | Optional ntfy access token for a protected topic, sent as Authorization: Bearer <token>. Server-only secret — never exposed to the browser. |
HEALTH_ALERT_TWILIO_ACCOUNT_SID | (unset = Twilio disabled) | Twilio Account SID. Set together with HEALTH_ALERT_TWILIO_AUTH_TOKEN, HEALTH_ALERT_TWILIO_FROM, and HEALTH_ALERT_TWILIO_TO to enable the global Twilio SMS channel. There are no per-rule/per-host SMS routes, but the credentials are editable from the Server delivery channels UI (see callout below). |
HEALTH_ALERT_TWILIO_AUTH_TOKEN | (unset = Twilio disabled) | Twilio Auth Token. Server-only secret — never exposed to the browser. Authenticates as HTTP Basic (AccountSid:AuthToken) against the Twilio Messages API. |
HEALTH_ALERT_TWILIO_FROM | (unset = Twilio disabled) | The Twilio phone number (E.164, e.g. +15557654321) messages are sent from. |
HEALTH_ALERT_TWILIO_TO | (unset = Twilio disabled) | Comma-separated list of SMS recipients (E.164, e.g. +15551234567,+15559876543). One SMS is sent per recipient per finding. |
HEALTH_ALERT_TWILIO_MIN_SEVERITY | critical | Minimum severity that triggers an SMS specifically (independent of HEALTH_ALERT_MIN_SEVERITY above). Values: warning or critical. Defaults to critical because SMS is a last-resort paging channel that costs real money per message — set to warning to also page on warnings. |
HEALTH_ALERT_PUSHOVER_TOKEN | (unset = Pushover disabled) | Pushover application API token. Server-only secret — never exposed to the browser. Set together with HEALTH_ALERT_PUSHOVER_USER to enable the global Pushover channel; each finding is POSTed to the Messages API (severity → priority: critical 2/emergency with retry+expire, warning 0/normal, recovery -1/low-quiet). Per-rule/per-host Pushover routes (Routing tab) override this fallback. |
HEALTH_ALERT_PUSHOVER_USER | (unset = Pushover disabled) | Target Pushover user or group key. Both this and HEALTH_ALERT_PUSHOVER_TOKEN must be set for the global Pushover channel to fire. |
HEALTH_ALERT_DIGEST_MINUTES | 0 (off) | Optional time-window digest for alert batching. 0/unset dispatches every finding on the sweep tick (in-pass grouping still applies — see the callout below). A positive value buffers non-critical findings and flushes them together once the window elapses, collapsing a burst into one message per target; critical findings always bypass the buffer and page immediately. Requires a D1 binding for the buffer; with none it degrades to 0 (off). Overridable from Health → Settings → Alerts → Digest batching. |
Alert grouping & digest batching
When one sweep finds several alerts bound for the same delivery target (e.g. disk filling on 8 hosts, all routed to one Slack channel), they are combined into one message — a summary line (3 critical, 2 warning on 4 hosts) followed by a line per finding — instead of N separate pings. This in-pass grouping is always on and needs no configuration; it applies to the generic webhook, Slack, and Telegram channels (every other channel keeps per-finding sends). To also batch across time, enable the optional time-window digest (HEALTH_ALERT_DIGEST_MINUTES or the Alerts tab): non-critical findings wait up to the window and flush together, while criticals still page immediately.
Edit channels from the UI (no redeploy)
Every channel above is also editable from Health → Settings → Alerts → Server delivery channels. A channel saved there is persisted per-owner (in D1, when configured) and takes precedence over its HEALTH_ALERT_* environment variable on the next sweep — so you can add or rotate a destination without a redeploy. Leaving a secret field blank on save keeps the stored secret. A channel with no saved config falls back to its env variable (shown as "Configured via server env"), and a deployment with no D1 binding simply keeps using the env variables. Browser notifications stay per-browser (local to your session).
SMS costs real money
Every Twilio dispatch is a billed SMS — one message per configured recipient, per finding. HEALTH_ALERT_TWILIO_MIN_SEVERITY defaults to critical so routine warnings never page a phone; only raise it to warning if you understand the cost. Message bodies are also truncated to Twilio's 1600-character SMS limit.
CRON_SECRET is required
CRON_SECRET is required for the cron endpoints. When it is unset, /api/cron/health-sweep and /api/cron/retention-prune fail closed and return HTTP 503 (they do not run) — retention-prune is destructive, so it must never be reachable unauthenticated. Set CRON_SECRET and pass it as Authorization: Bearer <secret> from your cron caller.
Set secrets
Example for Cloudflare Workers (using wrangler secret put):
wrangler secret put CRON_SECRET
wrangler secret put HEALTH_ALERT_WEBHOOK_URL
# Optional: Telegram channel (bot token is a secret; chat id is a plain var)
wrangler secret put HEALTH_ALERT_TELEGRAM_BOT_TOKEN
# Optional: ntfy channel (topic URL is a plain var; access token is a secret)
wrangler secret put HEALTH_ALERT_NTFY_TOKEN
# Optional: Twilio SMS channel (auth token is a secret; SID/from/to are plain vars)
wrangler secret put HEALTH_ALERT_TWILIO_AUTH_TOKEN
# Optional: Pushover channel (app token is a secret; user key is a plain var)
wrangler secret put HEALTH_ALERT_PUSHOVER_TOKENEnable alerting
Example environment block:
HEALTH_ALERT_ENABLED=true
HEALTH_ALERT_MIN_SEVERITY=warning
HEALTH_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
CRON_SECRET=<random-secret>Call it manually
curl -H "Authorization: Bearer $CRON_SECRET" \
https://your-chmonitor.example.com/api/cron/health-sweepThe endpoint returns a JSON array of check results. It always returns HTTP 200; alert dispatch happens server-side.
Scheduling (Cloudflare Cron)
The hosted deploy schedules the sweep every 10 minutes. In wrangler.toml:
[triggers]
crons = ["0 3 * * *", "0 8 * * 1", "*/10 * * * *"] # retention, weekly-report, health-sweepThe Cloudflare scheduled trigger is routed to the route's GET handler. Two gates apply, in order:
- Auth — the handler requires
CRON_SECRET(returns HTTP 503 when unset). Configure the secret and invoke withAuthorization: Bearer <secret>(or?secret=<secret>) — e.g. from an external scheduler, or a Worker Cron that forwards the secret. An unauthenticated scheduled hit is rejected by design. - Enablement — after auth, the handler consults
CHM_HEALTH_SWEEP_ENABLED. When it is falsy the route returns200 {"skipped": true}without running the sweep, so you can pause scheduled alerting without touching the cron or the secret. When unset it defaults to enabled (sinceCRON_SECRETis already present at that point).
The */10 cadence (rather than */5) leaves CPU-time headroom: the sweep also generates AI insights per host, so its cost scales with the number of configured CLICKHOUSE_HOST entries. For very large host fleets, either lengthen the cadence or pause the scheduled sweep (CHM_HEALTH_SWEEP_ENABLED=false) and drive it from an external scheduler.
Weekly health report
Beyond the health-sweep, a second cron job builds a proactive weekly HTML report per opted-in host: a 7-day narrative combining the AI insights engine's top findings, the number of adaptive statistical baselines fitted to that cluster, and a disk-capacity outlook from the capacity forecaster.
Each report also includes real cluster data over the window, when the host's system tables allow it (each section is fail-open and simply omitted otherwise):
- Query activity — total and failed query counts, p50/p95 duration, and a per-day sparkline (from
system.query_log). - Ingestion — rows and bytes written by INSERTs per day, with sparklines (from
system.query_log; uncompressed measurement, same model as the Traffic page). - Storage — total on-disk size plus the top 5 tables by size, each annotated with the bytes written during the window (from active
system.parts).
Reports are opt-in, never opt-out — set CHM_WEEKLY_REPORT_HOSTS to a comma-separated list of host indices (e.g. 0,2) to enable it for those hosts. Leave it unset and no reports are generated, so a self-hosted deployment stays quiet by default.
| Variable | Default | Description |
|---|---|---|
CHM_WEEKLY_REPORT_HOSTS | unset (no reports) | Comma-separated host indices to opt in, e.g. 0,2. Garbage entries are dropped; an opted-in index with no matching configured host is skipped with a warning. |
The report is generated by GET /api/cron/weekly-report, guarded by the same CRON_SECRET as the health-sweep endpoint above, and scheduled weekly on Mondays at 08:00 UTC:
[triggers]
crons = ["0 8 * * 1"]The report is always persisted, even when no delivery channel is configured — so a deployment with no webhook still accrues viewable reports. When HEALTH_ALERT_WEBHOOK_URL is set, the report is also best-effort posted to that same webhook.
View or download the most recent persisted report:
# Self-contained HTML document
curl "https://your-chmonitor.example.com/api/v1/insights/weekly-report?host=0"
# Parsed JSON summary instead
curl "https://your-chmonitor.example.com/api/v1/insights/weekly-report?host=0&format=json"
# A specific past week (YYYY-MM-DD week-start)
curl "https://your-chmonitor.example.com/api/v1/insights/weekly-report?host=0&week=2026-07-06"
# PDF (Cloudflare Browser Rendering; falls back to HTML when unavailable)
curl -L "https://your-chmonitor.example.com/api/v1/insights/weekly-report?host=0&format=pdf" -o report.pdfScheduled reports (per-user)
Beyond the env-var opt-in above, Scheduled Reports (/report-settings in the dashboard) lets each user subscribe to a weekly or monthly cluster health report delivered through their already-configured alert channels — email (full HTML), webhook, Telegram, ntfy, and Pushover. Paging channels (Opsgenie, PagerDuty, Twilio) are deliberately excluded: a digest must never page anyone.
- Cadence — monthly (1st of the month, 30-day window,
GET /api/cron/monthly-report, cron0 8 1 * *) or weekly (Mondays, 7-day window, shares the weekly cron above). On chmonitor Cloud, weekly cadence requires a paid plan; monthly is included on Free. - Generate now — build a fresh report on demand and open/download the self-contained HTML. Works on any deployment with zero delivery configuration (
POST /api/v1/reports/generate). - Download PDF — render the same report to PDF via Cloudflare Browser Rendering (
POST /api/v1/reports/generatewith{ "format": "pdf" }, or?format=pdfon the read API). PDF export is a Pro+ (data_export) capability on chmonitor Cloud. It is optional and fail-closed: when noBROWSERbinding is configured (self-hosted Docker / K8s, or any Cloudflare account without Browser Rendering) or a render fails, the request degrades gracefully to HTML (signalled by anX-Report-PDF: unavailableheader). On Cloud, a Pro+ subscriber's scheduled email delivery also gets the PDF attached automatically. - Send test report — deliver one immediately through your configured channels and see the per-channel outcome (
POST /api/v1/reports/test-send). - Audit — the subscription stores the last delivery time and a compact per-channel status.
- Fleet report — a subscription covering more than one host is delivered as ONE combined fleet report instead of N separate ones: a comparison table (findings, critical, queries, ingested bytes, disk %) followed by each host's full sections. Per-host reports are still persisted individually.
Subscriptions live in the report_subscriptions D1 table (owner-scoped, like alert channels); deployments without D1 keep the env-var webhook flow and the on-demand Generate button. The AI agent can also produce the same report conversationally via its generate_cluster_report tool.
Notes & limitations
system.error_logis checked separately by the Errors page (under Operations). The Health page usessystem.errors(in-memory error counts), which resets on server restart.- If a system table is missing (e.g., no
system.replicason a standalone node), that check is skipped with an "unavailable" state rather than a false positive. - The webhook payload is a plain Slack/Discord-compatible JSON object (
{"text": "...", "content": "..."}). Custom payload shapes are not supported in v1. - The health-sweep endpoint queries all configured
CLICKHOUSE_HOSTentries. High host counts increase sweep latency.