learn-ai-observability-with-phoebe / Builder session 8 of 10
Learn AI Observability with Phoebe · Builder track · Session 8 of 10

Alerting and SLOs: burn rate, not thresholds

You now emit spans, attributes and metrics on a sampled stream. This session decides who gets woken up. The short version: almost every threshold you would reach for first is wrong on an LLM system, and it is wrong for five reasons that are specific to LLMs plus four that Google SRE wrote down for every system ever built. Burn rate fixes the four. Segmentation and assertion-verified success fix the five.

🟠 Builder track AI engineers · platform engineers · technical PMs 45 min
0-3 · Welcome 3-20 · Why thresholds misfire 20-40 · Build-along: the rule, then the inventory 40-45 · Q&A
Part 0

The alert is the only part of observability that costs someone their sleep

Everything before this session was about being able to answer a question. This session is about deciding which questions are urgent enough to ask a human at 03:00. That is a different discipline, and it is the one where a badly built observability stack does active harm rather than merely failing to help: a channel that fires 200 times a week gets muted, and a muted channel is worse than no channel because it looks like coverage.

Live - presented in session Self-study - read after class Official sources covered
★ What you walk out with today The five reasons a static threshold misfires specifically on an LLM system, Google SRE's four generic windowing failures with the arithmetic that proves them, the multi-window burn-rate table for a 99.9% SLO and why the alert needs both windows, one working burn-rate rule you can paste, and a fourteen-row alert inventory that says page, ticket or neither for every signal this course has taught you to emit.
One sentence for this session Evals tell you whether it works. Observability tells you what actually happened. An alert is the narrow subset of "what actually happened" that is worth interrupting a person for, and burn rate is how you decide the width of that subset using arithmetic rather than taste.
Part 1 · the LLM-specific problem

Five reasons a static threshold misfires here 6 min live

Classic APM alerting assumes three things that an LLM endpoint quietly violates: that a status code tells you whether the request succeeded, that a metric is meaningful for one request, and that a single endpoint produces one distribution. All three are false, and each falsehood breaks a different threshold.

Green is not success a 200 can be empty An HTTP 200 can carry an empty response, a refusal, a truncation or a confident fabrication. Alert on assertion-verified task success, not on the status code. Noisy per request 1000 in, 80 outputs 1000 identical prompts at temperature 0 produced 80 distinct completions. Any per-request quality threshold flaps. Alert quality as a rate over a sample. Multi-modal by design cache hit vs miss Cache hit vs miss and 1-step vs 12-step are different distributions living in one endpoint. A single p95 either screams or hides a mode. Segment first. Ground truth moves no deploy needed The provider changes under you with no deploy of yours. The newer tokenizer makes about 30% more tokens for the same text, so token limits shift. See b9. Bursts trip quota token bucket A rate of 60 RPM may be enforced as 1 request per second, and acceleration limits reject a ramp. Average utilisation shows headroom while you are throttled. Static absolute thresholds survive for hard safety rails only: max steps, max spend.
🔍 Click to zoom - the five failures that are specific to an LLM endpoint
LiveSuccess is not a status code, and this is the one that matters most4 min▶

Everything else on this page is a tuning problem. This one is a category error, and it is the reason the Beacon incident held a green dashboard for a full working day. Four documented outcomes arrive as HTTP 200 with no error field set:

  • The empty response. A two or three token response with stop_reason: "end_turn". Documented as happening particularly after tool results. Nothing errors, nothing is logged, and the response has no content in it.
  • The refusal. stop_reason: "refusal" with stop_details, returned as HTTP 200 and not as an error. If refusal rate is not its own SLI, a policy change that starts refusing a whole customer segment is invisible to you.
  • The truncation. stop_reason: "max_tokens" and "model_context_window_exceeded". Treat both as truncated. The answer is well formed right up to the point where it stops mid-sentence.
  • The confident fabrication. No signal at all in the response object. The only telemetry that catches it is an assertion against a source of truth, and almost nobody instruments that.

