Files
cairnobs/docs/phase-3-runbook.md
jcoffey-dev 7a86008062 Complete the low-risk half of the Sentry -> Cairn OBS rebrand
Sweeps the references that carry no runtime coupling, and fixes one that
turned out to be a real bug rather than stale branding.

Docker network: sentry_default -> cairnobs_default across 23 runbook and
test-header `docker run` commands. Compose derives the network from the
directory name, so this lands together with renaming the working copy to
cairnobs/ -- the two are only correct as one change.

Stale references corrected: four Dockerfile "repo root (sentry/)"
headers; .env pointing at the long-renamed deploy/helm/sentry/ chart;
five Helm comments describing the topic as sentry.logs.raw when all four
code paths have defaulted to cairnobs.logs.raw for some time; an
absolute /home/john/Projects/sentry/ path in the operator's package doc,
now repo-relative; the hand-written Tenant CRD description in both of
its identical copies, whose Go source already said Cairn OBS.

Migration 0043 repoints the default tenant's data source. 0026 seeded it
with ('sentry', '/var/lib/sentry-search') to match what
api/internal/config then defaulted to; the rebrand later moved those
defaults to "cairnobs" and /var/lib/cairnobs-search without moving the
already-applied row, leaving the default tenant naming a ClickHouse
database nothing writes to. Scoped to the exact stale values so it is a
no-op on any deployment that set them deliberately. 0026's comment is
annotated as superseded; its applied SQL is untouched.

Deliberately not included: the gRPC wire packages (sentry.logs.v1,
sentry.agent.v1) and proto/sentry/ import paths, which cannot change
without a lockstep agent/server upgrade; the Helm chart's
sentry_metadata database and sentry role, which need a real Postgres
migration on existing deployments; and the compliance audit records in
docs/compliance/, which are a dated historical record.

go build, go vet, and go test pass for ingest and deploy/operator.
2026-08-22 18:39:25 -07:00

261 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
`cairnobsctl`:
```sh
cairnobsctl 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 cairnobs-webhook-sink --network cairnobs_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://cairnobs-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 cairnobs-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 `cairnobsctl`
```sh
cairnobsctl dashboards list
cairnobsctl dashboards apply exported-dashboard.json
cairnobsctl alerts list
cairnobsctl 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.