Module 36 / 46 · Phase G — Microservices in Practice · 50 min

Distributed
tracing.

The request touched fifteen services. One of them took four seconds. Which one — and what did it call, and what did that call, and where did the latency actually come from? The instrumentation pattern that makes distributed request paths visible end-to-end — and the sampling strategies that make it affordable at scale.

// What you'll know by the end

  • Why logs and metrics leave a blind spot
  • The trace, span, and context propagation
  • Sampling strategies and their tradeoffs
  • Jaeger · Tempo · OTel · real production tools
§ 01 — The 3 AM diagnostic gap

The tools you have.
The gap they leave.

It's 3 AM. The pager says checkout P99 is 3.4 seconds. You open dashboards. Payment-service P99: elevated. Fraud-service: elevated. Order-service: elevated. Gateway: elevated. Everything is elevated. Nothing is clearly the culprit. You look at logs. Each service is doing what it's supposed to do; the problem isn't inside any one service — the problem is between them, somewhere in the chain of calls that a specific 4-second checkout request took across your architecture. And no single log stream, no single dashboard, no single per-service tool tells you what that request's journey actually looked like. The tools you have are per-service. The problem is cross-service.

// SIX OBSERVABILITY TOOLS · WHAT THEY GIVE YOU, WHAT THEY DON'T · THE 3 AM VIEW
Logs
Tells you exactly what one service did for one request — SQL queries, exception traces, business decisions
Correlating one request across 15 services means stitching by request_id — 100M+ lines to search, 40-minute exercise
PER-
SERVICE
Metrics
Cheap, aggregated, alerts fire, dashboards show the shape of P50/P95/P99 latency and request rates
Aggregation destroys per-request detail. P99 was bad, but the specific slow requests are invisible — you know it happened but not why
AGGREGATE
ONLY
APM
Per-service performance breakdown — which endpoints, which methods, which DB queries are slow inside each service
Each service viewed in isolation. When 5 services are all "slower than usual" — which one is the actual cause, which are downstream victims?
NO CROSS
SERVICE
Health checks
Tells you which services are up or down right now. Feeds discovery (M.35) and orchestration
Says nothing about whether a healthy service is slow or fast for a specific request. Green ≠ good
BINARY
ONLY
Error rates
Tells you how many errors are happening, per service, per endpoint
Doesn't tell you where an error originated vs where it was re-thrown. Payment-svc "errored"; but the actual cause was fraud-svc timing out inside it
NO ROOT
CAUSE
Distributed traces
ONE request, ALL its spans, causality reconstructed. Every service the request touched, every child call, every timing. The 3 AM view.
Latency contribution per service is visible instantly — the waterfall lights up wherever the time actually went
FILLS
THE GAP
// THE OBSERVABILITY GAP

Every other tool is either per-service (logs, APM, health) or aggregate (metrics, error rates). Distributed tracing is the only tool that gives you one specific cross-service request in full detail. That's not a small addition — it's the entire observability question at the layer where microservices actually live: between the services, not inside them.

The reason this gap exists is a design decision that made sense when we had monoliths and doesn't scale to distributed systems. Logs and metrics were built for a world where "one process handles one request start to finish" — the process log and the process metrics were the request log and the request metrics, so nothing was lost. Break that request into 15 processes across 15 network hops, and each process now only sees its slice of the request. The slices are still there in the logs; they just aren't glued back together. The tooling was never built to glue them. Distributed tracing is the missing glue.

The core insight is small: propagate a shared identifier — a "trace ID" — through every service the request touches, and let every service emit a "span" record for its slice of the work, tagged with the trace ID and the parent span ID. When you aggregate all the spans with the same trace ID, you get the full picture back. The waterfall you see in Jaeger or Tempo is just that aggregation, visualized by start time and nesting depth. The engineering effort in the tracing ecosystem — OpenTelemetry SDKs, W3C Trace Context standardization, sampling algorithms, storage backends — is all downstream of that one insight. Section 02 turns the insight into a working model; section 04 lets you feel what the visualization actually reveals when things get bad.

Metrics tell you P99 is bad. Logs tell you what one service did. Neither tells you which one to blame. The trace is the missing glue that binds their slices back together.
§ 02 — The trace & the span

One request.
Many spans.

