Systems That Confess · Nº 09

Systems That Confess

A field guide to observability & reliability

Observability is designing systems that can testify about themselves; reliability is deciding — with a budget, before the incident — how much failure you will tolerate. This guide covers the three signals and their division of labor, SLOs and error budgets as the decision framework, alerting that respects the pager, the reliability math that makes five nines an honest impossibility for most systems, one full incident run from page to postmortem, and the telemetry LLM systems need that ordinary services don't.

Module 01 Three signals, one event

Every production system emits evidence. The question is whether the evidence was designed or merely accumulated. A system that was instrumented deliberately can be cross-examined: you arrive with a question you have never asked before — why did this one tenant's answers start citing a repealed policy on Tuesday? — and the system answers it in minutes, from telemetry that already exists. A system that was not can only be deposed, one grep at a time, at incident prices.

This module establishes the division of labor that the rest of the course assumes. Logs, metrics, and traces are not redundant, and they are not interchangeable; each is expensive in a different geometry, and each answers a class of question the others answer badly or not at all. Get the mapping wrong and every investigation starts with the wrong tool — which is not a small tax, because the wrong tool usually produces an answer, just not a true one.

A system that can testify

Monitoring is watching for failure modes you predicted. You knew the disk could fill, so you graphed the disk; you knew the queue could back up, so you alerted on its depth. Monitoring is necessary and it is finite: it covers exactly the failures someone thought of in advance, which is a strictly smaller set than the failures available to you.

Observability is a stronger property. A system is observable to the degree that you can answer questions about its internal state — including questions nobody anticipated — from its external outputs, without shipping new code. The clause that carries the weight is the last one. If answering "which tenants saw truncated answers between 09:20 and 09:40?" requires a deploy, you did not have that answer during the incident; you had it forty minutes after the incident, which is a different and much less valuable thing.

From your other life

The distinction is the one between a witness and a document dump. A witness who can be cross-examined answers questions the examiner invents on the spot; a box of unindexed paper technically "contains" the answer and still costs you weeks. Instrumentation is the work of converting the second into the first, and like most evidentiary work it is far cheaper done before the dispute than during it.

Observability is therefore a design property, not a product you install. Vendors sell storage and query engines for telemetry; they cannot sell you telemetry your code never emitted. The decisions that make a system observable — what constitutes an event, which identifiers travel with a request, which dimensions are worth aggregating — are made in code review, months before the incident that depends on them.

The division of labor

The three signals differ in what they record, what they can answer, and how their cost grows. Those three facts are related, and once you see the relation the routing rule becomes obvious rather than memorized.

Logs record individual occurrences in arbitrary detail. They answer what exactly happened in this one case. Cost grows with events × bytes, which means arbitrary high-cardinality detail — a request id, a tenant id, a corpus version, the user's own query string — is comparatively cheap to carry. What logs cannot do is answer aggregate questions cheaply: computing "p95 latency this week" from raw log lines means scanning the week.

Metrics record pre-aggregated numbers over time. They answer how much, how often, is it getting worse. Cost grows with the number of unique label combinations — every distinct pairing of dimension values is a separate time series to store and index forever. That geometry makes metrics almost free to query and unforgiving of unbounded dimensions, which is the whole of module 2's cardinality discipline in one sentence.

Traces record one request's path across services as a tree of timed operations. They answer where in the chain did the time or the failure go. Cost grows with stored spans, and because a single request may emit dozens, tracing at full volume is the signal that most reliably surprises people on the invoice. Traces are the only signal in which causation across service boundaries is a recorded fact rather than an inference from clock-skewed timestamps.

One production request to the compliance assistant emitting three signals in parallel lanes: a structured log event, metric increments, and a trace, each annotated with the question it answers and how its cost growsOne request: POST /assistLog eventMetricsTraceevent: answer_completereq_id: req_8h3ktenant: acme-legallatency_ms: 11240outcome: timeoutrequests_total +1errors_total +1latency_seconds bucket le=12 +1tokens_total +1842root 11.2 s- retrieval 0.6 s- provider 9.8 s- lookup_case 0.4 strace 4f2a91c7AnswersWhat exactly happenedin this one case?AnswersHow much, how often,is it getting worse?AnswersWhere in the chain didthe time go?Cost grows withevents × bytes.High cardinality isaffordable here.Cost grows withunique label combos.Bounded dimensionsonly.Cost grows withspans stored.Sampling is notoptional at volume.
Figure 1.1 — One event, three signals. A single request to the compliance assistant emits a wide log event (arbitrary detail about this case), a handful of metric increments (aggregates over all cases), and a trace (the path and timing across services). The three lanes are not copies of each other: each answers a different question and each grows expensive along a different axis, which is why a system needs all three and why the routing decision matters.
The load-bearing idea

Signal choice is dictated by the shape of the question, not by taste. One case → logs. Aggregate over many cases → metrics. Where in the chain → traces. Every debugging session that feels like archaeology started by asking one of those questions of the wrong signal.

Choosing the signal for the question

Run the rule against real questions from the compliance assistant — the retrieval-plus-tools system from Guide Nº 02, now serving roughly 10,000 queries a day for law firms answering policy questions against their own corpus.

"Why did request req_8h3k cite a policy that was repealed in 2023?" — one case, needing arbitrary detail: the log event for that request, which carries the corpus version it retrieved against, then its trace to see whether the stale document came from retrieval or from the model ignoring the retrieved set. A dashboard cannot answer this; an aggregate has already averaged away the instance you care about.

"Is p95 answer latency worse this week than last?" — an aggregate over hundreds of thousands of requests. A metric answers it in milliseconds because the aggregation already happened at write time. Answering it from logs means scanning two weeks of events, which is slow, expensive, and — if your hot retention is 14 days — sometimes impossible.

"Which dependency makes Tuesday afternoons slow?" — a question about location in the chain, asked in aggregate: trace data grouped by span, comparing Tuesday afternoon's span duration distribution against baseline. Metrics per service could approximate it, but only traces attribute a request's wall clock to the specific hop that consumed it.

Decision flowchart routing a production question to logs, metrics, or traces based on whether it concerns one case or an aggregate, and whether it asks about rate or about location in the call chainYour questionOne specific case, or many?one casemanyDetail, or timing?Rate and trend, or whichhop is responsible?LogsTracesMetricsWhy did req_8h3kcite a repealed policy?Which dependency makesTuesday afternoons slow?Is p95 latency worsethan last week?Note: traces serve both the single-case and the aggregate branch — they are the only signal that does.
Figure 1.2 — Question in, signal out. Two cuts decide the signal: is the question about one case or about many, and does it ask about existence and detail, about rate and trend, or about which hop consumed the time? Traces appear on both branches because a span tree is useful read individually (this request) and in aggregate (all requests grouped by span).

Instrumentation is a design activity

Telemetry decided at write time costs a code review. Telemetry missing at incident time costs an outage-length archaeology session plus a deploy under pressure, which is the most expensive way anyone has yet invented to add a log line. The asymmetry is large enough that instrumentation belongs in the definition of done rather than in the backlog.

The standing rule the rest of this course assumes: every new endpoint ships with three things or it is not finished. One wide structured event per unit of work (module 2). Its RED metrics — rate, errors, duration — with bounded labels (module 2 again, for what "bounded" costs). Trace context propagated in and out, including across every async boundary it touches (module 3). Three artifacts, reviewable in the same pull request as the feature.

When more telemetry misleads

"Turn on debug logging everywhere so we never miss anything" sounds like rigor and buys noise plus an invoice. Observability is answerable-questions-per-dollar, not bytes-per-day. Ten well-chosen fields on one event per request beat two hundred undifferentiated lines, because the ten are queryable and the two hundred must be read. Volume is what teams reach for when they never decided what questions they intended to ask.

One consequence worth internalizing early: you cannot instrument your way out of an architecture that hides its own behavior. If work happens in a background thread with no context, in a third-party call with no timing, or in a queue consumer that mints fresh identifiers, no amount of log volume reconstructs the causal chain. Observability constrains design — which is the first hint of this course's thesis that reliability is a computed property of architecture, not an attitude toward it.

Module 02 Events, not prose

Most logging in most systems is a stream of sentences written by five different authors over three years, addressed to nobody in particular, and read only under duress. It is not that those lines contain no information; it is that extracting it requires regular expressions over English, which is a technique with no ceiling of effort and no floor of reliability.

The alternative is not "better log messages." It is a different unit: the structured event — one record per unit of work, with named typed fields, emitted at completion, carrying everything the incident query will want. This module builds that discipline: what belongs on the event, how to decide between a metric label and a log field when the storage bill is the tiebreaker, how a correlation ID survives the hop where naive implementations drop it, and what must never be written down at all.

Events over prose

Here is a real logging style, lightly cleaned up:

