Sampling Strategies
Key Points
- You can't trace everything. At 100K req/s, full-fidelity tracing is millions of dollars/year. Sampling is mandatory.
- Head-based sampling decides at the start of a trace (in the SDK). Cheap, simple, but biased toward fast happy-path traces — slow/error traces are statistically rare and likely dropped.
- Tail-based sampling decides after the trace completes (in the OTel Collector). Lets you keep 100% of errors and 100% of slow requests. Costs collector memory + a buffering window.
- Parent-based sampling propagates the upstream decision. Mandatory for trace consistency: either all spans of a trace are kept or none are. Otherwise you get orphan spans — useless.
- Adaptive sampling auto-tunes the rate to hit a target events-per-second budget.
- Sampling for logs is different: usually rule-based ("drop healthy 200s, keep 5xx"), not probabilistic. Driven by cost more than fidelity.
- Worst outcome of bad sampling: lose the slow tail and the error traces — exactly the ones you needed.
Concepts (deep dive)
Why sample at all
A typical trace has 5–50 spans. At 100K req/s, that's 500K–5M spans/s. Even at $0.50/M spans, full sampling costs $20–200K/day. You also can't store, index, or search that volume usefully — most traces are 200 OK in 50 ms and tell you nothing.
The goal: keep the interesting traces, drop the boring ones.
Head-based vs tail-based
HEAD-BASED (decision at trace start, in the SDK)
[Service A] [Service B] [Service C]
| | |
sampled?-----traceparent flags-------------> respect
yes/no
|
emit or drop ---- decision is final ---->
Pros: cheap, no coordinator, low latency.
Cons: random — favors common paths. Errors usually slip through unsampled.
TAIL-BASED (decision after trace completes, in the Collector)
[A]----all spans----+
[B]----all spans----+----> [Collector buffer (e.g., 30s)]
[C]----all spans----+ |
v
{ policy evaluation }
- error? keep
- slow > p99? keep
- else: 1% sample
|
v
[Backend]
Pros: keep 100% of errors / slow requests.
Cons: collector RAM, completion-window latency, sticky routing required.
Parent-based sampling — the consistency rule
A trace spans many services. If service A samples in but service B samples out, you get a broken trace with missing spans. Useless.
ParentBasedSampler enforces: if upstream sampled, we sample; if upstream didn't, we don't. The decision is made once, at the trace root, and rides on the traceparent header's sampled flag.
.SetSampler(new ParentBasedSampler(
rootSampler: new TraceIdRatioBasedSampler(0.05))) // 5% of new traces
If your service is a root (entry point), the root sampler decides. If it's downstream, parent decision wins.
Probabilistic / TraceIdRatioBased
Hashes the traceId to decide. Two crucial properties:
- Deterministic: same traceId → same decision. Great for joining traces with logs/metrics.
- Consistent across services: every service hashing the same traceId at the same ratio reaches the same answer. Even without parent-based, you'd get coherent traces (in theory). In practice, mixed sample rates exist; use parent-based for safety.
Rule-based / per-service
# OTel Collector tail_sampling processor
policies:
- name: errors-policy
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-policy
type: latency
latency: { threshold_ms: 500 }
- name: probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 1 }
Combine: keep all errors, all slow requests, 1% of the rest. This gives you a usable signal at a fraction of the cost.
Adaptive sampling
Adjust the rate dynamically to hit a target spans-per-second budget. If traffic doubles, halve the rate automatically. Many vendors (Datadog, New Relic) ship this; in OTel, you can build it in the Collector with transform + dynamic ratio, or use vendor extensions.
Rule of thumb: target a fixed traces/second budget (e.g., 1000/s), not a fixed ratio. Ratios punish you when traffic spikes.
Stratified sampling
Different rules per service / per route:
- Public API: 1% probabilistic + 100% errors.
- Background worker: 100% (low volume; high diagnostic value).
- Health-check probe: 0%.
- Payment service: 100% always (compliance).
The Collector's tail_sampling processor with named policies + services filter is how you express this.
Sampling and traceId determinism
OpenTelemetry uses the lowest 14 hex digits of traceId for ratio sampling. As long as all services use compatible algorithms (W3C Trace Context defines a recommended approach), they reach the same decision independently — useful when parent-based is impractical.
.NET SDK configuration
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddSource("MyApp.*")
.SetSampler(new ParentBasedSampler(
new TraceIdRatioBasedSampler(0.10)))
.AddOtlpExporter());
For environment-driven config:
Collector tail-sampling
processors:
tail_sampling:
decision_wait: 30s # buffer window
num_traces: 100000 # in-flight traces cap
expected_new_traces_per_sec: 5000
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: 1.0 }
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlp/tempo]
⚠️ Tail-sampling collectors are stateful for the buffer window. Scale them carefully: - All spans of a trace must reach the same collector instance. - Use loadbalancing exporter with routing_key: traceID to hash-route trace spans to a fixed collector. - Memory sized for num_traces × avg_spans × span_size.
Sampling for logs
Tracing sampling is statistical; log sampling is editorial:
| Strategy | Description |
|---|---|
| Drop healthy | 200/204 access logs at INFO → drop or aggregate |
| Keep errors | Always keep 4xx/5xx |
| Rate-limit chatty loggers | "DB connection acquired" 1M/s → cap to 100/s |
| Sample by trace decision | If trace was kept, keep its logs |
The last is powerful: trace_sampled = true → keep logs. Implement in the Collector or in your logging filter.
Cost vs fidelity
The single worst sampling outcome is uniform low rate (e.g., 1% probabilistic). Errors are <1% of traffic; you'll lose most of them. Always combine with rule-based "keep errors / slow."
Anti-patterns
- Different rates per service without parent-based → orphan spans.
- Pure random with no error keep-rule → invisible incidents.
- Tail-sampling without sticky routing → spans of one trace land in different collectors → tail-sampling sees partial trace → wrong decision.
- Over-aggressive sampling (0.1%) → can't reproduce customer issue from traces.
- Sampling decisions made post-collection → already paid the bandwidth.
Sampling and metrics
Metrics are aggregated; you do not sample metrics. A 5% sample rate would corrupt counters, histograms, and rates. Metrics are cheap because they're aggregated upstream — keep them at 100%. Cardinality control is the metrics analogue of sampling.
How it works under the hood
[Trace lifecycle with parent-based + ratio sampler]
Service A (entry)
- new traceId T
- root sampler: hash(T) < 0.10? → SAMPLED flag = 1
- emits spans tagged sampled=1
- sets traceparent: 00-T-S1-01
|
v
Service B
- reads traceparent flags=01 → sampled
- parent-based sampler: respect parent → SAMPLED
- emits spans tagged sampled=1
|
v
Service C
- same: respect parent → SAMPLED
- emits spans
All sampled or none sampled — never partial.
[Tail-sampling collector]
spans arrive (all sampled at SDK = 100% to collector)
|
v
buffered by traceId for `decision_wait`
|
v
on completion timeout OR span signaling end:
- evaluate policies
- keep | drop
|
v
exported to backend
Code: correct vs wrong
❌ Wrong: AlwaysOn in production
Cost balloons; backend chokes.
✅ Correct: parent-based ratio
❌ Wrong: independent ratio per service
Half of A's sampled traces lose B's spans.
✅ Correct: parent-based downstream
// Service B
.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.10)))
// Will defer to parent flag from A.
❌ Wrong: probabilistic only
Errors lost.
✅ Correct: errors + slow + baseline
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 1.0 }
❌ Wrong: tail-sampling without sticky routing
Round-robin LB to the tail-sampling collector pool.
✅ Correct: traceId-hash routing
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp: { tls: { insecure: true } }
resolver:
static: { hostnames: [collector-tail-1, collector-tail-2] }
Design patterns for this topic
Pattern 1 — "Parent-based ratio at the SDK"
- Intent: consistent traces, low overhead.
Pattern 2 — "Tail-sampling for errors and slow tail"
- Intent: keep what matters; drop noise.
Pattern 3 — "Stratified per-service rules"
- Intent: payment 100%, public 1%, health 0%.
Pattern 4 — "Adaptive rate by budget"
- Intent: stable cost despite traffic spikes.
Pattern 5 — "Trace-aware log keep"
- Intent: keep logs of sampled traces; drop the rest.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Head-based | Cheap; simple | Biased to common paths |
| Tail-based | Captures errors/slow | Stateful collector; latency window |
| Parent-based | Coherent traces | Tied to root decision |
| Adaptive | Stable cost | Implementation complexity |
| Stratified | Optimized per service | More config |
When to use / when to avoid
- Always use parent-based at the SDK.
- Use tail-sampling at the gateway collector for production.
- Use "keep errors / slow / baseline %" combo as the default policy.
- Avoid different head ratios across services without parent-based.
- Avoid sampling metrics — cardinality control instead.
Interview Q&A
Q1. Why sample traces? Cost. At scale full tracing is unaffordable and unsearchable. Sampling keeps signal, drops noise.
Q2. Head vs tail sampling? Head: decide at start, fast, biased to common. Tail: decide after completion in the collector, captures errors/slow tail, requires buffering and sticky routing.
Q3. Why parent-based? Trace consistency. Without it, services make independent decisions and you get orphan spans.
Q4. TraceIdRatioBased — how does it work? Hashes the traceId; compares to ratio. Deterministic per traceId; consistent across services with the same algorithm.
Q5. What's the worst sampling outcome? Losing slow/error traces. A pure 1% probabilistic policy will drop most errors because errors are rare.
Q6. Sampling vs cardinality control? Sampling for traces and logs. Cardinality control for metrics — never sample metrics; you'd corrupt aggregates.
Q7. Tail-sampling memory? num_traces × avg_spans × span_size. With decision_wait=30s and 5K traces/s, expect GBs.
Q8. Sticky routing? LB hash by traceId so all spans of a trace land on the same tail-sampling collector instance.
Q9. Adaptive sampling? Auto-adjust rate to hit a target events/sec budget. Stable cost under traffic spikes.
Q10. Sampling logs? Rule-based usually: drop healthy 2xx, keep all errors, rate-limit chatty loggers, or keep logs whose trace was sampled.
Q11. Sampling and metrics? Don't. Aggregates would be wrong. Use cardinality limits and meter pre-aggregation.
Q12. Multiple ratios across a service mesh? Use parent-based with the strictest root rate; don't ask each service to roll its own.
Gotchas / common mistakes
- ⚠️ No parent-based → orphan spans across services.
- ⚠️ Pure probabilistic → errors invisible.
- ⚠️ Tail-sampling without sticky routing → wrong decisions.
- ⚠️ Sampling metrics → broken aggregates.
- ⚠️ Decision_wait too short → late-arriving spans dropped.
- ⚠️ Decision_wait too long → collector OOM.
- ⚠️ Forgetting health-check exclusions → noise dominates the budget.