“Support has an order ID and a timestamp for a failed checkout. There's no trace for it — we only keep 10%.”
case name: The trace we needed was sampled away
- Service
- tracing pipeline
- Impact
- Error investigations regularly missing their traces; trace storage still expensive
- Detected by
- An engineer's frustration, repeatedly
- Time to resolve
- Half a day to redesign sampling
Skills you'll use on this case
tail_sampling policieswhy the collector topology matters00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 14:00
REPORT
Support: 'order failed at 13:52:14 for this customer — what happened?'
- 14:05
QUERY
Search for error traces around that time
TraceQL· Tempo{ resource.service.name = "shoplite" && span.http.route = "/checkout" && status = error }result(a handful of results — roughly 1 in 10 of the failed checkouts) - 14:07
FINDING
Head sampling decided at the START of each request, before anyone knew it would fail
traceidratiokeeps a random 10% of traces, decided when the root span starts. Errors are no more likely to be kept than successes, so 90% of error traces are thrown away, and those are exactly the ones worth keeping. Meanwhile 10% of the millions of boring 200 OK traces are still stored.traces stored per minuteOK traces kepterror traces kept - 16:30
RESOLVED
Tail sampling in an OpenTelemetry Collector: keep every error and slow trace, 5% of the rest
Root cause
Sampling was decided at the head of each trace, randomly, before its outcome was known. That keeps a uniform slice of everything: most of it uninteresting, and most of the rare, important traces (errors, slow requests) discarded.
02 The concepts behind it
Head vs tail sampling
HEAD sampling decides when the trace starts (in the SDK) and propagates the decision in the traceparent flags so every service agrees. It's cheap and simple, but blind to what happens later. TAIL sampling buffers ALL spans of a trace in a collector, waits until the trace is complete (decision_wait), then decides with full knowledge: keep it if any span errored, if it was slow, if it touched a VIP customer, otherwise sample a small percentage.
The cost of tail sampling
Every span must be sent to the collector, which must hold complete traces in memory for the decision window, so it needs memory proportional to traffic × wait. With several collector replicas, all spans of one trace must reach the SAME replica: use a two-tier setup with the loadbalancing exporter (routing by trace ID) in front of the tail-sampling tier. A single collector, as in the lab, avoids that complexity.
Metrics should come from 100% of spans
If span metrics (Case 3.1) are generated AFTER sampling, rates and error ratios are skewed by the sampling policy. Generate them before sampling (the collector's spanmetrics connector on the unsampled pipeline, or Tempo's generator when Tempo receives everything) so metrics stay accurate while trace storage stays cheap.
03 The fix
01Add an OpenTelemetry Collector with tail sampling
The contrib distribution includes the
tail_samplingprocessor. Policies are OR-ed: a trace is kept if ANY policy says keep. Point both services'OTEL_EXPORTER_OTLP_ENDPOINTathttp://otel-collector:4318, and remove the SDK head-sampling variables so the SDK records everything.obs-lab/otel-collector.ymlwhole fileyaml # compose: # otel-collector: # image: otel/opentelemetry-collector-contrib:0.130.0 # command: --config=/etc/otel/config.yml # volumes: ["./otel-collector.yml:/etc/otel/config.yml:ro"] receivers: otlp: protocols: grpc: { endpoint: 0.0.0.0:4317 } http: { endpoint: 0.0.0.0:4318 } processors: tail_sampling: decision_wait: 10s num_traces: 50000 policies: - name: keep-errors type: status_code status_code: { status_codes: [ERROR] } - name: keep-slow type: latency latency: { threshold_ms: 1000 } - name: baseline type: probabilistic probabilistic: { sampling_percentage: 5 } batch: {} exporters: otlp/tempo: endpoint: tempo:4317 tls: { insecure: true } service: pipelines: traces: receivers: [otlp] processors: [tail_sampling, batch] exporters: [otlp/tempo]02Verify: every failed checkout has a trace
Compare error checkouts counted by metrics with error traces found by TraceQL for the same 5 minutes. They should now match closely. Then switch the scenario off.
PromQL· Prometheussum(increase(http_request_duration_seconds_count{job="shoplite", route="/checkout", status="500"}[5m]))result{} 361 ← and TraceQL now returns ~361 error traces for the same windowterminal$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"errors","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Watch the collector itself
The collector exposes Prometheus metrics on port 8888 by default (add a scrape job). Track spans received versus exported, dropped spans, and memory. A tail-sampling collector that runs out of memory drops traces silently.
PromQL· Prometheussum(rate(otelcol_receiver_accepted_spans_total[5m])) - sum(rate(otelcol_exporter_sent_spans_total[5m]))
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Add a policy that keeps every trace where the payments service took longer than 500 ms, regardless of total duration.
Why is decision_wait: 10s a trade-off?
06 Interview questions from this case
Compare head-based and tail-based trace sampling.
How do you scale tail sampling across multiple collector instances?