Vibe Engines
YouTube
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

The whole design, in writing

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

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

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.

ServicesA → B → C
New in this step: Services.

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.

What the new pieces do

Servicesclient
The microservices handling one user request as it hops from service to service. Each does a slice of the work and calls the next.

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?

ServicesA → B → C
The system as it stands at this step.

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

  1. Retention isn't the issue. The gap is that metrics are aggregates (they lose the individual request) and logs are per-service and disconnected (no thread links one request's log lines across services).

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

  3. More dashboards still show aggregates. The missing primitive is a per-request identifier that connects every service's work on that one request into a single timeline.

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.

Why this piece earns its place

The cheap version of this idea already exists, and it’s worth saying why it isn’t enough: stamp a request id on every log line and grep for it across services. That buys you the thread, and it is genuinely better than nothing — but it gives you a list of events, not a shape. Log lines are instants; spans are intervals with a parent, so only the span model can tell you that inventory spent its time waiting on payments rather than working. No nesting, no per-hop duration, no way to see that two calls ran in parallel. And the expensive part is identical either way — you still have to carry that id through every hop in every language, which is the work of step 3. So you pay tracing’s hard cost and collect a fraction of its value. If you are going to thread the request anyway, thread something that has structure.

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?

ServicesA → B → C
The system as it stands at this step.

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

  1. A single summary line can't show the nested timing of each hop or which downstream call was slow. You need a record per operation, linked into a tree.

  2. Counters are metrics — they aggregate and lose the individual request's structure entirely. Tracing needs per-operation timed records that form a causal tree.

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

Why this piece earns its place

Notice what this model does not give you: a moment when a trace is done. Spans are emitted independently by different processes, so a trace is not an object anyone writes — it is a bag of spans that agree on an id, assembled by whoever reads them. The root finishes last, because it cannot end until its children do, so the first spans you receive are the leaves, and the parent a span points at may not have arrived yet, or may never arrive at all. Two consequences follow. The read path has to render a forest with dangling parents rather than wait for a well-formed tree, or the incident page shows nothing at the moment you need it. And nothing downstream can truly ask “is this trace complete?” — the best available answer is “no new spans for a while”, which is a timeout, and every later decision that wants a whole trace inherits that uncertainty.

  • spanone timed operation
  • trace idshared across all spans
  • parent idbuilds the tree

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?

ServicesA → B → CTrace Contextids propagated
New in this step: Trace Context.

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

  1. Independent ids can't be reliably stitched after the fact — timing overlaps and shared fields are ambiguous. The id must be propagated with the request so causality is explicit, not guessed.

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

  3. A shared global lookup per hop adds latency and a dependency on the request path, and races under concurrency. Context travels in-band with the request via headers — no central coordination needed.

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.

Why this piece earns its place

Riding the id in-band has a consequence at the edge: traceparent is just a header, and a public endpoint that trusts it lets any caller choose your trace ids. That is an unauthenticated write into your observability system — someone can pin every request onto one id until that trace is unopenable, or supply ids that collide with real ones. So apply the ordinary rule for input crossing a trust boundary: internal hops extract the context, the public edge does not. At the edge you start a fresh trace and keep the caller’s claimed id as a tag, so a real client-side SDK still correlates without handing strangers your id space. The second edge case is duller and far more common: proxies, load balancers and gateways drop headers they do not recognise, so the hop where a trace fragments is often not a service at all — you find it by hunting for traces whose root has no children.

  • traceparentheader format
  • inject/extractat each hop
  • in-bandno central lookup

What the new pieces do

Trace Contextservice
The trace id + parent span id passed in headers from service to service, stitching all the spans into one trace.

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?

ServicesA → B → CTrace Contextids propagated
The system as it stands at this step.

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.

Why this piece earns its place

The knob this step introduces is granularity, and both ends of it hurt. Instrument too little and the waterfall has gaps: one span covers a service’s whole handler, so all you learn is that the time went somewhere inside it, which is where you started. Instrument every function and a single request emits hundreds of spans, each one multiplying through the buffer, the collector, the sampler and the bill — and the waterfall becomes a wall nobody can read. Where I would set it: a span at every network boundary automatically, because that is where the interesting time usually hides and it costs nothing to decide, plus manual spans only where someone has an actual hypothesis about in-process time. And be honest about the cost you do pay inside the app. Emission is asynchronous, but creating a span is not free — it allocates, reads the clock and copies tags — so per-request overhead scales with span count, not with how far away the backend is.

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.

