Design a distributed tracing system
The interviewer asks
“Design a distributed tracing system like Jaeger or Grafana Tempo for a company with 800 microservices handling 200,000 requests per second. An engineer should be able to open one trace and see every hop of a request, including Kafka consumers and LLM calls, and find slow or failing traces quickly.”
What a strong answer looks like
A strong answer starts with the data model (trace = tree of spans, each span with trace_id, span_id, parent_id, timing and attributes) and context propagation (W3C traceparent over HTTP/gRPC, and in Kafka message headers). Services use OpenTelemetry SDKs and send spans to a local Collector agent, then to a gateway Collector tier that does tail-based sampling: it buffers all spans of a trace for ~10-30 s, then keeps 100% of errors and slow traces and a small percentage of the rest. Kept spans go through Kafka to storage designed for 'fetch by trace_id': trace blocks in object storage with bloom filters (Tempo-style) or a wide-column store keyed by trace_id. Search by attributes uses a small separate index or columnar store. The deep dives are sampling (head vs tail and its consistency problem), propagation across async boundaries, storage cost, and linking traces with logs and metrics through exemplars. Senior candidates add LLM spans with prompt, tokens and cost stored separately with PII redaction.
Concepts to mention
1 Requirements
Functional
- Instrument services with minimal code: auto-instrumentation for HTTP, gRPC, Postgres, Redis, Kafka
- Propagate trace context across sync calls and async hops (Kafka, SQS, Temporal workflows)
- Fetch a full trace by trace_id and render it as a waterfall
- Search traces by service, operation, duration, status, and attributes such as tenant_id or http.route
- Always keep error traces and slow traces; sample normal ones
- Jump between traces, logs (trace_id in log lines) and metrics (exemplars on latency histograms)
- Derive service dependency graph and RED metrics (rate, errors, duration) from spans
Non-functional
- Tracing overhead in the application under 1-2% CPU and under 1 ms added latency per request
- Trace available for lookup within 60 seconds of the request finishing
- Lookup by trace_id p95 under 2 s; attribute search over 1 hour p95 under 5 s
- Retention 7-14 days for sampled traces
- Tracing failures must never break requests (drop spans, never block)
- Handle 200k req/s × ~25 spans per request = ~5M spans/s before sampling
Out of scope (say it out loud)
- Continuous profiling
- Log storage and full-text log search
- Real-user monitoring in browsers and mobile apps (could be added later)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
gRPCopentelemetry.proto.collector.trace.v1.TraceService/Export | OTLP export from SDKs to the local agent and from agents to the gateway tier; batched and compressed. |
GET/api/traces/{trace_id} | Return all spans of one trace for the waterfall view. |
GET/api/search?service=checkout&minDuration=2s&status=error&tags=tenant_id%3Dt42&start=&end= | Find candidate traces by attributes; returns trace summaries (root span, duration, span count). |
GET/api/dependencies?start=&end= | Service graph with call rates and error rates, computed from spans. |
PUT/api/sampling/policies | Change tail-sampling rules (e.g. keep 100% for route /v1/payments for 1 hour during an investigation). |
4 Data model
spanIn flight: OTLP protobuftrace_id (16 B)span_id (8 B)parent_span_idnamekindstart_nsend_nsstatusattributes{}events[]links[]resource{service.nameversionk8s.pod}Links connect spans across traces, used when one Kafka consumer batch processes messages from many producer traces.
trace blocksS3 (Parquet blocks)block per tenant per ~5-10 min: rows of spans sorted by trace_id; columns for attributes; per-block bloom filter on trace_idFetch by trace_id: check bloom filters of candidate blocks in the time range, read only matching row groups. Columnar Parquet also allows attribute search by scanning dedicated columns.
recent spansIngester memory + WALspans grouped by trace_id until the block is cutServes lookups for the last ~10 minutes before blocks reach S3.
span buffer for samplingCollector memorytrace_id → spans[]first_seendecisionAll spans of a trace must reach the same Collector: load-balance by trace_id hash.
span-derived metricsPrometheus / Mimircalls_total{serviceoperationstatus}duration histogram with exemplar trace_idsGenerated from 100% of spans before sampling, so RED metrics stay exact even though traces are sampled.
llm_payloadsS3 / Langfuse (separate, access-controlled)trace_idspan_idprompt (redacted)completionmodeltokens_in/outcostLarge and sensitive: kept out of the main span store, linked by ids.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Trace one checkout across HTTP and Kafka
- 1The API gateway receives a request without a traceparent header, creates trace_id T and a root span, and forwards traceparent: 00-T-span1-01.
- 2Checkout service's SDK extracts the context, creates child spans for its Postgres insert and its call to payments (traceparent injected into the gRPC metadata).
- 3Checkout writes an outbox event; the relay publishes to Kafka with traceparent in the message headers.
- 4The fraud consumer extracts the header and starts a span with parent = the producer span (or a span link if it processes a batch), then calls the AI gateway, which records an LLM span with model, tokens and cost attributes.
- 5Each SDK batches finished spans (e.g. every 5 s or 512 spans) and sends them to the node-local Collector; if the agent is unreachable the SDK drops spans and increments a counter.
- 6Agents forward to a load-balancing tier that routes by hash(trace_id), so every span of T lands on the same tail-sampling Collector.
- 7That Collector waits ~10-30 s after the first span, sees one span has status=ERROR, and keeps the whole trace; it writes it to Kafka.
- 8Ingesters consume, group spans by trace_id, and every few minutes cut a Parquet block with a bloom filter to S3.
Find why p99 latency jumped
- 1An alert fires on checkout p99 latency (from span-derived metrics, computed from 100% of spans).
- 2The engineer opens the latency panel and clicks an exemplar dot: a real trace_id attached to a slow histogram bucket.
- 3The query service checks ingesters for recent spans and bloom filters of S3 blocks in the time window, reads the matching row groups and assembles the tree.
- 4The waterfall shows 1.8 s spent in a Redis call with a retry; the span's log link opens the matching logs filtered by trace_id.
7 Deep dives
Head sampling vs tail sampling
Head-based sampling decides at the start of the request: the root service flips a coin (say 1%) and writes the decision into the sampled flag of traceparent; every downstream service obeys it. It is cheap and consistent (whole traces are kept or dropped), but it is blind: the decision is made before you know whether the request will fail or be slow, so 99% of interesting error traces are thrown away. Tail-based sampling decides after the trace finishes. All spans are sent to a Collector tier, buffered per trace_id for a decision window (10-30 s), then policies run: keep if any span has an error, keep if root duration > 2 s, keep 100% for tenant X under investigation, keep 2% of the rest. The cost is real: you must ship 100% of spans off the hosts, hold them in memory, and route every span of a trace to the same Collector with consistent hashing on trace_id. Late spans (a Kafka consumer that runs 5 minutes later) arrive after the decision; keep a short decision cache (trace_id → kept/dropped) so late spans follow the same decision. A common hybrid is head sampling at ~10-20% to cut volume, then tail sampling on that. Metrics must be computed before sampling, or your error rates will be wrong.
Context propagation, especially across async boundaries
Tracing only works if every hop forwards the context. Over HTTP and gRPC, OpenTelemetry injects the W3C traceparent header (version, trace_id, parent span_id, flags) and optional tracestate and baggage. Auto-instrumentation covers common clients, but gaps appear in custom thread pools, goroutines started without passing context.Context, and in-house RPC frameworks; a single missing hop splits the trace in two. Async systems need explicit rules. For Kafka, the producer injects traceparent into record headers and the consumer extracts it. If one consumer poll processes 500 messages from 500 different traces, do not pick one parent: create a batch span with span links to each producer context. For long workflows (Temporal, cron-driven retries) that run for hours, putting every step in one trace creates huge, never-ending traces; better to start a new trace per step and link to the originating one. Baggage (e.g. tenant_id) is propagated to all downstream services, so keep it small and never put PII in it, because it travels to third parties too unless stripped at the edge. Also strip or regenerate incoming traceparent from external clients to stop abuse.
Storage designed for trace-id lookup
The main read is 'give me trace T'. Older systems (Zipkin, early Jaeger) used Cassandra or Elasticsearch with a row per span and several secondary indexes for search; the indexes cost more than the data and Elasticsearch clusters needed constant care at millions of spans per second. Tempo-style storage avoids most indexing: ingesters group spans by trace_id and write large blocks (e.g. Parquet, ~100s of MB) to S3, each with a bloom filter over the trace_ids it contains. A lookup lists blocks for the time range, checks bloom filters (false positive ~1%), and reads only matching row groups. Object storage makes 14 days of traces cheap. Search by attributes uses the columnar layout: dedicated columns for common attributes (service, http.route, status, tenant_id) are scanned in parallel by query workers, which is slower than an index but fine for 'last hour' searches. The compactor merges small blocks so lookups touch fewer files. The best entry point to traces is usually not search at all but exemplars from metrics and trace_ids in logs.
LLM spans and sensitive payloads
With AI features, the cause of a bad answer is usually the context given to the model: wrong retrieved chunks, a truncated prompt, a stale cached answer. So traces should include spans for retrieval (query, top-k chunk ids, scores) and for each LLM call, using OpenTelemetry GenAI semantic conventions for model name, input and output token counts and finish reason, plus cost and cache-hit flags as attributes. Full prompts and completions are large (often 5-50 KB) and contain personal data, so they should not sit in the main span store with broad access. Store them in a separate, access-controlled store (an LLM observability tool such as Langfuse, or S3 with strict IAM) linked by trace_id and span_id, after PII redaction or tokenisation at the gateway. This lets an engineer open a trace, see that the answer used chunk 7 from an outdated document, and replay the exact prompt against another model.
8 Trade-offs
Tail sampling vs head sampling only
Head sampling is almost free and fine for small systems or when you only want rough latency breakdowns. Tail sampling keeps the traces that matter (errors, slow) but needs a stateful Collector tier with tens of GB of memory and trace-id-aware load balancing. At 200k req/s with on-call engineers who need error traces, tail sampling is worth it; use head sampling first to cap the volume entering it.
Object storage blocks vs Elasticsearch/Cassandra
Indexed stores give fast arbitrary search but cost much more to run at millions of spans per second. Block storage in S3 is cheap and simple; search is slower and relies on columnar scans. Most teams accept slower search because they enter traces through metrics exemplars and logs anyway.
Build on OpenTelemetry vs vendor agents
Vendor agents are quick to adopt but lock you in. OpenTelemetry SDKs and Collectors are vendor-neutral, so you can switch backends (Jaeger, Tempo, a SaaS) by changing exporter config. The cost is more moving parts you run yourself.
9 Bottlenecks
Tail-sampling tier memory
At 10x traffic, buffering 10-30 s of all spans needs hundreds of GB. Add head sampling for high-volume, low-value routes (health checks dropped entirely, static assets at 1%), shorten the decision window, and scale the Collector tier horizontally with consistent hashing so a resize moves only a fraction of traces.
Huge traces
A batch job or fan-out request can produce 100,000 spans and break the UI and the ingester. Cap spans per trace (e.g. 10k) in the Collector, and model batch jobs as many linked traces instead of one.
High-cardinality attributes in derived metrics
If the metrics generator turns user_id or full URLs into metric labels, the metrics store explodes. Use route templates (/orders/{id}) and an allow-list of dimensions.
10 Failure handling
Tracing backend down
SDKs use bounded in-memory queues and drop spans when full; agents buffer briefly and then drop. The application is never blocked. You lose traces for that window, which is acceptable; span-derived metrics alert you that spans are being dropped.
A tail-sampling Collector crashes
Spans buffered in it (a few seconds of a slice of traffic) are lost and some traces become partial. The load balancer reroutes its hash range to the remaining nodes. Partial traces are marked so the UI shows a 'missing spans' warning instead of a misleading waterfall.
Clock skew between hosts
Child spans can appear to start before their parent. The UI adjusts using the parent-child relationship (shift child spans to fit inside the parent), and hosts run NTP/chrony; durations within one span are measured with a monotonic clock, so they are still correct.
11 What impresses
- Explains why head sampling loses most error traces and why tail sampling needs trace-id-aware routing
- Handles batch consumers with span links instead of fake parent-child relations
- Computes RED metrics from 100% of spans before sampling
- Storage choice driven by the dominant query (lookup by trace_id) and cost math
- Keeps prompts and PII out of the shared span store, with redaction at the gateway
- Mentions the decision cache for late-arriving spans
12 Mistakes
- Sampling randomly per span instead of per trace, producing broken trees
- Sending spans synchronously from the request path
- Forgetting async hops, so the trace stops at the Kafka producer
- Computing error rates from sampled traces
- Putting user ids or raw URLs into metric labels
- No retention or cost estimate for storing every span
13 Follow-ups
Lessons this design uses
Practice questions
- Your system has 15 services connected by Kafka. A customer's refund is 'stuck' and nobody can tell where. How do you make event-driven flows debuggable?
- p99 latency of your search API jumped from 200 ms to 2 s ten minutes ago. Walk me through how you use metrics, logs and traces to find the cause.
- Your platform serves 50,000 requests per second across 120 services. Storing every trace would cost too much. How would you sample traces and still catch the interesting ones?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.