Command Palette

Search for a command to run...

Hectal
Case 3.4·Tracing Deep DiveSEV3

“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

head vs tail samplingthe OpenTelemetry Collectortail_sampling policieswhy the collector topology matters

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ # head sampling at 10% (add to shoplite AND payments environment in compose):
# OTEL_TRACES_SAMPLER: parentbased_traceidratio
# OTEL_TRACES_SAMPLER_ARG: "0.1"
docker compose up -d shoplite payments
curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"errors"}'
── lab output ──
{"active":["errors"]}

01 The investigation

  1. 14:00

    REPORT

    Support: 'order failed at 13:52:14 for this customer — what happened?'

  2. 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)
  3. 14:07

    FINDING

    Head sampling decided at the START of each request, before anyone knew it would fail

    traceidratio keeps 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
    0.0070.114013:3014:00
  4. 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

  1. 01Add an OpenTelemetry Collector with tail sampling

    The contrib distribution includes the tail_sampling processor. Policies are OR-ed: a trace is kept if ANY policy says keep. Point both services' OTEL_EXPORTER_OTLP_ENDPOINT at http://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]
  2. 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· Prometheus
    sum(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 window
    terminal
    $ 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

  1. 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· Prometheus
    sum(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.

01

Add a policy that keeps every trace where the payments service took longer than 500 ms, regardless of total duration.

02

Why is decision_wait: 10s a trade-off?

06 Interview questions from this case

01

Compare head-based and tail-based trace sampling.

02

How do you scale tail sampling across multiple collector instances?

0/4 · 0%