Skip to content

refactor(alerting): unify alert compilation and evaluation - #278

Merged
Makisuo merged 7 commits into
mainfrom
refactor/unify-alert-evaluation
Jul 29, 2026
Merged

refactor(alerting): unify alert compilation and evaluation#278
Makisuo merged 7 commits into
mainfrom
refactor/unify-alert-evaluation

Conversation

@Makisuo

@Makisuo Makisuo commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Stages A, B1 and B2 of the alerting simplification plan. No DB migration; one v1 service-signature change and one new (tightened) authorization check on raw-SQL preview.

Why

Alerting has three superficially different rule kinds — built-in trace signals, the query builder, raw ClickHouse SQL — that almost already share a pipeline. The real problem is that the compiled plan was incomplete, so everything it didn't carry got re-implemented per call site.

What changed

1. One bucket producer (Stage A)

Four near-identical implementations of "lower a plan to observations" collapsed into one:

was now
computeEvaluateBuckets computeAlertBuckets — the single lowering, four arms (traces/logs/metrics/raw_sql)
makeQueryEngineEvaluate (same three blocks copy-pasted) thin: computeAlertBucketsreduceAlertBuckets
makeQueryEngineEvaluateSeries thin: computeAlertBuckets
makeQueryEngineEvaluateRawSql deleted

The traces blocks in the first two were byte-equivalent modulo request.query vs query; same for logs and metrics. Any per-source fix had to be made twice.

2. One evaluate entry point (Stage B2)

QueryEngineService.evaluateRawSql is gone. evaluate / evaluateSeries take an AlertEvaluateRequest carrying an AlertBucketSource discriminator, so all three rule kinds are the same request. AlertsService.evaluateRule loses its plan.kind === "raw_sql" branch — planEvaluateSource is now the only place a plan's kind is inspected on the evaluation path. Annotation, validation and bucket sizing moved into a shared prepareAlertEvaluation so the two entry points can't drift on what they accept.

3. One group-key vocabulary (Stage B2)

The engine emits a generic "all"; storage uses "__total__", which is public (v2 wire, alert_checks.GroupKey, the Electric-synced web collection) and cannot be renamed. evaluateRule is now the single translator and returns storage vocabulary only; every remaining literal is the new UNGROUPED_GROUP_KEY constant.

This fixes a live bug: previewRule emitted "all" for ungrouped rules while the scheduler stored "__total__", so the preview chart and the tracking chart keyed the same series differently.

4. Raw SQL stops being second-class

  • New $__timeGroup(col) macro → toStartOfInterval(col, INTERVAL <granularity> SECOND), so a raw alert can return one row per evaluation window. Without it the range collapses into one synthetic bucket that reduces to the same scalar — scheduler behavior is unchanged either way.
  • The raw-SQL guards (group count, key length, sample validity) moved into the shared arm, so they now cover preview too — closing a real gap.
  • Reject NUL in raw group keys. encodeEvalPoints uses NUL-delimited prefixes and relies on keys never containing NUL — true for CH-derived keys, not for arbitrary user SQL.
  • Raw SQL previews now work (they were rejected server-side despite the frontend offering the chart).

Reviewer notes — two things worth your attention

① The raw-preview rejection was load-bearing for authorization. Preview is an alerts:read endpoint; creating or testing a raw rule requires alerts:write + org-admin. So "Raw SQL alerts cannot be previewed" was, incidentally, the only thing stopping a read-only API key from executing arbitrary ClickHouse. Removing it without a gate would have been a privilege escalation.

Raw previews are now gated twice, because roles and scopes protect different callers:

  • scope, in the v2 route — API keys carry root roles by default (apiKeyDefaultRoles), so scope is what actually separates a read-only key.
  • org-admin role, in previewRule — covers the session/app path, where non-admin users exist.

The v2 test that asserted "cannot be previewed" is now a real scope test (403 for alerts:read, zero warehouse calls) plus a positive alerts:write case.

② A timezone bug that would have silently broken every unbucketed raw preview. The synthetic bucket was range.startTime, a Tinybird datetime (2026-01-01 00:00:00). Consumers key buckets with Date.parse, which reads that space-separated form as local time — so every point missed its bucket and the series came back entirely no-data. It now goes through normalizeBucket like every other source. Caught by the inverted preview test.

