Observabilitytracingspanstrace idtraceparentcontext propagation

Distributed Tracing

Follow one request Browser → Gateway → Service A → Service B → Database as a tree of timed spans stitched together by a propagated trace id, so that fan-out, serial-versus-parallel calls and the 140 ms of database time hiding behind an N+1 become visible in a way no log search can reproduce.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

Once a request crosses process boundaries, no single process knows where the time went. Logs from five services can be searched but not *shaped*; a trace reconstructs the request as a tree with durations, so "checkout takes 800 ms" becomes "the order service made 38 sequential database calls".

One request, five hops, one tree

A trace is identified by a 128-bit trace id. Each unit of work along the way is a span: a name, a start time, a duration, a service, attributes (http.route, db.statement), a status, and a parent span id. The gateway starts the root span; every downstream call creates a child span whose parent is the caller’s span. The result is a tree, rendered as a waterfall: each span a horizontal bar, indented under its parent, positioned by start time. Reading the waterfall is the skill: gaps between a parent’s start and its first child are *own time* (deserialisation, business logic); children laid end to end are *serial* calls; children stacked at the same x-position are *parallel*.

What a trace shows that logs cannot: fan-out (this request touched nine services); serial versus parallel (three 50 ms calls took 150 ms instead of 50 ms because they were awaited one at a time); the long tail’s cause (p99 is slow because 1% of requests hit a cold cache and cascade into a database storm); and who is waiting on whom during an outage (every trace ends in a span to the same slow dependency). A log search can find the lines; it cannot tell you the second call started after the first one ended.

Trace context rides along every hop
traceparent (optional)traceparent: 00-<trace>-<span>-01child span38 query spansspans, asyncspans, asyncBrowserGateway (root span)Service A: checkoutService B: ordersDatabaseTrace collector
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Context propagation and the 140 ms database span

The tree only forms if every hop passes the context along. The standard is W3C Trace Context: a traceparent header of the form 00-{trace-id}-{parent-span-id}-{flags}, where the last byte says whether this trace is sampled. HTTP clients inject it; HTTP servers extract it and make it the parent of their first span. For queues, the same values go in message headers so a consumer’s span is a child of the producer’s — hours later, if that is when the message is processed. The most common tracing bug is a broken chain: a hand-written HTTP client, a thread pool, or a queue library that drops the header, and the trace ends at that hop while a fresh, orphaned trace starts on the other side.

Here is the trace that motivates all of this. The gateway span is 235 ms. Under it, user service 35 ms, order service 180 ms. Under the order service, a single database span would be unremarkable — but there are 38 of them, 3–5 ms each, one after another. The order service fetched an order, then looped over its 37 line items and fetched each product by id. In the code that is one innocent for loop with an await inside; in a log search it is 38 lines that look like healthy fast queries; in the trace it is an unmistakable staircase. The fix is one query with WHERE id = ANY($1) (see Joins: INNER, LEFT, RIGHT, FULL, CROSS, SELF) or a batched lookup — and the trace shows the 140 ms collapsing to 6 ms the moment it is deployed. Across a service boundary the same pattern is worse: 38 HTTP calls at 20 ms each is 760 ms, which is the challenge the-140ms-database-span in its full form.

Propagating and consuming trace context; the N+1 that the waterfall exposes
1import { context, propagation, trace } from '@opentelemetry/api'
2
3// outbound: inject traceparent so the callee's span becomes our child
4async function callOrders(path: string) {
5 const headers: Record<string, string> = {}
6 propagation.inject(context.active(), headers) // adds traceparent / tracestate
7 return fetch(`http://orders${path}`, { headers })
8}
9
10// inbound: extract, then run the handler inside that context
11app.use((req, res, next) => {
12 const parent = propagation.extract(context.active(), req.headers)
13 const span = trace.getTracer('orders').startSpan(`${req.method} ${req.route?.path}`, undefined, parent)
14 context.with(trace.setSpan(parent, span), () => { res.on('finish', () => span.end()); next() })
15})
16
17// the staircase: one span per iteration, sequential — visible instantly in the waterfall
18for (const item of order.items) item.product = await products.byId(item.productId) // 38 × ~4 ms
19// the fix: one span, one query
20const byId = await products.byIds(order.items.map((i) => i.productId)) // 1 × ~6 ms

Failures a trace makes visible, sampling, and cost

Beyond latency, traces show failures with their shape. A retry storm appears as a span with three identical children, each with three identical children (Circuit Breaker). A timeout mismatch appears as a caller span that ends at exactly 2,000 ms while its child keeps running for 5,000 ms — the work completed after the caller gave up, which is how a payment gets charged after the client saw an error. A cascading failure appears as every trace in the last five minutes ending in a red span to the same dependency. A missing bulkhead appears as unrelated routes sharing a queueing gap before their first span, because they share an exhausted pool (Reliability Patterns).

Tracing every request costs more than serving it — a request with 40 spans at 300 bytes each is 12 KB of telemetry for a 2 KB response. So traces are sampled. Head sampling decides at the root (the gateway keeps 5% by trace id, and the sampled flag propagates so every hop agrees), which is cheap and consistent but blind: the one slow request in a thousand is probably not in the sample. Tail sampling buffers every span at the collector for a few seconds and then keeps the whole trace if it was slow, errored, or otherwise interesting — which is what you want for debugging, at the cost of a stateful collector that must see all spans of a trace. Most production setups combine them: head-sample a baseline for latency distributions, tail-sample 100% of errors and everything over the p99 threshold.

The remaining cost is human: instrumenting hand-rolled clients, keeping span names low-cardinality (GET /orders/{id}, not GET /orders/81723), and resisting the urge to record request bodies in attributes. A trace is a diagnostic tool for the request you are looking at; it is not a log, and it is not a metric. The three signals in Logs, Metrics and Traces each keep their own job.

  • Serial children at the same depth: sequential awaits that could run in parallel or be batched.
  • A parent that ends before its child: a timeout shorter than the work; the caller gave up, the work finished anyway.
  • The same child repeated N times under one parent: retries or an N+1.
  • A gap before the first child: own time — or a queue for a saturated pool.

Key points

  • A trace is a tree of spans sharing one trace id; each span has a parent, a start and a duration, and the waterfall makes serial versus parallel visible.
  • Context propagates via the W3C traceparent header on HTTP and via message headers on queues; a hop that drops it orphans the rest of the trace.
  • Traces show shape that logs cannot: fan-out, sequential awaits, the 38-span staircase of an N+1, a child outliving a timed-out parent.
  • Head sampling is cheap and consistent but misses the slow tail; tail sampling keeps every error and slow trace at the cost of a stateful collector.
  • Keep span names low-cardinality and never put bodies in attributes; the trace is for shape and time, the log is for content.

Follow one request through five hops

Follow one request through five hops
A trace is a tree of spans sharing a trace_id; each span carries its parent, start offset and duration. Click a span to inspect its attributes.
total duration
2355 ms
critical path
Browser › Gateway › Service A › Service B › Database
spans
42
errors
0
0.0 ms589 ms1178 ms1766 ms2355 msBrowser · fetch POST /api/checkoutGateway · proxy /api/checkoutService A · POST /ordersService B · GET /inventoryDatabase · SELECT inventory
service · operation
Service B · GET /inventory
start · duration · status
30 ms · 2290 ms · ok
parent
Service A (a)
http.method
GET
http.status_code
200
traceparent
00-4f1c9a7e2b3d5f604f1c9a7e2b3d5f60-00f067aa0ba90003-01
db.calls
38
Service B issues 38 database queries one after another. Logs from the database would show 38 fast queries (~4 ms each) and look healthy; only the waterfall shows they are serial and that Service B is the one lining them up. One query with WHERE order_id = ANY($1) would bring this back to ~140 ms. Bold outlines mark the critical path; a log line per hop would show the same five services in some order and nothing about which waited for which.
Sampling 1%: at 10 000 req/s that is 100 traces/s × ~42 spans × ~1 KB ≈ 4 MB/s into the tracing backend. Head-based sampling decides at the root, before anyone knows this request will take 2.4 s — so this trace had a 1-in-100 chance of existing. Tail-based sampling buffers the whole trace and keeps it if it was slow or errored: all the interesting ones, at the price of a collector that holds every trace in memory until it completes.

How data moves through it

One request or event, hop by hop.

  1. 1Browser → Gateway: the gateway mints a trace id (or honours an incoming traceparent), decides sampling, and opens the root span.
  2. 2Gateway → Service A: traceparent is injected into the request; Service A extracts it and opens a child span named by route.
  3. 3Service A → Service B: the same injection; Service B’s span is a grandchild of the root.
  4. 4Service B → Database: the driver opens one client span per query with db.statement (parameterised) and duration.
  5. 5Every service → Collector: spans are batched and exported asynchronously; the collector tail-samples and writes the trace to the store, keyed by trace id.

When to use — and when not

Use it when
  • Any request that crosses two or more processes, including queue hops; that is where per-process profiling stops helping.
  • Diagnosing p99 latency: the slow tail almost always has a structural cause (serial calls, N+1, cold cache cascade) that only a waterfall shows.
  • Incidents in service graphs: "which dependency is everyone waiting on?" is one trace search.
Avoid it when
  • A single-process monolith with a profiler; add tracing when the second process appears, not before.
  • As a replacement for metrics: sampled traces cannot alert on error rate or compute an accurate p99 over all traffic.
  • At 100% sampling on high-traffic services; the telemetry outweighs the traffic and the bill outweighs the insight.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Microseconds per span on the request path; the cost is the collector tier, the trace store, and the discipline of propagating context through every client library.

How it fails

  • A hand-written HTTP client or a queue library drops traceparent; every trace ends at that hop and an orphan trace begins on the other side.
  • Head sampling at 1% never captures the request that is slow; the p99 investigation has no trace to look at.
  • Span names include ids (GET /orders/81723); the trace backend indexes millions of distinct operations and searches time out.
  • Request bodies recorded as span attributes; personal data lands in a third-party trace store with 30-day retention.
  • The trace exporter is synchronous; an outage of the collector adds seconds to every request instead of dropping telemetry.

How it scales

  • Volume scales as requests × spans × sample rate; hold the product roughly constant by lowering the head-sample rate as traffic grows and relying on tail sampling for the interesting traces.
  • Collectors scale horizontally, but tail sampling needs all spans of one trace on one collector — route by trace id (Consistent Hashing).
  • Trace storage is retention-bound: 7 days hot, then aggregates only; long-term latency history belongs in metrics.

How it interacts with databases, queues, caches, APIs and external systems

  • Database: query spans via driver instrumentation; the staircase of an N+1 is the most common finding (Why Is This Query Slow? Indexes for the next most common).
  • Queue: context in message headers links producer and consumer spans across minutes or hours (Message Queues).
  • Cache: a cache-hit span of 1 ms versus a miss that fans out into database spans; the ratio is visible per request (Caching Architecture).
  • API gateway: root span, sampling decision, and the request id echoed to the client so support tickets can be matched to traces (API Gateway).
  • External APIs: client spans with the vendor as peer; when their p99 shifts, the trace shows it before the vendor’s status page does.
Don't delegate understanding
The manifesto →