Elastic contributed two upstream improvements to the OpenTelemetry Collector's tail sampling processor (tailsamplingprocessor) that cut memory usage by up to 65%.
sampling_strategy: span-ingest lets sampling decisions happen at ingest time, releasing traces before decision_wait elapses.
pebbletailstorageextension moves trace buffering to a Pebble LSM database on disk, so storage scales with disk capacity instead of RAM. That means operators can increase decision_wait and num_traces without OOM kills. The cost is roughly 2x CPU.
What is Tail Sampling?
Distributed tracing is useful for debugging, but at production scale it comes with processing overhead and storage costs, at which point sampling becomes a natural way to maintain the value of tracing while keeping costs under control. Tail-based sampling, or tail sampling, is a technique that makes a sampling decision conditionally at a later stage, so that high-value traces like errors or slow transactions are more likely to be sampled. The opposite is head sampling, which makes the decision at the start of a trace, before any such information is available.
How does the tail sampling processor work?
The tail sampling processor buffers 100% of incoming traces (or spans, used interchangeably), then forwards the sampled subset after applying the sampling policies. Buffering is a major source of memory usage, and it scales proportionally to the volume of spans, a well known pain point in the community.
Memory usage is bounded by configuration parameters like decision_wait and num_traces.
Setting decision_wait to 1 minute means a sampling decision is made for a trace after 1 minute, during which all spans for that trace are expected to have arrived.
If a trace is slower than 1 minute, the decision is made with some spans missing.
As a side note, scaling out the tail sampling setup involves using the loadbalancingexporter to satisfy the requirement that all spans for a trace must be routed to the same collector.
This introduces some operational complexity and potentially data loss during collector restarts.
But this post focuses on the memory usage of a single tail sampling processor instance, regardless of horizontal scaling.
Why does tail sampling cause memory pressure?
These parameters introduce a tradeoff between data loss and memory usage, and they require assumptions about the shape of traces: how slow they can be, how many spans they contain, how large each span is. These assumptions can become stale as instrumentation evolves.
How much data loss is acceptable to limit memory usage, and can the tradeoff be improved? The following two contributions aim to give operators more flexibility.
How span-ingest reduces tail sampling memory by releasing spans early
sampling_strategy is a new configuration option added to the tail sampling processor in v0.149.0.
sampling_strategy defaults to trace-complete, which matches the original behavior: sampling policies are only evaluated when decision_wait has elapsed, at which point the trace is considered complete.
(There is a similar config, decision_wait_after_root_received, for optimization, but it is excluded from this discussion for simplicity.)
This means all spans are buffered in memory for roughly decision_wait before being released, regardless of whether a decision could have been made earlier.
For example, health check spans that should always be dropped are still held in memory until policy evaluation time.
Alternatively, sampling_strategy can be set to span-ingest, where spans are evaluated individually at ingest time.
This allows terminal decisions, specifically drop or sampled, to be made earlier, freeing memory by dropping or exporting all spans buffered so far for that trace before decision_wait elapses.
In the health check example, a policy can be configured to drop the entire trace as soon as the root span belongs to a health check.
It is worth noting that an unsampled decision, unlike an explicit drop, is not terminal, as it can be overruled by a sampled or drop decision from another span in the same trace, so unsampled traces cannot be released early.
Switching from trace-complete to span-ingest will require policy adjustments, as policies can no longer assume all spans are available at evaluation time.
Moreover, not all policy types are supported with the span-ingest strategy.
Disk-backed tail sampling storage with Pebble
Even with span-ingest, all spans are still buffered in memory.
As decision_wait is increased to accommodate slow traces and num_traces is increased to limit data loss, the collector will eventually hit its memory limit and get OOM killed, resulting in further data loss.
What if traces were buffered on disk instead, where there is an order of magnitude more capacity?
The main drawback is performance: disk throughput and latency, even with SSDs, are at least an order of magnitude slower than memory, so disk writes need to be efficient.
For this reason, Pebble, an LSM database, was chosen as the storage backend for its fast write performance.
Read performance is less of a concern, as reads only happen for the sampled subset of traces when sampling_strategy is set to span-ingest.
The implementation introduces a TailStorage interface for trace storage operations, and a new tail_storage option in v0.150.0 (behind feature gate processor.tailsamplingprocessor.tailstorageextension) to configure the storage backend.
The default in-memory behavior is unchanged, but it is now possible to swap in a different storage backend, like the new pebbletailstorageextension contributed to Collector Contrib.
Tail sampling memory benchmarks: trace-complete vs span-ingest with Pebble
Benchmark setup: OpenTelemetry Demo with fan-out collectors
The following benchmarks were produced by running OpenTelemetry Demo with increased load against a pipe collector, which receives all spans and fans them out to two identical collectors under observation (CUO-A and CUO-B), differing only in their tail sampling configuration.
Measurements include pipe collector throughput, spans received, spans sent (sampled), CPU usage, and memory usage.
Benchmark setup diagram
demo ns
+----------------------------------------+
| opentelemetry-demo |
| loadgenerator (locust) |
| services: frontend, cart, ... |
| demo-collector |
+----------------------------------------+
| OTLP/gRPC
v
chamber ns
+----------------------------------------+
| pipe-collector |
| receive once, fan out |
| exporters: [otlp/a, otlp/b] |
+----------------------------------------+
| OTLP | OTLP
v v
+----------------+ +----------------+
| CUO-A | | CUO-B |
| tail_sampling | | tail_sampling |
| (config A) | | (config B) |
+----------------+ +----------------+
Tail sampling processor configurations
CUO-A
config:
processors:
tail_sampling:
sampling_strategy: trace-complete
decision_wait: 5m
num_traces: 5000000
block_on_overflow: true
decision_cache:
sampled_cache_size: 10000
non_sampled_cache_size: 200000
policies:
- name: root_1pct
type: and
and:
and_sub_policy:
- name: root_span_only
type: ottl_condition
ottl_condition:
error_mode: ignore
span:
- "IsRootSpan()"
- name: root_probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 1.0
CUO-B
CUO-B uses the same tail sampling processor configuration as CUO-A, except it sets sampling_strategy: span-ingest and tail_storage: pebble_tail_storage/main, along with its corresponding pebbletailstorageextension configuration.
extensions:
pebble_tail_storage/main:
directory: /var/lib/otelcol/pebble
Memory, CPU and throughput results
The following tables compare trace-complete (CUO-A) against span-ingest with Pebble disk storage (CUO-B) across memory, CPU and throughput. The process RSS, Go heap allocation, and per-process CPU measurements come from OpenTelemetry Collector internal process and runtime metrics, while container working set and container CPU come from Kubernetes cgroup metrics scraped by kubelet/cAdvisor.
Memory (peak over the window)
| Metric | cuo-a | cuo-b | Δ (B vs A) |
|---|---|---|---|
| process RSS | 916.4 MiB | 442.7 MiB | -51.7% |
| Go heap alloc | 699.3 MiB | 241.7 MiB | -65.4% |
| container working set | 763.0 MiB | 282.9 MiB | -62.9% |
CPU (total over the window)
| Metric | cuo-a | cuo-b | Δ (B vs A) |
|---|---|---|---|
| per-process CPU | 11.9 core-s | 22.7 core-s | +90.7% |
| container CPU | 11.9 core-s | 22.7 core-s | +90.1% |
Throughput (total over the window)
| Metric | cuo-a | cuo-b | Δ (B vs A) |
|---|---|---|---|
| spans received | 257,804 | 257,804 | 0.0% |
| spans sent | 2,477 | 2,477 | 0.0% |
Tail sampling
| Metric | cuo-a | cuo-b | Δ (B vs A) |
|---|---|---|---|
| traces in memory peak | 29,284 | 29,245 | -0.1% |
| traces sampled by root_1pct policy | 496 | 496 | 0.0% |
cuo-a=trace-complete,cuo-b=span-ingest-pebble- Window: 15m 39s (
t+0:00start,t+10:06drain start,t+15:39drain end)
The results show a significant memory reduction when using pebbletailstorageextension with span-ingest, at the cost of increased CPU usage from event serialization and database overhead.
What's next for OpenTelemetry tail sampling
Both sampling_strategy and pebbletailstorageextension are still in their early stages at the time of writing.
Feedback and contributions are welcome in the OpenTelemetry Collector Contrib repo.
Stay tuned for more improvements.