Two deliberate behavior deltas, both on rows that were already skipped:

  1. A raw row with value: null reports sampleCount: 0 (was 1), so hasData === sampleCount > 0 holds uniformly — the invariant the bucket codec assumes. Status stays skipped; only the reason string changes.
  2. A row with a non-null value and an explicit samples: 0 now reads as no-data, consistent with the spec sources.

Watch the metrics arm: composeMetricsGroupKey was called in both copies and must be called exactly once in the merged branch — a groupName || "all" slip would silently re-key every metrics-alert incident and orphan-resolve them. Group keys are asserted explicitly in the tests.

Signature change: AlertsService.previewRule takes roles as its second argument.

Verification

  • Alerting suite — 139 passed (AlertsService, QueryEngineEvaluateCache, AlertDeliveryDispatch, AiTriageService.alert, v2 alerts.http, alerts-error-map, alert-templating/renderer, run-raw-sql, AlertDestinationHydration)
  • @maple/query-engine 952 passed · @maple/domain 364 passed
  • typecheck clean across api, query-engine, domain, web
  • The pre-existing parity harness in QueryEngineEvaluateCache.test.ts passing unchanged was the gate for the Stage A refactor; 5 new tests assert raw SQL and a spec query produce byte-identical output for the same row shape.

CI notes — three pre-existing failures, all fixed

