System design interview
Design a distributed tracing system
Spans, context propagation, tail sampling, trace storage and indexes.
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- Each service finishes its spans. Its local agent sends them in batches, keyed by trace ID, and never waits for an answer.
- One sampler instance receives all the spans of the trace, from every service, and groups them under the trace ID.
- The sampler reads its rules, refreshed every minute: keep errors, keep root spans over 1 second, and keep 1% by a hash of the trace ID.
- For every span, kept or not, it adds to the service graph's counts of calls, errors and latency.
- After 30 seconds it decides. This trace had an error, so all its spans are written to the trace store as one row.
- It also writes index entries for the trace: the services, the operations, the duration and the error flag.
- Services: library adds trace ID
- Span stream: keyed by trace ID
- Sampling rules: errors, slow, 1%
- Tail sampler: buffers 30 seconds
- Trace index: service, op, duration
- Trace store: one row per trace
- Service graph: calls, errors, latency
- Query API
- Engineer
On this page
In 2 minutes
I would put a small library in every service. It creates a span for each call, with a trace ID, a span ID and a parent ID, passes them on in request headers and message metadata, and sends batches from a bounded buffer to a local agent, so it costs about 0.03% of a request and never blocks. Spans go on a stream keyed by trace ID. That puts every span of a trace on one tail sampler instance, which buffers for 30 seconds.
At 800,000 spans a second that is about 12 GB, or 24 GB with overhead, over 16 instances. It then keeps every trace with an error or a root span over 1 second, plus 1% by hash of the trace ID, about 3% in all. Head sampling would keep only 3% of the 17.3 million daily error traces. Before sampling, the sampler updates service-graph counters from every span, so the service map is exact.
Kept traces are written as one row per trace, keyed by trace ID, so a fetch is one read. A small index on service, operation, duration and error finds trace IDs, at about 62 GB a day. Kept traces live 7 days, about 3.6 TB, and error traces another 23 days in cheap storage.
Step 1 of 6: Clarify the scope
Interviewer asks
A request to our product passes through dozens of services. Design a tracing system that records the path of a request through all of them, so an engineer can see where its time went and what failed. It must add almost no cost to the services.
Questions to ask first
Ask these before you draw anything. Under each one is why its answer changes the design.
- How many services and requests are there, and how many spans does a typical request create?
Spans a second, not requests a second, sets ingest and storage. A request that fans out to 20 services makes 20 or more spans.
- Which traces matter most: errors, slow requests, or a random sample of everything?
Keeping traces at random loses most of the rare, valuable ones. The answer decides between head and tail sampling.
- Must we find a trace by ID only, or also by service, operation, duration and error?
A lookup by ID is one key read. Searching by other fields needs secondary indexes, which can cost more than the traces if built carelessly.
- How much slowdown may tracing add to a request, and what if tracing itself is down?
The client library runs inside every service. It must be cheap, and it must never block or fail a request.
- How long do we keep traces, and must some last longer than others?
Retention tiers let error traces outlive routine ones without paying to keep everything.
Requirements
Functional
- Record spans in every service, and follow one request across HTTP calls, message queues and background jobs.
- Keep every error trace and every slow trace, plus a small random share of the rest.
- Find a trace by ID, or search by service, operation, duration range and error flag.
- Show a trace as a tree with timings, and a service map with call rate, error rate and latency.
Non-functional
- Instrumentation adds under 1% CPU to a service, and it never blocks a request.
- Ingest 800,000 spans a second at the peak.
- Fetch a trace by ID: p99 under 100 ms. Search the last hour: p95 under 2 seconds.
- A kept trace is searchable within 60 seconds of the request.
- Normally keep 100% of error and slow traces, with a per-service cap so an incident cannot flood the store.
Step 2 of 6: Size it
Keeping every span would cost 17.3 TB a day, so we must sample. Sampling before the request finishes throws away 97% of the error traces, so decide after the trace is complete, and pay for it with a 12 GB buffer at the peak.
Assumed for the exercise, not facts about a real system:
- 20,000 requests a second on average, each creating 20 spans of about 500 bytes. The peak is 2 times the average.
- 1% of requests fail, 1% are slow, and 1% are kept at random, so about 3% of traces are kept.
- The sampler waits 30 seconds for the rest of a trace before it decides. Sixteen sampler instances share the stream.
- Kept traces stay 7 days in full. Error traces stay a further 23 days, in cheaper storage.
- Spans per second
- 20,000 requests × 20 spans; × 2 at the peak
- 400,000/s average, 800,000/s at the peak
- Raw span volume
- 400,000 × 500 bytes = 200 MB/s × 86,400 s
- ≈ 17.3 TB a day if all were kept
- Sampler buffer at the peak
- 800,000 × 30 s = 24M spans × 500 bytes = 12 GB; × 2 for map overhead = 24 GB; ÷ 16 instances
- ≈ 1.5 GB per instance
- Kept traces, 7 days
- 17.3 TB × 3% = 518 GB a day; × 7
- ≈ 3.6 TB
- Error traces, 23 more days
- One third of kept = 173 GB a day × 23 days
- ≈ 4.0 TB in cheap storage
- Error traces a day
- 1% × 20,000 = 200/s × 86,400 s. A 3% head sample keeps 3% of them
- 17.3M exist, but head sampling keeps ≈ 518,000
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
A tracing library in each Service creates a span for every call it handles or makes. A span records the service, the operation, the start time, the duration and the status. It carries a trace ID, shared by the whole request, and a parent span ID. Spans are batched and published to the Span stream, which is partitioned by trace ID.
A Tail sampler reads the stream. Because of the partitioning, every span of a trace reaches the same instance. It holds the spans for about 30 seconds, then applies the Sampling rules: keep errors, keep slow traces, and keep a small random share. It writes kept traces to the Trace store and adds entries to the Trace index. For every span, kept or not, it also updates counters in the Service graph.
An engineer uses the Query API. A search goes to the index, which returns trace IDs. The API then fetches each trace from the store by its ID and rebuilds the tree. The service map is read from the service graph.
Other paths through the system
Find a slow trace
- An engineer asks for checkout traces slower than 2 seconds with errors in the last hour.
- The API looks in the index for that service, operation and time range, filtered by error and duration.
- The index returns a page of 20 trace IDs, slowest first.
- The API fetches the first trace by ID. It is one row, so it is one read.
- The store returns the 34 spans of the trace.
- The API rebuilds the tree from parent IDs, marks the longest path, and the engineer sees where the time went.
Read the service map
- An engineer opens the service map for the last 15 minutes.
- The API reads the counters for each pair of calling and called service.
- The graph returns call rates, error rates and latency percentiles, based on all spans, not only on kept traces.
- The map draws each service with a colour for its error rate and a line to each service it calls.
Step 4 of 6: Go deep on the hard part
Spans and context propagation
Interviewer asks
How does a trace follow a request across services, queues and clocks that disagree, without slowing anything down?
A trace is a tree of spans.
Read moreShow less: Spans and context propagation
A span is one unit of work: a request handled, a database call made. It holds a 128-bit trace ID, shared by the whole tree, a 64-bit span ID, the span ID of its parent, the service, the operation name, a start time, a duration, a status and a few attributes. Cap the attributes at about 32 per span and each value at 256 characters, so no span grows without limit.
Propagation. A service passes the context, which is the trace ID, the parent span ID and a sampled flag, to every service it calls. For HTTP and RPC it goes in a request header, such as the W3C Trace Context header. For a message, it goes in the message's metadata. A consumer starts a new span linked to the producer's span. For a background job, store the context in the job record. In-process, keep it in async-local storage and wrap thread pools so it survives a thread switch. If a service drops the header, the trace splits in two, so put propagation in the shared client and server libraries, not in app code.
Clock skew. Hosts disagree about the time by milliseconds. A child span can seem to start before its parent. Use monotonic clocks for each span's duration, which are reliable, and use the parent-child links, not timestamps, for the structure. To place a child, centre it in the parent's window. If a client span lasts 120 ms and the server span inside it lasts 100 ms, the 20 ms of slack is network time, so start the child 10 ms after the parent starts, whatever its own clock says.
A cheap library. Creating a span costs about 2 microseconds. A service with 3 spans on a request adds 6 microseconds to a 20 ms request, which is 0.03%. Spans go into a bounded ring buffer, flushed every second to an agent on the same host, which compresses and forwards them. If the buffer fills, drop the oldest spans and count the drops. Tracing must never block a request, or fail one because the collector is down.
Head and tail sampling
Interviewer asks
How do you keep the traces worth reading while storing about 3% of them, and how much memory does that take?
Storing everything costs 17.3 TB a day, so sample.
Read moreShow less: Head and tail sampling
There are two ways.
- Head sampling. The first service flips a weighted coin, and passes the result in the sampled flag. Every service then keeps or drops its spans to match. It is cheap and needs no buffering. But the choice is made before anyone knows whether the request will fail or run slow. Errors are 1% of requests, or 17.3 million traces a day. A 3% head sample keeps about 518,000 of them, so 97% of the traces you most want are gone.
- Tail sampling. Collect every span, wait until the trace is complete, then decide with full knowledge. Keep all errors, keep slow traces, and keep a small random share.
Tail sampling needs all spans of a trace in one place. So key the stream by trace ID, and give each sampler instance a share of the IDs. The instance holds a map of trace ID to its spans and a first-seen time. After 30 seconds it applies the rules. It keeps the trace if any span has an error status, if the root span lasts more than 1 second, or if a hash of the trace ID falls in 1%. A hash makes the random share consistent: every instance makes the same choice for the same ID.
The memory. At the 800,000 spans a second peak, 30 seconds of spans is 24 million, at 500 bytes is 12 GB. Maps and per-trace data double that to about 24 GB, which is 1.5 GB per instance across 16 instances. The buffer is also why a trace appears in search about 30 seconds late.
Edge cases.
- Late spans. A span can arrive after the decision. Keep each decision for 5 minutes in a small cache and apply it to late arrivals. About 600 kept traces a second over 300 seconds is 180,000 IDs, a few megabytes.
- Giant traces. One trace with 100,000 spans would sit on a single instance. Cap spans per trace at 10,000 and mark the rest as truncated.
- Incidents. A failing service makes every trace an error trace. Give each service a cap on kept error traces a second, and above it keep 10% at random, so a storm cannot flood the store.
- Full buffers. If memory runs out, decide early on the oldest traces from what has arrived.
Service graph. Because the sampler sees every span, it also adds each one to counters per calling service, called service and operation, per minute. The service map then reflects all traffic, not only the 3% kept.
Storing and searching traces
Interviewer asks
How do you store billions of spans, fetch a trace in one read, and search by service, duration and error?
Layout. Write each kept trace as one row in a wide-column or object store, keyed by a hash prefix and the trace ID.
Read moreShow less: Storing and searching traces
The columns, or entries, are the span IDs, each holding a compressed span. The sampler writes the whole trace in one write after its decision, so a fetch by ID is a single key read of about 10 KB, 20 spans of 500 bytes. The hash prefix spreads rows evenly, and sharding is by trace ID.
Search needs an index. An engineer rarely has the ID. They ask for checkout traces slower than 2 seconds with errors. So the sampler also writes index entries for each kept trace and each service in it. The key is the service, the operation, a 5-minute time bucket and the error flag, and entries sort by duration. The value is the trace ID and its start time. That is about 20 entries a trace. At 600 kept traces a second, that is 12,000 entries a second, at about 60 bytes each 720 KB/s, or 62 GB a day, far smaller than the traces. A search reads a few buckets and returns trace IDs.
Limit what is indexed. Each indexed attribute value multiplies the entries, and a field such as user ID has millions of values. Index a short allowlist: service, operation, status, error flag, duration bucket, region and a few tags a team asks for. Everything else stays in the trace, where it can be read once the trace is found.
Retention tiers. Keep kept traces and their index entries 7 days in the fast store, 3.6 TB. After that keep only the error traces, one third of the kept volume, in object storage as compressed files grouped by day. That is 173 GB a day for 23 days, about 4.0 TB. Cold traces are fetched by ID, usually from a log link, and a cold search scans a day's files and takes minutes.
Linking. Log lines carry the trace ID, so a log search can jump to the trace. Metric histograms keep one example trace ID per bucket, so a click on a latency spike lands on a slow trace.
Assembling the tree. The API groups spans by parent ID and links each child to its parent. A span whose parent never arrived is shown as an orphan under the root, not dropped. The critical path, the chain that set the total time, follows the child that finishes last at each level.
Step 5 of 6: Name the trade-offs
When to sample
Tail sampling at the collectors over Head sampling in the client library
Every error and slow trace is kept, not 3% of them. The cost is a 12 GB buffer at the peak and a 30-second delay before a trace appears.
Stream key
The trace ID over The service or the host
All spans of a trace reach one sampler without any coordination. The cost is that one giant trace loads one instance, so spans per trace are capped.
Trace layout
One row per trace, keyed by trace ID over One row per span
A fetch is a single read of about 10 KB. The cost is that a trace is written only after the sampling decision, and a very large trace is a large row.
Service map
Counters from every span, before sampling over Counters from the kept traces
Rates and error percentages are right, not distorted by the sampling rules. The cost is that the sampler does work on every span.
Search
A small allowlist of indexed fields over Index every attribute
The index stays near 62 GB a day. The cost is that some questions can only be answered after you have found a trace.
Step 6 of 6: Say what breaks next
Questions an interviewer asks once the design is on the board. Answer each one out loud first.
- What if a service does not pass the trace headers on?
The trace splits, and the second half starts under a new trace ID. Put propagation in the shared HTTP and RPC libraries, so it happens by default. Add a check that lists services whose outgoing calls arrive without a context.
- How do you keep sensitive data out of spans?
Record only names, timings, status and an allowlist of attributes, and never bodies or full headers by default. Strip query strings from URLs in the library. The sampler drops values that match patterns such as card numbers before anything is stored.
- How would you handle ten times the volume?
Add stream partitions and sampler instances. The per-instance buffer stays the same, because the trace ID hash spreads the work evenly. Drop the boring traffic, such as health checks, in the library, so it never leaves the host.
- How do you trace a request that crosses a message queue?
The producer puts the trace context in the message metadata. The consumer starts a new span that links back to the producer's span, instead of becoming its child, because it may run much later. The trace then shows both the wait in the queue and the processing.