INFO  Handling request for user
INFO  Retrieved 8 documents
WARN  Slow response from provider, retrying
ERROR Something went wrong in retrieval :(

Now the question an on-call engineer actually has at 09:20 on a bad morning: show me every failed answer for tenant acme-legal in the last hour, with its latency and which dependency failed. Against that log stream the query does not exist. There is no tenant on any line, no request identifier joining the four lines to each other, no latency, no machine-readable outcome, and the one line that reports a failure reports it in a form that cannot be counted, grouped, or alerted on.

Anti-pattern — prose logging

Symptom: incident debugging consists of regular expressions over sentence fragments; two services describe the same failure in different words; nobody can count anything without writing a parser. Corrective: one schema-consistent structured event per unit of work, with named typed fields. Queryability is a property of structure, and structure is decided at write time — you cannot add it during the incident, because the events you need were already written.

The same request, instrumented as a structured event:

{"ts":"2026-03-17T09:20:41.882Z","service":"assist-api",
 "event":"answer_complete","req_id":"req_8h3k",
 "trace_id":"4f2a91c7","tenant":"acme-legal","tenant_tier":"enterprise",
 "outcome":"error","error_kind":"provider_timeout",
 "latency_ms":11240,"docs_retrieved":8}

Now the query is a filter and a group-by. More importantly, so is the next question, and the one after it — including questions nobody has thought of yet, which is the entire point of module 1's definition.

Note

Structure does not mean losing the human-readable message. Keep a msg field if your team wants one; just stop making it the only field. The failure mode is not prose existing — it is prose being the payload.

The anatomy of a wide event

The discipline that gets the most out of structured logging is the wide event: one record per unit of work, emitted at completion, carrying everything known about that unit of work by the time it ends. Not one line per step — one wide record per request. This is a deliberate trade: you pay in bytes per event, and you are repaid in every join you do not have to perform, because the fields that would have needed correlating are already on the same row.

The compliance assistant's canonical answer event, in full:

{
  "ts": "2026-03-17T09:20:41.882Z",
  "service": "assist-api",
  "version": "2026.03.11-a91c",
  "event": "answer_complete",

  "req_id": "req_8h3k",
  "trace_id": "4f2a91c7",
  "session_id": "sess_71dd",

  "tenant": "acme-legal",
  "tenant_tier": "enterprise",
  "user_id": "u_2249",

  "corpus_version": "2026-03-01",
  "model_id": "primary-v4",
  "docs_retrieved": 8,
  "tools_called": ["lookup_case"],
  "tokens_in": 6120,
  "tokens_out": 1842,
  "tokens_cached": 4800,

  "latency_ms": 11240,
  "ttft_ms": 10980,
  "latency_breakdown_ms": {"retrieval": 610, "provider": 9800, "lookup_case": 420},

  "outcome": "error",
  "error_kind": "provider_timeout",
  "attempt": 2,
  "citations_valid": null
}

Four groups of fields, and the grouping is not decorative — each exists to answer a different class of incident question.

The wide event anatomized into four field layers — identity, correlation, business, and outcome — each annotated with the incident question it exists to answerOne event, four layersIdentity — who emitted thists · service · version · eventtenant · tenant_tier · user_idAnswersWhich build, which tenant, whichpopulation? Did the 09:11 deploy do this?Correlation — how it joinsreq_id · trace_id · session_idAnswersWhat else happened to this request,everywhere else in the system?Business — what it was doingcorpus_version · model_iddocs_retrieved · tools_called · tokens_*AnswersIs the bad behavior confined to onecorpus version or one model? What didthis answer cost?Outcome — how it endedoutcome · error_kind · attemptlatency_ms · ttft_ms · breakdownAnswersWas this a good event or a bad one forthe SLI? Where did the time go? Are weretrying ourselves into the ground?
Figure 2.1 — The wide event, anatomized. Every field on a wide event should be traceable to a question someone will ask under pressure. Identity fields locate the emitter and the population; correlation fields join this record to the rest of the request's life; business fields describe what the work was; outcome fields decide whether it counted as good — which is what makes the event stream a valid source for the SLIs of module 4.
The load-bearing idea

The outcome field is not one field among many — it is the bridge to the entire second half of this course. An SLI is a ratio of good events to valid events; if your events do not carry a machine-readable verdict on their own goodness, you do not have an SLI, you have an opinion with a dashboard.

Cardinality discipline

The routing question comes up in every instrumentation review: this dimension is interesting, should it be a metric label or a log field? The answer is arithmetic, and the arithmetic is unkind in one direction.

Metrics pay per unique label combination. Each distinct combination is a separate time series, stored and indexed for the full retention period whether or not anyone queries it. Suppose the assistant's latency histogram carries labels route (8 values), status (5 values), and someone adds user_id (10,000 values), with 12 histogram buckets. The series count is the product: 8 × 5 × 10,000 = 400,000 label combinations, each carrying 12 buckets plus a sum and count — on the order of five million stored series. Before the user_id label it was 8 × 5 = 40 combinations. One label multiplied the bill by ten thousand, and it did so silently, because nothing in the code review said × 10,000.

Logs pay per byte. Adding user_id to a wide event costs roughly twenty bytes on a record you were already writing — at 300,000 requests a month, about six megabytes. The same dimension is catastrophic in one signal and rounding error in the other, and the difference is entirely structural: metrics multiply dimensions, events append them.

DimensionDistinct valuesMetric label?Log field?Why
route8YesYesSmall and stable; you group by it constantly.
status5YesYesBounded by definition.
tenant_tier3YesYesBounded, and it is the cut you actually alert on.
model_id4YesYesBounded; module 8 needs per-model percentiles.
tenant~400BorderlineYesMultiplies every other label by 400; use the tier for metrics, the id for events.
user_id~10,000NeverYesUnbounded and growing; cost multiplies, query value does not.
req_id~300,000/moNeverYesUnique per event; as a label it makes a metric into a bad log.

Hence the routing rule: bounded dimensions you group by belong in metric labels; unbounded identifiers belong in events. The instinct behind the mistake is sound — you genuinely do want to slice latency by tenant — and the fix preserves it: alert and dashboard on tenant_tier, then drop into the event stream to find the specific tenant. Cheap aggregate, expensive detail, used in that order.

Correlation IDs end to end

A correlation ID is minted once, at the outermost edge that sees the request, and then carried by every record produced on that request's behalf anywhere in the system. The compliance assistant mints req_id at API Gateway and carries it — along with the W3C trace context of module 3 — through assist-api, into the retrieval service, out to the model provider, out to case-mgmt, and across the async queue that drives conversation summarization.

That last hop is where correlation chains go to die. HTTP propagation is usually handled by a framework middleware someone installed on day one. Queue propagation almost never is: a message is published with a body and no message attributes, the consumer starts work, the logging library helpfully mints a fresh request id, and every record the consumer emits is an orphan. Nothing errors. The logs look fine. The failure surfaces only during an incident, when you follow a request into the queue and it vanishes.

Swimlane sequence showing one correlation ID carried from the browser through API Gateway, assist-api, retrieval, the model provider and case-mgmt, with the async queue hop where naive implementations mint a new id and orphan the downstream records"BrowserAPI Gatewayassist-apiretrievalcase-mgmtsummarizerPOST /assistmint req_id=req_8h3k → headerreq_8h3k in call contextX-Request-Id: req_8h3kpublish to queue: body onlyconsumer mints req_c41f— records orphanedFix: publish req_id and traceparent as message attributes
Figure 2.2 — The chain and its weakest hop. The correlation ID minted at the gateway rides HTTP headers and in-process context without ceremony, because frameworks handle those hops. The queue publish carries only a body, so the consumer mints a fresh id and every record it writes is unjoinable — the dashed path. The fix is unglamorous: put the correlation ID and trace context in message attributes and read them back in the consumer, as part of the message contract rather than as a logging nicety.
Note

The two tempting substitutes for a propagated ID both fail under concurrency. Joining on timestamps requires clock agreement you do not have and uniqueness you definitely do not have at 30 requests a minute. Joining on user id gives you every request that user made, which during an incident is exactly the set you were trying to narrow.

Levels, retention, and what never gets logged

Three decisions remain, and all three are made once, in the logging contract, rather than repeatedly by whoever is writing a line at the time.

Severity is a query dimension, not an emotion. The useful definition is operational: ERROR means a human or an automated policy should eventually look at this; WARN means the system handled it but the frequency is worth a graph; INFO is the normal event stream; DEBUG is off in production by default and enabled by flag, per service, for a bounded period. What makes levels break down is treating them as intensity of feeling — every author's threshold differs, and the resulting stream cannot be filtered.

Retention is tiered and stated in advance. For the assistant: hot and fully indexed for 14 days, which covers essentially all incident investigation; warm and queryable-with-latency for 90 days, which covers trend analysis and quarterly review; archived beyond that only where a legal-hold or regulatory obligation names a specific category of record. Tiering matters because undifferentiated retention is how you simultaneously pay too much and, when the audit arrives, discover you kept the wrong things too long.

Some things are never written. The standing exclusion list: credentials, API keys, and session tokens; authorization headers; full request and response bodies containing case data; and any field a redaction pass has not seen. The last item is the operative one — an exclusion list defended by good intentions fails on the first hurried pull request, so the enforcement belongs in a serializer that drops unknown fields by default rather than in a wiki page.

Convenience capture is a liability decision

"Log the full request body at INFO so debugging is easier" is a sentence that creates a personal-data store with default retention and no lawful basis of its own, usually without anyone treating it as a decision at all. It is also genuinely useful, which is why it keeps happening. Module 8 builds the tiered capture policy that keeps the debugging value and survives an audit; for now, the rule is that request bodies are opt-in per field, never opt-out.

Module 03 Where the time went

You have a wide event per service now, and every event carries a correlation ID. That is already enough to gather one request's records from four services into a single result set. What it is not enough to do is tell you where the time went — because a set of records with timestamps is not a tree, and the question "which hop consumed the 11 seconds" is a question about structure: what called what, what waited on what, what could have run in parallel and didn't.

Tracing supplies that structure. A trace is one request's call graph, timed, with parent-child relationships recorded at the moment they happen rather than reconstructed afterward from clocks that disagree. This module covers what a span is, how the tree stays connected across boundaries that try to break it, how to read a waterfall and reach a verdict, and how to sample so the bill stays finite without losing the traces you would have wanted.

Spans and the trace tree

A span is a named operation with a start time, an end time, a span id, and a parent span id. That last field is the entire idea: it records causation. A trace is the tree of spans sharing one trace id — one request's complete life, structured.

Spans carry attributes (the model id, the number of documents retrieved, the HTTP status) and may carry timestamped events within them ("first token received", "retry 1 issued"). In practice a span's attributes and a wide event's fields converge on the same content, which is fine and increasingly deliberate: the same facts, once indexed by request for querying and once indexed by structure for timing.

What the tree gives you that correlated logs do not is containment. If the provider span ran from 09:20:32.041 to 09:20:41.841 and it is a child of the root span, then those 9.8 seconds are inside the request's wall clock and attributable to that hop — not adjacent to it, not concurrent with something else that might really be the cause. Reconstructing containment from four services' log timestamps requires clocks to agree to within the precision of your conclusions, and clocks on separate hosts do not.

The span tree for one compliance-assistant request, showing the root span at API Gateway and its children for assist-api, retrieval, the model provider, and the lookup_case tool call, with span ids and parent referencestrace_id 4f2a91c7 — one request, five spansGET /assist (root)span a1 · parent none · 11.2 sassist-api handlerspan b2 · parent a1 · 11.1 sretrieval.searchspan c3 · parent b2 · 0.61 sdocs_retrieved=8provider.completespan d4 · parent b2 · 9.80 smodel=primary-v4tool.lookup_casespan e5 · parent b2 · 0.42 scase-mgmt · 200Each child names its parent, so containment is recorded — not inferred from clocks that disagree.
Figure 3.1 — One request as a span tree. Five spans sharing trace id 4f2a91c7. Each span records its own duration and its parent's id, which makes containment a fact: the provider's 9.80 s sits inside the handler's 11.1 s, inside the root's 11.2 s. Attributes on each span carry the business detail (documents retrieved, model id, downstream status) that turns a timing picture into a diagnosis.

Context propagation

The tree stays connected because each hop passes the current trace and span identity to the next. On HTTP this is the W3C context propagation standard: a traceparent header carrying version, trace id, the calling span's id, and flags.

traceparent: 00-4f2a91c7c8e34f1b9a7d2e5c81b40f6a-00f067aa0ba902b7-01
             ^  ^                                ^                ^
           ver  trace id (16 bytes)          parent span id     sampled flag

The receiving service reads the header, starts its span with that parent, and passes its own span id onward. Auto-instrumentation for common frameworks does this without anyone writing code, which is why HTTP hops rarely break — and why the hops that are not HTTP break so reliably.

Where propagation silently fails:

  • Queues and event buses. Same failure as module 2's correlation ID, same fix: traceparent as a message attribute, read back and set as the consumer span's parent — usually as a link rather than a direct parent, since the consumer's work is triggered by, but not contained within, the producer's span.
  • Thread pools and executors. Trace context typically lives in thread-local or task-local storage; hand work to a pool and the worker thread has an empty context. The child span becomes a new root.
  • Background and scheduled jobs. There is no inbound request to inherit from, so they start fresh — correct behavior that becomes a gap when the job is really finishing a user's work.
  • Hand-rolled HTTP clients. The one place in the codebase where someone used a raw socket or a bespoke wrapper is the one hop that drops the header.

The diagnostic signature is consistent and worth memorizing: orphaned spans — work that appears in the tracing backend as its own tiny root trace, with no parent, at the same moment your real trace shows an unexplained gap. Traces that look suspiciously fast are the other tell: the work happened, it just is not in this tree, and the waterfall now under-reports the request's real cost.

When the waterfall lies

A broken propagation does not raise an error. It produces a trace that renders beautifully and is simply incomplete — which is more dangerous than no trace at all, because a plausible picture ends the investigation. Whenever a waterfall shows a gap between a parent's duration and the sum of its children's, the first hypothesis is a lost context, not a fast dependency.

Reading the waterfall

A waterfall lays each span on a horizontal time axis, indented by depth. Reading one is a disciplined act with a specific order: find wall-clock dominance first, then look at structure, then reach a verdict. Doing it in the other order — spotting something you know how to fix and fixing it — is how teams spend a sprint optimizing a query that accounts for five percent of the latency.

Here is the assistant's slow request from the morning of the incident.

Trace waterfall of an 11.2 second compliance-assistant request across four services, showing the model provider span at 9.8 seconds dominating wall clock, and retrieval and lookup_case running serially where they could run in parallel0 s3 s6 s9 s11.2 sapi-gatewayroot · 11.20 sassist-apihandler · 11.10 sretrievalsearch · 0.61 scase-mgmtlookup_case · 0.42 sproviderprovider.complete · 9.80 s (TTFT 9.55 s)1.03 s serial — these two share no data; they could overlap88% of wall clock — one span, one dependencyVerdict: the p99 is the provider. Parallelizing retrieval + lookup saves 0.4 s of 11.2 s.
Figure 3.2 — The waterfall that explains the p99. Of 11.20 s wall clock, the model provider's single span accounts for 9.80 s — 88% — and its time-to-first-token is 9.55 s of that, so the user stared at nothing for nine and a half seconds. Retrieval and lookup_case run serially for a combined 1.03 s despite sharing no data, which is a real but second-order finding: parallelizing them recovers about 0.4 s. Read dominance first and structure second, and the verdict is unambiguous — this request is slow because of one dependency, and no local optimization elsewhere changes that.

Three verdicts a waterfall can support, and the evidence each requires:

  • Parallelize — two or more child spans run end-to-start but share no data dependency. Evidence: the gold bracket in Figure 3.2. Upper bound on the gain: the shorter span's duration, never more.
  • Cache — the same span with the same inputs recurs across traces, and the result is stable for a useful window. Evidence comes from comparing traces, not from one waterfall.
  • Descope — a span is on the critical path and does not need to be. The lookup_case call becoming non-blocking (render the answer, populate live case data when it arrives) is a descope, and it is also what makes that dependency non-fatal in module 6's availability math.
The load-bearing idea

Optimize in order of wall-clock share, and only after checking that the span is really doing work rather than waiting on someone else's. A service that is 90% idle waiting on a dependency will not go faster with more capacity, and adding capacity to it during an incident spends the one thing you cannot get back — time — on a hypothesis the trace already refuted.

Sampling: keeping the interesting 2%

At 10,000 requests a day and roughly a dozen spans each, full retention is about 3.6 million spans a month — survivable. At a hundred times that volume it is not, and the invoice arrives before the realization does. Sampling is therefore not a compromise you make reluctantly; it is a design decision with a right answer that depends on what you intend to ask.

Head sampling decides at the root, before the request runs: roll the dice, set the sampled flag in traceparent, and every downstream service honors it — so traces are complete or absent, never partial. It is cheap and it requires no buffering. Its defect is fatal in exactly the case you care about: the decision is made before the outcome exists, so a 1% head sample keeps 1% of your errors and 1% of your slow requests. On a rare failure you will have nothing.

Tail sampling decides after the request completes, when the outcome is known: keep every trace containing an error, every trace over a latency threshold, a small baseline of ordinary ones, and drop the rest. You get precisely the interesting population. The cost is that every span must be buffered somewhere until the verdict — a collector holding traces in memory for a decision window, which is real infrastructure with its own failure modes and its own capacity to lose data under load.

Head sampling and tail sampling as two pipelines from request start to trace stored or dropped, annotated with what each can and cannot keep, and the hybrid policy that combines them"Head sampling — decide at the rootrequest startsroll dice: keep 10%?request runsstored or never emitted+ no buffering, complete traces, decision honored by every hop− blind to outcome: a 10% sample keeps 10% of your errorsTail sampling — decide after completionrequest runscollector buffers allerror? slow? baseline?verdict knownevery error kept+ keeps exactly the interesting population− buffering infrastructure, decision window, its own failure modesThe assistant's hybrid policyHead-sample 10% for a representative baseline · tail-keep 100% of errors and 100% of traces over 8 s · ≈ 12% of spans stored
Figure 3.3 — Two decision points, one policy. Head sampling is cheap and outcome-blind; tail sampling knows the outcome and pays for buffering. They are not exclusive: a modest head sample provides the unbiased baseline you need for aggregate analysis, while tail rules guarantee that every error and every slow trace survives — which is the population you will actually open during an incident.
Anti-pattern — 100% sampling forever

Symptom: the tracing bill approaches the compute bill; nobody has opened 99% of the stored traces; the proposal on the table is to turn tracing off entirely. Corrective: a hybrid policy. Head-sample a baseline for aggregate questions, tail-keep every error and every trace above the latency SLO threshold, and let the ordinary fast successes go. Full retention is the default that feels safe and is really just an undecided policy accruing interest — and "turn it off" is the equal and opposite error, which trades a bill for blindness.

The assistant's arithmetic: 10,000 requests/day × 12 spans ≈ 120,000 spans/day at full retention. Head-sampling 10% keeps ~12,000. Errors run around 0.3% in a healthy month (~30 requests/day) and traces over 8 s about 1.5% (~150/day), so tail rules add roughly 2,200 spans — call it 12% of full volume, with 100% of the errors and 100% of the slow requests retained. During the incident, when the error rate hit 30%, that policy adapted on its own: the interesting population grew because the interesting population was what the policy was defined over.

Module 04 The error budget

The first three modules built the machinery for finding out what happened. This one is about deciding, in advance and in numbers, how much of it you are willing to tolerate — which is the difference between a team that reacts to every blip and a team that knows which blips are worth reacting to.

"The system should be reliable" is not a specification; it is a sentiment, and sentiments cannot be traded against launch velocity, headcount, or cost. An SLO is the same commitment rendered as a number, and the number produces something a sentiment never can: a budget. If you commit to 99.5% availability over a rolling 30 days, you have simultaneously committed to tolerating 0.5% failure — roughly 1,500 bad requests a month for the compliance assistant — and that tolerance is a resource you can spend on shipping risky changes, running experiments, and taking maintenance. This module builds the SLI, defends the SLO, and turns the remainder into a policy with named deciders.

Indicators users feel

An SLI is a ratio: good events divided by valid events, over a window. Three words in that sentence are load-bearing. Good must be machine-decidable — which is why module 2 insisted on an outcome field. Valid excludes events that were never yours to serve: malformed requests, unauthenticated probes, a client that hung up. And events, rather than time, because "minutes of downtime" is a poor proxy for a system that degrades partially and unevenly across tenants.

The part teams routinely skip is that the measurement point is part of the definition. The same nominal SLI measured in different places produces different numbers and, more importantly, different blind spots.

The compliance assistant's request path from browser to model provider with four candidate SLI measurement points marked, each annotated with what it can and cannot see, and the chosen point flaggedBrowserLoad balancerCHOSENassist-apiretrieval · provider① Client-side② Load balancer③ Service self-report④ DependencySees: everything theuser feels, incl. DNS,CDN, their wifiBlind to: nothing —but noisy, and blamesyou for their café wifiSees: every requestthat reached us, incl.5xx from a dead podBlind to: DNS andCDN failures upstreamof the LBSees: rich internaldetail, error kinds,business outcomeBlind to: requests itnever received — reads100% while LB drops allSees: which dependencydegraded, useful asdiagnostic contextBlind to: whether theuser got an answer —a bad SLI, a good signalThe choiceMeasure at the load balancer: closest to the user among points we control, sees infrastructure failures the service cannot report, and is not contaminated by client networks. Cover the LB's own blind spot with an external synthetic probe.
Figure 4.1 — Where you measure decides which failures exist. A service reporting on its own health cannot count the requests it never received, which is why an outage at the load balancer can coexist with a 100% availability graph. Client-side measurement sees everything including problems that are not yours. The load balancer is the usual answer — nearest the user among the points you control — with a synthetic probe outside it to cover what it cannot see.

The compliance assistant's three SLIs, defined precisely:

SLIGood eventValid eventMeasured atWindow
AvailabilityHTTP 200 with a schema-valid answer bodyAuthenticated POST /assist requestsLoad balancer access logsRolling 30 days
Answer latencyTime-to-first-token ≤ 2.0 s and time-to-complete ≤ 8 sRequests that returned a valid answerLoad balancer, streaming-awareRolling 30 days, p95
Citation validityAnswer passes the citation validatorSampled answers, 200/dayEval pipeline (Guide Nº 03)Rolling 7 days

Note the latency SLI is split. A streaming answer has two moments a user feels differently: when text starts appearing, and when it stops. A single number averages a fast-starting long answer against a slow-starting short one and tells you about neither. Module 8 returns to why this split is an LLM-specific instrument rather than fussiness.

Objectives from user need, not from the dashboard

An SLO is the target the SLI must meet over its window. The most common way to choose it is also the most damaging.

Anti-pattern — the SLO that is just current performance

Symptom: the dashboard says 99.97%, so the SLO is set at 99.97%. Every ordinary regression is now an emergency; there is no budget to spend on velocity, because the target was drawn at the level of the best month anyone has had; and the team learns that SLOs are an instrument of blame rather than of decision. Corrective: derive the target from what users need and what your dependencies can support, set it below current performance, and treat the gap as spendable.

The right question is not "how reliable are we?" but "what does the user need, and what would they do differently if we were better?" For the compliance assistant: an associate researching a policy question tolerates an occasional failure they can retry — the work is not real-time, the alternative is reading the policy manual, and the retry costs them fifteen seconds. What they do not tolerate is an outage during a filing deadline, or answers that arrive too slowly to stay in the flow of work. That analysis points at high-but-not-heroic availability with real attention to latency and tail behavior, which is exactly what 99.5% with a hard latency SLO expresses.

Second input: the dependency math. A chain of respectable services composes to a number that may already be beneath the SLO you are considering. The assistant's chain composes to about 98.76% as originally built — module 6 does this arithmetic in full — which means 99.5% was not merely ambitious, it was arithmetically unavailable until the architecture changed. That is the discipline this course is arguing for: the SLO is a computed consequence of what you built, and if you want a better number you buy it with a design change, not with an intention.

The downtime table makes the stakes concrete. On a 30-day month of 43,200 minutes:

SLOBudget per 30 daysBad requests (at 300k/mo)What it takes
99%432 min (7.2 h)3,000Business hours on-call, manual recovery
99.5%216 min (3.6 h)1,500Real on-call, tested rollback, one degraded path
99.9%43.2 min300Redundancy at every tier, automated failover, fast rollback
99.99%4.32 min30Multi-region, no human in the detection loop
99.999%26 s3Full automation of detect and remediate; almost nothing needs this

Read the third column across and the honest negotiation becomes possible. Moving from 99.5% to 99.9% is not a 0.4% improvement; it is a commitment to absorb five times less failure, which converts into architecture (a second provider, a degraded mode, multi-AZ everything) and into operational cost (an on-call rotation that can respond inside 43 minutes a month, cumulative). The answer to "why not 99.9%?" is a price, and the price is legible.

SLO versus SLA

An SLO is an internal target. An SLA is a contractual commitment with remedies attached — service credits, termination rights, sometimes indemnities. They are different instruments with different audiences and, critically, different numbers.

The discipline is to set the SLO tighter than the SLA, so that the contract never triggers first. If the assistant's SLA promises 99.0% and the internal SLO is 99.5%, then the internal target is breached — and the error-budget policy engages, and engineering attention is redirected — while the contract is still comfortably satisfied. The gap is your warning track.

A bar showing the gap between a 99.0 percent contractual SLA and a tighter 99.5 percent internal SLO, with the intervening region labelled as the zone where the team responds before any contractual remedy is triggeredAvailability over the 30-day window≥ 99.5% — SLO met. Budget healthy, ship freely.99.0% – 99.5% — the warning trackSLO breached: freeze, redirect engineering. Contract untouched — no credits, no dispute.< 99.0% — SLA breachedService credits owed, measurement method now adversarial, account review.SLOSLAThe gap is not slack — it is the interval in which you can respond as an engineer rather than as a counterparty.
Figure 4.3 — The warning track between SLO and SLA. Setting the internal objective tighter than the contractual commitment creates a band in which the team is already responding — freeze, prioritize, mitigate — while no remedy has been triggered and no customer conversation has turned adversarial. Publishing the SLO as the SLA deletes this band and makes every internal miss a contractual event.
From your other life

You already know why the two numbers differ, from the other side of the table. An SLA is drafted with remedies (credits, usually capped and usually the exclusive remedy), a measurement methodology (whose logs? measured where? excluding what?), exclusions (scheduled maintenance, force majeure, customer-caused failures), and a claims process with notice periods. Every one of those clauses is a fight you can lose without ever having been unreliable — a measurement-methodology dispute is winnable by whoever wrote the definition. The SLI work in this module is, among other things, how you avoid arguing about the meter after the fact.

Which is why "just publish our SLO" is a bad trade even when the number is achievable. It converts an engineering target — revisable quarterly as you learn what users need — into a contractual floor that requires a negotiation to change. Internal targets should move. Contracts should not.

Spending the budget deliberately

Now the instrument. If the SLO is 99.5% over 30 days and you serve 300,000 requests a month, the error budget is 0.5% — 1,500 bad requests, or 216 minutes of full outage equivalent. That is not a shameful remainder. It is permission, purchased in advance, and it is the thing that lets an engineering organization move.

Things worth spending budget on: shipping a risky-but-valuable change behind a fast rollback; migrating the retrieval index during business hours instead of at 2 a.m., where the on-call is alert and mistakes are cheaper; running a deliberate failover exercise; letting a canary bake in production with real traffic rather than simulating forever. Each has a cost in expected bad requests, and each is a choice you can now make explicitly.

The error-budget policy is the pre-agreed rule for what happens at each state. Agree it when everyone is calm; its whole value is being decided before the argument.

State machine of the error-budget policy with three states — healthy, burning, and exhausted — showing the budget-remaining thresholds that move between them, what ships in each state, and the explicit exit condition from the freezeHEALTHY> 50% budget leftShip normally. Spend oncanaries and migrations.BURNING10–50% budget leftRisk review on every deploy.No infra migrations.EXHAUSTED< 10% budget leftFeature freeze. Reliabilitywork and fixes only.burnburn30-day window rolls forward, budget recoversrecovery + postmortem action items landedNamed deciders — agreed in advance, not duringHEALTHY → BURNING: automatic, from the SLO dashboard. No human judgment involved.BURNING → EXHAUSTED: automatic. The freeze does not require anyone to be brave.Freeze exit: service owner + eng lead jointly, only once P1 action items have shipped. Exceptions logged and reviewed monthly.
Figure 4.2 — The budget policy as a state machine. Transitions on the burn side are automatic and mechanical, which is the point: nobody has to argue for a freeze while the incident is fresh and the roadmap is loud. Recovery is deliberately asymmetric — the window rolling forward restores budget on its own, but exiting a freeze requires a named pair of humans and evidence that the fixes landed, so a bad month cannot be waited out.
The load-bearing idea

The error budget converts a recurring political argument — "ship faster" versus "be more careful" — into a shared number with a pre-agreed rule. Nobody has to win the argument on temperament. The budget's state answers it, and both parties agreed to the answer while calm.

The corollary is uncomfortable for cautious teams: an unspent budget is waste. If you end three consecutive months at 99.95% against a 99.5% objective, you did not win — you paid for risk capacity and did not use it, which means either you were too conservative with your roadmap or the SLO is set below what your architecture now delivers and should be re-examined. Budgets are for spending.

Quality SLIs for judgment systems

Availability and latency have a convenient property: the verdict on each event is free and instant. A status code exists; a clock exists. Citation validity has neither. Whether an answer correctly cited a policy that supports it is a judgment, produced by the eval pipeline of Guide Nº 03 running a validator over a sample of answers — and that changes four things about how the SLI behaves.

It is sampled, so it has error bars. At 200 graded answers a day, an observed 97% could be a true 97%, or a true 98% having a mediocre day; the standard error on a 98% rate at n = 200 is about one percentage point, so single-day movements of a point or two are noise. Treating them as signal produces a team that chases ghosts.

It is slow. Detecting a real one-point drop with confidence takes days of accumulated samples, not minutes. This is not a defect to be engineered away — it is the nature of measuring a distribution rather than an event.

It therefore never pages. Module 5's routing rule falls out of the arithmetic rather than from a preference: a signal that cannot distinguish a real regression from noise inside a few hours cannot justify waking anyone. Quality regressions get a ticket with a named owner and a deadline.

Its window is shorter. Citation validity uses a rolling 7 days rather than 30, because a corpus update or a silent model change can shift quality abruptly and a 30-day window would dilute a fresh regression under three weeks of good history.

Cross-reference — Guide Nº 03

The eval pipeline is the sensor; this module is the wiring. Everything Guide Nº 03 established about grader reliability applies with force here, because a quality SLI inherits its grader's error: if the validator agrees with a careful human 95% of the time, your 98% SLI has a systematic component you must know the sign of before you defend the number to anyone.

What does not change is the governance. Citation validity has an SLO (≥ 98% over 7 days), an error budget (2% of graded answers), and a policy: sustained breach blocks prompt and retrieval changes until the regression is understood, on the same logic that a burned availability budget blocks infrastructure migrations. Same instrument, different sensor and different clock.

Module 05 Guarding the pager

An alert is a claim that a human should stop what they are doing. That is an expensive claim — it costs attention, sleep, and, cumulatively, the responder's belief that the pager means anything. Most alerting systems are built as though the cost were zero, which is why most on-call rotations are worse than the systems they defend.

Module 4 produced the raw material for doing this properly: an SLI users would agree with, and a budget being consumed at a measurable rate. This module turns those into a small number of alerts that earn their interruptions — symptom-based, burn-rate-driven, multiwindow — and into a routing discipline that puts everything else where it belongs: a ticket, a dashboard panel for the responder who already knows something is wrong, or the trash.

Symptoms, not causes

The rule is one sentence: page on what users feel; ticket what might explain it. A symptom-based alert fires on an SLI degrading — availability falling, latency exceeding its threshold. A cause-based alert fires on an internal condition that might produce that, and might not.

Anti-pattern — cause-based alerting

Symptom: the pager fires for CPU above 80%, disk above 90%, pod restarts, replica lag, queue depth — a rotation full of conditions users never felt. Two failure modes follow, and they compound. False positives: CPU spikes during a harmless nightly job and someone is woken to confirm nothing is wrong. False negatives: a failure mode nobody enumerated degrades the service while every cause alert stays quiet, because you can only alert on causes you thought of, which is module 1's monitoring limitation arriving with a pager attached. Corrective: every page must reference an SLI users feel. Causes become dashboard context for the responder who has already been paged, and thresholds on causes become tickets when they predict future trouble.

Three real cause-alerts from the assistant's stack, rewritten:

Cause alert (before)Why it misfiresRewritten (after)
Page: assist-api CPU > 80% for 5 minThe service is I/O-bound on the provider; CPU is high during healthy peaks and low during the worst outage, when it is idle waitingPage: availability SLI burn rate > 14.4× over 1 h and 5 min. CPU stays as a dashboard panel
Page: Postgres replica lag > 30 sRetrieval reads tolerate 60 s of staleness by design; lag alone changes nothing a user perceivesTicket at > 120 s (predicts trouble); page only if the latency SLI is burning
Page: provider error rate > 5%The retry path absorbs a 5% provider error rate invisibly — this pages for a failure the system already handledPage: the assistant's own availability SLI burning. Provider error rate becomes the first dashboard panel the responder opens

Notice what the rewrite preserves. The cause signals do not disappear — they are exactly what the responder needs in the first ninety seconds of an investigation. What changes is their authority to wake someone. A signal can be indispensable for diagnosis and still have no business interrupting a human at 2 a.m.

Burn rates and windows

"Error rate above 0.5%" is a bad alert, and it is bad in an instructive way: 0.5% is the budget, and a budget is a monthly quantity, so an instantaneous comparison against it is a category error. Sustained 0.5% failure spends exactly the budget over 30 days — nothing to page about. A 30% error rate for six minutes is a genuine emergency. One threshold cannot express both.

Burn rate fixes this by normalizing: it is how many times faster than sustainable the budget is being consumed. Burn rate 1 means you will spend exactly the budget over the window. Burn rate 60 means you will spend the whole month's budget in 12 hours.

burn rate = observed error rate / error budget rate

  0.5% errors  ÷ 0.5% budget =   1×   sustainable
  7.2% errors  ÷ 0.5% budget =  14.4× fast burn
 30.0% errors  ÷ 0.5% budget =  60×   the incident

Now the thresholds derive from a decision that is actually meaningful: what fraction of the month's budget am I willing to lose before a human intervenes? A burn rate of B sustained for a window W consumes B × W / 30 days of the budget. Choose the fraction, solve for the rate:

Burn rateLong windowBudget consumed if sustainedShort windowAction
14.4×1 hour2%5 minPage — an incident is eating the month
6 hours5%30 minPage — slow bleed, still serious
3 days10%6 hTicket — chronic burn, fix in hours not minutes

Check the arithmetic on the first row: 14.4 × 1 hour = 14.4 hours of budget-equivalent, and 14.4 / 720 hours in a 30-day month = 2%. The number is not folklore; it is the answer to "page me before I lose 2% of the month."

The short window is the second half of a multiwindow alert, and it exists to solve a specific annoyance: an alert on a 1-hour window keeps firing for up to an hour after the problem resolves, because the window still contains the bad minutes. Requiring the short window to also be burning means the alert says "this was severe over the last hour and it is still happening right now." Blips that self-heal in four minutes never page, and resolved incidents stop paging promptly.

Error budget consumption over a 30-day window for the compliance assistant, showing a steady baseline burn, a sharp fast-burn incident on day 12 consuming about 7 percent of the budget in 51 minutes, and the 14.4x and 6x burn-rate alert threshold slopes with the moment the fast-burn alert firedError budget consumed — rolling 30 days (budget = 1,500 requests)0%10%20%30%40%50%day 0day 10day 20day 30baseline burn ≈ 0.4× sustainable09:14 — fast-burn alert firesburn ≈ 60×, within minutes of onset51 min, ≈7% of the month's budget14.4× slope — 2% of budget per hour → PAGE6× slope — 5% per 6 h → PAGEpost-incident slope returns to baseline — budget recovers as the window rollsRead the slope, not the level: a threshold on "budget remaining" fires too late; a threshold on how fast it is falling fires while there is still budget left to protect.
Figure 5.1 — The budget, and the slopes that page. Cumulative budget consumption across a 30-day window. The gentle baseline is a healthy month burning about 0.4× sustainable. On day 12 the provider-latency incident consumes roughly 7% of the month's budget in 51 minutes — a burn rate near 60×, far steeper than either alert slope, which is why the fast-burn alert fired at 09:14 within minutes of onset. The two dashed slopes are the alert thresholds: 14.4× (2% of budget per hour) and 6× (5% per six hours). Alerting on slope rather than on level is what buys the response time.

The pager is a scarce resource

Every page spends three things: the responder's attention now, their sleep tonight, and their trust in the pager permanently. The first two recover. The third does not recover easily, and its loss is the most dangerous failure in an on-call system, because a responder who has learned that pages are usually nothing will treat the real one as usually nothing.

A page is legitimate only if it is all three of the following. Urgent — it requires action within minutes, not by Thursday. Actionable — the person paged can do something; a page that only informs is a notification wearing a costume. User-visible — it corresponds to something a user is experiencing or imminently will. Fail any one and the alert belongs elsewhere.

The hygiene practice that keeps this honest is a weekly alert review, and it needs exactly one question per page: did a human take an action they would not otherwise have taken? If the answer is no — it self-resolved, it was investigated and found benign, the runbook said wait — that alert is demoted this week, not eventually. The default direction of travel is downward, because alerts accumulate by ratchet otherwise: everyone can add one, nobody feels authorized to remove one.

Anti-pattern — dashboards as wallpaper

Symptom: a wall-mounted TV of graphs is described as the monitoring strategy; "we would have caught it if someone had been looking" appears in a postmortem; new dashboards are the standard remediation for a missed incident. Corrective: alerts detect, dashboards diagnose. Anything that requires continuous human attention to work is an alert that has not been written yet — and its detection latency is not a number you control, it is whoever happens to glance up. Dashboards are for the responder who already knows something is wrong and is deciding what to do about it.

The comparison that ends the argument: an alert's detection latency is a configured window, typically measured in minutes and identical at 4 a.m. and 4 p.m. A dashboard's detection latency is the interval until someone looks, which on a weekend is not measured in minutes and is not measured at all.

Routing: page, ticket, or log line

Every candidate alert gets routed by three questions asked in order, and the order matters — a signal can pass the first two and still fail the third, which is the case people find hardest to accept.

Decision flowchart routing a candidate alert to page, ticket, dashboard context, or deletion based on whether it is user-visible now, budget-threatening at the current rate, and actionable by the person pagedA proposed alertAre users feeling it now?noyesThreatening the budget atthe current burn rate?noyesCan the person paged actwithin minutes?noPAGETICKETDASHBOARDor deleteTICKETThe third gate is the one teams skip: a real, urgent, user-visible problem nobody on call can fix is a ticket and an escalation path, not a page.
Figure 5.2 — Routing a candidate alert. Three gates in order. Not user-visible: dashboard context at best, and deletion if nobody would open it. User-visible but not budget-threatening at the current rate: a ticket — it matters, but not tonight. User-visible and budget-threatening but not actionable by the responder: still a ticket plus an escalation path, because paging someone who can only watch teaches them that pages are for watching.

The assistant's complete alert specification:

AlertSLIConditionWindowsRouteFirst runbook line
Fast burnAvailabilityBurn rate ≥ 14.4×1 h and 5 minPageOpen the availability SLI by error_kind and tenant_tier; if provider_timeout dominates, go to the failover runbook
Slow burnAvailabilityBurn rate ≥ 6×6 h and 30 minPageCompare against the last deploy timestamp; roll back first, diagnose after
Chronic burnAvailabilityBurn rate ≥ 1×3 d and 6 hTicketGroup failures by error_kind over 7 days; the top kind is the sprint's reliability item
TTFT burnLatency (TTFT)Burn rate ≥ 14.4×1 h and 5 minPageCheck provider span p95 in the trace aggregate before touching our own services
Completion burnLatency (complete)Burn rate ≥ 6×6 h and 30 minTicketSegment by answer length; long-answer drift is usually a prompt change, not an outage
Citation validityQuality< 98% over 7 d7 d and 24 hTicketDiff corpus version and model id against the last known-good window
Synthetic probeAvailability2 consecutive failures from 2 regions3 minPageThe load balancer cannot see this class of failure; check DNS and CDN first

Seven alerts. Four page, three ticket. That is the whole surface area, and its smallness is the point — every addition should have to argue against the responder's finite attention.

The citation-validity route is the instructive exception. It is genuinely user-visible: an answer citing a repealed policy is worse for the user than a slow answer. It nevertheless routes to a ticket, because module 4's sampling arithmetic says the signal cannot distinguish a real regression from noise inside a few hours. Waking someone for a signal that cannot yet be trusted is how you get a 3 a.m. investigation that concludes "probably noise" — twice — after which nobody responds to the third one, which is real. The exception proves the rule rather than breaking it: the constraint is that pages must be actionable now, and a signal without confidence is not.

Module 06 The arithmetic of failure

Module 4 asserted something this module has to earn: that an SLO is a computed consequence of architecture rather than a statement of intent. Three pieces of arithmetic do the earning. Availability composes multiplicatively across serial dependencies, so a chain of respectable services is a mediocre system. Latency grows roughly as 1/(1 − utilization), so a system that feels comfortable at 70% capacity collapses at 95%. And retries multiply offered load precisely when capacity is scarcest, which is how a slow dependency becomes an outage.

None of this is deep mathematics. All of it is arithmetic that most teams never actually perform, which is why "can we do 99.9%?" gets answered with enthusiasm instead of a number. Work through it once on a real system and the answer becomes available in about ten minutes, along with the specific architectural change that would move it.

Composition: serial multiplies, parallel forgives

Take three services, each available 99% of the time. Wire them so that a request needs all three — a hard serial composition — and the system's availability is the product:

0.99 × 0.99 × 0.99 = 0.970299  →  97.03%

Three "two nines" services compose to worse than two nines:
1% + 1% + 1% of failure, near enough, because failures are rare
and overlaps are negligible. Unavailability adds; availability multiplies.

Now wire the same three as alternatives — any one suffices, with failover — and you get parallel composition, where the failure probabilities multiply:

1 − (0.01 × 0.01 × 0.01) = 1 − 0.000001  →  99.9999%

Three "two nines" services compose to six nines.
The same three 99 percent available services wired serially, producing 97.03 percent, and wired in parallel behind failover, producing 99.9999 percent, with the arithmetic shown beside each wiringSerial — every hop must workA99%B99%C99%0.99 × 0.99 × 0.99 = 0.970397.03% — worse than any single partParallel — any one sufficesclientA · 99%B · 99%C · 99%failoverany one works1 − (0.01)³ = 0.99999999.9999% — six nines from two-nines parts…if failures are independentShared region, shared deploy pipeline, shared config,shared upstream provider, correlated load — each onecollapses the exponent toward 1.Every serial hop you canmake optional is worthmore than a nine you buy.
Figure 6.1 — The same parts, two wirings, five orders of magnitude. Serially, availabilities multiply and the system is worse than its worst component; in parallel behind failover, failure probabilities multiply and three mediocre services become a very good one. The gold panel is the caveat that matters more than the arithmetic: parallel math assumes independent failures, and shared regions, shared deploy pipelines, shared configuration, and shared upstream providers all make that assumption generous.
Independence is the assumption that fails first

Two providers behind a failover are only two providers if they fail for different reasons. If both run in the same cloud region, a regional event takes both. If both are reached through the same misconfigured egress proxy, the proxy is the real single point of failure. If your failover logic has never been exercised, it has an availability of its own that nobody has measured — and untested failover code fails at roughly the rate of code that has never been run. Write the six-nines number down, then write next to it the correlated failure that makes it three.

The practical asymmetry: removing a serial dependency is usually cheaper than making one more reliable. Going from 99.5% to 99.9% on a service you own is a quarter of engineering work. Making that service non-fatal — degrade gracefully when it is gone — often takes a sprint and removes its term from the product entirely.

The assistant's ceiling

Apply it to the real chain. The compliance assistant's request path traverses five components, each with an honest modeling number for its availability:

CloudFront + API Gateway     0.9995
assist-api (Flask service)   0.999
retrieval + Postgres         0.999
primary model provider       0.995
case-mgmt API                0.995

0.9995 × 0.999 × 0.999 × 0.995 × 0.995 = 0.98755…
                                       ≈ 98.76%

98.76% is roughly 536 minutes of expected unavailability per 30-day month — just under nine hours. The SLO in module 4 is 99.5%, which permits 216 minutes. The system as built could not meet its objective, and no amount of care, on-call discipline, or vigilance would have changed that: the number was arithmetically unavailable from the day the architecture was drawn.

Notice where the damage is concentrated. The two 99.5% dependencies contribute about 1.0 percentage point of the 1.24 points of unavailability between them. The two services the team owns and worries about most contribute 0.2 points combined. Effort follows attention rather than arithmetic unless someone does this calculation.

The redesign. Two changes, each targeting a 99.5% term:

Change one — make case-mgmt non-fatal. The lookup_case tool enriches an answer with live case status. When it fails, the answer is still correct and useful; it simply lacks the live enrichment. So the call gets a 1.5 s timeout and a degraded path: return the answer, flag it as "case data unavailable," carry on. The dependency's term leaves the product entirely — not improved, removed. This is the descope verdict from module 3's waterfall reading, arriving here as availability.

Change two — add a secondary model provider. Two independent providers at 99.5% each, with failover on timeout or error: 1 − (0.005 × 0.005) = 0.999975. The model dependency goes from 0.995 to ≈ 0.99998, and the honest asterisk is that both providers share the public internet and our egress path, so the realized number is somewhat below the arithmetic.

0.9995 × 0.999 × 0.999 × 0.999975 = 0.99748…
                                  ≈ 99.75%

Ceiling moves from 98.76% to 99.75%.
Expected unavailability: 536 min/month → 109 min/month.
The 99.5% SLO (216 min) now has ~107 minutes of headroom
for everything the model doesn't capture: deploys, config
errors, human mistakes, correlated failures.
The load-bearing idea

This is the thesis of the whole course arriving as a number. The reliability you can promise is computed from your architecture, not resolved upon. "Can we do 99.5%?" is not a question about the team's commitment; it is a question about a product of five numbers, and if the answer is no, the fix is to change one of the numbers or delete one of the terms.

On the numbers

The availabilities above are stipulated modeling figures for the worked arithmetic, not citations of any real vendor's published SLA. Use your own measured numbers where you have them and conservative estimates where you do not — and remember that a vendor's SLA is a contractual floor with remedies, typically well below their expected performance and therefore a poor input to this model. Measure what you actually observe.

Why five nines is usually a lie

99.999% availability is 26 seconds of unavailability per 30-day month. Sit with that number before agreeing to it, because it forecloses things people rarely think to check.

Humans are excluded by arithmetic. If a page reaches a responder in 60 seconds and they take 90 seconds to orient, the acknowledgement alone has consumed six months of budget. Five nines means detection and remediation are automated end to end; the human role is reviewing what the automation already did.

Your dependencies compose below it. A single 99.99% dependency in the serial path caps you at 99.99%, an order of magnitude short, before you have made a single mistake of your own. Five nines requires every serial dependency to be five nines or better, or to have been made non-fatal.

Measurement noise swamps it. At 300,000 requests a month, 26 seconds of budget is roughly three failed requests. Three. Your measurement pipeline has more error than that — a load balancer log line lost in shipping, a synthetic probe flapping on its own DNS, one client that hung up in a tunnel. You would be reporting on the noise floor of your own telemetry.

TargetBudget / 30 dWhat it actually requiresTypical honest use
99%7.2 hBusiness-hours on-call, manual recoveryInternal tools, batch systems
99.5%3.6 hReal rotation, tested rollback, one degraded pathMost B2B application features
99.9%43.2 minRedundancy at every tier, automated failover, no single-region stateRevenue-path services, primary APIs
99.99%4.32 minMulti-region active-active, no human in the detection loopPayments authorization, auth
99.999%26 sFull automation of detect and remediate; dependencies at the same tierTelephony switching, some control planes

So when an executive asks for five nines, the useful response is neither agreement nor refusal. It is a counter-offer with the arithmetic attached: "Five nines is 26 seconds a month, which excludes any human from the loop and requires every dependency at that tier — including the model provider, whose observed availability is around 99.5%. What we can commit to is 99.9% on the answer path with a documented degraded mode, which costs roughly one quarter of engineering and $14k a month in redundancy. If some specific operation genuinely needs more, name it and we will price that operation alone." That last clause is where the conversation usually resolves, because the request is almost never about all operations — it is about one flow somebody is worried about.

Queueing intuition: the hockey stick

Every team eventually discovers that a system running comfortably at 70% capacity becomes unusable at 95%, and that the transition is not gradual. The shape behind this is worth carrying around as intuition, and one formula supplies it: for a simple single-server queue, mean time in system ≈ service time / (1 − utilization).

ρ = 0.50  →  1/(1−0.50) =   2×  service time
ρ = 0.70  →  1/(1−0.70) = 3.3×
ρ = 0.90  →  1/(1−0.90) =  10×
ρ = 0.95  →  1/(1−0.95) =  20×
ρ = 0.99  →  1/(1−0.99) = 100×

Between 50% and 70% utilization, latency less than doubles. Between 90% and 99% — an increase of nine percentage points, the kind of change a capacity plan describes as a rounding error — latency grows tenfold. That asymmetry is the whole lesson: the cost of load is not linear in load, and the interval in which the system tells you it is unhappy is short.

Curve of mean latency against utilization following one over one minus rho, with operating zones marked as comfortable below 70 percent, degrading between 70 and 90 percent, and the cliff above 90 percent, annotated with the retry storm ignition zone and where backoff with jitter operatesUtilization ρLatency (× service time)050%70%90%98%10×20×50×ρ=0.5 → 2×ρ=0.9 → 10×ρ=0.95 → 20×ρ=0.99 → 100×Comfortable — ρ < 0.7headroom absorbs spikes; latency nearly flatDegrading0.7–0.9The cliffRetry-storm ignitiontimeouts fire → retries add load → ρ climbs→ latency rises → more timeoutswhere backoff + jitter + breakers push ρ backshed offered load
Figure 6.2 — Latency against utilization. Mean time in system grows as 1/(1 − ρ): flat and forgiving below 70%, visibly degrading through 90%, vertical beyond. The dashed panel marks where retry storms ignite — once timeouts start firing, retries add offered load, which raises ρ, which raises latency, which fires more timeouts. Backoff, jitter, and circuit breakers all do the same job from different angles: reduce offered load so the system slides back down the curve to where it is stable.

Three operational consequences follow directly.

Headroom is what your latency SLO costs. Running at 50% rather than 90% doubles your compute bill and buys a 5× improvement in queueing delay. That is not waste; it is the purchase price of the latency number you committed to in module 4. If someone proposes cutting it, the honest framing is that they are proposing to spend latency budget on infrastructure savings — a legitimate trade, made explicit.

Autoscaling targets near 90% are a cliff, not an optimization. Scaling takes time — instance start, warm-up, health checks, connection draining — during which traffic keeps arriving. Starting the scale-up at 90% means the curve is already steep when the new capacity begins its two-minute journey. A target between 60% and 70% keeps the system on the flat part while capacity catches up.

The metric to watch is not the mean. The formula describes mean time in system; the tail is worse and steeper. A system whose p50 has doubled has usually already lost its p99 entirely, which is why the latency SLI in module 4 is a percentile.

Note on rigor

1/(1 − ρ) is the M/M/1 result and real systems violate its assumptions — arrivals are bursty rather than Poisson, service times are not exponential, and there are many servers. Multi-server systems are more forgiving and hold their shape longer; bursty arrivals are less forgiving and reach the cliff earlier. The shape is what transfers, and the shape is what you need: gentle, then knee, then vertical.

Retry storms, backoff, and jitter

Now put the two curves together, because the incident that runs through this course lives at their intersection.

At 09:10 the primary model provider's p95 time-to-first-token begins drifting from 1.4 s toward 11 s. Nothing has failed. The provider is slow, which by itself would be a latency-SLO problem and not an availability one. Then the client timeout — 2 seconds, chosen years ago to protect the user experience — converts every slow response into an error. And the client, correctly by its own lights, retries. Three attempts each.

Offered load, minute by minute:

  baseline           30 req/min → 30 provider calls/min
  slow, 1st timeout  30 req/min → 60 calls/min  (each request now 2)
  full retry policy  30 req/min → 120 calls/min (3 attempts + original)

Provider capacity meanwhile: effectively halved by its own degradation.
Offered load ×4, capacity ×0.5 → utilization ×8 against the curve above.

This is a retry storm. Its defining property is that it is self-reinforcing: retries raise utilization, higher utilization raises latency, higher latency fires more timeouts, more timeouts produce more retries. The system does not drift into this state — it accelerates into it, which is why the transition from "slightly slow" to "completely down" took four minutes.

Swimlane sequence showing a retry storm igniting across clients, assist-api, and the model provider as timeouts spawn retries and multiply offered load, followed by a counterfactual strip showing backoff, jitter, and a circuit breaker flattening the amplificationWithout countermeasures — the stormclientsassist-apiprovidert=0 · 30/minTTFT 1.4 s → 11 s2 s timeout → errorretry 1, retry 2 — 60/minretry 3 + synchronized wave — 120/minoffered load ×4, capacity ×0.5 → collapseWith countermeasures — the counterfactualassist-apiproviderretry 1 after 1 s ± jitterretry 2 after 2–4 s, decorrelatedbreaker opens: stop calling, serve degradedoffered load stays ≈ 1.4× baseline; provider recovers on its own; users see a degraded answer instead of an error
Figure 6.3 — Ignition, and the counterfactual. Above: a 2-second client timeout converts provider slowness into errors, three-attempt retries multiply offered load fourfold, and the synchronized retry waves arrive together because every client timed out at the same instant. Below: exponential backoff spaces the attempts, full jitter decorrelates them so they no longer arrive as a wave, and a circuit breaker stops offering load entirely once the failure rate crosses its threshold — the dependency gets room to recover, and users see a degraded answer rather than an error.

The four countermeasures, and where each operates on Figure 6.2:

CountermeasureWhat it doesWhere it actsFailure if omitted
Exponential backoffSpace attempts: 1 s, 2 s, 4 sReduces the rate of added loadRetries arrive as fast as the timeout allows
Full jitterRandomize each delay over [0, backoff]Removes the synchronized waveEvery client retries at the same instant — backoff alone just moves the spike
Retry budgetCap retries at, say, 10% of requestsBounds total amplificationAmplification is unbounded during a broad failure
Circuit breakerStop calling after N failures; probe to reopenDrops offered load to ~0You keep hammering a dependency that cannot answer

Jitter deserves its own sentence, because backoff without it is a common half-measure. If a thousand clients all time out at the same moment and all wait exactly one second, they retry at the same moment. Backoff alone reschedules the thundering herd; it does not disperse it. Randomizing each client's delay uniformly over the interval is what converts the wave into a flow.

Retries are not resilience

The instinct after a wave of timeout errors is to retry harder — raise attempts from 3 to 6, lengthen the timeout. Both add offered load to a system already past its knee, and the second one also holds connections open longer, consuming a resource you are short of. The fix for an overloaded dependency always subtracts offered load; it never adds. If you must change something during the incident, cut concurrency, open the breaker, and shed the least valuable traffic first.

Module 07 From page to postmortem

Everything so far has been preparation for a specific fifty-one minutes. The alert fires at 09:14; by 10:05 the system is healthy again and about 7% of the month's error budget is gone. What happens in between is not a technical problem with a coordination overhead — it is a coordination problem with technical work inside it, and the difference between a good hour and a bad one is almost entirely process.

This module runs the incident end to end and then writes it up. The postmortem gets equal weight because it is where the money is: an incident costs you budget once, but the fixes it produces — or fails to produce — determine whether you spend that budget again next month on the same failure.

Declare early, grade by impact

Severity is a claim about user impact and budget burn — not about how embarrassing the cause is, how senior the person who caused it was, or how confident anyone is about what is happening. The last point is the one teams get wrong: severity is assigned from the symptom, before the cause is known. If you wait to understand the problem before declaring, you have made declaration a reward for having already done the work.

SevTriggerResponseComms
SEV1Total outage, or burn rate > 100×, or data loss/exposureAll roles staffed, exec notified immediatelyEvery 15 min, status page updated
SEV2Major degradation, burn rate > 10×, or a tenant tier fully impairedThree roles staffed, service owner notifiedEvery 30 min, internal channel
SEV3Partial degradation, burn rate 1–10×, workaround existsOn-call plus one, business hoursAt start and resolution

The morning of the incident: the fast-burn alert fires at 09:14 with an observed error rate near 30% — a burn rate around 60×. That is a SEV2 by the table, and it is declared at 09:19 on the burn rate alone. Nobody knows the cause. Declaring anyway is correct, and it is the single highest-leverage decision of the hour.

The load-bearing idea

Declaring is cheap and downgrading is free; the asymmetry is enormous. A wrongly declared SEV2 costs twenty minutes of three people's time and a message saying "stood down, it was a bad canary." An undeclared real incident costs the interval between the first person noticing and the moment they finally ask for help — an interval that is, in every postmortem anyone has read, the largest single block on the timeline.

Teams under-declare for reasons that are social rather than technical: declaring feels like admitting you cannot handle it, and there is a persistent belief that the cost is measured in disruption. The counter is structural — make declaration a routine act with a low ceremony floor, praise it in review even when the incident turns out to be nothing, and put the burn-rate threshold in the severity table so the decision is mostly arithmetic.

Roles and comms

Three roles, and the discipline is that one person holds one of them.

Incident commander. Owns the incident: assigns work, makes calls, decides when to mitigate versus keep investigating, decides when it is over. Incident commander is explicitly not a debugging role. The moment the commander opens a terminal, the coordination function stops — and coordination is what the incident is short of, because investigation is naturally absorbing and someone has to keep track of who is doing what, what has been ruled out, and whether it is time to stop diagnosing and start mitigating.

Ops lead. Hands on the system. Runs the queries, reads the traces, executes the mitigations. Reports findings to the commander rather than acting unilaterally, so the commander's model of the incident stays current.

Comms lead. Stakeholder updates on the cadence the severity table requires. This role exists because otherwise the most senior person in the room does it — badly, between queries, while being the person everyone is waiting on.

Swimlane sequence of the three incident roles across declare, triage, mitigate and resolve phases, with an overlay showing the role-collapse anti-pattern where one person holds all three lanesDECLARE 09:19TRIAGE 09:19–09:31MITIGATE 09:41RESOLVE 10:05Incident commanderdoes not debugdeclare SEV2, assignroles, open channeltrack hypotheses,set a decision clockcall it: mitigate now,diagnose laterdeclare resolved,assign postmortemOps leadhands on the systemconfirm blast radiusfrom the SLItrace waterfall →provider span 09:27failover, disablere-rankingverify SLI recovery,restore re-rankingComms leadcadence, not vibesfirst update: impact,next update time30-min updates:facts onlynotify affectedenterprise tenantsall-clear + when thepostmortem landsThe anti-pattern: role collapseOne senior engineer holds all three lanes. Debugging is interrupted every four minutes by an exec asking for status;the updates are thin because they are written between queries; nobody is tracking which hypotheses are already ruledout, so two people investigate the same dead end. Mitigation stalls — not for lack of skill, for lack of attention.One person,three lanes
Figure 7.1 — Three roles across the incident's phases. The commander tracks hypotheses and sets a decision clock; the ops lead touches the system; the comms lead keeps stakeholders on a cadence so they stop interrupting the people working. The dashed panel is the common failure: one capable person holding all three, where the binding constraint stops being expertise and becomes attention — and mitigation stalls while the most qualified person in the room is writing a status update.

Comms discipline: one channel, timestamps, facts not speculation. One channel because a distributed conversation cannot be reconstructed. Timestamps because the postmortem's timeline is assembled from this record. Facts not speculation because of what the record becomes later.

From your other life

Incident channels are business records. They are discoverable, they are read months later by people with an interest in a particular reading, and they are quoted without their context — by opposing counsel, by a regulator, by an enterprise customer's security reviewer, by an acquirer's diligence team. This is not a reason to sanitize; a scrubbed record is worse than a candid one and much easier to impeach. It is a reason to write about systems and observations rather than about people and blame. "Provider p95 TTFT is 11 s, up from 1.4 s at 09:10" ages well. "Looks like the timeout change from last sprint broke us" is a speculation, probably an incomplete causal account, and a sentence someone will read aloud.

The blameless discipline of the postmortem does not begin at the postmortem. It begins in how the channel is written at 09:23, when the pressure to attribute is highest and the information is worst.

The worked incident, end to end

The full run. Every number here is consistent with the burn chart of Figure 5.1 and the arithmetic of module 6.

09:10 — Onset. The primary model provider's p95 time-to-first-token begins climbing from 1.4 s. No alert; nothing has crossed a threshold yet.

09:12 — TTFT passes the 2 s client timeout. Slow responses start becoming errors. Retries begin, quadrupling offered load to the provider (module 6's amplification).

09:14 — Fast-burn alert fires. Error rate ≈ 30%, burn rate ≈ 60×.

ALERT  availability-fast-burn  SEV-candidate
service   assist-api
sli       availability (LB, schema-valid 200s)
burn_rate 59.8x  (windows: 1h ✓  5m ✓)
budget    93.1% remaining → projected exhaustion in 12.0h
top_error provider_timeout  (94% of failures)
runbook   go/assist-runbook#failover

The alert payload is doing real work: it names the dominant error kind, so the responder starts with a hypothesis rather than a blank page.

09:19 — SEV2 declared on burn rate alone; roles assigned; channel opened. Cause unknown, and declaring anyway is the correct call.

09:23 — Ops lead confirms blast radius from the SLI: all tenants, all tiers, ~30% of requests. Not a single-tenant problem, which rules out corpus and tenant-config hypotheses in one query.

09:27 — Trace waterfall isolates the provider span (the read performed in Figure 3.2: 9.8 s of an 11.2 s request). The wide events agree:

{"ts":"2026-03-17T09:26:58.114Z","service":"assist-api",
 "event":"answer_complete","req_id":"req_8h3k","trace_id":"4f2a91c7",
 "tenant_tier":"enterprise","model_id":"primary-v4",
 "latency_ms":11240,"ttft_ms":10980,
 "latency_breakdown_ms":{"retrieval":610,"provider":9800,"lookup_case":420},
 "outcome":"error","error_kind":"provider_timeout","attempt":2}

09:31 — Retry amplification recognized. Outbound provider call rate is ~4× inbound request rate; the team is now part of the provider's problem. This recognition took seventeen minutes and should have taken two — attempt was on the event, but outbound call rate was not instrumented separately from inbound, so the amplification had to be inferred by comparing two dashboards by eye.

09:36 — Commander sets a decision clock: "failover by 09:45 unless the provider's own status changes." This is the most underrated move in incident management — a pre-committed time at which investigation stops and mitigation begins, decided while everyone is still calm enough to commit to it.

09:41 — Mitigation. Two actions: fail over to the secondary provider, and disable the non-essential re-ranking stage to cut per-request work. Error rate falls below 2% within three minutes.

{"ts":"2026-03-17T09:41:07.402Z","service":"assist-api",
 "event":"config_change","actor":"ops-lead","incident":"INC-2026-0317",
 "change":"model_provider.primary→secondary","scope":"all_tenants",
 "reason":"provider_timeout burn 60x"}

10:05 — Resolved. SLI stable for 20 minutes; re-ranking restored; incident closed with the failover left in place pending the provider's own resolution.

The intervals — the numbers a postmortem is actually graded on: detection 4 min (09:10 → 09:14, excellent, and entirely attributable to burn-rate alerting on a symptom); declaration 5 min (09:14 → 09:19); diagnosis 13 min (09:14 → 09:27, tracing earning its cost); mitigation 27 min (09:14 → 09:41, the interval with the most room in it); resolution 51 min total. Budget consumed ≈ 60× × 51 min ÷ 43,200 min ≈ 7.1% of the month — about 105 failed requests of the 1,500-request budget's 7%.

Timeline of the provider latency incident from 09:10 onset to 10:05 resolution, with the error rate curve above, actions annotated below, the detection triage and mitigation intervals measured, and cumulative budget consumption on the right axisINC-2026-0317 — provider latency spike0%10%20%30%error rate0%3%5%7%budget consumed09:1009:1409:1909:2709:3109:4110:05onsetalert firesSEV2 declaredtrace isolates providerretry amplification seenfailover + re-rank offresolveddetect 4 mintriage 13 minmitigate 27 min — the interval with room in it51 minutes total · burn ≈ 60× · ≈ 7.1% of the 30-day error budget · ≈ 105 failed requests
Figure 7.2 — One incident, measured. The error rate (solid) rises within two minutes of onset once the 2 s client timeout starts converting slow responses into errors, plateaus near 30%, and collapses on failover at 09:41. Cumulative budget consumption (dashed, right axis) climbs to about 7% and then stops. The three brackets are what a postmortem is graded on: detection was fast because the alert watched a symptom; triage was fast because traces existed; mitigation took 27 minutes, and that is where the action items belong.

Postmortems that fix systems

A blameless postmortem assumes competent people operating in a system that made the failure easy. This is a methodological commitment rather than a kindness: the moment attribution is on the table, the people with the most information have the strongest incentive to shade it, and you lose access to the only account of what actually happened. Blamelessness is how you keep the report accurate.

It follows that you look for contributing causes, plural, rather than a root cause, singular. Real incidents are conjunctions. This one required: a 2 s client timeout calibrated against normal provider latency with no margin; no retry budget, so amplification was unbounded; no automatic provider failover, so mitigation was a human decision requiring a human to be convinced; and no outbound-call-rate metric, so the amplification took seventeen minutes to see. Remove any one and the incident is smaller. Naming one of them "the root cause" is a choice about where to stop looking, and it is usually made at whichever cause has a person attached.

Anti-pattern — the "be more careful" postmortem

Symptom: action items are "monitor the provider more closely," "add a reminder to standup," "additional review training for the team," "be more careful with timeout changes." Every one of them requires a human to sustain new vigilance indefinitely, which no human does. Corrective: every action item must change a system property — a timeout value, a retry budget, an automated failover, an alert, a deploy gate, a default. The test: would this action item have changed the outcome with the same humans on a worse day? Vigilance items fail that test by construction, because a worse day is defined by the vigilance not being available.

The document skeleton — six sections, and the impact section stated in budget terms rather than adjectives:

## INC-2026-0317 — provider latency spike
Owner:    Status: draft | in review | final

### 1. Summary
Two or three sentences. What broke, for whom, how long,
how it was mitigated. Readable by someone with no context.

### 2. Impact
Users affected, requests failed, budget consumed (% and events),
SLO status after. Numbers, not adjectives.

### 3. Timeline
Timestamped, from onset to resolution. Include detection,
declaration, diagnosis, mitigation. Facts only.

### 4. Contributing causes
Plural. Each with the evidence that supports it.
No person is a cause.

### 5. What went well
Not morale decoration — these are the controls that worked
and must not be traded away in the fixes.

### 6. Action items
| # | Action | System property changed | Owner | Due | P |
Every row changes a system property. No vigilance items.

Section 5 earns its place: without it, the fixes proposed in section 6 sometimes undo the controls that limited the damage. Here, burn-rate alerting caught the incident in four minutes and tail-sampled traces made diagnosis take thirteen. If a later cost review proposes cutting tracing, section 5 is the record of what that cut would cost.

Module 08 Observing the model

Everything in the previous seven modules applies unchanged to an LLM application. The signals are the same signals, the SLO discipline is the same discipline, the arithmetic does not care what your service computes. What changes is that three assumptions ordinary telemetry quietly rests on stop holding, and one tension that ordinary services never face arrives with a legal clock attached.

The assumptions: that the cost of a request is roughly constant, that latency is a single number, and that correctness is carried by a status code. None of the three survives contact with a model call. The tension: the single most useful debugging artifact for a quality problem is the full prompt and response — which, for the compliance assistant, is case details, names, and a lawyer's own words about a client matter. This module builds the three new instruments and then faces the capture question squarely, because the honest answer is neither "log everything" nor "log nothing."

What the model breaks

Cost varies per request by orders of magnitude. An ordinary HTTP endpoint costs approximately the same to serve every time; capacity planning is about request count. A model call's cost is a function of tokens in and tokens out, and across the assistant's traffic that ranges from about 900 tokens for a short lookup to 14,000 for a long comparison across many retrieved documents. Request count no longer predicts spend, so cost has to become a measured dimension rather than an inferred one.

Latency is two numbers, not one. A streaming answer has time-to-first-token — how long the user stares at nothing — and time-to-complete, which is dominated by output length. These move independently and for different reasons: TTFT reflects the provider's queue and the prompt's size, completion time reflects how much the model decided to say. Averaging them produces a number describing no experience anyone had. Module 4's split SLI is the consequence.

Correctness is a distribution, not a code. A 200 response tells you the pipeline worked. Whether the answer cited a policy that actually supports the proposition is a judgment, produced by a grader over a sample, arriving hours later with error bars. And the dependency can change its behavior without any version bump you can observe — the same model id, the same prompt, quietly different outputs.

The LLM observability stack layered on the standard signals: a base layer of logs metrics and traces, then a cost layer of token telemetry, a latency layer of time-to-first-token and completion percentiles per model, and a quality layer of sampled eval verdicts, each annotated with the question it answersBase — modules 1–3Wide events · RED metrics · traces with propagationUnchanged by the model. Still the foundation everything else joins to.AnswersDid it work? Where did the time go?What happened to this one request?Cost layertokens_in · tokens_out · tokens_cached, dimensioned bymodel_id · feature · tenant_tier — never tenant idAnswersWhy did the bill double? Which feature'scost-per-answer moved, and when?Latency layerTTFT and time-to-complete percentiles, per model,segmented by output length bandAnswersIs the wait before the first word growing?Is one provider fleet slower than another?Quality layerSampled eval verdicts as an SLI stream · refusal rate ·answer-length drift · retrieval hit rateAnswersDid the answers get worse while everystatus code stayed 200?
Figure 8.1 — Three layers on top of the ordinary stack. The base is unchanged: wide events, RED metrics, propagated traces. Above it sit three instruments an ordinary service does not need — token cost as a measured dimension, latency split into the two moments users feel differently, and a quality stream fed by sampled grading. Each layer joins to the base by req_id and trace_id, which is why module 2's correlation discipline is a prerequisite rather than a nicety.
The load-bearing idea

Same discipline, three new instruments. Nothing about SLOs, burn rates, or incident response changes because there is a model in the path. What changes is that cost, latency, and correctness each need a sensor they did not previously need — and that the most informative sensor for correctness is also a personal-data collection point.

Cost and latency telemetry

Token counts become first-class metrics, dimensioned by model_id, feature, and tenant_tier. Note the last one — tier, not tenant id. This is module 2's cardinality rule arriving in a place where the temptation to violate it is strongest, because per-tenant cost attribution is a thing finance genuinely wants. The resolution is the same as before: bounded dimensions in metrics for alerting and trend, the tenant id on the wide event for the drill-down, and a nightly batch job over the event stream for the per-tenant cost report finance actually needs monthly rather than in real time.

The assistant's numbers, per answer, averaged over a healthy week:

FeatureTokens inCachedTokens outCost/answerShare of spend
Policy question (primary)6,1004,8001,840$0.04171%
Case-context enrichment2,4001,900380$0.01114%
Conversation summarization3,2000240$0.01311%
Citation validation (eval)1,800090$0.0064%

Alert on cost per answer, not on total spend. This is the single most useful design decision in cost telemetry, and it is not obvious. Total spend rises when traffic rises, which is good news; it also rises when a prompt change adds 3,000 tokens of instructions to every request, which is not. A total-spend alert cannot distinguish them, so it either fires on growth (and gets muted) or is set high enough to miss a 40% per-unit regression hiding inside a good month. Cost per answer, dimensioned by feature, isolates the regression: traffic doubling leaves it flat, and a prompt regression moves it immediately.

Worked example

In February the assistant's monthly model bill rose about 8%. Total spend alone would have supported three stories: more traffic, a price change, or a regression. The per-feature cost-per-answer panel resolved it in one query — policy-question cost per answer was flat at $0.041, but summarization had gone from $0.004 to $0.013. The cause: a prompt change had stopped truncating conversation history, so every summarization call now carried the full transcript, and the cached fraction had dropped to zero because the prefix changed on every turn. An 11%-of-spend feature had tripled and the fix was a four-line change. Without per-feature attribution, the investigation starts by staring at a single rising line.

Latency percentiles, per model. A provider serves from a heterogeneous fleet, and providers roll out new serving stacks progressively. Aggregate p50 hides the slow fleet entirely; aggregate p95 shows something is wrong without saying what. Percentiles dimensioned by model_id — and, where the provider exposes it, by region or fleet id — turn "latency is worse" into "the p95 on primary-v4 is worse and primary-v3 is unchanged," which is a sentence you can act on within the hour.

Segment completion latency by output-length band as well. A model that has started producing longer answers will show rising p99 time-to-complete while nothing about the system got slower — the answers got longer. That is a product change disguised as a performance regression, and telling them apart requires the segmentation.

Quality drift

The failure this section exists for: every status code is 200, latency is nominal, availability is green, and the answers have quietly become worse. Nothing in modules 1 through 7 detects this, because every instrument built so far measures whether the machinery ran.

Quality drift has three usual causes. A silent model change — the provider updates the weights behind a stable model id, and outputs shift. Corpus rot — documents are added, superseded, or reindexed, and retrieval starts returning subtly wrong context. And prompt entropy — a series of individually reasonable prompt edits accumulate into instructions that pull against each other.

Four signals, in decreasing order of directness and increasing order of cheapness:

Citation validity from sampled eval grading — the primary quality SLI, 200 answers a day graded by the validator pipeline of Guide Nº 03. Directly measures the thing you care about, and costs a real grading run.

Refusal rate — the fraction of answers declining to answer. Cheap, computed from the answer itself, and a superb early-warning tripwire: a model update that made the model more cautious shows up here within hours, long before enough graded samples accumulate to move the citation SLI.

Answer-length distribution — nearly free, and a surprisingly sensitive change detector. A shift in mean answer length is not itself a quality verdict, but it is strong evidence that something about generation changed, which is exactly the alert you want when the provider will not tell you.

Retrieval hit rate — the fraction of answers citing at least one retrieved document, and the mean rank of cited documents. Isolates corpus rot from model drift, which is the first fork in any quality investigation.

Sample-size honesty

At 200 graded answers a day against a 98% target, the standard error is about 1 percentage point. A day at 96.5% is well inside noise. A drop from 98% to 94% is real — that is four standard errors — but confirming it takes accumulating days, which is why the SLI window is 7 days and why the alert is a ticket rather than a page. Quoting a single day's quality number to three significant figures in a status update is a way of misleading people with a true number.

Which is why the drift protocol is written in terms of the cheap signals first: refusal rate and answer-length distribution alert on a 24-hour window because they have thousands of samples a day; citation validity confirms over 7 days because it has 200. The cheap signals say something changed; the expensive one says and it is worse. Wiring them in that order is what gets you from a silent model change to a confirmed regression in a day instead of a week.

Cross-reference — Guide Nº 03

The eval pipeline is the sensor; this module is the wiring that makes it a production signal rather than a pre-release gate. One inheritance is worth restating: a quality SLI cannot be more reliable than its grader. If the citation validator agrees with a careful human 95% of the time, a measured 98% carries a systematic component, and you need to know its sign before defending the number to a customer. Run the grader against a human-labelled set periodically for the same reason you calibrate any instrument.

Capture versus minimization

Here is the tension, stated without softening. The single most useful artifact for debugging a quality problem is the full prompt and the full response: the user's actual question, the retrieved documents, the model's actual words. For the compliance assistant those contain client names, matter details, and a lawyer's own characterization of a live case — personal data, some of it special-category, all of it professionally sensitive. It is simultaneously the thing you most need and the thing you least want in a general-purpose log store with 90-day retention and organization-wide read access.

Anti-pattern — capture everything, forever

Symptom: full prompts and responses logged at 100% into the general log store on default retention, readable by every engineer, with no purpose limitation, no redaction, and no deletion path. It arrives innocently — someone needed it once for a hard bug and never turned it off. Corrective: the tiered policy below. The finding an auditor writes is rarely "you captured personal data"; it is "you captured it without a stated purpose, retained it past that purpose, and could not say who had read it." Each of those three is an implementation detail you control.

The resolution is that "capture" is not one decision. It is four decisions about four classes of telemetry, each with its own purpose, sampling rate, redaction, retention, and access control.

Decision flow for four classes of LLM telemetry — metadata, token counts, redacted samples, and full prompt capture — each routed through purpose, sampling rate, redaction, retention tier, and access control, with the capture-everything default path marked as the anti-patternCLASSPURPOSESAMPLINGREDACTIONRETENTIONACCESSMetadataids, outcome, latencyoperate the service100%n/a — no content14 d hot / 90 d warmall engToken countsin / out / cachedcost management100%n/a — no content13 mo aggregatedall engRedacted sampleprompt + responsequality debugging5%at ingest: names,case ids, contacts30 d, then deletedqualitygroupFull captureverbatim, unredactednamed incident onlyflag on, flag offon requestnone — that is why7 d auto-deleteno extension pathnamed2, loggedThe default path this policy exists to blockEverything → 100% → no redaction → default 90-day retention → readable by all engineering → no deletion path.Arrives innocently: someone needed it once for a hard bug, and nobody turned it off. Purpose expires; the data does not.
Figure 8.2 — Four classes, four policies. Capture is not one decision. Metadata and token counts carry no content and can be kept broadly and long. Redacted samples are the quality-debugging workhorse — a 5% sample with names and case identifiers stripped at ingest answers most quality questions at a fraction of the exposure. Verbatim capture exists, is scoped to a named incident, is switched on and off deliberately, auto-deletes at seven days with no extension path, and every read is logged.

Three design choices in that table carry most of the weight.

Redaction happens at ingest, not at query time. If unredacted text lands in storage and is masked on the way out, you have stored the personal data and built a permission system — one misconfiguration away from exposure, and no help at all against a subject access request that asks what you hold. Redacting before persistence means the data was never collected in that form.

Full capture is a flag, and the flag has an expiry. The failure mode is not turning it on; it is leaving it on. Bind it to an incident id, default it to a seven-day auto-delete, and provide no extension path — if you still need it on day eight, that is a new decision with a new justification, which is exactly the friction you want.

Access to full captures is logged. Not to distrust engineers, but because "who accessed this?" is a question you will be asked and should be able to answer in one query. An access log is also what makes a narrow permission grant credible to a reviewer.

Cross-reference — Guide Nº 04

This policy is an implementation of machinery covered there: a lawful basis for each purpose (operating the service and cost management sit comfortably under legitimate interests; verbatim capture of client-matter content needs a narrower and better-documented justification), purpose limitation (debugging data may not quietly become training data — a boundary that is crossed accidentally more often than deliberately), data minimization (the 5% sample exists because 100% was never necessary for the purpose), and storage limitation (the seven-day auto-delete is what makes "we keep it only as long as needed" a mechanism rather than an assertion).

The reframing worth carrying: retention is not a storage-cost decision that happens to have legal implications. It is a legal commitment that happens to have storage costs. Configure it accordingly, and configure it in code rather than in a policy document.

Concept index

Observability
The property that you can answer questions about a system's internal state — including questions you didn't predict — from its external outputs, without shipping new code.
Monitoring
Watching for failure modes you predicted in advance; necessary, and strictly weaker than observability.
RED metrics
Rate, errors, duration — the three aggregate measurements every request-serving endpoint ships with.
Structured event
A log record with named, typed fields that a query can touch — the unit of logging, as opposed to prose.
Wide event
One event per unit of work carrying everything the incident query will want: identity, correlation, business, and outcome fields.
Cardinality
The number of distinct values a dimension takes; metrics pay per unique label combination, so unbounded identifiers belong in events.
Correlation ID
An identifier minted at the edge and propagated through every hop so one request's records can be joined across services.
Retention tier
The storage class and lifetime a class of telemetry is kept under; part of the logging contract, not an afterthought.
Span
A named, timed operation with a parent reference — the node of a trace.
Trace
The tree of spans one request grows across services; the only signal in which cross-service causation is a first-class fact.
Context propagation
Carrying trace identity across boundaries (headers, message attributes) so the span tree stays connected.
Head sampling
Deciding at the trace's root whether to keep it — cheap, and blind to how the request turns out.
Tail sampling
Deciding after completion — keeps every error and slow trace, at the cost of buffering everything until the verdict.
SLI
A service-level indicator: the ratio of good events to valid events, defined with its measurement point, chosen so users would agree with the verdict.
SLO
A service-level objective: the target an SLI must meet over a window, set from user need — the number the error budget is derived from.
SLA
A contract with remedies; kept looser than the internal SLO so the contract never triggers first.
Error budget
The failure the SLO permits (1 − SLO, over the window); a spendable allowance for risk, not a shameful remainder.
Error-budget policy
The pre-agreed rule for what ships at each budget state — healthy, burning, exhausted — and who decides.
Burn rate
How many times faster than sustainable the error budget is being consumed; the quantity page-worthy alerts are written in.
Symptom-based alert
An alert on what users feel (an SLI degrading) rather than on an internal cause that might explain it.
Multiwindow alert
A burn-rate alert requiring both a long window (magnitude) and a short window (still happening) so recovered blips don't page.
Page
An interrupt that demands a human act now; legitimate only when urgent, actionable, and user-visible.
Serial composition
Availability multiplication across hard dependencies: every nine in the chain erodes the product.
Parallel composition
Redundancy math: with independent alternatives, failure probabilities multiply — and independence is the assumption that fails first.
Utilization
The fraction of capacity in use; latency grows roughly as 1/(1 − utilization), which is why 95% busy is a cliff, not a goal.
Retry storm
The load multiplication that ignites when timeouts convert slowness into retries at the moment capacity is scarcest.
Backoff with jitter
Retry spacing that grows exponentially and is randomized so retries don't arrive as synchronized waves.
Circuit breaker
A client-side guard that stops offering load to a dependency that is failing, and probes for recovery.
Incident commander
The role that owns coordination and decisions during an incident and deliberately does not debug.
Blameless postmortem
Incident analysis that assumes competent people in a system that made failure easy — the assumption that keeps reports honest.
Contributing causes
The set of conditions that jointly produced an incident; the alternative to hunting one root cause and one culprit.
Time to first token
The latency users feel from an LLM system before anything streams; tracked separately from time-to-complete.
Quality drift
Gradual movement in an LLM system's output quality — after a silent model change or corpus rot — visible only to sampled evaluation, never to status codes.
Data minimization
Capturing the least personal data that serves the stated purpose, for the shortest sufficient retention — the constraint LLM capture policies are written under.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.