System Design

Design a Distributed Tracing System

Step 1 / 9

Learn system design by building a distributed tracing system like Jaeger, Zipkin or Honeycomb step by step.

The numbers to beatspanone timed operationtrace idshared across all spansparent idbuilds the tree

In the interview room

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

Functional requirements

What it must do — agree on these before drawing a single box.

  • Follow one request: stitch every service’s slice of a single request into one end-to-end waterfall.
  • Model the work: a span per operation (name, start, duration, tags, parent id); spans sharing a trace id form a trace.
  • Propagate context: carry the traceparent (trace id + parent span id) in headers across every hop.
  • Sample: keep the errors and slow outliers, drop the boring majority — tracing you can actually afford.
  • Query: fetch a whole trace by id (the waterfall) and search by service, duration, error or tag.

Non-functional requirements

The qualities that shape the whole design — each one names the mechanism that buys it.

Explain one slow request, not an aggregate
A per-request trace id threads every service’s span into one causal timeline — the primitive metrics (aggregates) and logs (per-service, disconnected) both lack.
Never break the trace across a polyglot fleet
Propagate the trace context in-band in request headers (W3C traceparent); each hop injects/extracts so all spans link into one trace — miss a hop and it fragments.
Tracing never slows or endangers production
Spans are emitted fire-and-forget to a local agent, off the request’s critical path; a backend outage drops traces, never slows or breaks the request.
Keep tracing affordable at scale
Tail-based sampling buffers each full trace then keeps errors and slow ones plus a small random baseline, dropping the boring 99%.
Serve both “show this trace” and “find traces like this”
Store spans indexed by trace id (assemble the waterfall) plus secondary indexes on service/duration/tag (search), under bounded retention.
Survive messy reality (skewed clocks, async hops)
Reconstruct order from causal parent-child links over raw timestamps, cap tag cardinality, and model async work with span links, not fake parents.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Span-per-operation treeover one summary log line per request

A single end-of-request summary can’t show nested per-hop timing or which downstream call was slow. A tree of timed spans linked by parent id reconstructs both the path and where the time went.

In-band header propagationover correlating independent per-service ids later

Ids each service invents can’t be reliably stitched after the fact — timing overlaps and shared fields are ambiguous. Riding the id in the request headers makes causality explicit, with no central lookup on the hot path.

Async emit via a local agentover a synchronous write to the trace store

Writing spans synchronously on the request path adds latency and couples the app’s availability to the tracing backend. Buffering off-thread means a backend outage costs trace completeness, never request latency.

Tail-based samplingover uniform head sampling

Head sampling decides up front — cheap, but blind to whether a trace will error, so it keeps mostly boring traces. Tail sampling waits for the whole trace and keeps errors and slow ones; you pay to buffer everything briefly to spend storage on what you’ll investigate.

Bounded retention (days)over keeping every trace forever

Traces are extremely high-volume and their investigative value is short-lived, so retention is measured in days — unlike the indefinite retention you’d give aggregated metrics.

What this teaches

Learn system design by building a distributed tracing system like Jaeger, Zipkin or Honeycomb step by step. An interactive guide covering why logs and metrics can't explain a slow request across microservices, trace and span context propagation, in-process instrumentation, an agent + collector ingest path, tail-based vs head-based sampling, a store optimized for trace lookup, the query/waterfall UI, and the unhappy paths (clock skew, sampling bias, cardinality).

Key takeaways

  • Metrics aggregate and logs are disconnected — tracing adds a per-request thread across services.
  • The model: spans (timed operations) sharing a trace id, linked by parent id into a trace tree.
  • Propagate trace context in headers (W3C traceparent) so the id rides in-band across every hop.
  • Instrument with auto + manual spans, emitted asynchronously so tracing never slows the request.
  • Ship spans fire-and-forget via local agents to a scalable collector — off the critical path.
  • Sample (tail-based) to keep errors and slow traces plus a baseline, dropping the boring majority.
  • Store indexed by trace id (assemble) + secondary indexes (search), with bounded retention.

Concepts covered

  • What is distributed tracing?
  • The needle across a haystack of services
  • Spans and traces
  • Context propagation
  • Instrumentation
  • Agent → collector ingest
  • Sampling: head vs tail
  • The trace store
  • Clock skew, bias & cardinality

Design a Distributed Tracing System — read the full walkthrough as text

the same steps, decisions & trade-offs, for reading, reference & search

The big idea

What is distributed tracing?

A user says "checkout is slow." The request touched the gateway, auth, cart, pricing, inventory, payments and three databases — across a dozen services owned by different teams. Metrics say "p99 is up"; logs are a million disconnected lines. Which hop actually made this request slow? Neither can tell you.

