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.

Student edition · start with the guarantee

Connect distributed operations through propagated identities and interpret incomplete, sampled traces honestly.

Correctness contract: Trace IDs/links propagate through supported synchronous/asynchronous boundaries; retention, sampling coverage, clock uncertainty, and missing spans are visible.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

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. API & Data Model
  6. High-Level Architecture
  7. Sampling: Head vs Tail
  8. Storage, Assembly & Dependency Graphs
  9. Bottlenecks & Tradeoffs
  10. Failure Modes
  11. 45-Minute Interview Walkthrough
  12. Common Mistakes
  13. Summary
  14. Check Yourself
  15. Key Terms
  16. Further Reading
  17. Concepts to understand first
  18. Worked example
  19. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Spans and trace context

A span records one operation with a trace ID, span ID, parent or links, timing, and attributes. Context propagation sends the current trace/span identity across HTTP, RPC, thread, and queue boundaries so related work can be assembled.

Example. A gateway starts a trace, injects W3C traceparent into a service call, and the service links its queue consumer work to the producer span.

Limit. Cross-host wall clocks can skew timelines. A parent-child graph is not always a tree because asynchronous work can have multiple links. Instrumentation must be configured; IDs are not automatically present in every log.

Head and tail sampling

Head sampling decides at trace start. Tail sampling buffers received spans and decides after a configured decision interval using observed errors/latency. Sampling reduces retained volume but introduces coverage and bias choices.

Example. A 1% head sampler forwards only one in 100 traces. A later tail sampler cannot recover an error trace that the head stage never sent.

Limit. No tail policy guarantees all errors when input spans are lost, dropped, late, or evicted. Route compatible trace state together, bound memory, and report coverage before drawing aggregate conclusions.

Transactional outbox

A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them.

Example. An order and its OrderCreated outbox row commit together. A relay crash after publish but before marking sent publishes the same event ID again on restart.

Limit. The relay is at-least-once, not magically exactly-once. Consumers dedup transactionally; preserve per-entity sequence when order matters. CDC is change data capture: reading committed database changes.

Cardinality

The number of distinct identities a store must track. A metric series is its metric name plus the full label combination. The product of label-value counts is an upper bound when not every combination occurs.

Example. 100 hosts × 20 routes × 5 status codes can produce up to 10,000 series. Adding a unique request_id can make every request create a new series.

Limit. Hashing every ID to a distinct digest does not reduce cardinality or guarantee anonymity. Use bounded categories, explicit limits, or intentional fixed-bucket aggregation.

SLIs, SLOs, and error budgets

An SLI measures a user-relevant outcome; an SLO sets its target over a window. For a success-ratio SLO, the allowed bad fraction is 1−SLO. An SLA is a contractual commitment that may use different definitions.

Example. 99.9% successful requests over 1 billion requests allows 1 million bad requests. A time-based 30-day availability target instead allows 43.2 unavailable minutes.

Limit. Request and time budgets are not interchangeable under variable traffic or partial failure. SLOs are not universally stricter than SLAs; choose both from their definitions and user needs.

1. Requirements & Scope

Intuition

Picture a parcel with a tracking number. Every depot scans it and writes the time. Later you read all scans for that number and see where it got stuck. A trace is the same: the trace_id is the tracking number and each span is one scan.

Clarifying questions to ask

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.

Worked estimate, step by step

StepMathResult
Raw spans1M req/s × 20 spans20M spans/s
Raw bytes20M × 1 KB20 GB/s
Raw per day20 GB/s × 86,400 s≈ 1.7 PB/day
Kept after 1%1.7 PB × 0.01≈ 17 TB/day
Tail-sampling buffer20 GB/s × 30 s decision window≈ 600 GB RAM (fleet-wide)
7-day retention17 TB × 7≈ 120 TB on object storage
Read the table

The 600 GB buffer is the hidden cost of tail sampling. It must be spread over many collectors, sharded by trace_id.

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.

A: api-gateway B: orders Kafka topic C: billing HTTP header traceparent message header traceparent extract Resulting waterfall (same trace_id 4bf9…) A GET /checkout B POST /orders B produce C consume (link) queue wait: the gap
The same traceparent rides in an HTTP header and then in a Kafka message header. The consumer span starts after the request ended, so it is often a span link rather than a child. The gap shows time spent waiting in the queue.

5. API & Data Model

# ingest (OTLP)
POST /v1/traces   ResourceSpans{ resource{service.name, host}, spans[ Span ] }
Span { trace_id: bytes16, span_id: bytes8, parent_span_id: bytes8|null,
       name, kind: SERVER|CLIENT|PRODUCER|CONSUMER|INTERNAL,
       start_unix_nano, end_unix_nano, status: OK|ERROR, attributes{}, events[], links[] }

# query
GET /api/traces/{trace_id}                         -> all spans, assembled as a tree
GET /api/search?service=orders&min_duration=2s&status=error&start&end&limit=20
GET /api/dependencies?start&end                  -> service graph edges with rate, errors, p99

Storage layout: span blocks in object storage, grouped by time and sorted by trace_id. A small per-block index (or bloom filter) answers “is this trace_id in this block?”.

6. 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.

7. Sampling: Head vs Tail

Head sampling decides before the outcome; a 1% independent sample keeps about 1% of errors on average. Tail sampling groups received spans by trace and decides after a configured interval, favoring observed errors/slow traces. It cannot know with certainty every span has arrived; late arrivals, eviction, and delivery loss weaken coverage.

