Skip to content

Deterministic anomaly detection means a versioned, unit-tested alert rule decides when production is unhealthy, and an AI agent only starts after that rule fires. The agent reads a frozen evidence bundle under a read-only identity and drafts a sourced diagnosis. It never decides whether an anomaly exists, and severity never grants it write access.

Your checkout error rate crept up for 40 minutes last Tuesday and nobody was paged, because the static “error rate above 5%” alert only fires on outages. The week before, the same alert paged twice at 03:00 for a service handling four requests a minute. Someone suggests letting an agent watch the dashboards instead. This page is for the developer who owns that alert and the tech lead who answers scorecard Q22 for the team: it shows how to make detection testable first, and where an agent adds value after that.

Scorecard question (Q22): How do you monitor production health and detect actionable anomalies?

Maximum-score answer: Version-controlled monitoring policy with tested thresholds, read-only diagnosis, evidence retention, and approval-tier routing

  • An SLO burn-rate alert rule for Prometheus, with a volume floor and a missing-telemetry alert, that you can adapt to your own service.
  • A promtool unit-test file that replays four windows (normal, spike, sparse traffic, missing data) and goes red when someone weakens the threshold.
  • A diagnosis run for Claude Code, Codex, and Cursor that reads only an exported evidence bundle and writes an intake record for a human to accept.
  • Three copy-paste prompts: draft rules and tests, diagnose a firing alert, and review a rule change.
  • A failure-mode list and a checklist that proves the control works without anyone rereading the rules.

The broader monitoring stack (metrics, tracing, dashboards) lives in monitoring and observability. Live incident response, including a read-only Grafana MCP setup, lives in scoped AI incident triage. This page covers the step between them: the rule that fires and the first diagnosis.

A detector has to give the same answer when you replay the same data. That property is what lets you unit-test it, review a change to it, and explain afterwards why it did or did not page. A model reading a dashboard gives a plausible answer that varies from run to run, so you cannot prove it would have caught last Tuesday’s incident. Split the work by what each part is good at:

LayerDecided byOutputHow you test it
DetectionA versioned rule evaluated by your monitoring systemFires or does not fireReplay unit tests in CI; same input, same decision
RoutingLabels on the rule (severity, risk_class) and the alert routerWho is paged, whether a diagnosis run startsRouter config tests, dry-run notifications
DiagnosisA read-only agent over a frozen evidence bundleObservations, hypotheses, missing evidenceEvery claim cites a source a second pass can re-open
ActionA named human, through the deploy, flag, or rollback systemMitigation, rollback, or a new intentThat system’s own audit log and approval gate

The model’s narrative may differ between two runs on the same alert. That is acceptable, because the narrative is analysis. The trigger decision is not allowed to differ.

Use the monitoring system you already run. The example below is a Prometheus rule file, because Prometheus rules have a first-party unit-test runner; Grafana Mimir and Thanos evaluate the same rule format. If you use Datadog, Grafana Cloud alerting, or CloudWatch, the same three ideas apply: keep the rule in the repository, attach routing labels to it, and replay history against it before merging.

The rule targets an SLO: 99.9% of checkout requests succeed over 30 days, so the error budget is 0.1%. It uses the multiwindow burn-rate pattern from the “Alerting on SLOs” chapter of Google’s SRE Workbook. A burn rate of 14.4 sustained for one hour spends 2% of a 30-day budget (14.4 × 1 h ÷ 720 h). The one-hour window stops a two-minute blip from paging; the five-minute window stops the alert from firing long after recovery.