Distributed tracing follows a single request end to end. Each service records a span (its slice of work, with timing), all tagged with one shared trace id, and the system stitches them into a waterfall that shows exactly where the time went. It's the third pillar of observability — metrics say something is wrong, logs say what happened, traces say where.

How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the pipeline grow, and at the end kill the collector and disable sampling to see why tracing stays off the hot path and can't keep everything. Hit Begin.

Step 1 · Why logs & metrics fall short

The needle across a haystack of services

You have per-service metrics and mountains of logs. Yet you can't answer "why was this specific request slow?" What's fundamentally missing?

Design decision: Metrics + logs exist. Why can't they explain one slow cross-service request?

The call: Metrics aggregate away the single request; logs aren't linked across services. — A metric like p99 is a statistic over many requests — it can't point at one. Logs record events per service with no shared identifier tying one request's lines together across a dozen services. Tracing adds exactly that missing thread.

Metrics aggregate — they tell you p99 rose but not which request or where. Logs are per-service and disconnected — no shared thread ties one request's lines together across services. Tracing supplies the missing primitive: a single trace id that follows one request through every service, turning scattered events into one causal timeline.

The three pillars: Metrics: aggregate health (cheap, always-on). Logs: discrete events (detailed, per-service). Traces: the causal path of one request across services. They complement each other — an alert (metric) leads you to an exemplar trace, which links to the relevant logs.

Step 2 · The unit of work

Spans and traces

To reconstruct a request's journey you need a data model. What's the smallest useful record, and how do many of them add up to one request's story?

Design decision: What's the right data model for one request across services?

The call: A span per operation (name, start, duration, tags, parent id), many spans sharing a trace id form a trace. — Each unit of work is a span with timing, tags and a parent-span id; all spans from one request carry the same trace id, so they assemble into a tree/waterfall showing the full path and where time was spent.

The unit is a span: one operation (an HTTP handler, a DB query) with a name, start time, duration, tags, and a parent span id. Every span from one request shares a single trace id. Assemble all spans with the same trace id by their parent links and you get a trace — a tree/waterfall of the entire request, with each bar's width showing where the time went.

Span tree = trace: A trace is a directed tree of spans: the root is the entry request; each child is a downstream call. Parent-child links reconstruct causality; start+duration reconstruct timing. The waterfall you stare at during an incident is just this tree laid out on a time axis.

Step 3 · Carry the id across the wire

Context propagation

Service A calls B calls C, each in a different process (or language). For all their spans to share one trace id, that id has to travel with the request across every network hop. How does the id get from A to C?

Design decision: A→B→C are separate processes. How do all their spans share one trace id?

The call: Propagate the trace context (trace id + parent span id) in request headers on every call. — When A calls B, it injects the trace id and its own span id into standard headers (W3C traceparent); B reads them, makes its span a child, and injects updated context when calling C. The id rides the request through every hop, so all spans link into one trace.

Propagate the trace context in request headers. When A calls B, it injects the trace id and its current span id into standard headers (the W3C traceparent format). B reads them, starts its span as a child, and injects updated context when it calls C. The identity rides in-band with the request across every hop and language, so every span links into one trace with no central coordination.

In-band context: Propagation is the make-or-break detail: miss it at one hop and the trace breaks into disconnected fragments. Standard formats (W3C Trace Context, B3) and auto-instrumentation libraries handle injection/extraction at RPC boundaries so it "just works" across a polyglot fleet.

Step 4 · Where spans come from

Instrumentation

Someone has to actually create spans, start the timer, attach tags, and read/write the context headers — in every service, ideally without developers hand-writing it everywhere. How do spans get produced?

Use instrumentation libraries (OpenTelemetry SDKs). Auto-instrumentation hooks common frameworks and clients (HTTP servers, gRPC, DB drivers) to create spans and propagate context automatically at the boundaries; developers add manual spans and tags around important business logic. Spans are emitted asynchronously to a local agent so instrumentation adds negligible latency to the request itself.

Auto + manual: Auto-instrumentation gives you the whole request skeleton (every inbound/outbound call) for free; manual spans add the meaningful in-process detail ("cache lookup", "risk check"). Emitting spans off-thread keeps the observed system fast — you must not slow the request to trace it.

Step 5 · Get spans out of the app

Agent → collector ingest

Spans are produced inside thousands of short-lived processes. You need to get them to central storage without coupling the app to the pipeline, adding request latency, or losing the app if the tracing backend hiccups.

Design decision: How do you ship spans off thousands of processes without risking the app?