Agentlocal sidecarCollectoringest · assemble
New in this step: Agent, Collector.

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

  1. A synchronous write to the backend on the request path adds latency and couples the app's availability to the tracing store — a tracing outage would slow or break production. Emission must be async and buffered.

  2. That's neither timely nor reliable. You want a local buffering agent that forwards to a scalable collector tier, fully decoupled from the request.

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

Why this piece earns its place

The thing to get right here is the buffer, because an unbounded one turns a tracing outage into an application outage: spans pile up in the process’s own memory until it dies, which is exactly the coupling this step exists to remove. So the buffer is fixed-size and it drops — and then the real question is whether anyone finds out. Drop silently and tracing lies in the most dangerous way. During an incident the degraded service emits more spans and slower ones, so the buffer fills precisely while the traces you want are being produced, and a search that comes back empty reads as “nothing happened here”. Emit a dropped-span counter as an ordinary metric alongside everything else, so “we have no trace” can be told apart from “there was no problem”. The same bound belongs on the agent’s queue: the point of two layers is that neither of them may grow without limit.

  • agentlocal buffer
  • collectorscalable ingest
  • fire-and-forgetno request impact

What the new pieces do

Agentgateway
A lightweight process on each host/pod. Buffers spans emitted by local services and forwards them onward, off the request path.
Collectorservice
Receives spans from all agents, validates and batches them, and hands them to sampling and storage.

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?

Collector+ tail samplerSamplerkeep / drop
New in this step: Sampler.

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

  1. Uniform random head sampling is cheap but blind — it keeps mostly boring traces and drops most errors and slow outliers, exactly the ones you need. You want the decision to favor interesting traces.

  2. Time-window sampling gives a wildly biased, unrepresentative view and misses later incidents entirely. Sampling must be per-trace and biased toward what matters.

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

Why this piece earns its place

Tail sampling buys its fidelity with two costs the one-line description hides. The first is the decision window. You cannot judge a trace until its last span arrives, and a slow trace is by definition the late one — so the window has to outlast the latency you are hunting, or you decide on incomplete traces and systematically discard the outliers you built this for. Make it too long and the collector holds more in-flight traces in memory, which is the real ceiling on this tier. The second is easier to miss and harder to undo: every span of a trace must reach the same collector instance to be buffered together, so this tier can no longer be round-robin. It needs routing by trace id, which turns something you were treating as stateless into a hashed, rebalancing fleet — with hot instances behind heavy traces, and split decisions while the ring moves. That is the price of deciding late.

  • headdecide up front, cheap
  • taildecide after, keeps errors/slow
  • 1%baseline of normals

What the new pieces do

Samplerservice
Decides which traces to keep. Tail-based sampling waits for the whole trace, then keeps errors and slow ones, dropping the boring majority.

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?

Collector+ tail samplerSamplerkeep / dropTrace Storeby trace id
New in this step: Trace Store.

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.

Why this piece earns its place

Every secondary index is paid for on the write path, and here the write path is the whole firehose while the read path is a handful of humans during an incident. Let that asymmetry decide the design: index narrowly, filter late. Service, operation, duration and status earn an index because nearly every search starts there. Arbitrary tags do not — and the version of this that quietly fails is the one where someone points the span stream at a search cluster with dynamic mapping and indexes every field anyone has ever attached. Then the index outgrows the data it points at, and the component that falls over in your busiest hour is the search tier rather than the store. Keep tags as payload on the span, narrow to candidate traces on the indexed fields plus a time range, and filter the rest after the fetch. Scanning candidates is cheap when a person is waiting, and it costs nothing on everything you wrote and nobody ever reads.

  • by trace idassemble waterfall
  • secondary indexsearch by tag/latency
  • retentiondays, not forever

What the new pieces do

Trace Storedata
Stores spans indexed so an entire trace can be fetched by its id, and traces can be found by service, duration or tags.

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.

Trace Storeby trace idQuery UIwaterfall
New in this step: Query UI.

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.

Why this piece earns its place

Ordering from parent-child links is the right call, but be precise about what it cannot recover. Order is not duration. Two spans on hosts whose clocks disagree still carry absolute start times you cannot compare, and the number you most want in a latency hunt is exactly that comparison: the gap between a parent issuing a call and the child beginning work, which is network plus queueing. Trust the raw timestamps and that gap can come out negative. The way around it is to take both measurements from one clock — the parent’s span already spans the entire call, so parent-observed duration minus the child’s self-reported duration is the unaccounted time, obtained without ever subtracting one host’s clock from another’s. Attribute that remainder to the hop, not to either service. And treat any figure derived by subtracting timestamps across hosts as a hint: the UI should say so rather than drawing a confident bar.

What the new pieces do

Query UIui
The flame-graph/waterfall view: reconstructs the trace tree so you can see exactly which span made the request slow.

You did it

