Configuring Tail Sampling in the Collector
Tail sampling buffers every span of a trace until the trace is complete, then applies policies to the whole thing — so it can keep 100% of errors and slow traces while dropping most successful ones, at the cost of holding several gigabytes of spans in memory and requiring that every span of a trace reach the same Collector instance.
Context and when it matters
Head-based sampling decides at the root, before anything is known. That is cheap and predictable, and it means a rare error is kept only by luck — at a 1% rate you see one error trace in a hundred, and the one you need is almost certainly among the ninety-nine that were dropped. See parent-based vs always-on sampler behaviour for why raising a downstream rate does not help.
Tail sampling inverts the trade. The decision moves to the Collector and happens after the outcome is known, so “keep every trace that errored, every trace over two seconds, and 2% of the rest” becomes expressible. What you pay is memory, latency to storage, and a hard architectural constraint: the deciding Collector must see the entire trace.
The architectural constraint
# agent layer — route by trace ID so each sampler sees whole traces
exporters:
loadbalancing:
routing_key: traceID # NOT service — traceID is the requirement
protocol:
otlp:
tls: { insecure: true }
resolver:
k8s:
service: otel-tailsampler.observability
The policy set
Policies are evaluated per trace; a trace is kept if any policy says so. Order them by value, and always finish with a probabilistic catch-all so you retain a baseline of healthy traffic for comparison.
processors:
tail_sampling:
# Must exceed the p99.9 duration of your slowest trace, plus export lag.
decision_wait: 30s
num_traces: 100000 # traces held in memory at once
expected_new_traces_per_sec: 2000
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 2000 }
# Business-critical paths worth full retention regardless of outcome.
- name: keep-payments
type: string_attribute
string_attribute:
key: service.name
values: [payments-api]
# Debug override: an authenticated header forced this trace to be kept.
- name: keep-forced
type: boolean_attribute
boolean_attribute: { key: sampling.force_keep, value: true }
# Baseline of successful traffic, so latency comparisons stay honest.
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 2 }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [otlp/tempo]
The baseline policy is the one people omit and later regret. Without a sample of successful traces there is nothing to compare an error trace against, and every latency percentile computed from stored traces is biased towards failures.
Composite and rate-limiting policies
For finer control, a composite policy applies sub-policies with individual rate limits, which prevents one noisy service from consuming the entire budget:
- name: composite-budget
type: composite
composite:
max_total_spans_per_second: 5000
policy_order: [errors-first, slow-second, everything-else]
composite_sub_policy:
- name: errors-first
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-second
type: latency
latency: { threshold_ms: 1500 }
- name: everything-else
type: always_sample
rate_allocation:
- policy: errors-first
percent: 60
- policy: slow-second
percent: 30
- policy: everything-else
percent: 10
Memory is the real constraint
Every span of every in-flight trace is held until the decision. The arithmetic is unforgiving:
decision_wait is the parameter with the sharpest trade-off. Too short and slow traces — precisely the ones the latency policy exists to capture — are judged incomplete and dropped. Too long and memory grows linearly. Set it above the p99.9 of end-to-end trace duration, then leave it alone.
Designing the policy set
Policies look simple and accumulate badly. A set that starts as “keep errors and slow traces” becomes a dozen overlapping rules that nobody can reason about, and because a trace is kept if any policy matches, the effective retention rate drifts upward until storage costs force a review.
Three habits keep the set manageable.
Write down what each policy is for. Not what it matches — that is in the configuration — but which question it exists to answer. “Keep-payments exists so the finance team can audit any transaction” is a rationale that can be revisited; “string_attribute service.name = payments-api” is not. Policies without a stated purpose are never removed, because nobody can argue against them.
Measure each policy’s contribution. The Collector exposes sampled counts per policy, so you can see which ones are actually doing work. A policy matching nothing is dead configuration; a policy matching forty percent of traffic is your real sampling rate wearing a disguise. Both deserve attention, and both are invisible without the per-policy metric.
Prefer few broad policies to many narrow ones. Every additional policy multiplies the reasoning required to predict what is kept, and narrow policies tend to encode a specific incident rather than a durable need. A set of five — errors, slow, one or two business-critical paths, a debug override, and a probabilistic baseline — covers nearly every requirement teams actually have.
One further consideration: policies interact with the span attributes your services emit. A policy keyed on an attribute that only some services set will behave inconsistently across the fleet, and the inconsistency will look like a sampling bug rather than an instrumentation gap. When adding a policy, confirm the attribute it depends on is set everywhere it needs to be.
Verification
# Decisions by policy — every policy should be firing
sum by (policy) (rate(otelcol_processor_tail_sampling_count_traces_sampled[5m]))
# Traces dropped because the buffer was full — must be zero
rate(otelcol_processor_tail_sampling_sampling_trace_dropped_too_early[5m])
# Actual buffer occupancy against num_traces
otelcol_processor_tail_sampling_sampling_trace_status_count
Then confirm the behaviour end to end: force an error on a test endpoint and check the trace is stored despite a 2% baseline, then issue a normal request and confirm it usually is not.
Common pitfalls
- Round-robin load balancing. The most common misconfiguration; each sampler sees a fragment and judges it, so error traces are kept partially or not at all.
decision_waitshorter than the slowest trace. Silently drops exactly the traces the latency policy targets.- No baseline policy. Storage fills with failures only, and every comparison against “normal” becomes impossible.
- Running tail sampling on the agent layer. An agent sees only its own node’s spans. Tail sampling belongs in a gateway tier — see deploying the Collector as agent vs gateway.
- Forgetting head sampling still applies. If the SDK dropped the trace, the Collector never sees it. Tail sampling can only select from what was exported.
The latency cost nobody mentions
Tail sampling delays every trace by the decision wait. With a thirty-second window, a trace is not queryable until roughly thirty seconds after the request completed, which changes how the system feels during an incident: you fix something, reload the trace view, and the evidence is not there yet.
That is usually acceptable and occasionally not. If your incident workflow depends on seeing a trace seconds after reproducing a problem, either shorten the window and accept dropping the slowest traces, or run a parallel head-sampled pipeline at a low rate that writes immediately. The second option costs a little storage and removes the wait entirely for the traces you are actively watching.
Finally, remember that tail sampling composes with head sampling rather than replacing it. Most production pipelines run a modest head rate to bound Collector ingest, then tail sampling to decide what is worth storing from what survived — the two stages answer different questions and tuning them together is what keeps both memory and storage predictable.
Related
- When to use tail-based sampling for microservices — deciding whether to take on this complexity
- Deploying the Collector as agent vs gateway — the topology tail sampling requires
- Handling collector backpressure and queue overflow — what happens when the buffer does not fit