So the first SLI you define is not availability and it is not latency. It is task success, verified by an assertion on the output, and it is the one you attach the fast-burn page to. Everything else on the inventory is supporting evidence.

Self-studyWhy segmenting before thresholding is not optional3 min read▶

One endpoint, four legitimate populations: cache hit and cache miss, single-step and twelve-step. A cache read is priced at 0.1x base input and returns almost immediately; a 1-hour cache write is 2x. A twelve-step agent run can take two orders of magnitude longer than a one-step answer, which is why the spec's own gen_ai.invoke_workflow.duration histogram has a bucket at 7200 seconds.

Mixing those into one p95 gives you a number that is not a property of any real request. Two consequences, both of which you have probably lived through:

  • Set the threshold high enough to stop paging on legitimate long runs, and a total collapse of the fast cached path stays under it forever.
  • Set it where the cached path lives, and every long run pages you, so within a fortnight somebody widens the limit until it stops firing. That is the documented human response to a noisy alarm and it is how a monitored system becomes an unmonitored one.

The fix is boring and it works: cut every latency and cost SLO by gen_ai.request.model, by cache-hit state, and by a coarse step-count bucket, then threshold inside each segment. The spec hands you the segmentation keys for free because they are the low-cardinality sampling-time attributes from b7.

Part 2 · the generic problem

Google SRE's four failure modes, with the arithmetic 6 min live

Before LLMs there was already a written proof that "alert when the error rate exceeds X for Y minutes" cannot be tuned into correctness. It fails on four axes at once, and every attempt to fix one of them makes another worse. This is the argument that produced burn-rate alerting, and the numbers below are the primary source's own.

Google SRE's four ways a simple threshold fails, with the arithmetic Precision 0.1% errors for 10 minutes burns 0.02% of the month and it still pages you. Almost everything that fires is not worth waking anyone for. Recall 100% spikes, 5 min in every 10 burn 35% of the budget and never trigger the alert at all. The worst outage of the month goes entirely unreported. Detection time a 100% and a 0.2% outage are detected at the same time A window long enough to stay quiet is also far too slow for the outage that actually matters. Reset time a long window keeps firing after the incident is resolved so the page you silence at 03:00 is the page you needed at 03:20. This is how muting starts. Every one of these is a windowing bug, not a threshold-value bug. Tuning cannot fix it.
🔍 Click to zoom - the four axes a single-window threshold fails on at once
Read the recall row again A series of 100% error spikes lasting 5 minutes every 10 minutes never triggers an alert, despite consuming 35% of the error budget. That is the primary source's own example. Half of every ten minutes is a total outage, a third of your month's budget is gone, and the pager is silent, because no averaging window you chose ever sees a sustained breach. If you take one thing from this part: the metric that is wrong is not the error rate, it is the window.
Part 3 · the fix

Multi-window burn rate, and why both windows matter 7 min live

Burn rate reframes the question. Instead of "is the error rate high", you ask "how fast am I spending this month's error budget, relative to the rate that would exactly exhaust it". Burn rate 1 means you finish the budget exactly at the end of the month. Burn rate 14.4 means you finish it in about two days. That single change of denominator fixes precision, recall and detection time, and the second window fixes reset time.

One 99.9% SLO, threshold burn rate 14.4, and the AND between two windows budget rate = 0.1% of requests · burn rate = observed error rate divided by 0.1% A brief 0.1% blip ten minutes long 1h burn 0.17x 5m burn 1.0x AND No page. Neither window is near 14.4, and the event cost 0.02% of the month. A naive rule would have paged. Sustained 2% errors still happening now 1h burn 20x 5m burn 20x AND PAGE. Both windows are over 14.4, and at the moment it fires you have spent about 2% of the monthly budget. Same incident, fixed deploy landed at 03:20 1h burn 18x 5m burn 0x AND Alert clears in five minutes, not in an hour. The short window is why nobody reaches for mute. The long window earns the page. The short window is what lets you stop paging.
🔍 Click to zoom - burn rate, and the AND that makes the page trustworthy