You just designed a distributed tracing system.

ServicesAgentCollectorSamplerTrace StoreQuery UITrace Context
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

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

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why can't metrics and logs answer "why was this request slow?"

    Because each throws away exactly what you need. Metrics are aggregates by construction — a p99 latency or an error rate is a statistic computed over many requests, so it can tell you that something got slower but never point at an individual request or say which service in the chain caused it. Logs are the opposite problem: they're detailed per-event records, but each service writes its own logs independently with no shared identifier linking one request's log lines across the dozen services it touched, so reconstructing a single request's path means correlating millions of disconnected lines by guesswork. Tracing adds the missing primitive — a single trace id propagated with the request through every service — which turns those scattered events into one causal, timed tree. The three are complementary: a metric alert leads you to an exemplar trace, and the trace links you to the specific logs that matter.

  2. What exactly is context propagation and why is it the make-or-break detail?

    Context propagation is passing the trace identity — the trace id plus the current span id — along with the request as it crosses every network boundary, so that the next service can make its spans children of the caller's span under the same trace id. Concretely, when service A calls B, A injects the trace context into request headers (the W3C traceparent header is the standard), and B extracts it, starts its work as a child span, and re-injects updated context when it calls C. This is make-or-break because the entire value of tracing is the linkage: if propagation is missing or broken at even one hop — a service that doesn't forward the headers, a message queue that drops them, a language whose library isn't instrumented — the trace fragments into disconnected pieces and you lose the end-to-end picture precisely where you needed it. That's why standard formats and auto-instrumentation that inject/extract at every RPC boundary are so important in a polyglot fleet.

  3. Head-based vs tail-based sampling — what's the trade-off?

    Head-based sampling makes the keep-or-drop decision at the very start of a trace, before anything has happened — typically "keep this trace with probability 1%." It's cheap because you only ever process the sampled fraction, and the decision propagates so the whole trace is consistently kept or dropped, but it's blind: at decision time you don't yet know whether the request will error or be slow, so you mostly keep boring successful traces and drop most of the errors and outliers you actually care about. Tail-based sampling defers the decision until the trace is complete: the collector buffers all spans of a trace, and once it finishes, applies rules like "keep it if any span errored, or if total latency exceeded 1 second, plus keep 1% of normal traces for a baseline." That captures the interesting minority with high fidelity, at the cost of having to ingest and briefly buffer every trace (more compute and memory in the collector tier) and needing enough buffering to hold a trace until all its spans arrive. Many systems combine them: a light head sample to bound raw volume, then tail rules to preferentially keep what matters.

  4. Why must span emission stay off the request's critical path?

    Because the cardinal rule of observability is that watching the system must never endanger it. If a service wrote its spans synchronously to the tracing backend as part of handling the request, two bad things follow: every request pays the added latency of that write, and — far worse — the application's availability becomes coupled to the tracing backend's availability, so a slow or down trace store would slow or break production traffic. To prevent this, spans are emitted fire-and-forget to an in-process buffer and handed to a local agent that ships them onward asynchronously, completely decoupled from the request. When the downstream pipeline (collector or store) is unhealthy, spans back up in the buffer and are eventually dropped — you lose trace completeness for that window, which is an acceptable degradation, while the request path stays exactly as fast and available as if tracing weren't there at all.

  5. How does the trace store serve both "show this trace" and "find traces like this"?

    Those are two different access patterns and the store is designed for both. To render a waterfall you need every span belonging to one trace, fetched together and fast, so spans are partitioned/indexed by trace id — a get-by-id that assembles the full tree. But you usually arrive without a specific id; you're asking "show me slow checkout-service traces with an error in the last hour," which requires searching by attributes. So the store also maintains secondary (often inverted) indexes over fields like service name, operation, duration, status/error, and selected tags, letting you filter down to candidate traces and then pull each by id to inspect. Because traces are extremely high-volume and their investigative usefulness is short-lived, the backend is a scalable write-optimized store (Cassandra, Elasticsearch, or a columnar engine) run with bounded retention measured in days, not the indefinite retention you might give aggregated metrics.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. Tracing exists because metrics and logs…

    Metrics lose the individual request; logs are per-service and unthreaded. Traces add the thread.

  2. A trace is built from…

    Each span is a timed operation; same trace id + parent links reconstruct the waterfall.

  3. All spans share one trace id because…

    Inject/extract traceparent at each boundary so the id rides in-band; miss a hop and the trace breaks.

  4. Spans are emitted fire-and-forget to a local agent so that…

    Async emit + buffering means a tracing outage drops traces, never slows or breaks the request.

  5. Tail-based sampling is valuable because it…

    It spends the storage budget on the interesting minority you'll actually investigate.

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.

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.