The call: Services emit spans fire-and-forget to a local agent, which forwards to a scalable collector tier. — A local agent (sidecar/daemon) buffers spans off the request path and ships them to a horizontally-scaled collector that validates, batches and forwards to sampling/storage. If the backend is down, spans buffer and then drop — the app never notices.

Two decoupling layers. Services emit spans fire-and-forget to a local agent (sidecar/daemon) that buffers off the request path. Agents forward to a horizontally-scaled collector tier that validates, batches, and hands spans to sampling and storage. Because emission is asynchronous and buffered, a tracing-backend outage causes dropped traces at worst — never a slowed or broken request.

Off the critical path: The golden rule of tracing: observing the system must not endanger it. Async emit → local agent buffer → collector fleet means backpressure or failure downstream degrades trace completeness, not application latency or availability. OpenTelemetry's Collector is exactly this tier.

Step 6 · You can't keep it all

Sampling: head vs tail

At scale, tracing every request would multiply your ingest, storage and cost by orders of magnitude — and 99% of traces are boring, fast, successful requests nobody will ever look at. But the 1% you do want are exactly the errors and slow outliers. How do you keep the interesting traces cheaply?

Design decision: Tracing 100% is far too expensive, but you want the errors/slow ones. How do you sample?

The call: Tail-based sampling: buffer each full trace, then keep errors, slow ones, and a small random baseline. — Head sampling decides up front (cheap, but can't know if a trace will error). Tail sampling waits until the trace is complete, then keeps it if it errored or was slow, plus a small random sample of normal traces for baseline — capturing the interesting minority while dropping the boring majority.

Sample. Head-based sampling decides at the start (cheap, but you don't yet know if the request will error or be slow). Tail-based sampling buffers each complete trace and then keeps the interesting ones — errors, high latency — plus a small random baseline of normal traces, dropping the boring majority. Tail sampling costs more to run (you buffer everything briefly) but keeps exactly the traces you'll actually investigate.

Bias toward the interesting: The whole game is spending your storage budget on traces someone will look at. Tail sampling can say "keep every trace that errored or took >1s, plus 1% of the rest." A consistent sampling decision must be propagated so an entire trace is kept or dropped together — never half a trace.

Step 7 · Store for trace lookup

The trace store

Kept spans must be stored so you can (a) pull an entire trace by its id instantly, and (b) find traces by service, duration, error, or tag. Those are different access patterns. How do you store spans?

Store spans indexed by trace id so all spans of a trace are fetched together to render the waterfall, and maintain secondary indexes (service, operation, duration, status, tags) so you can search "slow checkout traces with error=true in the last hour." Use a scalable backend (Cassandra/Elasticsearch/columnar) with retention — traces are high-volume and short-lived in usefulness, so keep them days, not forever.

Two access patterns: Get-by-id: assemble one trace's spans (the waterfall). Search: find candidate traces by attributes. The store must serve both — a trace-id partition for assembly, plus inverted indexes for discovery — under heavy write load and bounded retention.

Step 8 · The sharp edges

Clock skew, bias & cardinality

Real traces get messy: servers' clocks disagree (so a child looks like it started before its parent), sampling can bias your view, high-cardinality tags blow up storage, and async work (queues, batch) breaks simple parent-child timing.

Handle clock skew by reconstructing order primarily from causal parent-child links (not raw timestamps) and correcting obvious inversions. Remember sampling makes traces not statistically representative — use metrics for rates, traces for exemplars. Cap tag cardinality (no unbounded ids in span tags). Model async flows with span links (a queue consumer links back to the producer's span) rather than pretending it's a synchronous child.

Design for the unhappy path: Skewed clocks → trust causal links over timestamps. Sampled data → don't compute rates from traces. Exploding tags → cardinality caps. Async hops → span links, not fake parents. The clean span tree needs these corrections to match messy reality.

You did it

You just designed a distributed tracing system.

  • Metrics aggregate and logs are disconnected — tracing adds a per-request thread across services.
  • The model: spans (timed operations) sharing a trace id, linked by parent id into a trace tree.
  • Propagate trace context in headers (W3C traceparent) so the id rides in-band across every hop.
  • Instrument with auto + manual spans, emitted asynchronously so tracing never slows the request.
  • Ship spans fire-and-forget via local agents to a scalable collector — off the critical path.
  • Sample (tail-based) to keep errors and slow traces plus a baseline, dropping the boring majority.
  • Store indexed by trace id (assemble) + secondary indexes (search), with bounded retention.
built to follow one request across a hundred services — make the calls, drop the collector, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs