{"slug": "how-adaptive-tail-sampling-works-in-the-opentelemetry-collector", "title": "How Adaptive Tail Sampling Works in the OpenTelemetry Collector", "summary": "Honeycomb contributed its adaptive_tail_sampling processor to the OpenTelemetry Collector, re-expressing the adaptive sampling algorithms behind Refinery and dynsampler-go in OpenTelemetry-native terms. The processor, built by Mike Goldsmith and maintained upstream with Yingrong Zhao, encodes sampling decisions as a threshold in W3C TraceState (ot=th) per the OpenTelemetry consistent probability sampling specification and validates OTTL rule expressions at startup. Unlike the existing tail_sampling processor, whose OR-composed policies produce plain keep/drop decisions with no sampling probability attached, adaptive tail sampling combines tail-based buffering with adaptive samplers that always know the probability they are applying.", "body_md": "# How Adaptive Tail Sampling Works in the OpenTelemetry Collector\n\nA technical deep dive into Honeycomb's adaptive tail sampling processor for the OpenTelemetry Collector: how decisions get made, the samplers available, how thresholds compose with the rest of a sampling pipeline, performance benchmarks, deployment limitations, and how it compares to Refinery.\n\nBy: [Mike Goldsmith](https://www.honeycomb.io/author/mike)\n\n#### The Engineer’s Guide to Sampling in the Age of AI\n\n[Read Now](https://www.honeycomb.io/resources/guides/the-engineers-guide-to-managing-data-volumes)\n\nYou're producing more trace data than you want to pay to store, so you sample. A fixed 1-in-100 rate cuts your bill, but it's blind. It keeps 1% of your errors, 1% of the requests to that rarely-hit route, and 1% of the health checks, all at the same rate. The noisy traffic you care about least dominates what you keep while the traces you need during an incident are the ones most likely to be gone.\n\nWe [recently announced](https://www.honeycomb.io/blog/bringing-most-advanced-sampling-opentelemetry-collector) the [adaptive_tail_sampling](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/adaptivetailsamplingprocessor) processor, Honeycomb's contribution of the adaptive sampling algorithms behind Refinery and [dynsampler-go](https://github.com/honeycombio/dynsampler-go) to the OpenTelemetry Collector. I built the processor and maintain it upstream with my colleague Yingrong Zhao. The announcement covers why we did it and how to try it. In this post, we'll go over the decision mechanics, the samplers, and how it composes with the rest of a sampling pipeline.\n\nOne thing worth saying up front: this is not Refinery ported into the Collector. It's the same algorithm family re-expressed in OpenTelemetry-native terms. Rules are OTTL expressions validated at startup. Sampling decisions are encoded as a threshold in W3C TraceState (`ot=th`) following the [OpenTelemetry consistent probability sampling specification](https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/), not a vendor field. It composes with SDK head samplers and other Collector samplers. Anything that understands the specification can reweight the sampled data, whichever backend you send it to.\n\n## Three ways to sample traces\n\nThe announcement blog introduced the three approaches available in the Collector today. It's worth being precise about their semantics, because the differences are what motivated a new processor.\n\n**Probabilistic (head) sampling** decides at trace start. It's the cheapest option, no buffering at all, but it can't see what it's throwing away. Errors and rare traffic are kept at the same rate as everything else. The numbers get stark for rare events. Say a payment failure occurs once in every 1,000 requests and you head sample at 1%. Each failure's trace survives with the same 1% probability as everything else, so on average, you keep one failure trace per 100,000 requests. A service handling 50,000 requests a day hits that failure around 50 times a day, but keeps evidence of it once every two days. The sampler can't favor the failure because nothing has failed yet when the decision is made.\n\n**Tail sampling** buffers the whole trace and decides with full knowledge, using the `tail_sampling` processor. Its policies compose as an OR. Decisions are plain keep/drop with no sampling probability attached, so kept data generally can't be reweighted downstream and counts computed from sampled data no longer reflect real traffic. The OR composition also shuts out adaptive sampling. A policy that doesn't keep a trace returns no decision rather than a drop, so the trace falls through to the next policy in the list and any later policy can still keep it. An adaptive sampler under those semantics has no control over the probability it's supposed to be enforcing.\n\n**Adaptive tail sampling**, this processor, combines tail-based buffering with adaptive samplers that always know the probability they're applying. Rules evaluate first match rather than OR-composed. Every decision carries an explicit sampling threshold encoded as `ot=th`. You keep the interesting tail without hand-tuning, and your counts stay statistically honest after sampling.\n\n## How it works\n\nSpans buffer in memory, grouped by trace ID. A trace becomes ready for a decision when a root span arrives, when `trace_timeout` (default 30s) expires or when the trace accumulates `span_limit` spans (default 10,000), whichever comes first. The span limit bounds how much memory a single giant trace can hold and decides immediately, since waiting would let the trace keep growing. For the other two triggers, the processor waits with `decision_delay` (default 2s) for straggler spans before evaluating. What counts as a root span is itself configurable with an OTTL expression (`root_span_condition`), which matters when only the server side of a cross-process trace reaches your Collector, or when a producer tags a message consumer span as the effective root.\n\nRules evaluate in order and the first match wins. Each rule names a sampler and a rule with no conditions acts as the catch-all. A typical config keeps every error and lets an adaptive sampler settle the rest:\n\n```\nprocessors:\n  adaptive_tail_sampling:\n    rules:\n      - name: keep-errors\n        conditions:\n          - span.status.code == STATUS_CODE_ERROR\n        sampler:\n          type: always_sample\n\n      - name: default\n        sampler:\n          type: adaptive_percentage\n          goal_percentage: 10\n          fingerprint_attributes:\n            - resource.attributes[\"service.name\"]\n            - span.attributes[\"http.route\"]\n```\n\nA quick word on terminology, because OpenTelemetry and Honeycomb describe the same decision differently. In OpenTelemetry terms, every trace carries 56 bits of randomness, either explicit in TraceState as `ot=rv` or taken from the least-significant 7 bytes of the trace ID, giving a value between 0 and 2^56 - 1. A sampler picks a sampling threshold in that same range and keeps any trace whose randomness is at or above it. Keep everything above the halfway point and you've kept 50%. The threshold travels with the kept spans as `ot=th`, so any later stage can recover the probability the trace survived with. Honeycomb expresses the same decision as a sample rate: keep 1-in-N. The dynsampler-go algorithms produce rates in that form. The processor translates between the two, converting each rate into the equivalent threshold.\n\nSampled traces are forwarded with the threshold in TraceState plus span attributes naming the matched rule (`otelcol.processor.adaptive_tail_sampling.rule`) and the event that triggered the decision (` otelcol.processor.adaptive_tail_sampling.trigger`), so you can see exactly which rule kept each trace on your dashboards. A decision cache remembers recent outcomes, so late-arriving spans are stamped or dropped consistently with the rest of their trace.\n\nThe processor is also honest under pressure. When the buffer fills (`num_traces`, default 50k), the oldest trace is evicted with a real sampling decision rather than merely discarded. The default eviction policy runs your rules on the spans seen so far, so keep-errors keeps working under duress. A constant-time probabilistic policy is available for deployments where eviction means genuine overload. Shutdown drains the buffer rather than discarding it, so a clean restart doesn't lose data.\n\n## Choosing a sampler\n\nThe adaptive samplers group traffic by `fingerprint_attributes`, scoped selectors such as `resource.attributes[\"service.name\"]` and `span.attributes[\"http.route\"]`. Pick attributes that classify traffic, like route, method or status code, rather than ones that identify individual requests, which would give every trace its own fingerprint and defeat the adaptation. Per fingerprint, the sampler computes a sample rate. The processor converts that rate to a threshold and compares it against the trace's randomness. Two adaptive types cover the common goals:\n\n- `adaptive_percentage` targets a goal percentage of span volume (`goal_percentage` ). It tracks per-fingerprint traffic with an exponential moving average, so rare fingerprints are kept at high rates while chatty ones are aggressively sampled and the total converges on the goal. In our validation runs, it held 9.4-10.3% against a 10% goal at every load level we tested.\n- `adaptive_throughput` adapts toward a volume budget instead (`goal_throughput` , in spans per second, enforced per collector instance). Use it when the constraint is a fixed downstream budget. The default`ema` algorithm smooths traffic with the same moving average, while`algorithm: windowed` recalculates over a sliding window, reacting faster to traffic shifts at the cost of being more sensitive to short spikes.\n\nThere are also `always_sample` and `probabilistic` (a fixed fraction, the inline equivalent of the `probabilistic_sampler` processor) for rules that don't need to adapt. One important detail is that samplers only ever produce a rate. The rate-to-threshold comparison against trace randomness is the decision mechanism for every rule, which is what makes decisions reproducible and every kept span correctly weighted downstream.\n\n## Decisions the rest of the pipeline can trust\n\nBecause the decision is a spec-encoded threshold rather than a bare keep/drop, the processor composes with upstream and downstream sampling stages. If an SDK head sampler or a `probabilistic_sampler` processor already wrote `ot=th`, the stricter stage wins and the surviving threshold always reflects the effective end-to-end probability.\n\nWe validated this with a `probabilistic_sampler` in equalizing mode at 50% upstream and a 10% `adaptive_percentage` goal in this processor. Roughly 10% of the original traffic survived (not 10% of the upstream's 50%). Every kept span carried the 10% threshold. With an always-keep rule downstream instead, the upstream 50% threshold was preserved untouched. In both directions, estimators reconstructed the original send volume within 1.5%.\n\nThe same property helps beyond traces. The spanmetrics connector reads `ot=th` to produce correctly weighted R.E.D metrics from sampled data, so your request rates and error rates stay accurate even though most spans were dropped.\n\nA fair question is why this isn't a set of new policies inside `tail_sampling`. The short answer is that `tail_sampling`'s policy contract has no way to carry a sampling probability or threshold. Its OR-composition model also breaks the accounting that adaptive samplers depend on. The [README](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/adaptivetailsamplingprocessor#relationship-to-processortail_sampling) walks through the mechanics in detail. Both processors remain useful and `tail_sampling` users lose nothing by this being separate.\n\n## Performance\n\nWe've benchmarked and soak-tested the processor throughout development. Yingrong and I ran a full load test of the standalone processor in a production-like two-tier deployment, with a sampling tier of two replicas at 7 vCPU and 13GiB each. A replica sustained around 100,000 spans per second on roughly 0.3 cores with typical span sizes, CPU grew in proportion to span payload size and the cardinality tests pushed past 170,000 spans per second.\n\nUnder a sustained 70x overload against a deliberately undersized buffer, eviction kept memory bounded and the process was never killed for exceeding its memory limit. The span_limit cap earned its place, cutting memory around 36% for about 34% more CPU at identical throughput on giant traces. It's performant, broadly in line with Refinery on comparable workloads. We'll continue improving it over time.\n\n## Deployment and current limitations\n\nHere are some things to know that will impact how you deploy the processor:\n\n- **Decisions are per-instance,** so all spans of a trace must reach the same Collector. Scale out with the standard two-tier`loadbalancing` exporter pattern, routing by trace ID. Routing by`ot=rv` isn't supported by the`loadbalancing` exporter yet, but there's an[open PR](https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/49666) to add it as a routing key.\n- **Memory is bounded by trace count** `(num_traces) and per-trace span count (span_limit` ), not bytes. It grows with span rate times the buffer window.\n- **Rules are fixed at startup,** so changing them means a restart. Shutdown drains the buffer, so a restart doesn't lose data.\n- **Traces only for now.** Rule conditions evaluate span context.\n\n## Getting started\n\nThe processor is available today in beta in the Honeycomb OpenTelemetry Collector distribution, ahead of the upstream stability milestones. The [announcement post](https://www.honeycomb.io/blog/bringing-most-advanced-sampling-opentelemetry-collector) has step-by-step setup for Kubernetes, Docker, and standalone configs. Upstream, the component lives in the collector-contrib repository and is working toward alpha stability, at which point it becomes part of the collector-contrib distribution.\n\nThe [README](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/adaptivetailsamplingprocessor) has the full configuration reference, examples for common deployment patterns, and the telemetry contract for building dashboards on the processor's own metrics. Feedback and issues are very welcome on [#49311](https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/49311).\n\nIf you've been running fixed-rate sampling and living with blind spots, or wanting tail sampling but needing accurate counts afterwards, this is built for you. I'd love to hear how it behaves on your traffic.\n\n## Why Refinery is still the leading sampling tool\n\nThe processor brings Honeycomb's sampling algorithms to the OpenTelemetry Collector, but [Refinery](https://docs.honeycomb.io/manage-data-volume/refinery/) remains the leading sampling tool. The reasons are operational rather than algorithmic. Sampling at scale is a system you operate, not just a component you configure. Refinery has years of production hardening behind it.\n\n- **Scale without a routing tier.** Refinery clusters share trace ownership across peers, so you add nodes to add capacity. The collector processor needs a two-tier topology and each instance learns rates from only its own slice of traffic.\n- **Live rule changes.** Refinery reloads sampling rules without a restart, which matters most mid-incident when you need to tighten or loosen sampling right now.\n- **Overload protection.** Stress relief detects saturation and switches to deterministic sampling until the cluster recovers, so it degrades predictably instead of falling over.\n- **Operational depth.** Environment-aware multi-tenancy, rich runtime telemetry and a managed option with Refinery as a Service.\n\nIf you want adaptive sampling native to an OpenTelemetry Collector pipeline, start with the processor. If you're already running Refinery, the [README](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/adaptivetailsamplingprocessor#samplers) includes a direct mapping from Refinery's sampler types to the processor's. When sampling becomes critical infrastructure with its own scaling, tenancy and incident-response demands, Refinery is built for exactly that.", "url": "https://wpnews.pro/news/how-adaptive-tail-sampling-works-in-the-opentelemetry-collector", "canonical_source": "https://www.honeycomb.io/blog/how-adaptive-tail-sampling-works", "published_at": "2026-09-21 13:00:00+00:00", "updated_at": "2026-09-21 14:02:08.645967+00:00", "lang": "en", "topics": ["ai-infrastructure", "developer-tools", "mlops"], "entities": ["Honeycomb", "OpenTelemetry Collector", "adaptive_tail_sampling processor", "Refinery", "dynsampler-go", "Mike Goldsmith", "Yingrong Zhao", "W3C TraceState"], "alternates": {"html": "https://wpnews.pro/news/how-adaptive-tail-sampling-works-in-the-opentelemetry-collector", "markdown": "https://wpnews.pro/news/how-adaptive-tail-sampling-works-in-the-opentelemetry-collector.md", "text": "https://wpnews.pro/news/how-adaptive-tail-sampling-works-in-the-opentelemetry-collector.txt", "jsonld": "https://wpnews.pro/news/how-adaptive-tail-sampling-works-in-the-opentelemetry-collector.jsonld"}}