The canonical table below is the primary source's own, for a 99.9% SLO. Three rules, two of them pages and one a ticket, each pairing a long window with a short window one twelfth its length. The "budget consumed" column is the honest cost of detection: by the time the fast rule fires you have already spent 2% of the month.

SeverityLong windowShort windowBurn rateBudget consumed when it fires
Page1 hour5 min14.42%
Page6 hours30 min65%
Ticket3 days6 hours110%
The short window is not a second opinion, it is an off switch Both windows must exceed the threshold for the rule to fire. The long window is what makes the page meaningful: a sustained 14.4x burn is genuinely worth someone's night. The short window exists so that the alert stops within five minutes of the system recovering instead of trailing the incident for an hour. Reset time is the failure mode that teaches engineers to distrust the pager, so it is the one worth spending a whole window on.
Demo 1 of 2

Write the rule that would have paged at 09:14 ★ 10 min · everyone builds

Beacon's Insight Bot answered every request at 09:14 with a green 200 and a confident summary of nineteen-hour-old data. No availability alert could fire, because availability was perfect. Build the one rule that could have fired, on the one SLI that was actually broken.

Define the SLI as an assertion, not a status. For Insight Bot the assertion is cheap and specific: the answer cites at least one retrieved document, and the freshest cited document is under two hours old. Emit that as a boolean per task. Note what you just did: you turned "the answer was right" into something a counter can hold.

Pick the SLO and write down what the budget buys. 99.9% task success over 30 days allows 0.1% of tasks to fail. Beacon serves roughly 40,000 executive queries a month, so the budget is about 40 failed tasks. Say the number out loud in the SLO review: an SLO nobody can convert into a count of real failures never survives its first argument with a stakeholder.

Precompute the ratio at two window lengths. Do not compute burn rate inside the alert expression from raw counters; you want ratio_rate5m and ratio_rate1h as recording rules so that the alert reads like the table and so both windows come from the same definition.

Write the fast page as an AND of both windows against 14.4x the budget rate, then clone it for 6x over 6h/30m and 1x over 3d/6h as a ticket. Three rules, one SLI. Resist the urge to add a fourth.

Now test the recall case deliberately. Replay a load pattern of 100% failures for five minutes in every ten and confirm the burn-rate rule fires while a "5% errors for 10 minutes" threshold rule stays silent. That test is the whole argument for this session and it takes about ten minutes to build.

★ Fast-burn page · 99.9% task-success SLO · Beacon Insight Bot # budget rate for a 99.9% SLO = 0.001 # fast page: BOTH windows over 14.4x budget burn - alert: InsightBotTaskSuccessFastBurn expr: | (1 - task_success:ratio_rate1h{svc="insight_bot"}) > (14.4 * 0.001) and (1 - task_success:ratio_rate5m{svc="insight_bot"}) > (14.4 * 0.001) labels: { severity: page } annotations: summary: "burning 14.4x budget: 2% of the month already spent" # slow page: 6x over 6h AND 30m -> severity page # ticket: 1x over 3d AND 6h -> severity ticket, no pager
The trap in step 1, and it is worth being blunt about Almost every team defines the SLI as availability first, because availability is the metric the platform hands you for free. Availability was 100% throughout the Beacon incident. If your alerting stack contains no assertion on output, then no amount of burn-rate arithmetic helps you: you are computing a beautifully windowed rate of a thing that never went wrong.
Demo 2 of 2

Build the alert inventory for Insight Bot ★ 12 min · everyone builds

One rule is not an alerting strategy. The inventory below is the full set this course has taught you to emit, with a decision attached to each row: page, ticket, or neither. Copy it into a document, delete the rows you cannot currently compute, and the remainder is your instrumentation backlog with an urgency already attached.

