Skip to content

Deep audit + correctness overhaul: fix broken/FP alerts, add correctness harness, harden pipeline & security - #2

Open
RO-29 wants to merge 12 commits into
mainfrom
audit/deep-audit-overhaul
Open

Deep audit + correctness overhaul: fix broken/FP alerts, add correctness harness, harden pipeline & security#2
RO-29 wants to merge 12 commits into
mainfrom
audit/deep-audit-overhaul

Conversation

@RO-29

@RO-29 RO-29 commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Deep audit + correctness overhaul

A full audit of ch-analyzer against real ClickHouse semantics, then a correctness-first overhaul. The theme: the tool had grown broad (24 collectors, 68 alerts, 18 views, ~85 endpoints) but almost none of it was validated — so ~56% of alerts were noise or outright broken, several checks queried columns that don't exist, and the alerting lifecycle leaked. This PR makes the analysis correct and trustworthy, adds a harness so it stays that way, and cleans up the UI and security posture.

All commits build clean; go test ./... and go vet ./... pass, and the frontend passes tsc -b + 49 vitest tests throughout.

Phase 0 — Audit (docs/audit/)

Architecture map, docs/theory reconstruction, live-site-vs-repo drift, per-collector correctness findings, a 68-alert verdict table, and bloat/duplication list. Headline: the live ch-analyzer.pages.dev was a hand-built static page not in the repo; 12 alerts were broken, 26 false-positive-prone; the whole API was unauthenticated.

