OpenTelemetry Sampling at 1%: How We Lost the One Trace That Mattered
Head sampling at 1% feels responsible until a P1 incident hits and the trace you need is in the 99% you threw away. Here's how we rebuilt sampling around tail-based rules and what it actually cost.

We shipped OpenTelemetry across a dozen services with a tidy 1% head sampler and congratulated ourselves on a reasonable bill. Six weeks later, during a 40-minute checkout outage, we opened our tracing backend and found exactly zero traces for the failing request path. The 99% we threw away was, of course, the 99% we needed.
This is the story of how we rebuilt sampling, what it cost, and the specific OTel Collector config we landed on.
Why head sampling looked like the adult choice
When you first wire up OpenTelemetry, the sampling decision feels academic. The SDK defaults to ParentBased(AlwaysOn), your dev traffic is small, and everything just works. Then you deploy to production, watch your tracing vendor's bill, and panic-set TRACES_SAMPLER=traceidratio with a ratio of 0.01.
Head sampling has real virtues:
- The decision is made once, at the root span, and propagated via
tracestate. - Downstream services don't waste CPU on spans that will be dropped.
- Egress to your backend drops roughly 100x.
- Your bill becomes predictable.
The tradeoff is the one everyone glosses over in the docs: you decide what to keep before you know whether it's interesting. A 1% sampler keeps 1% of boring 200s and 1% of catastrophic 500s. During an incident, if your error rate goes from 0.1% to 4%, you'll still only see a thin slice of the failures, and you have no way to pull the rest back.
The specific moment it broke
Our incident was a Stripe webhook retry loop interacting with a stale idempotency key cache. The failing requests were a tiny fraction of overall traffic — maybe 20 per minute against a baseline of 3,000 RPS. At 1% head sampling, the collector saw roughly one failing trace every three minutes, and because our tail_sampling processor wasn't yet in place, even those were randomly dropped.
We ended up reconstructing the failure from Sentry breadcrumbs and raw application logs. It worked, but it took an extra 25 minutes we didn't have.
The three sampling strategies, honestly compared
There are really only three shapes of sampling worth discussing in 2026, and the OTel docs tend to describe them as interchangeable. They aren't.
Head sampling
Decision made at the root. Cheap, simple, propagates cleanly. Terrible for incidents. Fine for steady-state capacity planning and latency histograms where you only need statistical signal.
Tail sampling
Decision made after the full trace is assembled, in the Collector. You can keep every error, every slow request, every trace touching a specific tenant or route. The cost: the Collector has to buffer spans for a decision window (typically 10 – 30 seconds), which eats memory and adds a stateful component you now have to run reliably.
In our experience, a tail_sampling processor handling ~5k spans/second needs 2 – 4 GB of memory per Collector replica with a 20-second decision wait. Not catastrophic, but not free.
Probabilistic + tail hybrid
What we actually run now. A low-rate probabilistic sampler at the edge for baseline coverage, plus tail-based rules that force-keep anything matching error, latency, or business-critical predicates. You get a predictable bill floor and a sharp signal when things go wrong.
The Collector config we landed on
After two more near-misses, here's roughly what we deployed. This runs in a dedicated Collector tier — not as a sidecar — because tail sampling needs to see all spans for a given trace ID in one place.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
send_batch_size: 1024
timeout: 2s
tail_sampling:
decision_wait: 20s
num_traces: 100000
expected_new_traces_per_sec: 5000
policies:
- name: errors-always
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-requests
type: latency
latency: { threshold_ms: 1500 }
- name: checkout-critical
type: string_attribute
string_attribute:
key: http.route
values: ["/api/checkout", "/api/payments/.*"]
enabled_regex_matching: true
- name: enterprise-tenants
type: string_attribute
string_attribute:
key: tenant.tier
values: ["enterprise"]
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 2 }
exporters:
otlphttp/vendor:
endpoint: https://otel.your-vendor.example
headers:
authorization: ${env:OTEL_VENDOR_TOKEN}
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlphttp/vendor]
A few things worth calling out, because the defaults will bite you.
decision_wait is not free latency
It doesn't delay your users — spans are already exported from the app. But it does mean a 20-second gap between when a trace completes and when it appears in your backend. During an active incident, 20 seconds feels long. We tried 10 seconds and dropped roughly 3% of traces because long-running background jobs hadn't finished emitting spans. Twenty was the sweet spot for our workload.
num_traces is a memory ceiling, not a target
It caps the in-memory trace buffer. If your traffic spikes past expected_new_traces_per_sec, the Collector will evict traces before the decision window closes, and those evictions are silent unless you're scraping otelcol_processor_tail_sampling_sampling_trace_dropped_too_early. Scrape it. Alert on it.
Policies are evaluated as OR
If any policy says keep, the trace is kept. This is why the baseline probabilistic policy sits at the bottom — it's a floor, not a filter. The error, latency, and route policies are additive on top.
What it actually cost us
Moving from 1% head sampling to this hybrid setup roughly tripled our ingested span volume. Our tracing vendor bill went up about 2.4x. In exchange:
- Every 5xx response now has a full trace, no exceptions.
- Every request slower than 1.5s is retained, which caught two N+1 regressions in the first month.
- Enterprise tenant traffic is fully traced, which turned out to matter more for support than for engineering.
- The Collector tier added two
m6i.largeequivalents and about $140/month in infrastructure.
Is 2.4x worth it? For us, yes — one avoided 40-minute outage pays for roughly two years of the delta. For a side project or a service with no revenue tied to uptime, probably not. Keep the 1% head sampler and move on.
The things we got wrong on the way
A few mistakes worth not repeating.
We ran tail sampling as a sidecar first. It doesn't work. Spans from the same trace can land on different Collector instances, so the tail processor sees fragments and makes bad decisions. You need a load balancer that routes by trace ID (the loadbalancingexporter in a two-tier Collector setup) or a single Collector tier that all services export to.
We forgot about tracestate propagation. Our Next.js edge functions on Vercel were dropping the W3C trace context when calling internal APIs, so half our traces were orphans. The fix was explicit propagator config in the OTel SDK; worth auditing if you run anything on edge runtimes.
We over-indexed on error sampling and under-indexed on latency. Our first config kept every error but used a 3-second latency threshold. We missed a slow-burn regression where P95 crept from 400ms to 1.2s over a week because nothing tripped the threshold. Set latency thresholds relative to your current P95, not an absolute number.
Where we'd start
If you're staring at a 1% head sampler right now and wondering whether to change anything, do this in order:
- Add a Prometheus scrape on your Collector and watch
otelcol_processor_tail_sampling_count_traces_sampledand_dropped_too_early. You need the data before you can argue for the budget. - Stand up a second Collector tier (not sidecars) with a basic
tail_samplingprocessor that keeps errors and samples 1% of the rest. Point one non-critical service at it and measure the span volume delta. - Add latency and route-based policies once you have a baseline. Resist the urge to add a policy for every team's pet route — the policy list gets unmaintainable fast.
- Alert on dropped-too-early traces. That metric going non-zero means your sampling decisions are lying to you.
If you want a hand wiring this into an existing AWS or GCP stack, that's the kind of work our DevOps and cloud team does regularly. Otherwise, the OTel Collector docs are solid — just read the tail sampling section twice before you ship.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

The CloudWatch Logs Bill That Doubled Overnight: Tracing a Rogue Lambda Retry Storm
A single misconfigured SQS trigger pushed our CloudWatch Logs bill from $400 to $900 in 48 hours. Here's how we found it, what we changed, and the guardrails we now ship by default.

The Vercel Preview Deployment That DDoSed Our Staging Database
A single PR spun up 40 preview deployments overnight, each opening a connection pool to our staging Postgres. By morning, staging was dead. Here's what went wrong and the three-layer fix we landed on.

AWS NAT Gateway Costs Ate Our Margins: How We Cut Them 80% Without Rewriting the VPC
NAT Gateway data processing charges quietly became the second-largest line item on our AWS bill. Here's the audit, the fix, and the tradeoffs we hit along the way.
