What this module covers: The difference between monitoring and observability, stated as a practical test: can you answer a question you did not anticipate without shipping code? The three signals and what each one genuinely cannot do, plus the wide structured event that increasingly replaces the separation. Cardinality as the budget that governs your metrics bill, and the single label that has taken down more metrics backends than any other cause. Why you cannot average percentiles, and why histogram buckets must be chosen before you need them. Correlation IDs propagated through every hop including queues and background jobs, which is the gap most systems have. Traces, and why tail-based sampling is what you actually want. Then the synthesis section: every mechanism built in the previous three phases has one metric that proves it is working, and this is where they are listed together — because a circuit breaker, a retry budget, a replication slot and a reconciliation job are all silent until someone measures them.
Monitoring Answers Known Questions; Observability Answers New Ones
Monitoring is the set of dashboards and alerts you built for failures you predicted: CPU, error rate, disk space, queue depth. It works well, and it only covers what you thought of.
Observability is the property that lets you answer a question nobody anticipated. "Why are requests from Android clients in Sydney on the new checkout flow slow, but only for tenants on the enterprise plan, and only since Tuesday?" — a question with five dimensions nobody predicted, and which must be answerable now, from data you already have.
The practical test:
If answering a new question requires deploying code, you have monitoring. If you can slice the data you already collect along a dimension you did not plan for, you have observability.
That test explains why the shape of the data matters as much as its volume. A pre-aggregated counter of requests by status code cannot be sliced by tenant afterwards — the information was discarded at write time. A wide event carrying tenant, plan, region, client version, endpoint and duration can be sliced by any of them, forever.
Analogy: a car's dashboard versus an engine diagnostic port. The dashboard shows the six things the designer decided you would need — speed, fuel, temperature — and it is perfect for those. When the car makes an unfamiliar noise under load in third gear, the dashboard has nothing, and no amount of staring at it helps; you plug in a tool that reads everything the engine records and go looking. The dashboard is monitoring. The diagnostic port is observability. Both exist because they answer different questions, and building only the dashboard is what makes an unfamiliar noise a two-day investigation.
Three Signals, Three Different Bills
Metrics
Logs
Traces
Shape
numeric time series
discrete events with text
causal spans across services
Answers
"how many, how fast, trend"
"what exactly happened here"
"where did the time go"
Cannot answer
"why was this request slow"
aggregate questions cheaply
anything not sampled
High cardinality
catastrophic
fine
fine
Cost driver
unique label combinations
volume × retention
sampled volume
Best for
alerting, SLOs, dashboards
debugging one case, audit
distributed latency, dependency graphs
Each has a hard limit that the other two cover:
Metrics are pre-aggregated, so per-request detail is gone. You can know that 3% of requests failed and not which ones or why.
Logs are per-event and therefore expensive to aggregate — computing a p99 from raw logs means scanning them, which is why log-based dashboards get slow and costly as traffic grows.
Traces show causality across services, which nothing else does — and they are sampled, so the one request a customer is complaining about may not exist in your trace store.
The convergence: wide structured events
The modern position is that the three-pillar split is partly an artefact of tooling, and that the better primitive is one wide, structured event per unit of work:
json
One such event replaces a great many metrics, because metrics can be derived from events while events cannot be derived from metrics. The information is kept, so a new dimension is a new query rather than a new deploy. That is the observability test satisfied by construction.
The costs are real: storage and a backend that can aggregate over high-cardinality fields at query time, plus discipline about what goes in the event. And metrics do not go away — they remain the right substrate for alerting, because an alert must evaluate cheaply, frequently, and over a long window.
Cardinality Is the Budget, and One Label Has Ruined More Backends Than Anything Else
A metric's cost is not the number of data points. It is the number of unique label combinations, each of which is a separate time series that must be stored, indexed and held in memory.
text
That is not a gradual cost increase; it is an out-of-memory kill on the metrics backend, or a bill that rises by an order of magnitude overnight. The labels that cause it are always the same: user_id, request_id, session_id, email, a full URL path with IDs in it, an error message string, or a timestamp.
The rule, and it is close to absolute:
Metrics take bounded dimensions only. Every unbounded or high-cardinality dimension belongs in a log line, a trace, or a wide event.
Which makes path normalisation mandatory: /orders/8891/items/42 must be recorded as /orders/:id/items/:id, or you have one series per order. Most frameworks' auto-instrumentation does this; hand-rolled instrumentation frequently does not, and the discovery is usually a billing alert.
Two related traps:
Histogram buckets multiply cardinality. A latency histogram with 12 buckets creates 12 series per label combination — so a histogram is roughly an order of magnitude more expensive than a counter, and adding a label to a histogram costs proportionally more.
You must choose buckets before you need them. Percentiles are computed from bucket boundaries, so if your buckets stop at 1 second, you cannot learn anything about a 4-second p99.9 after the fact. Choose bucket boundaries that straddle your SLO thresholds, and accept that this is a decision made in advance — which is precisely the limitation wide events do not have, since a raw duration field supports any percentile computed later.
You cannot average percentiles
This error is extremely common and produces confidently wrong dashboards:
text
A percentile is not a linear quantity, so it cannot be averaged, summed or meaningfully compared across differently-loaded instances. The correct aggregation is over the histograms: merge bucket counts across instances, then compute the quantile once. Prometheus's histogram_quantile(0.99, sum(rate(bucket[5m])) by (le)) does exactly this, and the sum … by (le) is the part people omit.
Two further honesty requirements. Measure client-perceived latency, not only server-side handler time — the server's p99 excludes DNS, connection setup, queueing at the load balancer, and the network, which is where a meaningful share of user-perceived slowness lives (Module F-5). And look at the distribution, not just the percentile: a bimodal latency distribution (cache hit versus miss) has a p99 that describes neither mode.
Correlation IDs, and the Boundary Everyone Forgets
A single ID that identifies one logical operation, present on every log line, every span, and every downstream call, is the cheapest and highest-value thing in this module. Without it, "what happened to this request" is a manual join across services by timestamp, which does not work under load.
The standard is W3C trace context: a traceparent header carrying trace ID, span ID and flags, generated at the edge if absent and propagated everywhere.
text
It must survive asynchronous boundaries. This is where nearly every system's tracing breaks:
Queue and topic messages must carry the trace context in the message envelope, not just the payload, and the consumer must restore it (Modules P-14, P-15). Otherwise the trace ends at the producer and the work done by the consumer is an orphan.
Background jobs must store the trace context on the job row and restore it when claimed (Module P-19), so "why did this user's export fail" links back to the request that requested it.
Outbox events and CDC should carry it too (Module P-23), so a downstream effect is attributable to its cause.
Webhook deliveries should include it, so a partner's report of a problem can be traced (Module P-17).
In-process async context needs a mechanism — AsyncLocalStorage in Node — or the ID is lost the first time you await inside a callback chain.
And a trick worth knowing: attach the trace ID to your database queries as an SQL comment. Postgres records it in pg_stat_activity and slow-query logs, so a slow query found in the database can be traced back to the request that issued it, which is otherwise a genuinely hard correlation:
sql
Logs: Structured, Scrubbed, Sampled, Budgeted
Structured, not printf. JSON with consistent field names, so logs are queryable rather than greppable. log.info({ orderId, tenantId, durationMs }, "order created") is a data record; log.info("Created order " + id + " in " + ms + "ms") is a sentence you will regret writing a parser for.
Levels used meaningfully.ERROR means someone should look; WARN means it is notable and handled. A codebase where ERROR fires for expected validation failures has trained everyone to ignore errors, which is the most expensive possible outcome of a logging decision.
Sample deliberately. Keep every error, sample successful requests (1–10% is common at volume), and keep all logs for a sampled trace so a retained trace has its detail intact. Log volume grows with traffic and with every developer who adds a line, which makes it one of the largest observability line items — and it is a cost that appears without anyone deciding (Module O-8).
Never log secrets or personal data, and treat that as a compliance control rather than hygiene. Logs get shipped to third-party providers, retained for months, and replicated across regions — so a request body in a log is a PII store with residency and retention obligations (Modules A-9, O-9). Use an allowlist of fields to log rather than a denylist of fields to redact, because a denylist fails silently the first time someone adds a field. Scrub at the collector as a second layer.
Traces: Where the Time Went, and How to Sample Them
A trace is a tree of spans, and its value is the waterfall:
text
Nothing else in your toolkit makes that legible. It is also how you find the serial chain that should be parallel and the fan-out amplification of Module A-4.
Sampling is the cost control, and the choice matters:
Head-based: decide at the edge (keep 1%). Cheap, simple, and it discards most errors and slow requests — precisely the traces you wanted.
Tail-based: buffer a whole trace in a collector, then decide once its outcome is known. Keep 100% of errors, 100% of traces above a latency threshold, and 1% of the rest. This is what you actually want, and it costs a collector tier with memory proportional to trace duration times throughput.
Instrument boundaries automatically (OpenTelemetry auto-instrumentation covers HTTP, database drivers and queue clients) and add manual spans for the business-meaningful steps that framework instrumentation cannot know about — "reserve inventory," "run ranking model." Put high-cardinality identifiers on span attributes, where they are free, rather than on metric labels, where they are ruinous.
The Golden Signals, and the Synthesis: Every Mechanism Has One Metric
Start with the four golden signals per service and per dependency: latency (as a distribution), traffic, errors, and saturation — with saturation the leading indicator, since it moves before the other three (Module A-8).
Then the payoff. Every resilience and correctness mechanism built in the previous three phases is silent by default, and each has exactly one metric that proves it is working. This table is the observability plan for everything in this course:
Mechanism
The metric that proves it works
Why it is silent otherwise
Circuit breaker (A-7)
state, and duration open
an open breaker suppresses errors — the feature is just gone
Graceful degradation (A-7)
degraded-serve rate as an SLI
fallbacks succeed, so the error rate stays flat
Retries (A-6)
retry rate; retry-budget exhaustion
amplification looks like a traffic surge
Load shedding (A-8)
shed rate; queue wait time
rejections are intentional and look like errors
Job queues (P-19)
age of the oldest unstarted job, per queue
throughput looks healthy while the backlog grows
Connection pool (P-4)
pool wait time
belongs to no component's dashboard
Replication (P-5)
replica lag
stale reads produce no error
CDC (P-23)
end-to-end lag; retained WAL per slot
a stopped consumer fills the primary's disk
Cache (P-12)
hit ratio; evicted_keys; memory vs maxmemory
a full instance refuses writes silently
Search / vectors (P-20, P-21)
zero-result rate; recall@k
bad recall is fast and returns results
Payments (P-24)
unreconciled breaks; stale unknown attempts; ledger sums to zero
a wrong charge is a successful request
Webhooks (P-17)
per-endpoint queue age
a global failure rate is dominated by one broken customer
Feed fan-out (A-14)
post-to-timeline p99
the feed is simply behind
LLM answers (A-12)
groundedness, refusal rate, thumbs-down
a wrong answer is an HTTP 200
Clocks (A-1)
ntp_offset per host
skew produces lost writes and rejected tokens, silently
Multi-region (A-9)
every SLI, with a region dimension
a global average hides a dead region
Rate limiters (P-18)
rejections per principal
a TTL-less key locks one customer out forever
The pattern across every row: the mechanisms that protect you do so by converting a loud failure into a quiet one. That is their purpose, and it is exactly why each needs a metric. A system with good resilience and no observability is a system that degrades permanently without telling anyone.
Controlling the Cost
Observability commonly runs 10–30% of infrastructure spend, and occasionally exceeds the cost of the system it observes. The levers, in order of effect:
Cardinality limits and enforcement at the collector, so one bad label cannot take out the backend or the budget.
Tail-based sampling for traces, and log sampling of successes.
Retention tiers: hot for 7 days, warm for 30, then aggregated rollups or raw data in object storage (Modules P-22, O-8). Almost nobody queries raw traces from four months ago; many people pay to keep them.
Delete unused metrics. In most systems a large majority of time series are never queried by any dashboard or alert. Auditing that is a quick, sizeable saving.
Aggregate at the edge before shipping, where the raw detail has no consumer.
Why this matters in production: the failures observability prevents are the ones already listed in the table — a breaker open for three weeks, a dead-letter queue nobody drained, a CDC slot filling a disk, a cache silently refusing writes, a payment reconciliation nobody read. But observability also causes incidents, in two recognisable ways: a metrics backend killed by a user_id label added in a routine PR, and a log pipeline that costs more than the application and gets cut in an emergency, removing your ability to debug at exactly the wrong moment. Both are cardinality and volume problems, and both are cheaper to prevent with a limit at the collector than to fix under pressure.
Where This Shows Up in Your Stack
OpenTelemetry: the vendor-neutral instrumentation layer, and the reason to adopt it is portability — instrument once, change backend without re-instrumenting. The collector is where sampling, scrubbing, cardinality limiting and routing belong, because doing it in application code means a deploy per change.
Prometheus: pull-based, and its memory is a function of active series, so cardinality is a hard operational constraint rather than a cost curve. Use recording rules to precompute expensive queries, histogram_quantile over summed buckets (never averaged percentiles), and relabelling to drop labels you do not query.
Node.js: the runtime metrics that actually matter are event-loop delay (the saturation signal, Module A-8), heap usage and GC pause time, and active handles. Use AsyncLocalStorage to carry trace context across await boundaries, or correlation IDs silently disappear inside async call chains.
PostgreSQL:pg_stat_statements gives normalised query fingerprints with total and mean time — the single most useful database view there is. Add the trace ID as an SQL comment so a slow query in pg_stat_activity can be attributed to a request. Watch pool wait time from the application side, replica lag, and retained WAL per replication slot (Module P-23).
Redis:INFO gives evicted_keys, keyspace_misses, mem_fragmentation_ratio and used_memory versus maxmemory — the four numbers that explain almost every Redis surprise (Module P-12). SLOWLOG catches the KEYS someone ran in production.
Kafka: consumer group lag per partition is both the health metric and the backpressure signal (Modules P-15, A-8), and per-partition rather than aggregate, because one lagging partition is invisible in a total.
Summary
The test is whether you can answer an unanticipated question without deploying code. Pre-aggregated data cannot be sliced by a dimension you discarded at write time, so the shape of what you collect matters as much as the volume.
Three signals with three different limits: metrics cannot explain a single request, logs cannot aggregate cheaply, and traces are sampled so the request a customer is complaining about may not exist. Wide structured events increasingly replace the split, because metrics can be derived from events and events cannot be derived from metrics — at the cost of storage and a backend that aggregates high-cardinality fields at query time. Metrics remain the right substrate for alerting.
Cardinality is the budget: cost is unique label combinations, not data points. Adding user_id to an 18,000-series metric is a 36-billion-series outage, not a gradual increase. Metrics take bounded dimensions only; high-cardinality dimensions belong in logs, traces and events — which makes path normalisation mandatory.
Histograms multiply cardinality by their bucket count, and buckets must be chosen in advance — you cannot learn about a 4-second p99.9 from buckets that stop at 1 second. Choose boundaries straddling your SLO thresholds.
You cannot average percentiles. Merge histogram buckets across instances and compute the quantile once. Also measure client-perceived latency rather than only handler time, and look at the distribution, since a bimodal latency has a p99 that describes neither mode.
A correlation ID on every log line, span and downstream call is the highest-value cheap thing here — and it must survive asynchronous boundaries: queue message envelopes, background job rows, outbox events, webhook deliveries, and in-process await chains via AsyncLocalStorage. This is the gap most systems have, and it is why traces so often end at the producer. Attaching the trace ID as an SQL comment lets a slow query be traced back to its request.
Logs: structured JSON with consistent fields, levels that mean something (an ERROR for expected validation failures trains everyone to ignore errors), keep all errors and sample successes, and allowlist the fields you log rather than denylisting the ones you redact — because logs are shipped to third parties, retained for months and replicated across regions, which makes them a PII store with residency obligations.
Traces make the N+1 and the accidental serial chain visible instantly, and tail-based sampling is what you want — buffer the trace, then keep 100% of errors and slow traces and a small fraction of the rest — at the cost of a collector tier. Put high-cardinality identifiers on span attributes, where they are free.
Start with the four golden signals per service and per dependency, with saturation as the leading indicator.
Every resilience mechanism from the previous three phases is silent by default, and each has exactly one metric that proves it works: breaker state and time-open, degraded-serve rate, retry-budget exhaustion, shed rate and queue wait time, oldest-unstarted-job age per queue, pool wait time, replica and CDC lag, retained WAL per slot, cache evictions, search recall and zero-result rate, unreconciled payment breaks and a ledger that sums to zero, per-endpoint webhook age, fan-out latency, LLM groundedness, ntp_offset, per-principal limiter rejections — and a region dimension on all of it.
The pattern is that protective mechanisms work by converting a loud failure into a quiet one, which is exactly why each needs a metric. Good resilience without observability is a system that degrades permanently without telling anyone.
Control the cost with cardinality limits at the collector, tail-based and log sampling, retention tiers, and deleting the majority of series nothing queries. Observability can cause its own incidents — a metrics backend killed by a label added in a routine PR, or a log bill cut in an emergency, removing your ability to debug precisely when you need it.
The next module, SLIs, SLOs and Error Budgets, turns this data into decisions. Having metrics is not the same as knowing whether you are meeting a promise, and an alert on every anomaly is how a team learns to ignore its pager. O-2 covers choosing indicators users can actually feel, setting targets you can defend, spending an error budget deliberately, and alerting on symptoms with burn rates rather than on every cause.
Knowledge Check
A team adds a customer_id label to their existing HTTP request metrics so they can chart per-customer traffic. Within hours the Prometheus server is OOM-killed and the managed metrics bill for the day exceeds the previous month. What is the mechanism, and where should that dimension have gone?
A distributed system has OpenTelemetry auto-instrumentation on all HTTP calls and Postgres queries. Traces look complete for synchronous request paths, but any work that happens in a background worker or after a Kafka message appears as a separate, parentless trace — so "why did this customer's export fail" cannot be linked to the request that triggered it. What is missing?
An engineering manager notes that the platform's error rate has been flat at 0.02% for six months and concludes that the resilience work of the previous year is paying off. What does the module say about that inference, and which metrics would actually support or refute it?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
http_requests_total{method, status, endpoint, region}
5 methods × 6 statuses × 200 endpoints × 3 regions = 18,000 series ← fine
… add one label: user_id
18,000 × 2,000,000 users = 36,000,000,000 series ← the incident
instance A: p99 = 100 ms instance B: p99 = 900 ms
mean of p99s = 500 ms ← MEANINGLESS. Not the p99 of anything.
Edge → traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
↓ propagate on every outbound HTTP call
↓ include in every log line
↓ AND — the part that gets missed —
/* traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 */SELECT … FROM orders WHERE …