Fill the third column honestly before you argue about thresholds. A row you cannot compute today is not an alert, it is a ticket in b2 to b7 territory. Most teams find they can compute five of fourteen.

Mark every page-severity row with a burn-rate pair and every ticket row with a plain window. The separation is the point: a page is a claim that a human must act within minutes, and there should be very few of them.

Segment the latency and cost rows before you set any number, using gen_ai.request.model, cache-hit state and a coarse step bucket. If a row cannot be segmented, downgrade it to a ticket rather than shipping a p95 that means nothing.

Write the policy document. One page per surface: what fires, who it wakes, what they are expected to do, and when it was last reviewed. The alarm-fatigue literature is unambiguous that the fix is a written, reviewed configuration policy assessed for completeness and relevance, not a cleverer threshold.

What you alert onSignal you compute it fromPage, ticket or neither
Task success rateYour own assertion on the output. Never HTTP statusPage · burn rate, 3 rules
Error rate, 429 split outerror.type plus status class. Split because a 429 has a completely different fix from a 500Page · two separate rules
Rate-limit headroomThe anthropic-ratelimit-* header family. Alert on -remaining trending to zero, not on the 429 itselfTicket, then page at zero
TTFTgen_ai.client.operation.time_to_first_chunk, gen_ai.server.time_to_first_tokenPage · its own SLO
Total latencygen_ai.client.operation.duration. A separate SLO from TTFT because prefill and decode move for different reasonsPage · its own SLO
Cost per hourThe token quartet plus model price. The only rule that catches a runaway spend inside the hourPage · hard absolute cap
Cost per successful taskMean attempt cost divided by success rate. Moves the right way when you fix reliabilityTicket · weekly review
Refusal ratestop_reason: "refusal". Arrives as HTTP 200, so it is invisible to every error-rate rule you ownPage · burn rate, segmented
Guardrail trip rateGuardrail span or event count over request countTicket · page only on a step change
Truncation ratestop_reason of max_tokens or model_context_window_exceeded. Treat both as truncatedTicket · leading indicator
Eval-score drift on sampled trafficgen_ai.evaluation.result with gen_ai.evaluation.score.valueTicket, never a page. The sample is small and noisy
Loop and step-count distributiongen_ai.invoke_agent.inference_calls and .tool_calls. Buckets reach 128 precisely so a runaway is visible without opening a tracePage on the tail crossing a hard cap
Cache hit rateShare of gen_ai.usage.cache_read.input_tokens. Watch for a step change: a drop means a cache-invalidating change shippedPage on step change · it is a cost incident
Instrumentation coverageTraces missing an expected span type, and unaccounted time as a share of root durationTicket · its own incident class
gen_ai.response.model vs requestedBoth fields on the same span. A mismatch rate above zero means a floating alias moved under youPage on mismatch rate · see b9
Where static absolute thresholds do belong Burn rate is for quality-of-service objectives. It is the wrong tool for a hard safety rail, where the number is not negotiable and there is no budget to burn. Keep exactly three of those, as absolute caps that abort the run rather than notify anyone: max steps per task, max tokens per task, max spend per hour. They are not really alerts, they are circuit breakers with a notification attached, and they are the only place in this session where a fixed number is the right answer.
Homework

Try it yourself - this week ◐ 50-60 min total

Source material

Official sources covered

Primary sources are marked [P] and secondary [S]. The burn-rate table and the four failure modes are quoted from the Google SRE workbook; the metric names and buckets come from the OpenTelemetry GenAI semantic conventions, which are all Development status with no tagged release, so re-verify the spellings before you build on them.