All three were red on main before this branch (main's run); none were caused by the alerting refactor. All checks now pass.

TypeScript. AlertRuleDocument.environments became required in 2a1869722 but four web fixtures were never updated, so decodeRuleDoc threw SchemaError: Missing key at ["environments"], taking 24 tests with it. (Separately, QueryEngineService.test.ts needed updating for the unified request — that one was mine, see the test commit.)

Service Map Perf. The failing assertion was cursor.longTasks === 0, not the render-work ratio. The long-task count is environmental on GitHub's GPU-less runners — the synchronized baseline itself swings 2 → 12 between attempts — while the cursor mode's blocking time stays at or below it (15/26/124ms vs 23/63/133ms) and the render reduction holds at ~62%. Adopts the split logs.perf.spec.ts already uses for exactly this reason: strict zero-long-task gate locally, a blocking ceiling on CI that only rejects order-of-magnitude regressions. The render-work ratio — what actually detects a sync-storm regression — is untouched.

Local archive native — a time bomb, not a flake. Both archive probes pinned RANGE_DATE="2026-06-29" for their fixtures while traces carries TTL toDate(Timestamp) + INTERVAL 30 DAY. On 2026-07-29 the fixture turned exactly 30 days old, so every row expired the moment it was written: the INSERT returned 200 and the table stayed empty.

The dates confirm it: the job passed every main run on 07-28 (fixture 29 days old) and failed every run from 07-29. That is also why bisecting was misleading — it pointed at 6a059d8e, a Slack-only commit, because the trigger was a calendar date rather than a change.

  • native-archive-merge-probe.sh failed outright (expected 2 parts, got 0).
  • native-archive-gc-probe.sh was silently archiving an empty table — it never asserted its rows existed, so it "passed" while testing nothing, and only surfaced once the merge probe stopped aborting the job first, as paths_csv: unbound variable.

Both now derive the date from date (yesterday — a completed day, well inside the window; GNU and BSD forms handled). The GC probe also initialises paths_csv so an empty archive reports a real assertion instead of crashing the shell. No hardcoded fixture dates remain in apps/cli/test/*.sh, so neither can age out again.

Deliberately not in this PR

From the plan, in order: explicit noDataBehavior (stop inferring it from signal type), pushing multi-service + excludeServiceNames into the compiled spec (needs an expectedGroupKeys left-join or multi-service down-detectors stop paging — the plan's highest-risk item), and deleting the metric signal type (a three-deploy data conversion).

🤖 Generated with Claude Code

Alert evaluation had four near-identical implementations of "lower a plan
to observations": computeEvaluateBuckets and makeQueryEngineEvaluate held
byte-equivalent traces/logs/metrics blocks, makeQueryEngineEvaluateSeries
was a third wrapper, and makeQueryEngineEvaluateRawSql a fourth copy of the
byGroup/reduce tail. Any per-source fix had to be made twice.

Collapse them into computeAlertBuckets (the single lowering, four arms:
traces/logs/metrics/raw_sql) plus reduceAlertBuckets (the single reduce
tail, including the no-data fallback). evaluate, evaluateSeries,
evaluateRawSql and the bucket-cache callback are now thin derivations.
Net -20 lines while adding a whole new raw-SQL bucket path.

Raw SQL stops being a second-class source:

- New $__timeGroup(col) macro expands to toStartOfInterval(col, INTERVAL
  <granularity> SECOND), so a raw alert can return one row per evaluation
  window. Without it the range collapses into one synthetic bucket, which
  reduces to exactly the same scalar as before — scheduler behavior is
  unchanged either way. This is what makes raw-SQL preview possible.
- The raw-SQL guards (group count, key length, sample validity) moved into
  the shared arm, so they now cover the preview path too.
- Reject NUL in raw group keys: encodeEvalPoints uses NUL-delimited
  prefixes and relies on keys never containing NUL, which holds for
  CH-derived keys but not for arbitrary user SQL.

Two deliberate deltas, both on rows that were already skipped:
a raw row with value: null now reports sampleCount 0 (was 1) so
`hasData === sampleCount > 0` holds uniformly — the invariant the bucket
codec assumes; only the skip reason string changes. A row with a non-null
value and an explicit samples: 0 now reads as no-data.

Verified against the existing parity harness unchanged
(QueryEngineEvaluateCache.test.ts), plus 5 new tests asserting raw SQL and
a spec query produce byte-identical output for the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit c9f8e0a · View workflow run

Stage B1/B2 of the alerting simplification. Builds on the unified bucket
producer: now the callers stop branching too.

**One evaluate entry point.** `QueryEngineService.evaluateRawSql` is gone.
`evaluate` and `evaluateSeries` take an `AlertEvaluateRequest` carrying an
`AlertBucketSource` discriminator, so a trace built-in, a query-builder
draft and user SQL are the same request. `AlertsService.evaluateRule` loses
its `plan.kind === "raw_sql"` branch entirely — `planEvaluateSource` is now
the only place a plan's kind is inspected on the evaluation path. Span
annotation, validation and bucket sizing moved into a shared
`prepareAlertEvaluation` so the two entry points can't drift on which
queries they accept.

**One group-key vocabulary.** The engine emits a generic "all"; storage
uses "__total__", which is public (v2 wire, alert_checks.GroupKey, the
Electric-synced web collection) and can't be renamed. `evaluateRule` is now
the single translator and returns storage vocabulary only, so the scheduler
no longer re-derives the key and every remaining literal is the new
`UNGROUPED_GROUP_KEY` constant from @maple/domain.

That fixes a live bug: `previewRule` emitted "all" for ungrouped rules while
the scheduler stored "__total__", so the preview chart and the tracking
chart keyed the same series differently.

**Raw SQL previews now work.** They were rejected server-side even though
the frontend offers the chart. Two things had to be right:

- The synthetic bucket for an unbucketed raw query is `range.startTime`, a
  Tinybird datetime. Consumers key buckets with `Date.parse`, which reads
  that space-separated form as LOCAL time — so every point missed its
  bucket and the whole series came back no-data. It now goes through
  `normalizeBucket` like every other source.
- That rejection was also, incidentally, the only thing stopping an
  `alerts:read` API key from executing arbitrary ClickHouse. Preview is an
  `alerts:read` endpoint; creating or testing a raw rule requires
  `alerts:write` + org-admin. Removing the rejection without a gate would
  have been a privilege escalation, so raw previews are now gated twice:
  by scope in the v2 route (API keys carry root roles, so scope is what
  separates a read-only key) and by org-admin role in `previewRule` (which
  covers the session path, where non-admin users exist).

Tests: the raw-preview rejection test inverts into a positive preview test;
the v2 test that asserted "cannot be previewed" becomes a real scope test
(403 for alerts:read, no warehouse call) plus a positive alerts:write case.

Verified: 139 alerting tests, @maple/query-engine 952, @maple/domain 364,
typecheck clean across api/query-engine/domain/web.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@Makisuo Makisuo changed the title refactor(alerting): one bucket producer for every alert source refactor(alerting): unify alert compilation and evaluation Jul 29, 2026
`AlertRuleDocument.environments` became a required field in 2a18697
("fix: some alert creation bugs"), but these four fixtures were never
updated, so `decodeRuleDoc` failed with `SchemaError: Missing key at
["environments"]` and took 24 tests with it.

Pre-existing on main and unrelated to this branch — the same TypeScript
job fails on main's own CI run. Fixed here because it blocks this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

`QueryEngineService.test.ts` drives `makeQueryEngineEvaluate` and the
now-removed `makeQueryEngineEvaluateRawSql` directly, and I missed it when
folding raw SQL into `evaluate` — CI caught it. The requests now carry an
`AlertBucketSource` (`{kind: "spec", query}` / `{kind: "raw_sql", sql,
windowMinutes}`) and the raw describe exercises the same `evaluate` as every
spec source, which is the whole point of the fold.

Typecheck did not catch this because these request literals are contextually
typed at the call site, so a stale `query:` key reads as an excess property
on a structurally-satisfied object rather than a missing `source`.

Also drops a stale `evaluateRawSql` stub from the telemetry route test's
QueryEngineServiceShape, and refreshes the CompiledAlertQueryPlan doc
comment that still pointed at the deleted method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

Both jobs are red on main; neither is caused by the alerting refactor. Fixed
here so the branch can go green.

**Service Map Perf** — the failing assertion is `cursor.longTasks === 0`, not
the render-work ratio. Across three retries the baseline (recharts) mode swung
2 → 10 → 12 long tasks while the cursor mode's blocking time stayed at or
below it (15/26/124ms vs 23/63/133ms) and the React render reduction held at
~62%. The long-task COUNT is environmental on GitHub's GPU-less runners, so
zero is unachievable there; the render-work ratio is what actually detects a
sync-storm regression.

Adopts the split logs.perf.spec.ts already uses for the same reason: strict
zero-long-task gate locally, a blocking-time ceiling on CI that only rejects
order-of-magnitude regressions (1s, ~8x the observed worst case). Applied to
service-detail.perf.spec.ts too — identical assertion, identical latent flake.

**Local archive native** — the probe read the part layout immediately after
two INSERTs and reported `0 parts`, i.e. an empty table. Zero rather than one
rules out a background merge and points at insert visibility: the endpoint
returns once the write is accepted, which is not when the part becomes visible
to SELECT. Confirmed flaky rather than commit-caused — it passed at 6c1e9ee
and first failed at 6a059d8, which only touches apps/slack-agent.

Polls until all 8 rows are queryable, then asserts the layout, so the check
tests parts rather than timing. The `== 2` assertion stays strict: a real merge
collapsing the two parts defeats the scenario this probe exists to cover and
must still fail loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

The probe hardcoded RANGE_DATE=2026-06-29 and inserted its 8 spans at that
timestamp. `traces` carries `TTL toDate(Timestamp) + INTERVAL 30 DAY`
(local-schema.sql), so once real time reached 2026-07-29 the fixture was
exactly 30 days old and every row expired the moment it was written: the
INSERT returned 200, the table stayed empty, and the part count read 0.

Not a flake — a time bomb. `Local archive native` passed every main run on
2026-07-28 (fixture 29 days old) and has failed every run since. My first
attempt at this misread the empty table as an insert-visibility race and added
a poll; that theory was wrong (the count stayed 0 for the full 10s window), so
the poll is reverted.

Derives the range date from today instead (yesterday — a completed day, well
inside the window), handling both GNU and BSD `date`. Keeps a cheap row-count
assertion before the part check so a future retention problem names itself
instead of surfacing as a confusing part count.

Note: apps/cli/test/native-archive-gc-probe.sh has the same hardcoded date and
is therefore archiving an empty table today. It still passes because it never
asserts its rows exist, so it is silently degraded rather than failing —
worth fixing separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

Same 30-day TTL time bomb as the merge probe: RANGE_DATE was pinned to
2026-06-29, so since 2026-07-29 every fixture row expired on write and each
generation archived an empty table.

This was masked — the merge probe failed first and aborted the job before the
GC step ran. With the merge probe fixed the job reaches this step, which fails
at `paths_csv: unbound variable`: with no shards on disk the collection loop
never runs, and `local paths_csv` left it undeclared for the `[ -n ... ]` under
`set -u`. I flagged this as "silently degraded, fix separately" one commit ago;
it is not separate, it is the same bug one step later.

Derives the date from today like the merge probe, and initialises paths_csv so
an empty archive reports a real assertion instead of crashing the shell.

No hardcoded fixture dates remain in apps/cli/test/*.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@Makisuo
Makisuo merged commit 563c242 into main Jul 29, 2026
13 checks passed
@Makisuo
Makisuo deleted the refactor/unify-alert-evaluation branch July 29, 2026 22:08
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