Designing a Distributed Tracing System

A system design interview guide to building a tracing system that follows one request across dozens of services, so you can see exactly where the latency and errors went — the machinery behind Dapper, Jaeger, Tempo, and OpenTelemetry.

In a microservice architecture a single user request fans out across dozens of services, and when it is slow or fails, metrics tell you that something is wrong and logs tell you what happened on one machine — but neither tells you where, across the whole call path, the time went. Distributed tracing answers that by stitching together the work done for one request into a single causal timeline. The idea, popularized by Google’s Dapper paper and now standardized by OpenTelemetry, is to tag each request with a trace id, propagate it across every service hop, and have each service emit timed spans that a backend reassembles into a tree. This guide builds that system: the trace and span model, context propagation over the wire, the collector pipeline, the all-important sampling decision (head vs tail), storage at petabyte scale, and trace assembly into waterfalls and dependency graphs.

Contents

  1. Requirements & Scope
  2. Back-of-the-Envelope Estimates
  3. The Trace & Span Model
  4. Instrumentation & Context Propagation
  5. High-Level Architecture
  6. Sampling: Head vs Tail
  7. Storage, Assembly & Dependency Graphs
  8. Bottlenecks & Tradeoffs
  9. Summary

1. Requirements & Scope

The system captures the end-to-end path of requests and lets an engineer inspect any one of them. Pin down the requirements.

Interview tip: The two make-or-break topics are context propagation (how the trace id crosses every service boundary, including async and message queues) and sampling (how you keep the interesting traces without storing everything). Spend your time there; an interviewer probing tracing is almost always probing one of those two.

2. Back-of-the-Envelope Estimates

Assume 1,000,000 requests/second, each producing 20 spans across services, each span ~1 KB.

QuantityEstimateVolume math
Raw spans~20M spans/s1M req/s × 20 spans.
Raw volume (unsampled)~20 GB/s → ~1.7 PB/day20M spans/s × 1 KB × 86,400 s — infeasible to store fully.
After 1% sampling~200k spans/s → ~17 TB/daySampling is what turns petabytes into a manageable stream.
Retained (7 days)~120 TB17 TB/day × 7; object storage keeps this cheap.
Trace assembly windowsecondsSpans of one trace arrive within seconds; assembly and tail-sampling buffer over that window.

The dominant conclusion is unavoidable: at 1.7 PB/day raw, you cannot store every trace, so sampling is the central design decision — and where you make it (at the source vs after buffering the whole trace) shapes the entire pipeline.

3. The Trace & Span Model

The data model is small and worth stating precisely. A span is a single named, timed unit of work — one operation in one service. A trace is the collection of all spans for one request, linked into a tree by parent references.

A trace shown as a waterfall of spans over time: root span with nested child spans, and the span field schema
A trace is a tree of spans rendered as a waterfall over time. The root span (GET /checkout) spans the whole request; child spans (charge, auth RPC, db write, render) nest under it with their own start times and durations. Each span carries a trace_id, span_id, parent_span_id, timing, and attributes.

Each span carries:

The parent/child links are the essence: the backend does not need spans to arrive in order or together — it just needs each span’s trace_id and parent_span_id to reconstruct the tree later. That decoupling is what lets spans stream in independently from many services.

4. Instrumentation & Context Propagation

The hard part of tracing is not recording a span — it is making sure every service in the request shares the same trace_id. That is context propagation, and it is what turns per-service spans into one coherent trace.

How it works. When a request enters the system, the first service either reads an incoming trace context or starts a new trace. On every outbound call it injects the current trace context into the request — for HTTP, the W3C Trace Context standard puts it in the traceparent header. The next service extracts it, continues the same trace, and repeats. This propagation must cover every hop, including the tricky ones: async work, thread pools, and message queues (where the context rides in message metadata rather than an HTTP header).

# W3C traceparent header format
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             |  |                                |                |
           version   trace-id (16 bytes)      parent span-id   flags (sampled)

# each hop: extract -> start child span -> inject into next call
ctx  = extract(request.headers)                # continue the trace
span = tracer.start_span("db.query", parent=ctx)
inject(outbound.headers, span.context())       # propagate downstream

Instrumentation — the code that starts/ends spans and does inject/extract — comes in two forms: automatic (agents and libraries that instrument common frameworks with no code changes) and manual (developers adding spans around important custom logic). OpenTelemetry provides both a vendor-neutral API/SDK and the propagation standard, which is why it has become the default: instrument once, and any backend can consume the spans. The flags byte in traceparent also carries the sampled bit, so a head-sampling decision made at the edge is honored by every downstream service.