✓[P] Google SRE multi-window burn-rate table for a 99.9% SLO: 14.4 / 6 / 1Part 3 · the table and the AND rule, verbatim structure
✓[P] SRE's four generic failure modes with their arithmetic, including the 35%-of-budget recall examplePart 2 · all four numbers are the source's own
✓[P] Non-determinism: 1000 completions at temperature 0 produced 80 unique outputsPart 1 reason 2 · why per-request quality thresholds flap
✓[P] Token-bucket enforcement and acceleration limits: 60 RPM enforced as about 1 request per secondPart 1 reason 5 · average utilisation hides throttling
✓[P] The HTTP 200 failure set: empty response with end_turn, refusal, max_tokens and model_context_window_exceededPart 1 reason 1 · why success is not a status code
✓[P] Metric names and buckets: gen_ai.invoke_agent.inference_calls and .tool_calls to 128, invoke_workflow.duration to 7200sDemo 2 · loop detection without opening a trace
✓[P] gen_ai.evaluation.result and gen_ai.evaluation.score.value as the eval signal in the telemetry specDemo 2 · and it is a ticket, not a page
✓[P] The tokenizer change: about 30% more tokens for the same text at a stable pricePart 1 reason 4 · a token threshold that breaks with no bug
◐[P] Alarm fatigue evidence: ECRI's top hazard list and the 88.8% false-positive ICU studyNamed here as the reason for a written policy · the full case is leader session a5
◐[P] Anthropic caching multipliers and the cache-invalidation hierarchyUsed only to justify segmentation · the cost work is b4 and a4
◐Provider-side drift as an alerting hazardNamed in Part 1 reason 4 · the four drift types are b9
·Derived, not published: the 40,000-queries-a-month Beacon budget arithmetic and the burn figures in the Part 3 diagramLabelled as a worked derivation on a constructed case, not a vendor statistic
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Your monitoring never fires, yet a review shows a series of 100% error spikes lasting 5 minutes every 10 minutes ate 35% of the month's error budget. What is broken?

This is Google SRE's recall failure, in the source's own example. A single-window threshold is asking "is it bad right now, for long enough", and a pattern that is bad half the time never satisfies "for long enough". Burn rate asks a different question: how fast is budget leaving. At 100% errors for half of every ten minutes, budget leaves fast enough that both a 1-hour and a 5-minute window cross 14.4x, and the page fires.

2 · Your automated judge scores 5% of production traces and its mean score drops from 0.81 to 0.74 overnight. What severity does this get?

Eval-score drift on sampled traffic is real signal and it is the wrong shape for a page. The sample is small, quality is noisy at the unit level, and an uncalibrated judge can move for reasons that have nothing to do with your system, which means you would sometimes be paging on the judge's drift. Ticket it, investigate with traces, and keep the pager for assertion-verified task success, refusal rate and hard spend caps.

3 · Which pair of signals belongs on an absolute static threshold rather than a burn-rate rule, and why?

Burn rate exists to spend a budget deliberately: some failure is acceptable, and the question is only how fast. A safety rail has no budget. There is no acceptable rate of thirty-step runaway loops or of a five-figure hour, so the correct response is a circuit breaker at a fixed number, with the notification as a side effect. Keep three of these at most: max steps, max tokens per task, max spend per hour. Everything else gets a windowed rate.

Builder session 8 cheat sheet · pin this

Burn rateObserved error rate divided by the budget rate. 1 exhausts the month exactly. 14.4 exhausts it in about two days.
The table, 99.9% SLOPage 1h/5m at 14.4 (2% spent). Page 6h/30m at 6 (5%). Ticket 3d/6h at 1 (10%).
Both windows, alwaysThe long window earns the page. The short window clears it in five minutes instead of an hour. That is the reset-time fix.
Success is not a status codeA green 200 can be empty, a refusal, a truncation or a fabrication. Alert on an assertion over the output.
Quality is a rate, never a request1000 identical prompts gave 80 distinct outputs. No per-request quality threshold can be stable.
Segment before you thresholdCache hit vs miss and 1-step vs 12-step are different distributions in one endpoint. One p95 describes neither.
Two rows people always missRefusal rate (HTTP 200, invisible otherwise) and gen_ai.response.model against the requested model.
Static caps, exactly threeMax steps, max tokens per task, max spend per hour. Circuit breakers, not alerts. Everything else is a windowed rate.