The data model is deceptively small. A trace is one end-to-end request — it has a globally-unique trace_id, and by definition it contains every span that participated in serving that request. A span is one unit of work — usually one service handling one call — and it records the service name, operation name, start time, duration, status (ok/error), and a set of key-value attributes. Every span has a span_id and a parent_span_id — those two IDs form the parent-child structure that lets a bag of spans be reconstructed into a tree, showing exactly which service called which. The whole visualization you see in Jaeger, Tempo, or any other tracing backend is just this reconstruction, drawn as a waterfall or a tree.

// ONE CHECKOUT REQUEST · EIGHT SPANS · ONE TRACE ID · THE PARENT-CHILD STRUCTURE

api-gateway span_id: a1 · parent: root order-service span_id: b2 · parent: a1 inventory-service span_id: c3 · parent: b2 pricing-service span_id: d4 · parent: b2 tax-service span_id: e5 · parent: b2 payment-service span_id: f6 · parent: b2 fraud-service span_id: g7 · parent: f6 ledger-service span_id: h8 · parent: f6 trace_id: 7f4a9b2e... shared by all 8 spans · propagated across every hop as W3C traceparent header
// PROPERTY 1 · SHARED TRACE ID
Every span in one request carries the same trace_id. Querying "all spans with trace_id X" reconstructs the whole request. The identifier is generated once (usually at the gateway or client) and propagated everywhere.
// PROPERTY 2 · PARENT-CHILD LINKAGE
Every span records its parent_span_id. The pair (span_id, parent_span_id) across all spans in a trace forms a tree — the calling structure. That's how the waterfall knows what nests inside what.
// PROPERTY 3 · TIMESTAMPS & DURATIONS
Each span records start time and duration — the waterfall visualization is (X = start time, Y = depth, width = duration). Clock skew between services is a real problem (§05), but the model tolerates it well enough in practice.
// PROPERTY 4 · ATTRIBUTES & EVENTS
Each span carries key-value attributes (HTTP method, DB query, user ID, feature flags) and can emit timed events (log lines with timestamps). This is where the "one trace = one narrative" property comes from.

The mechanism that makes the shared trace_id work across processes is context propagation. When service A makes an HTTP call to service B, it injects the trace context (trace ID + current span ID) into an outbound header — the W3C standard is traceparent, which looks like 00-7f4a9b2e...-b2837f...-01. Service B reads that header, parses it, uses the trace ID for any spans it creates, and sets its span's parent to the injected span ID. When service B calls service C, it re-injects the header with its own span ID as the parent. The trace context flows with the request through every hop, and the parent chain builds itself as it goes. The service mesh from M.34 automates the HTTP-header side of this — sidecars propagate traceparent without app-level effort. But the mesh can only propagate what the app already has: the app is responsible for reading the incoming context and attaching it to outbound calls, especially into message queues, background jobs, and async workflows the mesh doesn't touch.

Where propagation breaks is where the trace ends. Async boundaries — Kafka messages, SQS jobs, scheduled tasks, background workers — need explicit context-propagation code (the trace context has to be embedded in the message envelope, then extracted on the consumer side). Every ecosystem has a pattern for this (OpenTelemetry's Kafka and SQS instrumentations, RabbitMQ headers, etc.), but it's not automatic. The most common tracing gap in a mature system is at the async boundary: a synchronous checkout looks perfect in the trace, but the moment the order goes into a queue for processing, the trace ends. The consumer picks up the message, does 30 seconds of work, and has no idea it's part of the original checkout. From the trace's perspective, the request just... stopped. The trace ends where the propagation ends. This is the single most reliable "why is my tracing incomplete?" answer in production, and understanding it saves days of debugging.

A trace is one request. A span is one hop. Everything else is bookkeeping — parent IDs, timestamps, propagation headers, careful reconstruction of causality.
§ 03 — Sampling · because 100% doesn't scale

Which traces
do you keep?

Tracing has a scaling problem the previous modules didn't have. A busy system handles millions of requests per hour; each trace has dozens or hundreds of spans; each span has attributes and events. Storing every span for every request produces terabytes per day, and querying that storage becomes its own performance problem. So every production tracing setup makes a sampling choice — keep some traces in full, discard others entirely. The interesting question is which ones to keep, and the answer isn't obvious. Keeping a random 1% might satisfy compliance and general observability, but it also means the specific slow checkout your on-call needs to see has a 99% chance of being one of the discarded ones. Sampling strategy is where distributed tracing's operational maturity actually shows up.

// FOUR SAMPLING STRATEGIES · WHEN THE DECISION HAPPENS, WHAT IT COSTS

// STRATEGY I · NO SAMPLING
Keep everything

Every trace, every span, stored in full. Nothing is discarded. Fine for low-volume services (small internal APIs, dev environments, systems with tens of thousands of requests per day). Rare in mature production systems — the storage and query costs are prohibitive at meaningful traffic.

complete data expensive at scale
// STRATEGY II · HEAD-BASED
Decide at the start

Flip a coin (or hash the trace ID mod 100) at request start; keep 1% (or whatever rate you configured). If sampled, propagate a "keep this" bit through the whole trace so every service records its spans. If not, everyone drops the trace. Cheap — the decision is local, the sampled flag rides in the same traceparent header. Predictable — you know exactly how many traces you'll store.

cheap + predictable misses rare + slow
// STRATEGY III · TAIL-BASED
Decide at the end

Every service emits every span to a collector; the collector holds spans in memory until the trace completes, then decides whether to keep it. Keep-decisions favor interesting traces — errors, slow ones (P99+), specific endpoints, anything matching a rule. The specific slow checkout your on-call needs to see is nearly always kept, because "slow" is the exact filter. Costs: memory + more infrastructure (a collector holding traces for their duration + a decision engine).

catches the interesting ones memory + collector cost
// STRATEGY IV · ADAPTIVE / DYNAMIC
Rate changes with load

Sample rate adjusts based on traffic — lower during peak, higher during quiet periods. Alternative implementation: rate-limit to N traces/sec per endpoint or per service. Bounds cost regardless of traffic spikes; useful for services with very uneven load. Usually combined with tail-based so the adaptive part determines the volume ceiling and tail-based picks the interesting subset within it.

bounded cost complex to tune

The head-vs-tail choice has a specific pathology worth naming. Head-based sampling at 1% catches ~1% of interesting traces because it doesn't know which are interesting. When the on-call gets paged about the 3.4-second P99 outlier, the trace they want to look at has a 99% chance of not being in the storage — because at request start, this trace looked like any other. The math is bad in exactly the way that matters: the higher the value of a particular trace (rare error, extreme latency), the lower the odds you have it. Tail-based sampling flips this: the interesting traces are the ones you keep, exactly because they're interesting. The tradeoff is infrastructural — a tail-sampling collector (like Grafana Tempo's tail-sampling processor, or a Jaeger/OpenTelemetry collector configured with tail_sampling) has to hold traces in memory long enough to make the decision, which means running a real service that scales with peak traffic. For mature systems, that infrastructure investment is worth it; the fresh-out-of-the-box teams typically start with head-based at a low rate and migrate to tail-based within a year of hitting real production load.

A common compound strategy in 2025-era systems: head-based at a high rate (e.g., 10%) for baseline coverage, plus tail-based rules that capture 100% of errors and 100% of slow requests. The head-based portion gives you statistical visibility into the "normal" traffic (so dashboards and aggregate views work); the tail-based portion gives you every anomaly for root-cause work. This composite pattern is what Datadog, Honeycomb, and Grafana Tempo all support out of the box; it's also easy to implement with an OpenTelemetry Collector pipeline. The general principle: sampling should be biased toward the traces you'll actually query, and in an incident response context, those are the anomalies. The lab in §04 lets you see this directly — inject a slow trace, see how each sampling strategy handles it, watch the head-based strategy miss the exact trace you need while the tail-based one catches it every time.

Head-based sampling optimizes for uniform coverage. Tail-based optimizes for interesting coverage. In production, the interesting traces are the ones that matter.
§ 04 — Waterfall & sampling explorer

One checkout.
Watch the waterfall.

Below: a synthetic e-commerce checkout traced across 8 services. api-gateway → order-service → [inventory, pricing, tax, payment] → payment fans out to [fraud, ledger]. Toggle scenarios — baseline (everything healthy), slow leaf (fraud-service takes 800ms extra), error propagation (ledger-service errors, propagates up), mixed (inventory slow + tax errors). Then toggle sampling strategy — no sampling (100%), head-based (1%), tail-based (interesting-only). Watch spans stretch, watch errors cascade up the call tree, watch which sampling strategy captures which traces. The waterfall makes the root cause visible in a way logs and metrics never can.

TRACE.SIM // m.36 lab
Scenario →
// WATERFALL · 8 spans · checkout request
// METRICS · TRACE SUMMARY
Total duration-
Root cause span-
Span count-
Error count-
Sampled?-
Storage cost-
// VERDICT
Loading...
...
§ 05 — Where tracing gets hard

The gaps
that survive.

Distributed tracing done well feels magical. Done incompletely, it feels like most of a picture with confusing holes in it. The five patterns below are the failure modes that survive even after teams have "adopted tracing" — the residual gaps where traces end unexpectedly, where interesting requests get discarded, where timing looks wrong, or where the observability picture just... stops halfway through the story. Recognizing them early is the difference between "tracing works for us" and "tracing works for us except in the exact cases we need it most."

// FIVE THINGS THAT BREAK TRACING IN PRODUCTION

i
The async boundary
"The trace ends at the queue. What happened to the background job?"

Context propagation works automatically for synchronous HTTP calls (the mesh does it for you), but stops the moment the code drops a job into Kafka, SQS, or a Redis queue. The consumer picks up a message with no trace context; it starts a completely new trace. The end-to-end story splits into two disconnected traces — the request that queued the job, and the worker that processed it. The fix: explicit context injection into message headers (OpenTelemetry has instrumentations for every major broker); explicit extraction on the consumer side. It's not automatic, but it's a solved pattern once you know to look for it.

ii
The sampling gap
"The bug happened once. That trace wasn't sampled. Now what?"

Head-based sampling at 1% catches ~1% of the rare bug that just happened. Aggregate metrics tell you it happened; logs from each service tell you something happened; but the one trace that would connect the dots isn't in the storage. The fix: tail-based sampling for anomaly capture (§03) — even at aggressive base rates, ensure 100% of errors and slow traces are kept. Complements head-based baseline coverage; doesn't replace it.

iii
The clock skew mystery
"Child span starts BEFORE parent span. How?"

Each service records span timestamps from its own local clock. If service B's clock is 40ms ahead of service A's, service B's spans appear to start before service A finished sending the request — which is causally impossible. The fix: NTP or PTP for approximate clock alignment (millisecond-scale is usually enough); tracing UIs that handle small clock skew visually (nudging misaligned spans); and, importantly, understanding that the parent-child linkage is the source of truth for causality, not the timestamps. Timestamps are useful for latency estimation; parent IDs are useful for structure. Don't confuse them.

iv
The cardinality explosion
"We added user_id to every span. Now storage costs 10× what we budgeted."

Span attributes are cheap individually and expensive in aggregate. Adding a high-cardinality attribute (user_id, session_id, request URL with variables inline) to every span creates enormous index size in the tracing backend. Query performance degrades; storage costs balloon. The fix: keep high-cardinality attributes but avoid them being indexed by default; use tools like Honeycomb designed for high cardinality; or scrub/hash user identifiers when they're not needed for debugging. Understanding what your backend indexes is essential before instrumenting.

v
The legacy blind spot
"Our tracing shows this call arrives, then two seconds pass, then a response leaves. What happened in the two seconds?"

A service that isn't instrumented — usually a legacy system, a third-party library, or a critical dependency the team doesn't own — appears in traces only via its call boundaries. Two seconds of work vanishes into "black hole" between the incoming and outgoing spans. The fix: OpenTelemetry auto-instrumentation for common libraries (JVM, .NET, Node, Python) catches most of this without code changes. For legacy systems, manual instrumentation of critical paths; for third-party services, sometimes acceptance that "this is a leaf" is the best you can do.

The composite lesson: distributed tracing is more social than it looks. Getting a technically correct picture requires every service to propagate context, every message queue to carry it, every legacy dependency to be instrumented, and every team to have agreed on sampling policy. The infrastructure is easy; the coordination is hard. This is the specific reason mature organizations spin up an "observability team" whose job is to own the tracing platform end-to-end: SDK versions, propagator libraries, collector configuration, backend storage, and — most importantly — the developer education that keeps teams from breaking propagation with well-intentioned refactors. Tracing is a shared infrastructure whose correctness depends on every team playing along, and the platform team is the one making that easy enough that they do.

The real-world tools have consolidated significantly. OpenTelemetry won the instrumentation-standard war around 2022 — the merger of OpenTracing and OpenCensus produced a vendor-neutral SDK spec that every major backend now supports. Jaeger (originally Uber, CNCF) is the go-to open-source backend for self-hosted setups; strong query UI, good storage backends (Cassandra, Elasticsearch, Kafka), CNCF governance. Grafana Tempo is the newer contender — object-storage-native (S3, GCS), cost-effective at large scale, integrates natively with Grafana's stack. Datadog APM, New Relic, and Honeycomb are the leading commercial hosted options; Honeycomb is worth calling out separately for its high-cardinality-first design that makes ad-hoc trace exploration dramatically better than the alternatives. The choice is usually about ops preference and cost model rather than capability, because at this layer the capabilities have converged. New systems in 2025 near-universally instrument with OpenTelemetry SDKs and pick a backend based on team preference; the days of proprietary tracing SDKs are essentially over.

The trace ends where the propagation ends. Every async boundary is a place to lose it — and the async boundaries are exactly where the interesting cross-service stories usually live.
§ 06 — Eight words for the trace conversation

Vocabulary,
for the waterfall.

The terms every OpenTelemetry config, every Jaeger dashboard, every "why is this trace incomplete?" postmortem assumes you already know.

Trace
/treɪs/
A single end-to-end request through a distributed system, identified by a globally-unique trace_id. Contains every span that participated in serving that request. The unit of analysis in distributed tracing.
Span
/spæn/
A single unit of work within a trace — usually one service processing one call. Has span_id, parent_span_id, start time, duration, status, and attributes. Spans nest via parent-child linkage to form the request's tree.
Context Propagation
/ˈkɒn.tɛkst/
The mechanism of passing trace context (trace ID + current span ID) across process boundaries — typically via HTTP headers, message queue headers, or explicit code. The W3C traceparent header is the standard format.
W3C Trace Context
/ˈdʌb.əl.juː θriː siː/
The W3C standard for propagating trace context in HTTP. Defines traceparent (version, trace ID, parent span ID, flags) and tracestate (vendor-specific extras). Format: 00-{trace_id}-{parent_id}-{flags}.
Head-based Sampling
/hɛd beɪst/
Sampling decision made at request start, propagated through the trace via a "sampled" bit. Cheap and predictable, but samples uniformly — so misses interesting rare traces (errors, slow ones) at the same rate as everything else.
Tail-based Sampling
/teɪl beɪst/
Sampling decision made after the trace completes, based on trace-wide attributes (errors, duration, endpoint). Catches the interesting traces exactly because they're interesting. Requires collector infrastructure that holds spans until decision.
OpenTelemetry (OTel)
/ˈoʊ.pən.tɛl.ɪ.mə.tri/
The CNCF-hosted vendor-neutral standard for tracing, metrics, and logging instrumentation. Formed from the merger of OpenTracing and OpenCensus in 2019. Universal SDK across languages; emits to any compatible backend (Jaeger, Tempo, Datadog, Honeycomb, ...).
Flame Graph
/fleɪm ɡræf/
A visualization of nested spans as stacked horizontal bars, with X = time, Y = call depth. Width shows duration; the widest bars at any level identify the biggest latency contributors. The standard rendering for both traces and CPU profiles.
§ 07 — Knowledge check

Five questions.
Follow the trace ID.

Test the tracing intuition. Click an answer; explanation drops in instantly.

QUESTION 1 OF 5
Loading question...
Score: 0 / 5
5 / 5

Traced.

Perfect. The trace/span model, propagation, sampling tradeoffs, failure modes — all yours. Now: your services are distributed. Should their data be? M.37 is polyglot persistence.

§ 08 — The recap

Three ideas to
carry forward.

The observability layer that binds the per-service slices back into one cross-service story.

i

The trace is the missing glue

Logs are per-service. Metrics are aggregate. Neither tells the cross-service story of a specific request. The trace does — a shared ID propagated across every hop, with a span per service, reconstructing causality end-to-end.

ii

Context propagation is the actual work

The mesh handles HTTP header propagation. But apps must propagate context across async boundaries — Kafka, SQS, background jobs. Every unpropagated boundary is a place the trace ends. The single most common tracing gap in production.

iii

Sample toward interesting

Head-based sampling optimizes for uniform coverage but misses the rare errors and P99 outliers that actually matter. Tail-based sampling keeps the interesting traces exactly because they're interesting. Compose them for the best of both.

↓ UP NEXT

M.37 — Polyglot
persistence.

Your services are distributed. Their data should be too — but differently. Search queries want an inverted index. Financial records want ACID. Analytical rollups want columnar. Session state wants sub-ms key-value. The pattern that lets each bounded context pick the data store its work actually needs — and the coordination costs of running six databases instead of one.

Continue to Module 37 →