chmonitor
Features

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

PageRouteWhat it showsSystem tables
Health/healthStatus grid with per-check severity and detailssystem.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=authenticated
CHM_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.

VariableDefaultDescription
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_ENABLEDfalseSet 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_SEVERITYwarningGlobal 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_SEVERITYcriticalMinimum 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_MINUTES0 (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_TOKEN

Enable 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-sweep

The 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-sweep

The Cloudflare scheduled trigger is routed to the route's GET handler. Two gates apply, in order:

  1. Auth — the handler requires CRON_SECRET (returns HTTP 503 when unset). Configure the secret and invoke with Authorization: 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.
  2. Enablement — after auth, the handler consults CHM_HEALTH_SWEEP_ENABLED. When it is falsy the route returns 200 {"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 (since CRON_SECRET is 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.

VariableDefaultDescription
CHM_WEEKLY_REPORT_HOSTSunset (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.pdf

Scheduled 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, cron 0 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/generate with { "format": "pdf" }, or ?format=pdf on the read API). PDF export is a Pro+ (data_export) capability on chmonitor Cloud. It is optional and fail-closed: when no BROWSER binding 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 an X-Report-PDF: unavailable header). 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_log is checked separately by the Errors page (under Operations). The Health page uses system.errors (in-memory error counts), which resets on server restart.
  • If a system table is missing (e.g., no system.replicas on 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_HOST entries. High host counts increase sweep latency.

On this page