A9 · Part 3 · Advanced
Observability at scale
Metrics say something is wrong, traces say where, logs say what — and p99 says whether anyone noticed.
10On this page
- Three pillars, three questions, one common order
- The three pillars, priced
- Averages lie, and they lie in the direction that hurts
- Fan-out: why p99 per service is not p99 per user
- Cardinality is the thing that makes metrics expensive
- Distributed tracing and context propagation
- Structured logging, and the volume problem behind it
- Alert on symptoms, not causes
- SLOs and error budgets: making the alert threshold non-arbitrary
- Recognizing it in an unseen problem
Three pillars, three questions, one common order
Observability is usually taught as a list of three data types, which is useless at a whiteboard. The useful framing is that each answers a different question, and during an incident you usually reach for them in a familiar order. Metrics answer "is something wrong, and since when?" Traces answer "where in the call graph is it wrong?" Logs answer "what exactly happened to the request that failed?" Run that order backwards and you spend the outage grepping. It is a default drill-down, not a law: teams that store one wide, structured event per request (what some call "observability 2.0") can slice by any field and jump straight to the evidence, and "three pillars" is itself a contested label. Interviewers ask about this because a design that cannot be debugged is not a finished design — and because the candidate who says "and here is how I'd know it broke" is visibly a different animal from one who draws boxes and stops.
The three pillars, priced
| Pillar | Question it answers | Shape | Cost driver | Typical retention (illustrative) | How it fails you |
|---|---|---|---|---|---|
| Metrics | Is something wrong, and when did it start? | Numeric time series, fixed label set, pre-aggregated at write | Unique time series (cardinality), not event count | 13-15 months (downsampled) | Tells you the error rate is 4%. Cannot tell you which 4%. |
| Logs | What exactly happened to this one request? | Semi-structured records, unbounded fields | Bytes ingested and indexed — priced per GB and vendor-dependent (for example, Amazon CloudWatch Logs lists standard ingestion at $0.50 per GB in US East) | 7-30 days hot, then cold storage | Volume. At real scale you cannot afford to keep them all, so the one you needed was sampled away. |
| Traces | Where in the call graph did the latency go? | A tree of timed spans sharing a trace id | Spans retained — so almost always sampled at 0.1%-10% | 3-14 days | Head-based sampling throws away the slow trace before it knows it was slow. |
A fourth thing is worth naming because senior candidates do: events — a durable, structured record of a business fact (order placed, payment captured). Those are not telemetry, they belong in the same queue infrastructure as everything else in the message-queue chapter, and they are the thing you reconcile against when metrics and reality disagree.
Averages lie, and they lie in the direction that hurts
A mean latency is a single number summarising a distribution that is never symmetric. Request latency is always right-skewed: it has a hard floor (you cannot be faster than the network) and no ceiling (a lock, a GC pause, a cold cache, a retry). Averaging a floor with a fat tail produces a number that describes no actual user. Worse, the mean blends the fast majority and the slow tail into one number that hides both: if the slowest 1% of requests all got twice as slow (p99 going from 1.9 s to 3.8 s), the mean would rise by only about 19 ms, from 205 ms to roughly 224 ms, while the p99 doubled.
Say it like this → "I'd set the SLO on p99, not
average. Average latency is a number that improves when your slowest users
give up and leave — p99 is the number that tracks whether the product feels
broken."
Fan-out: why p99 per service is not p99 per user
Here is the arithmetic that separates people who have run a distributed system from people who have read about one. A user request that touches 10 backend services is only fast if all ten were fast. If each service independently has a 1% chance of being in its slow tail, the chance the request avoids every one of them is 0.99 to the tenth power.
| Services on the critical path | P(all fast) = 0.99n | P(at least one slow path) |
|---|---|---|
| 1 | 0.990 | 1.0% |
| 5 | 0.951 | 4.9% |
| 10 | 0.904 | 9.6% |
| 100 | 0.366 | 63.4% |
Read the last row again: with a hundred-way fan-out, a per-service p99 means most requests hit at least one slow path. This is why Jeff Dean and Luiz Barroso's "The Tail at Scale" (CACM, 2013) matters — as you decompose into more services, the user-visible p99 degrades even though every individual service's p99 is unchanged. Turn it around to get the design rule: to hold a user-facing p99, each of ten backends needs roughly p99.9, because 0.99910 is 0.990. Every layer you add moves the percentile you must engineer for one notch further out.
⚠ Retries and hedging cut the tail but multiply the load
A standard fix, described in that paper, is a hedged request: if a
replica hasn't answered by p95, fire a second request to another replica
and take the first response. That converts a tail event into a
p95-plus-a-bit event for about 5% extra traffic (the paper's figure for
deferring to p95). The failure mode is that when the system is already
degraded, everything exceeds p95, so every request hedges, load
doubles, and you have built a retry storm. Always pair hedging with a
circuit breaker and a budget ("at most 5% of requests may hedge") — the same
discipline as the retry-budget discussion in the fault tolerance chapter.
Cardinality is the thing that makes metrics expensive
A metrics system does not charge you per event. Incrementing a counter a billion times costs essentially nothing. What it charges for is unique time series — one series per distinct combination of metric name and label values, each of which needs its own in-memory index entry and its own compressed chunk on disk. Cardinality is multiplicative:
http_requests_total{service, endpoint, status, region}
services 50
endpoints 200
statuses 15
regions 5
----------------------------------------------
50 * 200 * 15 * 5 = 750,000 active series // fine — a mid-size Prometheus handles this
at a 15s scrape interval:
750,000 * 4 samples/min * 1,440 min = 4.3 * 10^9 samples/day
at ~2 bytes/sample compressed = ~8.6 GB/day // affordable
now add one label: user_id, 20,000,000 values
750,000 * 20,000,000 = 1.5 * 10^13 series // the cluster is dead
The rule is mechanical: a metric label must be bounded and low cardinality — something you could write out on a whiteboard. User ids, request ids, session ids, raw URLs with path parameters, email addresses, and error strings all belong in logs or traces, where you pay per event but the schema is free. "Which user?" is a logs question. "How many users?" is a metrics question.
⚠ The accidental-cardinality bugs are almost always the same three
Un-templated URL paths as a label (
/orders/8814 instead of
/orders/:id); the raw exception message as a label when it
contains an id or a timestamp; and a pod name or container id as a label in
an autoscaling deployment, which creates a fresh series for every new pod.
Prometheus does age out series that stop receiving samples, but the number
of distinct series it has to hold, and the memory and index space that go
with them, grows with churn rather than with the size of the current fleet. Each one of these has taken down a real monitoring
cluster.
Distributed tracing and context propagation
A trace is one tree per user request. Each unit of work is a span
carrying a trace id (shared across the whole request), its own span id, and
a parent span id. The only genuinely hard part is context
propagation: every hop — HTTP call, gRPC call, queue publish, thread
handoff — must carry the ids forward, or the tree silently breaks into
disconnected fragments. The W3C Trace Context standard (a W3C Recommendation, updated November
2021) defines a traceparent header, plus an optional
tracestate header for vendor-specific data:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^ ^ ^ ^
| trace-id (16 bytes) parent span-id flags
version (8 bytes) (01 = sampled)
| Sampling strategy | How it decides | Cost | Reach for this when… |
|---|---|---|---|
| Head-based, fixed rate | Coin flip at the entry point; the decision rides in the traceparent flags so the whole tree agrees | Cheapest; trivially stateless | Default. You want a representative sample of normal traffic and you accept losing most rare events. |
| Head-based, rate-limited per route | N traces per second per endpoint | Cheap; needs a per-route counter | Traffic is wildly skewed and a fixed rate would give you a million traces of the health check and none of checkout. |
| Tail-based | Buffer all spans of a trace at the collector, decide once it completes: keep if slow, errored, or rare | Collector must hold every in-flight trace in memory for the duration; needs all spans of a trace routed to the same collector | You specifically need the pathological traces — which, since those are the ones you debug, is most serious deployments. |
Four pieces of vocabulary that make the rest practical
OpenTelemetry is the vendor-neutral standard for generating and
shipping all three signals: an instrumentation API and SDKs, the OTLP wire
format, and a Collector that receives, processes (including tail-based
sampling) and exports to whichever backend you choose. RED (rate,
errors, duration; Tom Wilkie) is the per-service dashboard; USE
(utilisation, saturation, errors; Brendan Gregg) is the per-resource
checklist for hosts, disks and pools. Percentiles cannot be averaged:
the mean of ten instances' p99 values is not the fleet's p99. Record
latency as a histogram (counts per bucket), sum the buckets across
instances, and compute the percentile from the sum; Prometheus
summaries, which pre-compute quantiles per instance, cannot be aggregated.
Exemplars attach a trace id to a histogram bucket, so you can click
from "the p99 spike" straight to a slow trace.
Structured logging, and the volume problem behind it
A log line that is a sentence is a log line you can only grep. A log line that is an object is a log line you can query, aggregate and join to a trace. The single most valuable field is the trace id — it is what turns three independent stores into one investigation.
{"ts":"2026-08-08T11:04:22.184Z","level":"error","svc":"ranking",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7",
"user_id":"u_88214","route":"/v1/feed","status":503,"dur_ms":1841,
"err":"model_pool_exhausted","pool_size":32,"queue_depth":417}
Now the volume. At 100,000 requests/sec with one 500-byte log line each:
100,000 * 500 B = 50 MB/s
50 MB/s * 86,400 s = 4.3 TB/day
4.3 TB/day at $0.50/GB ~ $2,150/day ~ $780,000/year // $0.50/GB is illustrative; one log line per request
That number is why sampling is not optional at scale, and why the sampling policy should be asymmetric: keep 100% of errors and warnings, keep 100% of anything on a trace that was already sampled, and keep 1% of successful requests. You lose almost nothing diagnostically and drop roughly 99% of the bill. Add a per-service log budget so one team's debug statement cannot consume the org's ingest quota — a noisy-neighbour problem identical in shape to the one solved in the rate-limiting chapter.
Alert on symptoms, not causes
The instinct is to alert on everything you can measure: CPU above 80%, disk above 70%, replica lag above 5 seconds. Every one of those is a cause alert, and cause alerts have two failure modes. They fire when nothing is wrong (CPU at 90% during a nightly batch job is correct behaviour), and they fail to fire when something is wrong in a way you did not predict. After six months of false pages, the team mutes the channel, and the monitoring system is now decorative.
Alert on symptoms instead: the things a user would complain about. Google's four golden signals are latency, traffic, errors, and saturation; the first three are symptoms, and saturation is the early warning. The SRE book's rule is to page when one signal is problematic or, in the case of saturation, nearly problematic: a disk or quota about to fill deserves a page before it causes errors. Page on that kind of imminent exhaustion, and leave a generic "CPU above 80%" on the dashboard. Keep cause metrics — you need them the moment you start debugging — but put them on dashboards, not on the pager. Every page should link to a runbook and have an owner, and every serious incident should end in a blameless postmortem; an alert that nobody can act on is noise, however well it was measured.
| Don't page on this (cause) | Page on this instead (symptom) | Why |
|---|---|---|
| CPU above 80% on app tier | Latency SLO burning its budget fast (multi-window burn rate on p99 requests) | High CPU with acceptable latency is a well-utilised fleet, not an incident. |
| Read replica lag above 5 s | Rate of stale-read complaints / stale-read guard trips | Lag only matters if a user reads their own write and doesn't see it. |
| Queue depth above 10,000 | Oldest-message age above 5 minutes | Depth depends on message size and consumer count; age is directly the user-visible delay. |
| A single host is unreachable | Error rate above the error budget burn threshold | If losing one host pages you, you did not build the fault tolerance you claimed. |
SLOs and error budgets: making the alert threshold non-arbitrary
An SLI is the measurement ("proportion of requests served in under 300 ms"). An SLO is the target on it ("99.9% over a rolling 30 days"). The error budget is the leftover: 0.1% of requests, which over 30 days is a concrete allowance you may spend on deploys, experiments and bad luck. This is the mechanism that converts a philosophical argument about reliability into arithmetic.
| Availability target | Budget per 30 days | Budget per year | What it implies |
|---|---|---|---|
| 99% | 7.2 hours | 3.65 days | An internal tool. One long maintenance window is fine. |
| 99.9% ("three nines") | 43.2 minutes | 8.8 hours | A normal SaaS product. Achievable with a single region done well. |
| 99.95% | 21.6 minutes | 4.4 hours | Multi-AZ done well, automated failover, no manual step in the recovery path. |
| 99.99% | 4.3 minutes | 52.6 minutes | Multi-AZ with no shared single point of failure; multi-region active-active can go further but costs far more, and bad deploys hit every region. A human cannot even read the page in the budget. |
| 99.999% | 26 seconds | 5.3 minutes | Almost never the right answer. Say so out loud when someone asks for it. |
Alerting on the raw SLI ("error rate above 0.1%") is too twitchy: a 30-second blip trips it. Alerting on the whole window is too slow: you find out you blew the budget on day 29. The standard answer is multi-window burn-rate alerting — page on how fast the budget is being consumed, measured over both a long and a short window so a recovered blip stops paging. The table below is the recommended policy for a 99.9% SLO from the Google SRE Workbook chapter "Alerting on SLOs"; the short window is about one twelfth of the long one.
| Burn rate | Budget consumed | Windows (long + short) | Response |
|---|---|---|---|
| 14.4× | 2% in 1 hour | 1 hour + 5 minutes | Page immediately. At this rate the month is gone in ~2 days. |
| 6× | 5% in 6 hours | 6 hours + 30 minutes | Page. |
| 1× | 10% in 3 days | 3 days + 6 hours | File a ticket. This is a slow leak, not an outage. |
Dry run: a 20-minute incident through a multi-window burn-rate alert
SLO: 99.9% over 30 days → an allowed error rate of 0.1%. Page when both the 1-hour and the 5-minute burn rate exceed 14.4× (an error rate of 1.44%). Traffic is steady; baseline error rate is 0.05%; the incident runs 10:00–10:20 at a 5% error rate.
| Time | What's happening | 5-min error rate → burn rate | 1-hr error rate → burn rate | Both exceed 14.4×? | Alert state |
|---|---|---|---|---|---|
| t = 09:55 | Baseline, healthy | 0.05% → 0.5× | 0.05% → 0.5× | No | Quiet |
| t = 10:05 | 5 minutes into the incident | 5% → 50× | 0.46% → 4.6× (5 bad minutes of the last 60) | No — long window hasn't caught up | Still quiet |
| t = 10:17 | 17 minutes in | 5% → 50× | 1.45% → 14.5× (17 bad minutes of 60) | Yes — the 1-hour window just crossed 14.4× (it needs about 16.9 bad minutes) | Pages |
| t = 10:20 | Incident ends | 5% → 50× | 1.70% → 17× (20 bad minutes of 60) | Yes | Still paging |
| t = 10:23 | 3 healthy minutes since the end | 2.03% → 20× (2 bad minutes still in the 5-minute window) | 1.70% → 17× | Yes | Still paging |
| t = 10:24 | 4 healthy minutes since the end | 1.04% → 10.4× (1 bad minute left) | 1.70% → 17× (the bad 20 min are still inside the trailing hour) | No — short window recovered | Clears (the crossing is at about 3.6 minutes after the end) |
The alert only fires once both windows agree, which is why it waits until about 10:17 rather than paging on the first bad minute — and it clears at about 10:24 even though the 1-hour window is still showing a 17× burn, because the 5-minute window is what tells the alert the incident is actually over. Without that short window, this page would stay lit for most of the remaining hour after the problem was already fixed. The price of the long window is the 17-minute wait; a total outage burns the budget far faster than 14.4× and trips the same rule in about 1 minute (a 100% error rate gives a burn rate of 1000×, and the 1-hour window crosses 14.4× after about 0.83 bad minutes).
The error budget also has a second, political job — when it is exhausted, feature launches stop until reliability work restores it. That is the only mechanism anyone has found that makes "we should invest in reliability" an automatic decision rather than a quarterly argument.
Say it like this → "I'd define the SLI as the
fraction of feed requests served under 300 ms at the edge, set the SLO at
99.9% over 30 days, and page on a 14.4× burn rate over a one-hour window
with a five-minute short window to suppress recovered blips. Everything else
— CPU, replica lag, queue depth — goes on a dashboard, not the pager."
Recognizing it in an unseen problem
- The prompt says "highly available", "five nines", "SLA", or asks how you would operate the system — that is an invitation to talk SLI/SLO/error budget, and most candidates skip it entirely.
- A naive design monitors hosts (CPU, memory, disk) and calls it observability. The tell of a senior answer is monitoring user-visible symptoms and treating host metrics as debugging aids.
- Any design with more than about five services on the critical path has a tail-latency problem by construction — bring up the 0.99n arithmetic unprompted; it is one of the highest-signal thirty seconds available to you.
- If you propose adding a label to a metric, immediately state its cardinality bound. If it is unbounded, it is a log field, not a label.
- Distinguish it from the fault-tolerance chapter: fault tolerance is about the system surviving a failure; observability is about humans finding out and locating it. A system can be perfectly redundant and completely un-debuggable.
- The pitfall: proposing logging everything. At 100k rps that is a multi-hundred-thousand-dollar annual line item. Name the sampling policy (all errors, 1% of successes) before the interviewer has to ask what it costs.
Before you move on
- Say, in order, which of metrics, traces or logs you'd reach for first during an incident, and why running that order backwards means grepping.
- Walk through the burn-rate dry run above and explain why the page fires at about 10:17 rather than 10:05, and clears at about 10:24 although the 1-hour window still shows 17×.
- Compute the chance that a request fanning out to 10 services is fast on all of them when each has a 1% chance of landing in its slow tail (about 90%), and say which per-service percentile you need to hold a 99% user-facing target (p99.9).
- Explain why a metric label needs a cardinality bound but a log field doesn't, using the
user_idexample. - Name two things you would alert on and two you would only dashboard, and justify the split using the symptom-vs-cause distinction.
Sources and further reading
- Google SRE book: monitoring distributed systems — the four golden signals, symptoms versus causes, and paging on nearly-saturated resources
- Google SRE Workbook: alerting on SLOs — the multi-window burn-rate table (14.4x, 6x, 1x) used in this chapter
- The Tail at Scale (Dean and Barroso, 2013) — why fan-out amplifies tail latency and how hedged requests help
- W3C Trace Context — the traceparent and tracestate headers and the sampled flag
- OpenTelemetry overview — vendor-neutral signals, the OTLP format and the Collector
- Prometheus: histograms and summaries — why quantiles cannot be averaged but histogram buckets can be summed
- Prometheus feature flags (exemplar storage) — exemplars as references from metrics to trace ids
- Amazon CloudWatch pricing — one real per-GB log ingestion price to anchor the cost arithmetic
- The USE method (Brendan Gregg) — utilisation, saturation and errors for every resource
- The RED method (Grafana Labs) — rate, errors and duration for every service
Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.
5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.
Finished reading?
0 of 36 chapters read in this series