Correctness harness (Phase 1)

  • internal/testsupport/fakech — an in-process fake ClickHouse HTTP server that exercises the real client/SQL/decode path, so collectors can be driven through known states and asserted.
  • scenario_test.go (behavior) + sql_contract_test.go (fails the build if any collector/web SQL references a ClickHouse identifier that doesn't exist). The contract test immediately caught two additional dead-query bugs in the web layer beyond the audited 12.

Collector correctness — 14 dead/wrong-data checks fixed

Async-insert enum, background merge-pool metric name, Keeper (real is_expired session check replacing 3 nonexistent columns), crash trace_full, CPU *Normalized (OSS + history-chart paths were dead), cache sizes from asynchronous_metrics, MV inner-table join, RejectedInserts now alerts on a per-poll delta (not the lifetime counter that fired critical forever), parts-age is metric-only (old parts aren't merge pressure), schema-drift keyed per-instance, k8s alerts routed to the real instance, the self-contradicting merges-stalled defaults, and the has_ttl_expression advisor. Three dead analyzer cross-alerts removed rather than resurrected (they duplicated dedicated alerts).

Alert quality — 26 FP-prone alerts reworked

Query failures/timeouts now require count floors (no more single-exception pages); long-running defaults 30s/60s → 2m/5m (OLAP-friendly); system.errors alerts on the per-poll delta not the lifetime total; dictionaries only alert on a real load exception (lazy-load is silent); "too many merges" demoted (active merges are the system healing); TTL/JBOD/parts-age demoted to metric-only where they couldn't be judged correctly; anomaly detection restricted to unlabeled scalar metrics; query-storm severity tied to load, not 0.67 QPS.

Alerting pipeline made trustworthy

  • Resolve side effects (PagerDuty resolve, webhook alert_resolved, ack clear) now fire from every resolve path (reconcile, UI, stale-sweep) — no more leaked PagerDuty incidents.
  • Severity escalation (warn → critical) now notifies instead of silently riding the existing row.
  • Ack stops escalation; snooze silences an already-firing alert.
  • Webhook rate limiter no longer drops resolves or escalations.
  • /ch snooze is now a real snooze (persist-but-silent, per-instance) instead of a maintenance window that dropped new alerts.

Health / SLO / audit correctness

Score bands are reachable (the old floor of 50 made "critical" unreachable and SLO uptime 100% by construction); health-trend uses max per bucket not sum; fleet-wide actions are audited; first_seen_at/fire_count are actually read back.

Landing page (web/landing/)

A self-contained static page that mirrors the real app's design tokens and carries only accurate copy (real Slack commands, version floor, collector/view counts), replacing the fabricated out-of-band page. The hero is a faithful CSS rebuild of the actual Overview dashboard.

Security hardening

Opt-in API token auth (security.api_token / CH_ANALYZER_API_TOKEN) gating /api/*; ClickHouse TLS verification now defaults on; Slack debug logging gated; repo-local .gitignore for credential files; SECURITY.md.

Frontend cleanup

Deleted 1,810 lines of verified-dead code; fixed 5 concrete bugs (CHLogs level filter, Alerts maintenance banner, Explore deep links, light-theme chart cursors, chat-session localStorage thrash); rebuilt the embedded bundle so fixes ship.

Not in this PR (needs visual QA or external infra)

  • Large frontend structural consolidations (merging the 3 chart wrappers, 2 chat UIs, 2 table-detail surfaces; collapsing 18 views → ~8; inline education) — tsc can't verify UI rendering, so these need a running app.
  • A hosted demo backend to wire the landing page's Live-demo button to a real free-tier ClickHouse.
  • Minor residuals: all_clear webhook; a few per-alert tunings.

Review guide

Full evidence with file:line citations is in docs/audit/. Each commit is scoped to one concern and independently green.

RO-29 added 12 commits July 10, 2026 16:09
- PHASE0-AUDIT.md: architecture map, theory summary, live-vs-repo drift,
  correctness findings, 68-alert verdict tally, bloat/duplication list,
  proposed direction
- Companion reports: collectors, alerting/API, frontend, docs/deploy,
  live-site drift (full file:line evidence)
…arness

Correctness harness (internal/testsupport/fakech + collector tests):
- fakech: in-process fake ClickHouse HTTP server exercising the real
  client/SQL/decode path; drives collectors through known states.
- scenario_test.go: asserts exact alerts+values for background_pool,
  async_inserts, system CPU, inserts RejectedInserts delta, parts_age,
  schema_drift per-instance isolation.
- sql_contract_test.go: fails if any collector/web SQL references a
  ClickHouse identifier that does not exist (the class of bug below).

Fixes (each check was silently dead, fired forever, or used wrong data):
- async_inserts: status enum 'ExceptionWhileFlushing'/'Flushed' ->
  'FlushError'/'Ok' (real asynchronous_insert_log values).
- background_pool: BackgroundMergesMutationsPoolTask ->
  ...AndMutations...; drop removed ProcessingPool, add CommonPool.
- keeper: system.zookeeper_connection has no outstanding_requests/
  avg_latency/max_latency; replace with is_expired session check.
- restart: system.crash_log.trace_str -> trace_full.
- system + web/history CPU: OSUserTimeCPU/... -> *Normalized (+gate fix);
  OSS/history CPU path was always dead.
- cache_health + web/compare: MarkCacheBytes/UncompressedCacheBytes read
  from asynchronous_metrics, not system.metrics.
- mvs bloat: join implicit inner table by name .inner_id.<uuid>, not uuid.
- analyzer: drop 3 cross-alerts reading nonexistent metric keys
  (also redundant with dedicated collector alerts); keep OOM-risk.
- inserts RejectedInserts: alert on per-poll delta, not the cumulative
  counter that fired critical forever after one rejection.
- parts_age: metric-only; old active parts are normal, not merge pressure.
- schema_drift: key baseline by instance (was clobbered across a fleet).
- k8s: route alerts to the polled instance, not literal "k8s" (never
  persisted; PD re-fired with no resolve).
- tables merges-stalled: fix contradictory defaults (min 30 > max 20 made
  every merge count simultaneously stalled+too-many).
- advisor no_ttl_large: system.tables has no has_ttl_expression column.
Replaces the out-of-band pages.dev landing page (whose source was never
in the repo and had drifted: wrong Slack commands, wrong version floor,
fabricated console, stale collector/view counts).

- Self-contained single HTML file (inline CSS, no external requests,
  no build step) — deploys to Cloudflare Pages as-is.
- Mirrors the real app design tokens from web/frontend/src/index.css
  (dark #0b1120 ground, violet #7c3aed accent, Inter stack, both themes).
- Hero is a faithful CSS rebuild of the real Overview dashboard.
- Accurate copy only: 24 collectors, OSS 23.3+/Cloud 25.3+, real /ch
  Slack subcommands, real nav/view groups, the correctness-harness story.
- Separate primary 'Live demo' CTA (scrolls to the in-page interactive
  Overview; README documents wiring it to a hosted instance for Phase 3).
- web/landing/README.md: deploy steps + why this exists.
…ed fleet actions

- analyzer health score: remove the hard floor of 50 (deductions were
  capped at 50, so score never dropped below it). The UI 'critical' band
  (<50) and the SLO metric counting score<50 were both unreachable.
  Recalibrate to crit=30/warn=8/info=2 (per distinct category) so bands
  map sensibly: 1 critical->70 (warning), 2->40 (critical). +test.
  This also de-degenerates SLO UptimePct (was 100% by construction).
- store health trend: max(criticals)/max(warns) per bucket, not sum() —
  one alert firing across a 4h bucket at 1m polls showed as 240.
- store LogAction: fall back to the first instance when called with an
  empty instance (fleet-wide actions) so those actions are audited
  instead of silently dropped (clientFor("") returned nil).
- store GetActiveAlerts/GetAlertHistory: SELECT first_seen_at + fire_count
  (already parsed but never queried) so Slack 'xN' repeat counts and
  'firing since' reflect reality instead of always 1/CreatedAt.
Query noise (queries.go + config):
- failed-query and timeout alerts now require a count floor (warn 10 /
  crit 50) — a single exception in 5m no longer pages; timeouts (usually
  intended client max_execution_time) demoted behind the same floor.
- long-running defaults 30s/60s -> 2m/5m (OLAP queries run for minutes).
- 'full table scan' (>1B rows) demoted warn->info (often the intended
  shape of an aggregation).
- query-storm floor raised 5 -> 20 concurrent-per-user.

Cumulative-counter fix (errors.go): system.errors.value is cumulative
since restart; alert on the per-poll delta (new occurrences) instead of a
lifetime total reported as 'last hour'. Per-instance state, baseline poll
is silent.

Lazy-load fix (dictionaries.go): only a dictionary with a real
last_exception is actionable; a bare NOT_LOADED (lazy_load / transient
reloading) and element_count==0 on a legitimately-empty source no longer
alert.

Others:
- tables merges 'too many' demoted critical->single warn (active merges are
  the system healing; contradicted merges-stalled).
- ttl: scope stuck-mutation query to real TTL commands (was matching every
  ALTER MODIFY); 'TTL not running' demoted to metric-only (can't judge
  without the table's TTL interval).
- tables JBOD imbalance demoted to metric-only (compared across storage
  tiers that are imbalanced by design).
- analyzer anomaly/sustained: only unlabeled scalar metrics feed detection;
  labeled per-table/query series collapsed by name made every z-score noise.

Tests: fp_rework_test.go covers the failure/timeout floors, errors delta,
and dictionary lazy-load via the fake-CH harness.
slow_query_fingerprint fired *critical* at 200 execs/5min (0.67 QPS) —
ordinary app traffic. Severity now requires frequency AND non-trivial
per-exec cost (crit: >=100x at avg>=5s; warn: >=50x at avg>=1s), so it
reflects real aggregate query-time load. Purely-frequent fast patterns are
left to the advisory repeated-patterns info alert.
…stworthy

- Resolve side effects now fire from EVERY resolve path, not just the
  reconcile clean-check path. Extracted notifyResolved() (PagerDuty resolve
  + webhook alert_resolved + ack clear); added ResolveAndNotify (UI resolve)
  and ResolveStaleAndNotify (heartbeat stale-sweep, via before/after diff).
  Wired server handleResolveAlert + handleResolveStale through them. Fixes
  PagerDuty incidents that leaked open forever on UI/stale resolves.
- Severity escalation (warn -> critical on an already-open alert) now
  notifies: detected in the reconcile diff, force-refreshed so the DB
  severity updates immediately, and routed to notify (respecting
  maintenance/inhibition/snooze). Previously it rode the existing warn row
  as a silent touch and never paged.
- Ack stops escalation: the heartbeat escalation notice is skipped when
  every active non-info alert on the instance is acked.
- Snooze silences an ALREADY-firing alert: snoozed alerts are hidden from
  the Slack per-instance message and escalation (still visible in the UI,
  which reads the store directly).
- Webhook rate limiter no longer drops resolves or escalations: only
  alert_firing is rate-limited, keyed by severity+dedup_key, so terminal
  resolve/all_clear events and genuine warn->critical escalations always
  deliver.
- Added replication:critical -> inserts:warn inhibition (broken replication
  makes inserts fail downstream).

Tests: pipeline_test.go covers escalation-notifies, resolve-fires-side-
effects, and stale-sweep-fires-side-effects via a webhook capture server.
All 21 alerter tests pass.
Slack snooze was implemented via the maintenance store, so `/ch snooze`
DROPPED new alerts entirely (no DB row, invisible in the UI) — the opposite
of what 'snooze' should do. The SlackApp wasn't even wired with the snooze
store.

- SnoozeStore gains per-instance snoozes: an entry with an empty DedupKey
  and Instance set silences a whole node. IsSnoozed(dedupKey, instance) now
  matches either a per-alert snooze (dedup key, from the web UI) or a
  per-instance snooze (from Slack). Added CancelInstance for /ch unsnooze.
- Threaded SnoozeStore into slackapp.App + New() + main.go wiring.
- doSnooze now writes an instance-level snooze (persist-but-silent) instead
  of a maintenance window; cmdUnsnooze cancels it; cmdSnoozed lists real
  snoozes. /ch status shows a 🔇 Snoozed indicator distinct from 🔧
  Maintenance.
- Updated the 3 alerter IsSnoozed call sites to pass the instance.

Net: /ch snooze now silences Slack/PagerDuty/webhook while still recording
alerts to the DB/UI (and, via the earlier Slack-view filter, silences an
already-firing alert) — genuinely distinct from /ch maintenance, which still
drops alerts.

Tests: instance-snooze-suppresses-notify + CancelInstance in pipeline_test.go.
… hardening

The dashboard/API (SQL terminal, KILL QUERY, OAuth token setters, alert
mutation) was fully unauthenticated and CH TLS verification was hardcoded off.

- Opt-in API auth: security.api_token (or CH_ANALYZER_API_TOKEN env) gates
  every /api/* request via Authorization: Bearer / X-API-Token / cookie,
  constant-time compared. Empty = unchanged open default. /health, /, and
  /assets/* stay open. A one-time /?token=<t> visit sets an httpOnly cookie so
  the bundled SPA authenticates with no rebuild (same-origin fetches carry it).
  Startup logs a warning when auth is off. Tested in auth_middleware_test.go.
- TLS verification now defaults ON: both chclient wiring sites read
  security.tls_skip_verify (default false) instead of a hardcoded true.
  Startup warns when disabled.
- Slack socketmode debug logging gated behind CH_ANALYZER_SLACK_DEBUG (was
  hardcoded on, flooding prod stderr).
- Secrets: repo-local .gitignore for staging-env.yaml / *-staging.yaml /
  ai-changes-md / .env* (previously only excluded by a user-global gitignore);
  SECURITY.md documents the threat model, auth, TLS, readonly-user guidance,
  and credential rotation.
- Documented security block in configs/ch-analyzer.yaml.

Note: /api/alerts/trigger stays enabled (it backs Run Check's promote-to-alert
action) — the token gate is its protection, not a dead-by-default flag.
Removed files with zero imports anywhere (verified via grep + routing check):
- views/Dashboard.tsx (803L) — superseded ancestor of Overview's inlined
  widget system; not in VALID_VIEWS or the sidebar.
- views/Discover.tsx (899L) — feature catalog superseded by FeatureGuide.
- components/EmptyState.tsx (36L), components/ThinkingSpinner.tsx (72L) —
  unreferenced.

Also fixed a pre-existing stale test: VALID_VIEWS has 18 routes (the 'guide'
view was added without updating the test, which asserted 17). tsc -b clean,
49/49 vitest pass.
- CHLogs level filter: send ALL selected levels (comma-joined) instead of
  just the first; the backend already filters with level IN (...), so the
  multi-select pills were silently dropping every selection but one.
- Alerts maintenance banner: derive it from the active maintenance windows
  (api.maintenance.list) instead of filtering cachedInstances — a string[]
  of names — on .in_maintenance, which is always undefined on a string, so
  the banner never rendered.
- Explore deep link: derive the valid-tab allowlist from the canonical TABS
  list so ?tab=querylog (and ?tab=connections) resolve instead of silently
  falling back to 'patterns' (the hand-maintained allowlist had drifted).
- Charts: replace white rgba(255,255,255,…) tooltip/hover cursors with a
  theme-neutral slate so they're visible in light theme too (they were
  invisible on a light background).

Rebuilt the embedded frontend bundle (internal/web/static) so the fixes ship
in the binary. tsc -b clean, 49/49 vitest pass, go build embeds cleanly.
setChatSessions serialized up to 100 sessions and wrote localStorage on every
call; during SSE streaming (one call per chunk) that stringified everything on
the main thread per token. Now state still updates synchronously (UI needs it
live) but the persist is debounced 400ms, with a flush on tab-hide/close and
unmount so the last streamed content isn't lost. Rebuilt embedded bundle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant