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.
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.
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.
1import { context, propagation, trace } from '@opentelemetry/api'2 3// outbound: inject traceparent so the callee's span becomes our child4async function callOrders(path: string) {5 const headers: Record<string, string> = {}6 propagation.inject(context.active(), headers) // adds traceparent / tracestate7 return fetch(`http://orders${path}`, { headers })8}9 10// inbound: extract, then run the handler inside that context11app.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 waterfall18for (const item of order.items) item.product = await products.byId(item.productId) // 38 × ~4 ms19// the fix: one span, one query20const byId = await products.byIds(order.items.map((i) => i.productId)) // 1 × ~6 msFailures 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
traceparentheader 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
How data moves through it
One request or event, hop by hop.
- 1Browser → Gateway: the gateway mints a trace id (or honours an incoming
traceparent), decides sampling, and opens the root span. - 2Gateway → Service A:
traceparentis injected into the request; Service A extracts it and opens a child span named by route. - 3Service A → Service B: the same injection; Service B’s span is a grandchild of the root.
- 4Service B → Database: the driver opens one client span per query with
db.statement(parameterised) and duration. - 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
- 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.
- 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
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.