A head+tail hybrid does not guarantee all errors: tail cannot recover traces discarded at the head stage. To target all observed errors, forward sufficient eligible traces/spans to tail first and provision bounded collector state, routing, and spill/drop policy. Separate cheap representative sampling from error-retention claims. Report missing-span and sampling coverage explicitly.

The W3C sampled flag communicates context; SDK/sampler policy may override it and should be configured consistently. Different fanout/batch work may use span links rather than one parent. A sampled dependency graph is biased unless corrected; avoid deriving fleet SLOs directly from a tail-biased trace set.

8. 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.

9. Bottlenecks & Tradeoffs

10. Failure Modes

FailureWhat happensDefense
Collector overloadedSpans dropped; traces have holesSDK export is async with a bounded queue; drop spans, never block requests; autoscale collectors
Spans of one trace hit different tail samplersEach sees part of the trace and decides wrongTwo-tier collectors: a load-balancing exporter routes by trace_id
A service does not propagate contextTrace splits into orphan fragmentsStandard W3C headers, auto-instrumentation, orphan-span dashboards
Late spans after the tail decisionKept trace is missing childrenDecision wait of 10–30 s; cache recent decisions so late spans follow them
Clock skew between hostsChild appears to start before parentNTP/PTP; UI clamps child spans inside the parent using causal links

Worked example: an error dropped before tail sampling

Assume 1000 traces, ten errors, and an independent 1% head sampler.

Worked example, in orderMake the head decision: About ten traces survive on average. Expected surviving errors are only 10×0.01=0.1, not all ten.. Apply a keep-all-observed-errors tail policy: It can retain errors that reached the collector. It cannot restore the 99% of traces discarded earlier.. Wait for late spans: Tail decisions use a bounded interval, not certain knowledge the whole trace finished. Late/missing spans can hide an error.. Report coverage: Expose sampling/drop rates and policy version. Use unsampled/appropriately weighted metrics for fleet-wide error rates, not biased tail-trace counts.1Make the head decision2Apply a keep-all-observed-errors tail policy3Wait for late spans4Report coverage
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Make the head decisionAbout ten traces survive on average. Expected surviving errors are only 10×0.01=0.1, not all ten.
2. Apply a keep-all-observed-errors tail policyIt can retain errors that reached the collector. It cannot restore the 99% of traces discarded earlier.
3. Wait for late spansTail decisions use a bounded interval, not certain knowledge the whole trace finished. Late/missing spans can hide an error.
4. Report coverageExpose sampling/drop rates and policy version. Use unsampled/appropriately weighted metrics for fleet-wide error rates, not biased tail-trace counts.
Check your understanding: Can head sampling plus tail sampling guarantee every error trace?

No. Tail only sees forwarded, delivered spans and is bounded by late arrivals, buffering, and outages.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py observability
python3 concepts.py test

The model quantifies pre-tail error loss and contrasts conditional retention with complete coverage.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

11. 45-Minute Interview Walkthrough

MinutesWhat to do
0–5Clarify scale, async needs, retention and search needs.
5–10Estimate: 1.7 PB/day raw means sampling is mandatory.
10–15Define span and trace. Write the traceparent header.
15–25Draw SDK → agent → LB collector (by trace_id) → tail sampler → object store → query.
25–35Deep dive head vs tail sampling. Size the tail buffer.
35–45Queues and span links, storage index, service graph, failure modes.

12. Common Mistakes

Watch out

13. 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.

14. Check Yourself

  1. What are the four fields of a W3C traceparent header?
    Answer

    Version, trace-id (16 bytes, 32 hex chars), parent span-id (8 bytes, 16 hex chars), and trace flags. The low bit of the flags means “sampled”.

  2. Why can a 1% head sample miss most errors?
    Answer

    The choice is made before the outcome is known. So it keeps 1% of error traces, the same as any other trace.

  3. Tail sampling with a 30 s window at 20 GB/s. How much memory?
    Answer

    20 GB/s × 30 s = 600 GB, spread over the collector fleet.

  4. How does a trace cross a Kafka topic?
    Answer

    The producer injects traceparent into the message headers. The consumer extracts it and starts a span, often linked rather than a child.

  5. Why do spans not need to arrive in order?
    Answer

    Each span carries trace_id and parent_span_id. The read side rebuilds the tree from those links at query time.

  6. Why is a Tempo-style store cheap?
    Answer

    It keeps only a small trace_id index and puts span blocks on object storage. Rich attribute search is harder as a result.

15. Key Terms

Span
A span records one operation with a trace ID, span ID, parent or links, timing, and attributes. Context propagation sends the current trace/span identity across HTTP, RPC, thread, and queue boundaries so related work can be assembled. Example and limits above.
Trace
All spans of one request, as a tree.
Context propagation
Passing trace ids across every call.
traceparent
A span records one operation with a trace ID, span ID, parent or links, timing, and attributes. Context propagation sends the current trace/span identity across HTTP, RPC, thread, and queue boundaries so related work can be assembled. Example and limits above.
Head sampling
Head sampling decides at trace start. Tail sampling buffers received spans and decides after a configured decision interval using observed errors/latency. Sampling reduces retained volume but introduces coverage and bias choices. Example and limits above.
Tail sampling
Head sampling decides at trace start. Tail sampling buffers received spans and decides after a configured decision interval using observed errors/latency. Sampling reduces retained volume but introduces coverage and bias choices. Example and limits above.
Span link
A reference to a related span, not a parent.
Service graph
Who calls whom, built from many traces.

16. Further Reading


⌂