Design a distributed tracing system
Open in the interactive app: practice, quiz, mock interview →In one minute
- Propagate identity, not data. Every service passes a
traceparentheader (W3C Trace Context) with a trace id, the caller's span id and a sampled flag. Each service reports its own spans out of band, and the backend stitches them together by trace id. - Sampling is the whole cost model. Recording every span at millions per second is unaffordable. Head sampling decides at the root, which is cheap but blind. Tail sampling decides after the trace finishes, which keeps the errors and slow traces but needs a stateful buffer.
- Tail sampling forces trace-aware routing. All spans of one trace must reach the same decision-making collector, so a stateless tier routes by trace id in front of a stateful tier that buffers.
- Store traces for lookup by id, search by attribute. Fetching by trace id is a key lookup on immutable blocks in object storage. Searching by attributes is a columnar scan, not a secondary index on every tag.
- Protect the application and the pipeline. Tracing must never slow or crash the service it observes, so exporters are asynchronous with bounded queues that drop on overflow, and the collector sheds load before it falls over.
Requirements
Functional
- Instrument services in several languages and propagate context across HTTP, gRPC and message queues.
- Collect spans from every service and assemble the full trace as a tree, even when spans arrive out of order and from different hosts.
- Fetch a trace by id. Search for traces by service, operation, duration, status and arbitrary attributes in a time range.
- Keep every trace that contains an error or exceeds a latency threshold, plus a small random baseline of the rest.
- Show service dependencies and the critical path of a trace.
Non-functional
- Under 1% added CPU and no blocking calls on the request path. A tracing outage must never become a service outage.
- 1M spans per second on average and 2M at peak, with spans visible in search within about a minute.
- Query a trace by id in under 2 seconds. Attribute search over the last hour in under 10 seconds.
- 7 days of full-fidelity traces, then 30 days for kept error and slow traces only.
Out of scope: metrics and logging pipelines (we only link to them by trace id), profiling, and the UI.
Back-of-envelope
Assume 50,000 user requests per second at the edge at peak, each touching about 40 spans across services. The average is half the peak.
- Spans: 50,000 × 40 = 2M spans/s at peak, 1M/s average.
- Wire bytes: a span with a dozen attributes is about 500 bytes in OTLP protobuf. 2M × 500 B = 1 GB/s, or 8 Gbit/s at peak, before any sampling.
- If we kept everything: 1M/s × 500 B × 86,400 s = about 43 TB per day, and about 300 TB for 7 days. That is the number that kills a design that skips sampling.
- With sampling: keep roughly 1% baseline, 1% slow and 0.5% errors, so about 2.5% of traces. That is 43 TB × 0.025 = about 1.1 TB/day. Columnar compression of 4:1 gives about 270 GB/day, so about 2 TB for 7 days and 8 TB for 30 days. At object-storage prices that is tens of dollars per month. Compute dominates the bill.
- Tail buffer: 25,000 new traces per second on average × a 30 second decision wait = 750,000 traces in memory. At 40 spans × 500 B = 20 KB per trace that is 15 GB of payload, and several times that in a language runtime. Spread it over 20 collectors and each holds about 40,000 traces, close to the OpenTelemetry default of 50,000 (tail sampling processor).
- Ingest CPU: if one core handles about 20,000 spans per second (measure your own), 2M spans/s needs about 100 cores at the first tier, before the tail tier and storage writers.
API
Applications never call the backend directly. They use an SDK that exports over OTLP, and the collector speaks the same protocol (OTLP spec).
# Propagation (HTTP header, W3C): version-traceid-parentid-flags
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: vendor1=opaque,othervendor=value
# Export (OTLP/gRPC, batched)
rpc Export(ExportTraceServiceRequest) returns (ExportTraceServiceResponse)
resource_spans[] -> scope_spans[] -> spans[]
{ trace_id(16B), span_id(8B), parent_span_id, name, kind,
start_time_unix_nano, end_time_unix_nano, attributes[], events[], status }
response.partial_success { rejected_spans, error_message }
# Query
GET /api/traces/{trace_id}
POST /api/search
{ "q": "{ resource.service.name = \"checkout\" && status = error && duration > 2s }",
"start": "2026-10-17T10:00:00Z", "end": "2026-10-17T11:00:00Z", "limit": 20 }
-> [ { trace_id, root_service, root_name, start, duration_ms, span_count } ]Data model
A span is immutable once it ends, so storage is append-only.
- Span:
trace_id(128-bit),span_id(64-bit),parent_span_id,name,kind,start,end,status, resource attributes (service.name,k8s.pod), span attributes and events. - Block: a batch of spans for a time window, sorted by
trace_id, written once to object storage as a columnar file with a footer, a trace-id index and a Bloom filter. Tempo's block format is Apache Parquet, which stores attributes in columns so a query reads only the columns it needs (Tempo architecture). - Partition key:
trace_id. Hashing by trace id keeps a whole trace on one node for the tail sampler and writes a trace to one block, so fetch by id touches few files. - Secondary data: a small, separate store of pre-aggregated service-to-service edges and RED metrics, computed in the collector, so dashboards never scan raw spans.
- Retention: blocks are named by time range, so expiry is deleting objects, not rewriting data.
High-level design
flowchart LR
A["Service + SDK"] -->|"OTLP, batched"| AG["Agent / sidecar"]
AG --> LB["Stateless collectors (route by trace id)"]
LB -->|"consistent hash"| TS["Tail-sampling collectors (buffer 30 s)"]
TS -->|"kept traces"| Q[("Queue (Kafka)")]
LB -.->|"RED metrics, edges"| M[("Metrics store")]
Q --> W["Block writers"]
W --> OS[("Object storage: blocks + index")]
U["Query UI / API"] --> QF["Query frontend"]
QF --> QR["Queriers"]
QR --> OS
QR --> RC["Recent-data cache"]
W --> RCWrite path.
- The SDK creates a root span, makes the head decision (usually "record everything and mark sampled" or a low-rate probability), and injects
traceparentinto outgoing calls. - On span end, the SDK hands the span to a batch processor with a bounded queue. A background thread exports batches to a local agent. A full queue drops spans and increments a counter.
- The first-tier collectors are stateless. They validate, enrich with resource attributes, compute RED metrics, and forward spans using a load-balancing exporter that hashes the trace id.
- The tail tier buffers every span of a trace until it is "done", then applies policies: any error, duration over a threshold, a rate limit per service, then a probabilistic baseline.
- Kept traces go to a queue, and block writers cut sorted, compressed blocks to object storage.
Read path.
- By id: the query frontend looks in the recent-data cache, then asks storage nodes. Each block's Bloom filter rules out most blocks without reading them. Matches are read by range from object storage.
- By attributes: the frontend splits the time range into jobs, queriers scan only the needed columns in parallel, and results are merged and limited.
Deep dives
1. Head versus tail sampling
Head sampling decides when the root span starts, from the trace id or a fixed rate. It is nearly free and the decision travels in the sampled flag, so every service agrees. Its flaw is that it cannot know the outcome: an error that appears in the fifth span of a 1% sampled trace is lost 99% of the time, and OpenTelemetry's own guide says tail sampling is the answer when you must keep every error (OpenTelemetry sampling).
Tail sampling decides after the trace is complete. It sees errors, latency and the whole shape, but each trace must be held in memory until the last span arrives, and "last" is unknowable. A decision_wait timer (default 30 seconds in the OpenTelemetry processor) is the guess. A late span either finds the decision already made or arrives after a trace was dropped.
Choice: both. Head-sample generously, for example record everything when the cost allows, or at 10 to 25% when it does not, and tail-sample for the keep decision. Drop most healthy, fast traces; keep all errors and slow traces. Make the baseline deterministic from the trace id: compare a hash of the id to a threshold. Every collector that sees the same id then agrees, and raising the rate only adds traces. OpenTelemetry's probability-sampling work encodes that threshold in tracestate so downstream components know the effective rate and can weight counts (spec).
2. Making the tail sampler scale
The tail sampler is stateful, and the documentation is blunt: all spans for a trace must reach the same collector instance (README). Two tiers solve this. A stateless tier receives spans from anywhere and uses a consistent hash on trace_id to choose a tail node. Consistent hashing matters because when a tail node joins or dies, only about 1/N of traces move. The traces in flight during the change get split across two nodes and make bad decisions, and you accept that and measure it.
Memory is the limit. The buffer holds num_traces traces, evicts the oldest when full, and every eviction is a trace decided too early. Alert on evictions, because they silently degrade the "keep every error" promise. Keep a small cache of recent decisions so late spans of an already-kept trace are forwarded and late spans of a dropped trace are discarded cheaply.
Alternatives: keep the buffer out of process by writing every span to a log keyed by trace id and deciding at read or compaction time. That costs 40 TB/day of writes, which is the cost we are avoiding.
3. Storage and query
Two queries with opposite shapes. By trace id is a point lookup, so blocks are sorted by trace id and carry a Bloom filter. A 1% false-positive filter for 100 million ids needs about 120 MB, small enough to cache. By attributes is a search, and the temptation is an inverted index on every tag. At 2 attributes × 40 spans × 25,000 traces per second that index would be larger and busier than the data, and attribute cardinality is unbounded (user ids, URLs).
The alternative is a columnar block format: each attribute is a column, with dictionary encoding and a min/max per row group, so a query on status = error AND service = checkout reads two columns and skips row groups by statistics. Cost scales with time range, not with trace count, and parallelism comes from splitting a range into many row-group jobs. This is the design Tempo uses; Jaeger's older Elasticsearch and Cassandra backends instead index chosen tags, which gives faster narrow queries but a heavy write and storage bill (Jaeger architecture).
To keep the last minute searchable, writers also keep recent spans in memory or a local WAL, and queriers merge that with object storage.
Down the stack
Layer 1The contract
- Propagation:
traceparentisversion-traceid-parentid-flags, 55 characters for version00: a 32 hex trace id, a 16 hex parent id, and 2 hex flags. All-zero ids and versionffare invalid, and uppercase hex is not allowed (W3C Trace Context). A service that cannot parse the header starts a new trace. - The sampled flag is the least significant bit of the flags byte. It means the caller may have recorded data, and a component that defers its decision should propagate it unchanged.
- Delivery: spans are best effort. We promise that kept traces are complete to within the decision window, not that every span is stored. Missing spans appear as gaps, and the UI marks an orphan whose parent never arrived.
- Freshness: a kept trace is searchable within about 60 seconds: 30 for the decision wait plus block cut time.
Layer 2The mechanism
- SDK: a
TracerProviderholds a sampler and a span processor. TheBatchSpanProcessorhas a bounded queue, a batch size and a scheduled delay, and it exports from its own thread (OpenTelemetry SDK spec). The sampler runs at span start, so a non-recording span costs almost nothing. - Collector: receivers, processors and exporters wired into pipelines. A memory limiter processor refuses data when the process nears its limit, and the refusal turns into back-pressure to the sender (Collector architecture).
- Tail sampler policies:
status_code,latency(duration from the earliest start to the latest end),rate_limitingand probabilistic, combined with OR logic. A trace is kept if any policy keeps it. - Writers: sort by trace id, build row groups, write the footer, Bloom filter and index, then upload with a single multi-part put. Compaction merges small blocks and removes duplicates, because retried exports can deliver the same span twice and
(trace_id, span_id)identifies it.
Layer 3The machine
- Hot path: starting and ending a span is a few allocations, two clock reads (about 20 to 50 ns each with the vDSO) and a lock-free enqueue, well under a microsecond. Dapper measured the cost of tracing every request as 16.3% added latency in a web search cluster, but rates below 1 in 16 were within experimental error (Dapper paper). Modern SDKs are cheaper, yet the lesson holds: a recorded span is much costlier than an unrecorded one.
- Wire: a batch of 512 spans at 500 B is 256 KB, which fits in one gRPC message and amortises TCP and TLS cost. gzip or zstd shrinks span data about 5 to 10 times because names and attribute keys repeat.
- Collector memory: Go-based collectors allocate heavily and the GC runs concurrently, so the heap needs headroom, often 2x the live set, and a memory limit set below the container limit to avoid OOM kills.
- Object storage: a GET has a first-byte latency of tens of milliseconds, so reading a trace means a few ranged GETs (footer, index, row group), around 100 to 300 ms with caching of footers and Bloom filters.
Why it's built this way
- Tracing every request is too expensive for the application and the budget, so we sample.
- Head sampling cannot see failures that happen later, so the valuable decision moves to the tail.
- The tail decision needs a whole trace in one place, so spans are routed by trace id into a memory buffer.
- Memory and wait time are bounded, so decisions are approximate, late spans are handled with a cache and evictions are monitored.
- Kept data is immutable and queried by time, so it fits large sorted columnar blocks in object storage rather than a database with secondary indexes.
Failure modes
- 10x traffic spike: tail buffers fill and evict. Mitigation: a memory limiter that sheds new traces cleanly, head-sample down at the SDK through remote config, and keep error policies ahead of baseline ones. A hot service that emits 1,000-span traces gets a per-trace span cap.
- Collector or tail node loss: the hash ring remaps traces in flight. Only about 1/N are affected. Mitigation: run with spare capacity, drain nodes before restart, and use a short decision wait for high-volume services.
- Hot trace id: a retry storm or batch job puts millions of spans under one trace id, overloading one tail node and one block. Mitigation: cap spans per trace, and split such jobs into linked traces using span links rather than one parent.
- Backend outage or slow object storage: the queue absorbs minutes of data, then writers lag. Keep the queue sized for a few hours and drop the oldest baseline traces before error traces.
- Region outage: each region has its own full pipeline and storage, because cross-region span traffic at 8 Gbit/s is too costly. A global query fans out to regional query frontends. A trace that crosses regions is stored in two places and joined at query time.
- Clock skew: child spans appear to start before parents. Display by parent links, not timestamps, and flag skew rather than rewriting.
- Cardinality bomb: a developer puts a user id into the span name, and the name index explodes. Limit name cardinality in the collector and push high-cardinality values into attributes.
In the wild
- Google, 2010: Dapper and the cost of sampling. In a web search cluster, tracing every request raised average latency by 16.3%, while rates below 1 in 16 were within experimental error. Production defaulted to 1 in 1,024, which was enough for high-volume services but missed events in low-traffic ones, so Google moved toward adaptive sampling by target rate; the collection daemon never used more than 0.3% of one core (Dapper paper).
- Slack, 2020: traces as queryable rows. Slack flattened spans into a simple SpanEvent record and ingested about 310 million traces and 8.5 billion spans per day (around 2 Tb of trace data, as the post states it) into Honeycomb and a Presto warehouse. Most services sampled at 1 to 2% while low-volume ones kept 100%, and a slash command could trace every request from one client for two minutes (Slack Engineering).
- Honeycomb, 2025: the telemetry pipeline is itself a production system. Honeycomb's EU Kafka cluster lost redundancy and about a quarter of event partitions had no leader. Unbalanced brokers filled their disks and ingest was switched off with under 5% disk left. Customers saw about 6 hours of full ingest outage and about 18 hours of delayed SLO processing, and the fix was a rehearsed evacuation to a new cluster (Honeycomb status page).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Head sampling only | No buffering, constant cost, consistent across services | Misses rare errors and slow outliers | Very high volume, cost matters more than completeness |
| Tail sampling | Keeps every error and slow trace | Stateful, memory heavy, needs trace-id routing, approximate under churn | Failures must always be debuggable |
| Tag-indexed store (Elasticsearch, Cassandra) | Fast narrow queries on chosen tags | Large write amplification, cardinality limits | Small volume or a fixed set of search tags |
| Columnar blocks on object storage | Cheap, any attribute searchable, scales with parallel scans | Search is a scan, slower for a very selective query | Large volume with free-form attribute search |
| Spans to a queue, decide at write | Absorbs bursts, replayable | Pays to ship and store all spans first | Moderate volume with bursty load |
| Generate metrics from all spans | Accurate RED metrics despite sampling | Extra CPU at the collector | Dashboards and alerts depend on span data |
Leadership lens
- Rollout: start with a library and a collector for one critical request path, 100% sampled at low volume. Add propagation through gateways and queues next, since a single service that drops the header breaks every trace through it. Add tail sampling only when the bill or the volume forces it.
- Ownership: the platform team owns the pipeline, SDK defaults and sampling policy. Service teams own naming and attributes. Publish a short convention: low-cardinality span names, no personal data in attributes.
- Cost drivers: spans per request (instrumentation depth), the kept fraction, retention, and collector compute. Show each team its own span volume. Cost per team changes behaviour faster than a policy document.
- Questions for design review: What happens to a trace when one service does not propagate? What is the eviction rate in the tail tier? Which attributes can contain personal data, and who scrubs them? How do you know that sampling kept all errors?
- Build first: propagation plus a single-tier collector writing to object storage, then metrics from spans, then the tail tier.
Check yourself
A service's error rate is 0.2%, and you head-sample at 1%. What fraction of failing requests produce a stored trace, and what changes with tail sampling?
Show answer
About 1% of them, because the decision is independent of the outcome, so 99% of errors are invisible. Tail sampling keeps all of them, at the cost of buffering every trace until it finishes.
Why must the tail-sampling tier be fed by a router that hashes on trace id?
Show answer
A policy such as "keep if any span has an error" is only right if one node sees every span of the trace. Without trace-id routing, each node sees a fragment and makes inconsistent decisions, so kept traces are incomplete.
Why store traces in Bloom-filtered blocks sorted by trace id rather than indexing every attribute?
Show answer
Fetching by id needs only a point lookup, which the sort order and Bloom filters give cheaply. A tag index would cost more to write than the data itself, and attribute cardinality is unbounded, so attribute search uses column scans.
At 1M spans per second and 500 bytes per span, how much do you ingest per day, and how much do you store with 2.5% kept and 4:1 compression?
Show answer
1M × 500 B × 86,400 = about 43 TB/day ingested. Keeping 2.5% gives about 1.1 TB, and compression gives about 270 GB/day.
Sources and further study
Primary
- W3C Trace Context: the
traceparentandtracestateheader formats, validity rules and the sampled flag. - Dapper, a Large-Scale Distributed Systems Tracing Infrastructure (Google, 2010): the original design, overhead measurements and sampling discussion.
- OpenTelemetry Protocol (OTLP) specification: the wire format and partial-success semantics for span export.
Systems docs
- OpenTelemetry: Sampling concepts: head and tail sampling, with their trade-offs.
- OpenTelemetry tail sampling processor: policies,
decision_wait,num_tracesand the same-instance requirement. - Jaeger sampling: remote sampling strategies and the default probabilistic rate.
- Grafana Tempo architecture: distributors, Kafka as a write-ahead log, Parquet blocks in object storage.
- OpenTelemetry Collector architecture: receivers, processors, exporters and pipelines.
Books
- Observability Engineering, Majors, Fong-Jones and Miranda, chapter 6 "Stitching Events into Traces" and chapter 7 "Instrumentation with OpenTelemetry": how traces are built from events and how to instrument.
- Designing Data-Intensive Applications, Martin Kleppmann, chapter 3 "Storage and Retrieval" (1st edition): column-oriented storage, compression and why scans beat indexes for analytics.
Free resources
- Tracing at Slack: Thinking in Causal Graphs: a production design with sampling rates and volumes.
- Jaeger architecture: agent, collector, ingester and query components, and the storage backends.
- Tempo Parquet schema: how spans are laid out in columns for attribute search.
- OpenTelemetry tracestate probability sampling: consistent sampling thresholds carried across services.
- OpenTelemetry trace SDK specification: samplers and span processors.