Phase 3: dashboards and alerting

Saved, shareable multi-panel dashboards (table/line/bar/single-stat
panels via gridstack + uPlot, global + per-panel time range, JSON
export/import) and threshold/absence alert rules with an
ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery.

- New /metadata component: Postgres control-plane store for dashboards,
  panels, notification targets, alert rules/state, and delivery log --
  see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree
  family isn't a fit for this access pattern (needs real row-level
  locking and read-your-writes consistency).
- api/internal/dashboards: dashboard/panel CRUD, pure -- panel query
  execution stays client-side, reusing the existing /query endpoint.
- New /alerting service: rule/target CRUD, a ticker-driven evaluator
  (claim-then-evaluate concurrency control, transactional-outbox
  delivery, query errors and threshold zero-rows never coerced into a
  false transition) and webhook/Slack/PagerDuty delivery with
  retry/backoff. See docs/phase-3-alerting-design.md for the full
  state-machine design and the four correctness properties it
  implements.
- web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts
  list/get/apply, seeding a future Terraform provider's JSON contract.
- hack/alert-load-test: 500 rules against real ClickHouse data, real
  measured results in docs/phase-3-runbook.md.

Five real bugs found by actually running this against a live stack
(documented in the runbook, not just fixed silently): a latent Phase 2
bug where ClickHouse rejected the timestamp format used for
earliest=/latest= queries; a "now" literal token injected into query
text; a GridStack/uPlot layout-timing race; JS's Date.parse being too
lenient to use as a timestamp-detection heuristic; a rule's "enabled"
field silently defaulting to false when omitted; and the evaluator's
claim-batch-size and worker-pool-concurrency defaulting to the same
value, causing 500 concurrently-due rules to take 125s to cycle through
instead of the configured 60s.
This commit is contained in:
2026-08-13 17:29:38 -07:00
parent fb5049a747
commit 9435115ab7
88 changed files with 7463 additions and 298 deletions
+358
View File
@@ -0,0 +1,358 @@
# Alerting design
> **Status:** Design only — no code written against this yet. Task 4 of
> Phase 3. Per explicit instruction, execution stops here for review
> before task 5 (building `/alerting`) begins: the firing/resolved state
> machine and debounce behavior are easy to get subtly wrong, and this is
> the artifact to sign off on before anything is built against it. If
> implementation later reveals this design is wrong somewhere, fix this
> doc in the same change — same discipline as
> `/docs/query-language-design.md` and
> `/docs/phase-3-dashboard-design.md`.
## Why this design, in one paragraph
A rule is a saved Phase 2 query plus a condition (threshold or absence)
plus an evaluation interval plus a notification target. The genuinely
hard part isn't the data model — it's the firing/resolved state machine
under concurrent evaluation and its interaction with notification
delivery, where a naive implementation can plausibly double-fire, lose a
resolve, or silently misreport an infrastructure outage as "all clear."
This doc's state machine mirrors Prometheus Alertmanager's well-understood
`pending`/`firing` `for:` model, then adds four specific correctness
properties on top of it — not because the base model is wrong, but
because the *concurrent, at-least-once* environment a Go ticker-based
evaluator actually runs in exposes gaps a single-threaded description of
the model glosses over. Each fix below is stated as: the failure it
prevents, and the mechanism, so it's reviewable as a claim rather than
just an assertion.
## Data model
A rule is a saved query, evaluated on an interval, checked against a
condition, with a debounce before it's allowed to notify, and one
notification target it notifies. Two condition types:
- **`threshold`**: the query's result must resolve to exactly one row;
the value in that row's first column is compared against
`threshold_value` via `comparator`.
- **`absence`**: the query returned zero rows. The evaluation *window*
is not a separate rule field — it's whatever `earliest=`/`latest=` the
rule's own saved query already expresses (e.g. `service=payments
severity=ERROR earliest=-5m`), reusing Phase 2's time-range syntax
rather than inventing a second one.
```sql
CREATE TABLE notification_targets (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('webhook', 'slack', 'pagerduty')),
webhook_url TEXT NOT NULL,
payload_template TEXT, -- generic ("webhook") targets only; NULL for slack/pagerduty
headers JSONB NOT NULL DEFAULT '{}',
secret TEXT, -- PagerDuty routing key / generic-webhook HMAC secret -- plaintext, see "Known gaps" below
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE alert_rules (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
query_language TEXT NOT NULL DEFAULT '',
condition_type TEXT NOT NULL CHECK (condition_type IN ('threshold', 'absence')),
comparator TEXT CHECK (comparator IN ('gt', 'gte', 'lt', 'lte', 'eq', 'ne')), -- NULL for absence
threshold_value DOUBLE PRECISION, -- NULL for absence
eval_interval_seconds INT NOT NULL CHECK (eval_interval_seconds >= 30),
for_minutes INT NOT NULL DEFAULT 0, -- 0 = fire on first true evaluation
renotify_interval_minutes INT, -- NULL = notify once per firing episode
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
enabled BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE alert_state (
rule_id UUID PRIMARY KEY REFERENCES alert_rules(id) ON DELETE CASCADE,
state TEXT NOT NULL DEFAULT 'ok' CHECK (state IN ('ok', 'pending', 'firing')),
condition_true_since TIMESTAMPTZ,
fired_at TIMESTAMPTZ,
last_notified_at TIMESTAMPTZ,
last_evaluated_at TIMESTAMPTZ,
last_eval_status TEXT NOT NULL DEFAULT 'ok' CHECK (last_eval_status IN ('ok', 'error')),
last_error TEXT,
last_value DOUBLE PRECISION,
consecutive_errors INT NOT NULL DEFAULT 0,
next_eval_at TIMESTAMPTZ NOT NULL, -- the claim column, see "Concurrency" below
claimed_at TIMESTAMPTZ
);
CREATE TABLE delivery_log (
id BIGSERIAL PRIMARY KEY,
rule_id UUID NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
event_type TEXT NOT NULL CHECK (event_type IN ('firing', 'resolved')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'retrying')),
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ, -- the delivery worker's own claim key
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
response_status INT,
payload JSONB NOT NULL, -- the actual rendered payload -- needed to debug template issues
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON delivery_log (rule_id, created_at DESC); -- per-rule delivery log UI
CREATE INDEX ON delivery_log (status, next_attempt_at) WHERE status IN ('pending', 'retrying'); -- delivery worker's claim query
```
**`alert_state` must be inserted in the same transaction as its owning
`alert_rules` row**, `state = 'ok'`, `next_eval_at = now()` (evaluate
immediately on creation). A rule with no `alert_state` row is silently
never picked up by the claim query below — worth stating loudly since
it's the kind of thing that "just works" in every test that remembers to
seed both rows and then silently doesn't in production the one time it's
forgotten.
Single notification target per rule for MVP. Multi-target (e.g. page AND
Slack) is a disclosed, straightforward future extension — a join table,
not a redesign.
## Notification delivery: generic webhook as the base primitive
All three `notification_targets.kind` values ultimately do the same
thing — an HTTP POST to `webhook_url` — with kind-specific *payload
formatting only*, per the explicit requirement that this stays pluggable
rather than accumulating per-vendor delivery logic:
- **`webhook`**: `payload_template` is a Go `text/template` string,
rendered against the firing/resolved event (rule name, condition,
current value, timestamp). No template = a sane default JSON shape.
- **`slack`**: fixed formatter producing Slack's incoming-webhook shape
(`{"text": "..."}`), no user template. `payload_template` is ignored
for this kind (kept nullable in the schema rather than removed, so a
target's `kind` can be changed later without a payload column migration).
- **`pagerduty`**: fixed formatter producing PagerDuty's Events API v2
shape (`{"routing_key": secret, "event_action": "trigger"|"resolve",
"payload": {...}}`) — `secret` here is the PagerDuty integration
routing key, not a delivery credential in the auth sense.
All three go through the exact same HTTP POST + retry/backoff mechanism
in `internal/delivery/webhook.go`; `slack.go`/`pagerduty.go` are payload
builders only, never their own delivery path.
## The state machine
States per rule: `ok``pending``firing`, tracked in `alert_state`.
Every evaluation produces one of three outcomes — `condition_true`,
`condition_false`, or `error` — and error is handled entirely separately
from the other two (see fix 3).
**On `condition_true`:**
- `ok``pending`: set `condition_true_since = now()`. No notification.
- `pending``firing`, once `now() - condition_true_since >=
for_minutes`: send a **firing** notification, set `fired_at =
last_notified_at = now()`. `for_minutes = 0` means this transition
happens on the very first true evaluation.
- `pending`, not yet past `for_minutes`: no transition, no notification.
- `firing` → stays `firing`: silent, unless `renotify_interval_minutes`
is set and `now() - last_notified_at >= renotify_interval_minutes`, in
which case re-send **firing** and update `last_notified_at`. Default
(`NULL`) is "notify once per firing episode, stay silent until
resolved" — stated explicitly since it's exactly the kind of default an
implementer would otherwise have to guess.
**On `condition_false`:**
- `ok` → no-op.
- `pending` → `ok`: clear `condition_true_since`. **No notification** —
this was a blip inside the debounce window, not a real alert. This is
deliberate, not an oversight: it's the entire reason `for_minutes`
exists.
- `firing` → `ok`: clear `condition_true_since`/`fired_at`, send a
**resolved** notification.
`condition_true_since` is a **wall-clock timestamp**, not a
consecutive-evaluation counter. This is what makes the debounce survive
evaluator restarts/downtime correctly: if the evaluator is down for part
of a rule's `for_minutes` window and comes back, wall-clock math
correctly resumes toward firing where it left off, while a counter would
have silently lost that progress and restarted the count. Worth
defending explicitly here since a counter looks like the "simpler"
choice and a future contributor might "simplify" it into one.
**Disclosed non-goal: no debounce on the way down.** `firing` → `ok`
happens on a single false evaluation — there's no symmetric "stay firing
for N more minutes" hold (Grafana calls this "keep firing for"). A
condition that flickers right at the threshold produces a firing/resolved
notification pair per flicker. Future work, not solved in Phase 3.
### Four correctness properties, and the concrete failure each one prevents
**1. Concurrent evaluation of the same rule (claim-then-evaluate).**
A worker-pool evaluator is required at the scale task 8 targets (~500
rules @ 60s ≈ 8+ evaluations/sec sustained). If a single evaluation's
round-trip to `api`'s `/query` ever takes longer than the rule's own
interval, the next scheduler tick can pick the *same* rule again while
the first evaluation is still in flight — two goroutines then read-
modify-write the same `alert_state` row concurrently. Depending on
timing, that produces either a duplicated firing notification (both see
`pending`, both compute "elapsed ≥ for_minutes", both fire) or a lost
resolve (a slow evaluation's stale write clobbers a faster one).
Fix: atomically claim due rules **before** the slow network call starts:
```sql
UPDATE alert_state
SET next_eval_at = now() + (eval_interval_seconds || ' seconds')::interval,
claimed_at = now()
FROM alert_rules
WHERE alert_state.rule_id = alert_rules.id
AND alert_state.next_eval_at <= now()
AND alert_rules.enabled
LIMIT $batch_size
RETURNING alert_state.rule_id, alert_state.state, alert_state.condition_true_since, ...
```
`next_eval_at` is bumped *before* the `/query` HTTP call ever starts, so
a second scheduler tick can't re-select the same rule while the first is
still running. The `/query` call itself happens **outside** any database
transaction — never hold a Postgres connection open across a network
call to another service. A second, short transaction applies the
resulting state transition once the query result is known.
This same claim pattern is what makes horizontal evaluator replicas safe
to add later (a named Phase 4+ path, see task 8) without redesigning the
state model — each replica's claim query naturally excludes rows another
replica already claimed. Worth stating positively: this is the one place
in Phase 3 that's actively built *for* that future need, not just
avoiding a trap.
**2. Notification loss/duplication on crash (transactional outbox).**
If the state transition and the webhook POST happen as separate,
sequentially-ordered steps, a crash between them is unrecoverable in one
direction or the other: crash after a successful POST but before the DB
commit → next evaluation replays the transition and double-fires; crash
after commit but before the POST → the notification is silently owed
forever with no record that it was ever decided.
Fix: the state transition and `INSERT INTO delivery_log (..., status =
'pending')` happen in the **same database transaction** — "we decided to
notify" becomes durable exactly once, atomically with the state change
itself, before any network call to a notification target is attempted. A
**separate** delivery worker polls `delivery_log WHERE status IN
('pending', 'retrying') AND next_attempt_at <= now()` (same claim
pattern as fix 1), performs the actual HTTP POST, and updates
`status`/`attempt_count`/`last_error`. This decouples "did we decide to
notify" (transactionally certain) from "did the HTTP call succeed"
(best-effort with retries) — which is exactly what task 5's retry-with-
backoff requirement needs anyway, so this isn't extra machinery bolted
on for correctness's sake, it's the same piece of work.
**3. Query errors must never be treated as `condition_false`.**
The state machine above only describes `condition_true`/
`condition_false` — what happens when the `/query` call to `api` itself
fails (timeout, ClickHouse down, a 5xx)? Coercing an error to "false" is
the tempting default and the worst possible one: a `firing` alert would
silently auto-resolve and go quiet at precisely the moment something is
broken enough that the query can't even run. Coercing to "true" is
equally wrong the other way (spurious pages on transient infra hiccups).
Fix: evaluation outcome is modeled as a three-way result, and an `error`
outcome **never transitions `state`** at all. It only updates
`alert_state.last_evaluated_at`, `last_eval_status = 'error'`,
`last_error`, and increments `consecutive_errors`. This must be explicit
in the implementation, not left to the "obvious" path — the obvious
path (an error propagating into whatever boolean the rest of the
function expects) is also the wrong one.
**4. Zero rows on a `threshold` rule is an error, not a `0`.**
`stats count by host` can legitimately return zero rows for a threshold
rule (nothing matched in the window) — that's different in kind from an
`absence` rule, where zero rows *is* the signal being checked for. If a
threshold evaluation coerces "no rows" to a scalar `0` for comparison,
`count > 100` silently reports "definitely fine" in exactly the case
where the honest answer is "the query returned nothing, which might mean
nothing happened, or might mean something upstream is broken" — often
the more alarming possibility, not the safe one.
Fix, stated as an explicit rule rather than left implicit: `threshold`
evaluation requires **exactly one** result row (first row, first numeric
column, per the dashboard/query design's single-row precedent). Zero
rows — or more than one — is treated the same as fix 3's evaluation
error, not coerced to a value. Named non-goal alongside "no per-group
alerting": a threshold rule's query must resolve to a scalar.
## Evaluator architecture
A single Go process (`/alerting`), ticker-driven — **not** a workflow
engine, per the explicit instruction not to reach for one at this stage.
Loop shape:
1. Every few seconds, run the claim query (fix 1) for due, enabled rules
up to a bounded batch size.
2. Dispatch claimed rules to a bounded worker pool (goroutines).
3. Each worker: `POST /query` against `api` (reusing the existing
endpoint — the same precedent `sentryctl query` already set, never a
second query-execution path), evaluate the condition, run the state
transition (fix 3/4 aware) and, if applicable, the fix-2 transactional
outbox insert, in one short DB transaction.
4. A separate delivery-worker loop claims and sends `delivery_log` rows
independently of the evaluation loop.
`alerting` is therefore hard-dependent on `api` being reachable — if
`api`/ClickHouse is down, every due rule's evaluation records an error
(fix 3), not a false resolve. This is documented behavior, not an
accident: you can't trust a condition you can't evaluate. `alerting`'s
docker-compose entry depends on `api`'s healthcheck (added in task 3)
for this reason.
## Component boundary
`/alerting` (new top-level Go service, own `go.mod`) owns rule CRUD,
notification-target CRUD, the delivery-log read endpoint, the evaluator
loop, and the delivery worker — all four pieces of "alerting" in one
service, since they share the same Postgres tables and the same
claim-based concurrency pattern. It does **not** import `api`'s
`querylang` package or talk to ClickHouse/Tantivy directly; it only
calls `api`'s `POST /query` over HTTP, exactly like `sentryctl query`
and the web UI's dashboard panels already do. `web` gets a second
backend base URL (`alerting`'s) alongside the existing `api` one.
## Known gaps (named, not hidden)
- **`notification_targets.secret` is stored plaintext** in Postgres —
the same posture `dashboard-design.md` already named for that domain.
Becomes both an enterprise-tier (secrets/KMS) and a multi-tenancy
(per-tenant secret isolation) concern later; naming it now avoids it
being "discovered" as a surprise security-review finding during a
future push.
- **CORS on `alerting`'s HTTP API is wide open**, matching `api`'s
existing no-auth-system posture from Phases 02. Not a new problem
Phase 3 introduces, just a second surface that inherits the same one.
- **No per-group/multi-row threshold alerting** (fix 4's scope decision)
and **no resolved-side debounce** (state-machine section) are both
named future work.
- **Single shared Postgres role** for `api` and `alerting`, same as
`dashboard-design.md`'s note — fine for Phase 3, a named Phase 4 item
once real auth exists.
## Load-testing plan (task 8, not run yet)
Seed ~500 rules via `alerting`'s own create API (not a direct DB insert
— exercise the real code path) at 60-second intervals, against a real
ClickHouse dataset (reusing `hack/benchmark-fixture`'s generator).
Measure: drift between `alert_state.next_eval_at` and the actual claim
timestamp under sustained load, `consecutive_errors`/`last_eval_status`
distribution (did the evaluator start erroring under load, as opposed to
just running slow), and `delivery_log.attempt_count`/`status`
distribution. Document real numbers, not projections — matching every
prior phase's benchmark discipline — and name what would need to change
for materially larger rule counts (horizontal evaluator replicas
partitioning by rule-id hash, which fix 1's claim design already makes
safe to add without a state-model change; moving off a single-process
ticker) as explicit Phase 4+ scope, not solved here.
+288
View File
@@ -0,0 +1,288 @@
# Dashboard design
> **Status:** Approved, in progress. Task 2 of Phase 3 — see `/CLAUDE.md`'s
> "What done looks like for Phase 3" section for the exit criteria this is
> built against. Task 3 (dashboard CRUD API + web UI) implements this
> doc; if implementation reveals this design is wrong somewhere, fix this
> doc in the same change, don't let them drift apart — same discipline as
> `/docs/query-language-design.md`.
## Why this design, in one paragraph
A dashboard is a named collection of panels, each wrapping one saved
Phase 2 query plus a visualization type and a grid position. That's a
straightforward data model; the one real design decision here is *where
it's stored*. Dashboards and their panels need real create/update/delete
semantics with immediate read-your-writes consistency for a UI —
ClickHouse's MergeTree family isn't built for that access pattern (see
"Why not ClickHouse" below). This doc also covers the mechanism that
makes one query language work for both ad hoc search and reusable
dashboard panels: injecting the dashboard's time range into a stored
query at execution time, rather than baking a fixed time range into the
saved query itself.
## Why not ClickHouse: PostgreSQL for control-plane config
This phase adds PostgreSQL as a new pinned-stack component — flagged and
confirmed with the project owner before implementation, per CLAUDE.md's
"ask before... making an architectural decision not already specified in
`/docs/architecture.md`." Scope is strictly control-plane config
(dashboards, panels, and — per `/docs/phase-3-alerting-design.md`
notification targets, alert rules, alert state, delivery log). Log data
itself is untouched: ClickHouse and Tantivy remain the only stores for
`logs`.
Two concrete reasons ClickHouse doesn't fit this job, not just "it feels
risky":
1. **No row-level locking primitive.** The alerting evaluator (see the
alerting design doc) needs to atomically claim a due rule so a second
evaluation tick can't touch it concurrently — a `SELECT ... FOR UPDATE
SKIP LOCKED` operation. `ReplacingMergeTree` + `FINAL` gives
eventually-consistent "latest write wins," not concurrency control.
That's a missing primitive, not a tuning problem.
2. **Read-your-writes consistency for a UI.** A user editing a dashboard
expects to immediately see their own edit reflected back. ClickHouse's
MergeTree engines don't guarantee this the way a transactional
database does without extra machinery (`FINAL`, careful ordering) that
amounts to reimplementing what Postgres already provides natively.
Scope of the addition: new top-level `/metadata` component (naming
mirrors existing precedent — transport=Redpanda, storage=ClickHouse,
search=Tantivy → metadata=Postgres), `postgres:16-alpine` in
docker-compose, `jackc/pgx/v5` as the Go driver in `api` and the new
`alerting` service. No ORM, no `sqlc` — hand-written SQL per store
package, matching the project's existing avoidance of query-generation
machinery (no cobra, no golang-migrate, nothing code-generated from SQL
anywhere in the repo today).
### Migrations: mirror `/storage/migrate.sh`, not a framework
`storage/README.md`'s objection to `golang-migrate` ("premature machinery"
for what's being built) is general, not specific to ClickHouse's
HTTP-interface constraint — six new tables in one change doesn't meet the
bar that would justify revisiting that call. `/metadata/migrate.sh`
mirrors it: bash + `psql -v ON_ERROR_STOP=1 -f <file>` per migration, a
`schema_migrations` tracking table, one DDL object per file (kept for
repo-wide consistency of what a migration "version" means, even though
Postgres itself supports multi-statement transactions unlike ClickHouse's
HTTP interface), applied by a one-shot init container
(`metadata-migrate`) that other services `depends_on: condition:
service_completed_successfully` — same shape as `clickhouse-migrate`.
```
metadata/
README.md Dockerfile migrate.sh docker-compose.yml
migrations/
0001_create_dashboards.sql
0002_create_dashboard_panels.sql
0003_create_notification_targets.sql (alerting design doc)
0004_create_alert_rules.sql (alerting design doc)
0005_create_alert_state.sql (alerting design doc)
0006_create_delivery_log.sql (alerting design doc)
```
All six tables live in one migrations directory / one Postgres database,
even though `api` (dashboards) and `alerting` (rules/targets/state/log)
are the two services that own different tables within it — mirrors how
ClickHouse already hosts tables conceptually "owned" by different
components (`logs` via ingest's consumer, `schema_migrations` via the
migration runner itself) inside one physical database. No shared Go
store code between `api` and `alerting` — each owns hand-written SQL for
the tables it's responsible for; nothing is shared across service
`internal/` trees in this repo except `/proto`, and that precedent holds
here too.
## Schema
All tables carry `tenant_id TEXT NOT NULL DEFAULT 'default'` (populated,
unenforced — see "Not painting Phase 4 into a corner" below) and
`created_by TEXT NOT NULL DEFAULT 'anonymous'` for the same reason.
`updated_at` is app-managed on every UPDATE, no PL/pgSQL trigger —
consistent with the project's avoidance of database-side procedural code.
```sql
CREATE TABLE dashboards (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
default_earliest TEXT NOT NULL DEFAULT '-1h',
default_latest TEXT NOT NULL DEFAULT 'now',
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE dashboard_panels (
id UUID PRIMARY KEY,
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL, -- pipe syntax only, no earliest=/latest=
query_language TEXT NOT NULL DEFAULT '', -- mirrors queryRequest.Language: ''|sql|spl
viz_type TEXT NOT NULL CHECK (viz_type IN ('table','line','bar','single_stat','top_n')),
viz_config JSONB NOT NULL DEFAULT '{}', -- e.g. {"x_column":"timestamp","series_column":"host"}
position_x INT NOT NULL,
position_y INT NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
earliest_override TEXT NULL, -- NULL = inherit dashboard default
latest_override TEXT NULL,
sort_order INT NOT NULL DEFAULT 0, -- deterministic export ordering
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
`query_language` deliberately excludes `'sql'` from being usable in
practice for panels (enforced at the API layer, not the DB CHECK
constraint — see "Raw-SQL panels out of scope" below): the column exists
because it mirrors the existing `/query` request shape exactly, not
because raw SQL panels are supported yet.
`position_x/y/width/height` map 1:1 to the web UI's grid layout library's
own `x/y/w/h` units — no translation layer between stored position and
rendered position.
IDs are app-generated UUIDs (`google/uuid`, already an `api` dependency),
assigned server-side once, matching how `ingest` assigns `record_id`
not `gen_random_uuid()` via a Postgres extension, to keep ID generation
in one place (Go) rather than split between the app and the database.
## Time-range mechanics
A dashboard has a default time range (`default_earliest`/
`default_latest`, in the query language's own relative syntax, e.g.
`-1h`/`now`). Each panel can override it. Critically, **panel query
strings never contain `earliest=`/`latest=` themselves** — the query a
user writes for a panel is time-range-agnostic (e.g. `service=api |
where status>=500 | stats count by host`), and the effective time range
is resolved and injected at execution time:
```
effective_earliest = panel.earliest_override ?? dashboard.default_earliest
effective_latest = panel.latest_override ?? dashboard.default_latest
executed_query = "earliest={effective_earliest} latest={effective_latest} " + panel.query
```
This works because `earliest=`/`latest=` are ordinary `base_search` terms
in Phase 2's grammar (`term := ... | "earliest" "=" time_expr | "latest"
"=" time_expr`), implicitly AND'd with whatever else is in the query,
and — like every term in `bool_expr` — order-independent. Prepending them
is syntactically identical to a user having typed them first.
**`"now"` is a UI-only sentinel, never injected literally.** The default
`default_latest` value shown in the picker is the human-readable string
`"now"`, but `time_expr` only accepts a quoted absolute timestamp or a
`"-N unit"` relative offset — there is no `now` token in the grammar.
Injecting `latest=now` produces a real compile error (`expected a quoted
absolute timestamp or a relative offset`), found by actually running a
dashboard panel against the live stack, not a hypothetical. The fix:
when the effective latest value is `"now"` (or empty), the `latest=`
clause is omitted from the injected query entirely — which is exactly
what the query language already does to mean "no upper bound" (see
`planner.go`'s handling of an absent `latest` term). `earliest=` has no
equivalent sentinel; a dashboard's `default_earliest` is always a real
`time_expr` value.
**This injection only works for pipe-syntax queries.** A raw-SQL query
(Phase 2's `SELECT ...` escape hatch) has no equivalent injection point —
there's no reliable way to splice a time bound into arbitrary SQL without
parsing it, which is exactly the work the raw-SQL escape hatch was
designed to avoid (see `/docs/query-language-design.md`'s "not parsed,
wrapped as opaque IR"). **Raw-SQL dashboard panels are out of scope for
Phase 3** — a disclosed non-goal, not a silent gap. `dashboard_panels.
query_language` exists in the schema for symmetry with the `/query`
request shape, but the dashboard API layer (task 3) rejects `'sql'` on
create/update with a clear error rather than accepting it and having the
time-range picker silently do nothing.
## Panel execution: client-side, not server-side
`GET /dashboards/{id}` returns panel *definitions* only (query, viz_type,
position, overrides) — it does not execute any queries. The web UI
resolves each panel's effective time range and calls `POST /query`
per panel directly, exactly the same endpoint the root query page already
uses. This keeps `api/internal/dashboards` pure CRUD with zero
query-execution code of its own, consistent with the project's standing
rule that nothing duplicates `querylang`'s execution path (the same
reason `sentryctl query` calls `/query` over HTTP instead of importing
`querylang` internals). It also means panels load and error
independently in the UI — one broken panel query doesn't fail the whole
dashboard.
## Visualization types
- **`table`**: renders `{columns, rows}` directly via (an extended)
`ResultsTable.svelte`. No `viz_config` needed.
- **`line`** / **`bar`**: `viz_config` names which result column is the
x-axis/category (`x_column`, typically `timestamp` or a `stats ... by`
grouping column) and which is plotted (`series_column` for
multi-series, `value_column` for the plotted value). Rendered via
uPlot (see below).
- **`single_stat`**: takes the first row's first numeric column, no
`viz_config` needed for MVP (a labeled big number).
- **`top_n`**: a table sorted/limited view — for MVP this is really
"table, but the query already did `sort`/`head`," so it renders
through the same table path as `table` with different framing in the
UI; no separate execution logic.
### New frontend dependencies
`web/package.json` currently has zero runtime dependencies. Two are
added this phase, both confirmed with the project owner before landing:
- **gridstack.js** — panel grid layout with drag-and-drop/resize.
Vanilla JS, framework-agnostic (wrapped in a thin Svelte component),
well-established, used by real dashboard products. Pre-approved
("a lightweight grid library is fine, don't build drag-and-drop from
scratch") — listed here so the concrete choice is visible in the
design record, not just in a `package.json` diff.
- **uPlot** — line/bar chart rendering. ~45KB, canvas-based, fast, no
heavy transitive dependency tree. Chosen over Chart.js (heavier) and a
hand-rolled D3/SVG approach (correct axes/scales/tooltips is a lot of
code to own well for something a library already does correctly).
Table, single-stat, and top-N panels need neither dependency.
## Export / import
`GET /dashboards/{id}/export` returns the dashboard and its panels
marshaled as one JSON document (the same `Dashboard`/`Panel` Go structs
used internally, not a separate export-specific shape). `POST
/dashboards/import` accepts that same shape and creates a new dashboard
from it (new IDs assigned, not a literal replay of the source IDs, so
importing into a different environment doesn't collide). This is
deliberately the *same* JSON contract `sentryctl dashboards apply` (task
7) consumes — "exportable/importable, Terraform-friendly" isn't a
separate design, it's one JSON shape used from two call sites (web
export button, CLI apply).
## Not painting Phase 4 into a corner
- **`tenant_id`** on every table, populated but unenforced — Phase 4's
multi-tenancy retrofit needs a partitioning/enforcement layer, not a
schema migration + backfill on every table.
- **`created_by`** on every table, defaulted to `'anonymous'` since no
auth exists yet — same reasoning, cheaper to add the column now than
during Phase 4 when real identity is also landing.
- **"Shareable" currently means "shareable within the single trusted
deployment."** There is no access-control model at all yet — not
tenant isolation, not even within-tenant per-dashboard permissions.
Stating this plainly here so it doesn't read as more solved than it is
once multi-tenancy is on the table.
- CORS stays wide open (`Access-Control-Allow-Origin: *`) on `api`,
matching the existing Phase 02 posture. Adding dashboards doesn't
change this tradeoff, just widens the same already-accepted surface
area — noted so it's not mistaken for a new gap introduced this phase.
## Where this lives
`api/internal/dashboards/` (`handler.go`, `store.go`, `types.go`, plus
tests) — mirrors `api/internal/queryapi`'s existing `Handler`/`Store`
shape. Requires one small prerequisite refactor to existing code:
`queryapi.Handler.Routes()` currently builds its own `http.NewServeMux()`
and applies CORS in one shot; with a second handler package, both need to
register onto one shared mux in `main.go`, with CORS applied once at the
top. `queryapi.Handler` gains a `RegisterRoutes(mux *http.ServeMux)`
method in place of `Routes()`.
+260
View File
@@ -0,0 +1,260 @@
# Phase 3 runbook
Extends `/docs/phase-0-runbook.md` through `/docs/phase-2-runbook.md`
with dashboards and alerting. Read those first — this assumes the stack
already works. Phase 3 adds a new Postgres-backed control-plane
(`/metadata`), a new dashboard CRUD surface in `/api`, and a new
top-level service (`/alerting`) plus its web UI and CLI subcommands.
## What's actually been verified
Every claim below was checked against the live stack, not asserted —
same discipline as every prior phase's runbook. This phase's real
findings (five of them, not hypothetical) are called out inline where
they were caught, matching the project's standing "actually run it"
rule: a passing test suite and a working feature are not the same claim.
## 1. Bring up the stack
```sh
docker compose up -d --build
```
New services beyond Phase 02: `metadata-postgres`, `metadata-migrate`
(one-shot, applies `/metadata/migrations/*.sql`), `alerting` (port 8081).
`api` now depends on `metadata-migrate` and exposes a `-healthcheck`
self-check mode (distroless, no shell/wget — see `api/cmd/api/main.go`)
so `alerting`'s `depends_on: api: condition: service_healthy` means
something real. Confirm everything is healthy:
```sh
docker compose ps
# api, alerting, metadata-postgres, clickhouse, redpanda should all show (healthy)
```
## 2. Dashboards
Generate a fixture dataset (reuses Phase 2's `hack/benchmark-fixture`):
```sh
cd hack/benchmark-fixture
go run . --count 500000
```
Create a dashboard and a couple of panels, either through the web UI
(`http://localhost:3000/dashboards` → "+ Create" → "+ Add panel") or via
`sentryctl`:
```sh
sentryctl dashboards apply my-dashboard.json # shape = GET /dashboards/{id}/export
```
**Verified live**: a table panel (`severity=INFO | head 10`) and a bar
chart panel (`| stats count by host`, viz_type `bar`) both render
correctly against real data, including drag/resize via the gridstack
grid.
### Real bugs this caught
Building and actually loading the dashboard UI against a live stack
(not just unit tests) found four real bugs, all now fixed:
1. **A Phase 2 bug, only surfaced now**: `earliest=`/`latest=` time-range
queries never actually worked against live ClickHouse. The executor
formatted `TimeRange` bounds with `time.RFC3339Nano`
(`2026-08-12T20:17:40.223505479Z`), but ClickHouse's *implicit*
string→`DateTime64` cast for a column comparison is strict and wants
`'YYYY-MM-DD HH:MM:SS[.fractional]'` — no `T`/`Z`. Failed with `code:
53, Cannot convert string ... to type DateTime64(9, 'UTC')`. Nothing
in Phase 2's own runbook queries or unit tests happened to exercise a
relative time filter live; the unit test asserted the broken format
without ever asking real ClickHouse whether it was valid. Fixed in
`api/internal/querylang/executor/sql.go` (`formatClickHouseDateTime64`).
2. **`"now"` as a literal query token**: the dashboard time-range picker
injected `latest=now` verbatim; the query language has no `now`
keyword (only quoted absolute timestamps or `-N unit` relative
offsets). Fixed by treating `"now"` as a UI-only sentinel that omits
the `latest=` clause entirely (`web/src/lib/api.ts`'s
`injectTimeRange`).
3. **A layout-timing race**: GridStack/uPlot measured a panel's width
before the grid had finished laying out, producing a 74px-wide chart
canvas in a 540px container. Fixed with a `ResizeObserver` in
`PanelViz.svelte` that re-renders whenever the container's actual
size changes (also fixes chart sizing after a manual panel resize).
4. **`Date.parse` is too lenient to use as a "is this a timestamp"
check**: `Date.parse("host-06")` returns a real (bogus) timestamp
rather than `NaN`, which silently misrouted a categorical `stats
count by host` column onto a numeric time axis and rendered
unreadable giant tick labels instead of host names. Fixed by requiring
a strict ISO-8601 prefix match before attempting `Date.parse`
(`PanelViz.svelte`'s `isoTimestampPrefix`).
## 3. Alerting
Bring up a local webhook receiver for testing (no real Slack/PagerDuty
needed):
```sh
docker run -d --name sentry-webhook-sink --network sentry_default \
-p 9099:9099 -v $(pwd)/hack/webhook-sink:/src -w /src golang:1.25-alpine go run .
```
Create a notification target and a rule, either via the web UI
(`http://localhost:3000/alerts/new`) or directly:
```sh
curl -X POST http://localhost:8081/targets -H 'Content-Type: application/json' -d '{
"name": "local sink", "kind": "webhook", "webhook_url": "http://sentry-webhook-sink:9099/"
}'
curl -X POST http://localhost:8081/rules -H 'Content-Type: application/json' -d '{
"name": "always fires", "query": "SELECT 1", "query_language": "sql",
"condition_type": "threshold", "comparator": "gte", "threshold_value": 1,
"eval_interval_seconds": 30, "for_minutes": 0,
"notification_target_id": "<target id>"
}'
```
**Verified live, through both curl and the actual web UI** (created a
rule via the `/alerts/new` form, watched it transition in the browser):
the rule transitions `ok``firing` on its first evaluation
(`for_minutes: 0`), the delivery log shows `firing / sent / 200`, and
`docker logs sentry-webhook-sink` shows the real received payload.
Also verified live: a threshold rule whose query returns **zero rows**
records `last_eval_status: "error"` with the exact expected message
(`"threshold rule query returned 0 rows, want exactly 1"`) and leaves
`state` untouched at `"ok"` — confirming fix 3/4 from
`/docs/phase-3-alerting-design.md` hold in practice, not just in the unit
tests that were written against them.
### Real bugs this caught
5. **`enabled` silently defaulted to `false`.** `rulestore.Rule.Enabled`
is a plain `bool`; a create request that simply didn't mention
`"enabled"` decoded it to Go's zero value (`false`) rather than the
intended "enabled by default." A rule created this way was silently
dead on arrival — never picked up by the evaluator's claim query, no
error anywhere. Fixed in `alerting/internal/httpapi/handler.go` with a
`createRuleRequest` wrapper using `Enabled *bool`: `nil` (omitted)
means enabled; only an explicit `"enabled": false` creates a disabled
rule. Caught by actually creating a rule through the endpoint and
checking the response, not by inspection.
## 4. Load test (task 8)
```sh
cd hack/alert-load-test
go run . --rule-count 500 --eval-interval-seconds 60 --duration 3m30s
```
Seeds 500 rules via `alerting`'s real create API (not a direct DB
insert), each querying a different host's count over the last minute
against the 500,000-row fixture dataset from §2, with an unreachably
high threshold so rules stay `ok` — isolating evaluator/ClickHouse
scheduling throughput from delivery-worker load.
**First run, before a fix**: every one of 500 rules showed
`consecutive_errors > 0`. Root cause: the load-test tool's own query
(`host=host-01`, unquoted) hit a real, pre-existing Phase 2 lexer quirk —
an unquoted comparison value containing a literal `-` fails to parse
(`unexpected MINUS after query`). Fixed by quoting the value
(`host="host-01"`) in the load-test tool itself; not a bug worth chasing
in the query language for this runbook.
**Second run, after that fix — a real, significant finding**: zero
errors, but the observed inter-evaluation interval was a suspiciously
exact **125.0s** for every one of 320 observed intervals, against a
configured `eval_interval_seconds: 60`. Root cause: `evaluator.tick()`
passed `workerPoolSize` (default 20) as *both* the claim batch size and
the concurrency limit — two genuinely different concerns conflated into
one number. With 500 rules all due at nearly the same instant (created
within ~65ms of each other), each 5-second tick could claim only 20 of
them regardless of how many more were already due, so draining the full
backlog took 500 ÷ 20 = 25 ticks × 5s = **125s** — more than double the
configured interval. Fixed by separating `EVALUATOR_CLAIM_BATCH_SIZE`
(default 1000 — how many due rules one tick can pull off the queue) from
`EVALUATOR_WORKER_POOL_SIZE` (default 20 — bounded concurrent `/query`
calls within that batch); see `alerting/internal/config/config.go`.
**Third run, after the fix**:
| Metric | Value |
|---|---|
| Rules seeded | 500 |
| Rules with `consecutive_errors > 0` | 0 |
| Configured `eval_interval_seconds` | 60 |
| Observed interval — min | 59.9s |
| Observed interval — mean | 63.3s |
| Observed interval — p95 | 65.0s |
| Observed interval — max | 65.2s |
| Drift vs. configured (p95 configured) | 5.0s |
The remaining ~5s of "drift" is attributable to the load test's own
5-second poll granularity (an evaluation can only be observed to within
one poll interval), not to the evaluator falling behind — 500 rules at
60s intervals is comfortably within the evaluator's capacity once the
claim-batch/worker-pool conflation was fixed.
**Phase 4+ scaling paths, named but not solved here**: the claim query's
`SELECT ... FOR UPDATE SKIP LOCKED` design (see
`/docs/phase-3-alerting-design.md`'s fix 1) is specifically what makes
horizontal evaluator replicas safe to add later without a state-model
redesign — each replica's claim naturally excludes rows another replica
already claimed. Moving off a single-process ticker to a distributed
scheduler, and materially larger rule counts (10,000+), are both
explicitly out of scope for this phase.
## 5. Confirm `sentryctl`
```sh
sentryctl dashboards list
sentryctl dashboards apply exported-dashboard.json
sentryctl alerts list
sentryctl alerts apply rule.json
```
Both `dashboards` and `alerts` hit the exact same REST endpoints the web
UI does — `dashboards` against `/api` (`--api`), `alerts` against
`/alerting` (`--alerting-api`) — no separate CRUD logic to drift out of
sync.
## Tearing down
```sh
docker compose down -v # wipes the fixture dataset and all dashboards/rules
```
## Troubleshooting
**A relative `earliest=`/`latest=` query fails with `code: 53, Cannot
convert string ... to type DateTime64`.**
You've reverted or bypassed `formatClickHouseDateTime64` in
`api/internal/querylang/executor/sql.go` — ClickHouse's implicit
string→`DateTime64` cast needs `'YYYY-MM-DD HH:MM:SS[.fractional]'`, not
an ISO-8601/RFC3339 literal. See §2, bug 1 above.
**A dashboard chart panel renders with a tiny or zero-width canvas.**
Check that `PanelViz.svelte`'s `ResizeObserver` is still wired up — this
is the exact symptom of the gridstack/uPlot layout-timing race from §2,
bug 3.
**A rule created via the API never evaluates, but no error appears
anywhere.**
Check the response's `"enabled"` field — if the create request omitted
`"enabled"` entirely and the response shows `false`, the
`createRuleRequest` fix in `alerting/internal/httpapi/handler.go` has
regressed. See §3, bug 5.
**500+ rules at a short `eval_interval_seconds` fall behind the
configured interval.**
Check `EVALUATOR_CLAIM_BATCH_SIZE` hasn't been set equal to (or below)
`EVALUATOR_WORKER_POOL_SIZE` — that's the exact regression `hack/alert-
load-test` caught in §4. Claim batch size should stay well above the
worker pool size; the worker pool is what bounds concurrent `/query`
load, not the claim.
**`hack/alert-load-test` reports errors on every rule.**
Check the tool's generated query quotes any comparison value that might
contain a `-` (e.g. `host="host-01"`, not `host=host-01`) — an unquoted
value with a literal hyphen fails to parse. See §4's first-run finding.
+21
View File
@@ -209,6 +209,27 @@ ClickHouse-side text indexing, a different join strategy, or simply
raising `max_query_size` server-side with matching memory sizing) is
explicitly future work, out of scope for Phase 2.
### Post-Phase-2 fix: `earliest=`/`latest=` never actually worked against live ClickHouse
Found during Phase 3's dashboard time-range picker work (the first thing
to run a relative `earliest=`/`latest=` query against real ClickHouse
end-to-end — none of Phase 2's own runbook queries or unit tests
happened to exercise it): `executor/sql.go` formatted `TimeRange` bounds
with `time.RFC3339Nano` (e.g. `2026-08-12T20:17:40.223505479Z`), which
ClickHouse's *implicit* string→`DateTime64` cast for a column-vs-literal
comparison rejects outright — `code: 53, Cannot convert string ... to
type DateTime64(9, 'UTC')`. ClickHouse's implicit cast is strict and
wants `'YYYY-MM-DD HH:MM:SS[.fractional]'` (space-separated, no `T`/`Z`);
the lenient ISO-8601-accepting `parseDateTimeBestEffort` is a different,
explicitly-invoked function, not what a plain `WHERE timestamp >= '...'`
comparison uses. Fixed by `formatClickHouseDateTime64` in `sql.go`. The
Phase 2 unit test that covered this (`TestBuildSQLTimeRange`) only
asserted the generated SQL *string*, against a fake `SQLRunner` — it
never caught this because nothing in that test actually asked real
ClickHouse whether the SQL was valid. Left here as a pointed reminder of
why this project's "actually run it" discipline exists: a passing test
suite and a working feature are not the same claim.
## Where this lives: `api/internal/querylang/`
Not a new top-level component. This subsystem always executes in-process