The preview→save gateway: validates and normalizes a candidate rule spec and, when valid, backtests how often it WOULD have fired over the last N days (default 7). READ: never persists anything, never throws for an invalid spec.
Call this before save_alert_rule to calibrate. IMPORTANT: check backtest.dataCoverage
first, before reading totalWouldFire — dataCoverage.status = NO_MATCHING_DATA means the
filter matched zero rows over the whole window (likely a typo'd field or wrong value in
the filter), NOT a calibrated threshold; fix the filter, don't touch the threshold. Only
when status = EVALUATED (rows were matched) does totalWouldFire being 0 suggest the
threshold may be too high — if it fires every window, too low. Fix any entries in
problems[] before saving — save_alert_rule re-runs this exact validation and will reject
the same way.
Two modes — supply EITHER the structured measurement fields OR fromQuerySql (a raw
QuerySQL SELECT parsed into a measurement draft, e.g. for "alert on this query"); when
fromQuerySql is set the structured fields are ignored.
groupBy takes plain field names only, e.g. service. To group by a computed value, pass
derivedGroupBy entries instead, each an object {"expression": "...", "label": "..."}, e.g.
{"expression": "regexp_extract(message, 'customerId=([0-9a-f-]+)', 1)", "label": "customer"}.
Both fields are required and non-blank, and the label is what rule pages and alert titles show,
so give it a short readable name. A groupBy string that looks like an expression (it contains a
parenthesis or a space) is rejected in problems[], because the rule page and the alert title
would otherwise show the raw expression as the group name. groupBy and derivedGroupBy can be
used together.
Static condition: comparator + warningThreshold (+ optional criticalThreshold escalation).
Anomaly condition: zScoreThreshold + direction instead of comparator/warningThreshold;
groupBy must be empty. Anomaly rules get a backtest too, with the same fields. Its
would-fire counts are approximate: the backtest scores each window against the whole
lookback, while the live rule compares against the same time of day 7, 14 and 21 days
back. Both read a window with no data as 0 for counts, sums and counter rates, and leave it
out for latencies, averages, minimums, maximums, error rates and burn rates.
Returns normalizedSpec (best-effort echo of the compiled spec), problems[] (empty when
valid), backtest (would-fire counts, per-series observed values, and dataCoverage —
rowsMatched/firstEventAt/lastEventAt/status over the backtest window — only when valid),
seasonality (a 0-1 daily-periodicity score of the backtest's primary series plus a
suggestedMode of ANOMALY/THRESHOLD/UNKNOWN — a data-driven nudge on baseline vs fixed-threshold
rules; null when there was no backtest), and warnings[] (calibration hints, NEVER a
reason to withhold saving — unlike problems[], a non-empty warnings[] still saves fine).
warnings[] currently carries one code, FIELD_NEVER_OBSERVED: a filter/groupBy field that
querysql couldn't resolve to a known column (so it silently reads from the JSON catch-all)
and that has never appeared in this customer's recent telemetry. FIELD_NEVER_OBSERVED
together with dataCoverage.status = NO_MATCHING_DATA is a strong signal of a typo'd field
name — fix the spelling and re-preview rather than loosening the threshold.