5. High-Level Architecture

The pipeline moves spans from instrumented services, through a collector that batches and samples, into scalable storage, with a read side that assembles and analyzes traces.

Instrumented services propagate context to a collector that batches and tail-samples, writes to petabyte trace storage, feeding trace assembly and a service graph
Services propagate the trace context (traceparent) between each other and export spans to a collector, which batches and applies tail sampling. Spans are written to petabyte-scale object storage keyed by trace_id; the read side assembles spans into traces and derives a service dependency graph with latency breakdowns.

6. Sampling: Head vs Tail

Since storing every trace is infeasible, you keep a representative and interesting subset. The central choice is when to decide.

Head-based sampling decides at trace start; tail-based buffers all spans and decides at the end, keeping errors and slow traces
Head-based sampling decides at the start of a trace — cheap and requiring no buffering, but blind to how the request turns out, so it drops rare errors and slow traces at the same rate as everything else. Tail-based sampling buffers all spans of a trace and decides after it completes, keeping the errors and slow traces that matter — at the cost of buffering spans in the collector.

Real systems (and Grafana Tempo, Jaeger with a tail-sampling collector) often combine them: light head sampling to shed obvious volume cheaply, plus tail sampling to guarantee errors and slow traces are always kept. State this hybrid in an interview — it shows you understand each one’s failure mode.

AspectHead-basedTail-based
WhenAt trace startAfter trace completes
BufferingNoneAll spans, per trace, until decision
Keeps errors/slow?No — blind to outcomeYes — policy on full trace
CostCheapMemory + routing complexity

7. Storage, Assembly & Dependency Graphs

Storage. The access pattern is “fetch all spans for a trace_id,” so spans are stored keyed by trace_id. The cost-effective modern design (Grafana Tempo is the clearest example) keeps only a minimal index and writes span blocks to cheap object storage: you do not build a heavy search index over every span attribute; you look up by trace id, and support attribute search via a lighter secondary index or by scanning columnar blocks. This decouples storage cost from compute and is what makes petabyte retention affordable.

Trace assembly. Because spans arrive independently and out of order, the read side collects all spans sharing a trace_id and rebuilds the tree from parent_span_id links, then lays them on a timeline to produce the waterfall. Assembly is a read-time (or collector-time) operation; the write path never needs a complete trace.

Dependency graph & latency analysis. Aggregating across many traces yields a service dependency graph — which service calls which, how often, and with what latency and error rate — and per-operation latency breakdowns that reveal where the critical path spends its time. This aggregate view, computed from sampled traces, is often as valuable as any single trace: it shows the shape of the system and where to look when a whole tier degrades.

Correlation. Because the trace_id and span_id are injected into logs and attached to metric exemplars, a trace is the hub that ties the three observability pillars together — jump from a slow span to its logs, or from a metric spike’s exemplar to the trace.

8. Bottlenecks & Tradeoffs

9. Summary

A distributed tracing system tags each request with a trace id, propagates it across every hop, and emits timed spans that a backend reassembles into a causal timeline — using sampling to keep the interesting traces affordably.

ConcernMechanism
What is the data model?Span = timed unit of work; trace = spans sharing a trace_id, linked by parent_span_id into a tree.
How does one request stay one trace?Context propagation: inject/extract the trace_id at every hop (W3C traceparent, incl. queues).
How do we instrument cheaply?OpenTelemetry SDK: auto-instrument frameworks + manual spans; async export, low overhead.
How do we avoid storing everything?Sampling — head (cheap, blind) + tail (buffers trace, keeps errors/slow); usually hybrid.
Where do spans live?Keyed by trace_id on cheap object storage with a minimal index (Tempo-style).
How is a trace rebuilt?Read-time assembly: gather spans by trace_id, rebuild the tree from parent links, lay on a timeline.
What aggregate value is there?Service dependency graph and latency breakdowns from many traces; correlation to logs/metrics.
The recurring theme: propagate a shared id so independent spans can be stitched into one story, and sample intelligently so you keep the traces that matter without drowning in the ones that do not. Everything downstream — storage keyed by trace id, read-time assembly, dependency graphs — follows from those two ideas that Dapper introduced and OpenTelemetry standardized.