The qualities that shape everything

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 tree over 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 propagation over 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 agent over 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 sampling over 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.

The answer, out loud

What a strong answer to “Design a Distributed Tracing System” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Frame what we are actually solving

    Let me narrow what we’re building first, because observability as a whole is too big for one session. The thing I want to be able to do is take one complaint — checkout felt slow for this user, at this time — and say which hop made it slow, without knowing in advance which service is at fault. That is a different job from alerting, and it is deliberately not a job I will try to do for aggregates. So my success criterion is: given one request, show me its whole path on a time axis. And the constraint I hold everything else against is that this must never be able to hurt the system it is watching.

  2. 4–9 min

    Why what we already have falls short

    The obvious first question is why the existing pillars don’t cover this. Metrics are aggregates by construction — a percentile is computed over many requests, so it can tell me something got slower and can never point at one request. Logs have the opposite problem: they’re detailed, but each service writes its own with nothing tying one request’s lines together across the dozen services it touched. So the missing primitive is an identifier that follows a single request everywhere it goes. A trace is really just that identifier, plus timing.

    Built in step 1: The needle across a haystack of services
  3. 9–15 min

    The data model

    For the model I’d take a span per operation: a name, a start time, a duration, some tags, and the id of the span that caused it. Every span from one request carries the same trace id. That buys me two things at once — the parent links reconstruct what called what, and start-plus-duration reconstructs when. Put that on a time axis and you have the waterfall people stare at during an incident. It is a tree rather than a list, and the tree is the point.

    Built in step 2: Spans and traces
  4. 15–21 min

    Getting the id across the wire

    Then the hard part, which is making separate processes agree on that id. When A calls B, A puts the trace id and its own span id into the request headers — traceparent is the standard format — and B reads them, starts its span as a child, and re-injects updated context when it calls C. I’d push hard on using the standard rather than anything homegrown, because the fleet is polyglot and the value collapses the moment one hop doesn’t forward it. And this rides in-band on purpose: no central lookup sitting on the request path.

    Built in step 3: Context propagation
  5. 21–26 min

    Where spans come from

    Nobody is going to hand-write that in every service, so I’d lean on instrumentation libraries. Auto-instrumentation hooks the HTTP servers, the RPC clients and the database drivers, so I get the skeleton of the whole request for free and, more importantly, the header injection and extraction happen at every boundary consistently. Teams then add manual spans around the logic they actually care about — a risk check, a cache lookup. All of it is emitted asynchronously; I am not going to make a user’s request wait on tracing.

    Built in step 4: Instrumentation
  6. 26–32 min

    Getting spans out without endangering the app

    For shipping them I’d use two layers. Services emit fire-and-forget to a local agent on the same host, and agents forward to a horizontally scaled collector tier that validates and batches. The local hop decouples thousands of short-lived processes from one central service. And the whole arrangement exists to make a single guarantee: if the tracing backend is unhealthy, I lose traces, and the request path doesn’t change at all. I’d state that out loud, because it is the property that lets you actually turn this on in production.

    Built in step 5: Agent → collector ingest
  7. 32–38 min

    Paying for it

    Now cost, which is where the real design decision lives. Keeping every trace multiplies ingest and storage by orders of magnitude, and almost all of it is fast, successful requests nobody will ever open. Head sampling decides at the start — cheap, but blind to whether the request will error or run slow, so you mostly keep boring traces. I’d go tail-based: buffer the trace, and once it is complete keep it if anything errored or it was slow, plus a small random baseline of normal traces so there is something to compare against. Whatever the rule, it has to apply to the trace as a whole, or you render a waterfall with holes in it.

    Built in step 6: Sampling: head vs tail
  8. 38–43 min

    Storing it and reading it back

    Storage follows the two questions people ask. Show me this trace, which means fetching every span for one id and assembling the tree, so trace id is the partition. And find me traces like this, which means searching by service, duration, error or tag, so a few secondary indexes on top. Retention is days rather than forever, because a trace’s investigative value expires quickly and the volume is enormous. The view on top is the waterfall, and I’d have it order spans by the parent links instead of raw timestamps, since the hosts’ clocks will disagree.

    Built in step 7: The trace store
  9. 43–45 min

    The trade-off, and what I’d do next

    So the trade-off I am making is fidelity for affordability. I am choosing to hold only a biased sample of traces, which means I must never compute rates from them — metrics answer how often, traces answer where. With more time I’d work through the asynchronous cases, where a queue breaks the neat parent-child relationship and you want links between spans instead, and I’d put real limits on tag cardinality early, because otherwise the storage bill starts growing with your user count rather than with your traffic.

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