monitoring/rules/checkout-slo.rules.yml
groups:
- name: checkout-slo
rules:
- record: checkout:error_ratio:rate5m
expr: sum(rate(http_requests_total{service="checkout",code=~"5.."}[5m])) / sum(rate(http_requests_total{service="checkout"}[5m]))
- record: checkout:error_ratio:rate1h
expr: sum(rate(http_requests_total{service="checkout",code=~"5.."}[1h])) / sum(rate(http_requests_total{service="checkout"}[1h]))
- record: checkout:requests:rate1h
expr: sum(rate(http_requests_total{service="checkout"}[1h]))
# SLO: 99.9% of checkout requests succeed over 30 days (error budget 0.1%).
# Page when the budget burns 14.4x too fast over 1h AND is still burning over 5m.
- alert: CheckoutErrorBudgetFastBurn
expr: |
checkout:error_ratio:rate1h > (14.4 * 0.001)
and checkout:error_ratio:rate5m > (14.4 * 0.001)
and checkout:requests:rate1h > 1
labels:
severity: page
risk_class: low
diagnosis: read-only
annotations:
summary: "Checkout is burning its 30-day error budget 14.4x too fast"
runbook: "docs/runbooks/checkout-errors.md"
# Silence is not health: page if the checkout series disappears.
- alert: CheckoutTelemetryMissing
expr: absent(http_requests_total{service="checkout"})
for: 10m
labels:
severity: page
risk_class: low
diagnosis: read-only
annotations:
summary: "No checkout request metrics for 10 minutes"
runbook: "docs/runbooks/telemetry-gap.md"

Three details in that file do the work a static threshold cannot:

  • The volume floor (checkout:requests:rate1h > 1, one request per second) stops a service with four requests a minute from paging on two failures.
  • The absent() alert catches the failure a ratio cannot see: when the exporter dies, the ratio has no data and the burn-rate alert stays silent.
  • The labels carry routing, not permissions. risk_class uses the low, medium, high, and critical classes from governance and autonomy; diagnosis: read-only tells the router to start a diagnosis run. No label mints a credential.

Pick the rule type from the shape of the signal instead of copying one ladder everywhere:

Signal shapeRule that fitsWhy not a plain σ threshold
Request success against an SLOMultiwindow burn ratePages on budget spent, which is what users feel
Strong daily or weekly cycleCompare with the same hour on previous weeks (offset 1w)A global mean flags every Monday morning
Sparse or bursty counts (payments per minute, sign-ups)Minimum-event floor plus absolute thresholdStandard deviation of a near-zero series is noise
LatencyPercentile threshold with a for: persistence clauseLatency is skewed; mean and σ hide the tail
Heartbeat, cron, or exporterabsent() or absent_over_time()No data produces no ratio at all

A rule you have not replayed is a guess. promtool test rules evaluates rules against synthetic series and compares the alerts that fire with the alerts you expect. The file below covers the four windows the scorecard asks about: normal, drift or spike, sparse, and missing data.

monitoring/rules/checkout-slo.test.yml
rule_files:
- checkout-slo.rules.yml
evaluation_interval: 1m
tests:
# Normal day: 0.05% errors at 100 req/s. Nothing fires.
- interval: 1m
input_series:
- series: 'http_requests_total{service="checkout",code="200"}'
values: '0+5997x120'
- series: 'http_requests_total{service="checkout",code="500"}'
values: '0+3x120'
alert_rule_test:
- eval_time: 90m
alertname: CheckoutErrorBudgetFastBurn
exp_alerts: []
# Spike: errors jump to 5% at minute 30. Fires once the 1h window catches up.
- interval: 1m
input_series:
- series: 'http_requests_total{service="checkout",code="200"}'
values: '0+5997x30 179910+5700x90'
- series: 'http_requests_total{service="checkout",code="500"}'
values: '0+3x30 90+300x90'
alert_rule_test:
- eval_time: 32m
alertname: CheckoutErrorBudgetFastBurn
exp_alerts: []
- eval_time: 60m
alertname: CheckoutErrorBudgetFastBurn
exp_alerts:
- exp_labels:
severity: page
risk_class: low
diagnosis: read-only
exp_annotations:
summary: "Checkout is burning its 30-day error budget 14.4x too fast"
runbook: "docs/runbooks/checkout-errors.md"
# Sparse traffic: 1 of 2 requests per minute fails. Below the volume floor, no page.
- interval: 1m
input_series:
- series: 'http_requests_total{service="checkout",code="200"}'
values: '0+1x120'
- series: 'http_requests_total{service="checkout",code="500"}'
values: '0+1x120'
alert_rule_test:
- eval_time: 90m
alertname: CheckoutErrorBudgetFastBurn
exp_alerts: []
# Missing data: the series never arrives. The gap alert fires after 10 minutes.
- interval: 1m
input_series: []
alert_rule_test:
- eval_time: 12m
alertname: CheckoutTelemetryMissing
exp_alerts:
- exp_labels:
service: checkout
severity: page
risk_class: low
diagnosis: read-only
exp_annotations:
summary: "No checkout request metrics for 10 minutes"
runbook: "docs/runbooks/telemetry-gap.md"

Run both checks in CI on every change under monitoring/ (terminal or CI job, promtool 3.15.0):

Terminal window
promtool check rules monitoring/rules/*.rules.yml
cd monitoring/rules && promtool test rules --junit=promtool-junit.xml *.test.yml

Both files above pass on promtool 3.15.0. To confirm the tests can fail, change 14.4 to 144 in the rule and run them again: the spike test goes red. Two details are easy to miss. absent() copies the equality matchers from its selector into the alert, so the missing-data alert carries service: checkout and the test must expect it. And the spike does not page at minute 32, because two minutes of errors barely move the one-hour ratio. That delay is the price of not paging on blips; decide it deliberately.

Let the agent draft rules and tests, not decide them

Section titled “Let the agent draft rules and tests, not decide them”

Writing burn-rate arithmetic and synthetic series by hand is where an agent saves real time. The workflow is the same in Claude Code, Codex, and Cursor: open the repository, paste the prompt, and let the agent run promtool until the tests pass. The human review then reads the test cases and the thresholds, not the PromQL line by line.

Treat the result like any other agent pull request: the tests are the evidence, and the reviewer checks that they encode the behaviour the team wants. See reviewing an agent’s pull request without reading every line for that review.

Route a firing alert to a read-only diagnosis run

Section titled “Route a firing alert to a read-only diagnosis run”

When CheckoutErrorBudgetFastBurn fires, the router pages a human and, because of the diagnosis: read-only label, starts a diagnosis run in parallel. The run never queries production itself. A small script exports a frozen evidence bundle first, so the agent works on the same data a human would audit later.

  1. Send the alert to a webhook receiver in your alert router (for Prometheus, an Alertmanager route that matches diagnosis="read-only"). The receiver saves the payload as alert.json in a fresh directory named after the alert fingerprint.

  2. Export the evidence with read-only credentials the runner holds, not the agent. For example, 60 minutes before and 30 minutes after the start of the alert:

    Terminal window
    promtool query --http.config.file=/srv/diagnosis/prom-readonly.yml \
    range --start="$START" --end="$END" --step=1m \
    "$PROM_URL" 'checkout:error_ratio:rate5m' > "$EVIDENCE_DIR/error_ratio_5m.txt"

    prom-readonly.yml is a promtool HTTP client configuration that holds the read-only bearer token (an authorization block with a credentials_file), so the token never appears on the command line or in the evidence directory.

    Add the deploy events for the same window from your deploy system, and error-level logs with secrets and personal data masked at the source.

  3. Run the agent over that directory with no write access: Claude Code gets only Read, Grep, and Glob; Codex gets a shell inside the read-only sandbox; Cursor gets a VM that holds only the evidence bundle. The tabs below show each run. It produces one intake file, intake.md.

  4. Post the intake file to the incident channel through your existing bot. A person accepts, rejects, or escalates it; any rollback, flag change, or code fix goes through its own gate.

  5. Store alert.json, the evidence files, the intake file, and the run log with the incident record for the retention period your incident policy sets.

Run from the evidence directory (terminal or CI runner; checked on Claude Code 2.1.283 (latest) and 2.1.274 (stable)):

Terminal window
cd "$EVIDENCE_DIR"
claude -p "$(cat /srv/diagnosis/diagnose-prompt.md)" \
--bare --restricted --setting-sources "" --strict-mcp-config \
--tools "Read,Grep,Glob" \
--allowedTools "Read,Grep,Glob" \
--permission-mode dontAsk \
--output-format json \
--max-budget-usd 2 > "$OUT_DIR/run.json" \
&& jq -r .result "$OUT_DIR/run.json" > "$OUT_DIR/intake.md"

--tools "Read,Grep,Glob" makes those three the only built-in tools, so there is no Bash, Edit, or Write, and --allowedTools pre-approves them explicitly. --restricted confines Read, Grep, and Glob to the working directory, so the agent can read the evidence directory and nothing else on the runner: no Codex auth, no cloud credentials in $HOME. Run the runner as a user that cannot read the service keys anyway, because a prompt injection in the logs can ask for any file it can reach, and step 4 posts the intake file to a channel. --bare skips hooks, plugins, and CLAUDE.md discovery, and authenticates with ANTHROPIC_API_KEY (or an apiKeyHelper passed via --settings), never OAuth or the keychain: give the runner a key owned by the incident service. --setting-sources "" loads no user, project, or local settings files, and --strict-mcp-config without --mcp-config starts no MCP servers, so nothing in the evidence directory or on the runner can add tools or permissions. dontAsk denies anything outside the allowed set instead of waiting for an approval nobody will give. run.json is the JSON envelope (result, cost, session ID) and serves as the run log that step 5 retains; jq extracts the Markdown record into intake.md.

Save this prompt as /srv/diagnosis/diagnose-prompt.md. It works unchanged in all three tools.

For a live session in which the agent queries Grafana, Sentry, or Datadog through MCP during the incident, use the read-only MCP configuration in scoped AI incident triage and the servers listed under observability MCP servers.

Rules drift. Someone raises a threshold to stop a noisy page, and six weeks later a real regression goes unnoticed. Run this prompt on every pull request that touches monitoring/, and require the owning team’s approval as well.

Severity changes who is paged. It never changes what the diagnosis identity can touch.

StepAgent’s roleDecided byRecorded in
Rule firesNoneThe versioned ruleMonitoring system, alert history
Page and start diagnosisNoneAlert router, from rule labelsRouter log
DiagnoseDrafts the intake record—Intake file plus evidence bundle
Accept, reject, or escalate the intakeNoneOn-call engineer, or a declared policy for the low risk class onlyIncident tool
Roll back, flip a flag, ship a fixNames the runbookA named human through that system’s gateDeploy or flag audit log
Turn it into new workDrafts intent.mdTriage ownerBacklog or repository

For the gates on the last two rows, see production approval as a system boundary and rehearsed rollback with human production authority.

Pages are still noisy after the rewrite. An agent summary does not fix a bad detector. Pull the last 30 days of pages, replay each firing window in a new test case, and tune the floor or window until the unwanted pages stop firing in the test before you change production.

A real incident did not page. Add the incident’s window to the test file as a case that must fire, watch it fail, then change the rule. That case now protects you from the next person who raises the threshold.

The metric disappeared and everything looked green. Every ratio-based alert needs an absent() companion. If the exporter is shared by several services, alert on each service’s series, not on the exporter as a whole.

Logs carry prompt injection. A log line or ticket body tells the agent to “silence this alert”. The diagnosis identity has no tool that can do it, and the prompt tells the agent to quote such text. Add a planted instruction to a test evidence bundle and check that the intake record quotes it and changes nothing.

Severity was wired to write access. Remove the coupling: a severity: critical label may page more people, but it must never start a run with deploy or database credentials.

The agent names a root cause from correlation. A deploy two minutes before the spike is a hypothesis, not a cause. Reject intake records that skip the counter-evidence or missing-evidence sections, and keep the audit prompt from scoped AI incident triage for high-risk incidents.

  • Every alert rule lives in the repository and changes only through a reviewed pull request.
  • promtool test rules (or your platform’s equivalent) runs in CI with normal, spike, sparse, and missing-data cases for each rule.
  • A deliberate threshold mutation makes at least one test fail.
  • Replaying a historical incident window produces the same fire or no-fire decision, with no model involved.
  • The diagnosis run cannot write outside its output file, can read only the evidence bundle, and holds no deploy or production credentials, and its identity shows up separately in audit logs.
  • Alert payload, evidence bundle, intake record, and run log are retained with the incident.
  • A dry run with a planted instruction in the logs changes nothing outside the intake file.
  • Accepted intake records re-enter the lifecycle through triage, not as direct commits.

Route accepted intake records to the Maintain stage, and measure detection and recovery with lifecycle metrics. To observe the agents themselves (tool calls, cost, audit trails), see agent observability.

Frequently asked questions

What is the role of an AI agent in deterministic anomaly detection?

A versioned, unit-tested alert rule decides when production is unhealthy, and the AI agent starts only after that rule fires to read a frozen evidence bundle under a read-only identity and draft a sourced diagnosis.

Does alert severity ever grant an anomaly-diagnosis agent write access to production?

No, the agent never decides whether an anomaly exists and alert severity never grants it write access or remediation authority in production.