A single Collector samples without trouble, but as soon as replicas are added, decisions become inconsistent. Trace ID load balancing fixes this: a first tier of Collectors spreads spans by traceID, so every span of a trace lands on the same instance of the second tier, the one running the tail_sampling processor.
Goal: build this two-tier architecture with the load_balancing exporter from the OpenTelemetry Collector contrib repository, check it works and know its limits (pods added or removed, data loss). The principle is backend-independent: it applies to Dynatrace SaaS and Dynatrace Managed alike, because everything happens in the Collector, before the OTLP export.
This article complements the OpenTelemetry tail sampling tutorial, which covers sampling policies. Here the focus is the routing that makes them reliable.
Why trace ID load balancing is required for tail sampling
According to the tail_sampling processor README, all spans of a trace must be received by the same Collector instance for a correct decision. The processor keeps spans in memory, grouped by trace_id, then evaluates its policies. If two replicas share the spans of one trace, each decides on a partial view: one keeps the failing part, the other drops the rest. The result is truncated traces without a root span, which on-call engineers cannot use.
A classic network load balancer (round robin on port 4317) does not help: it spreads connections or requests, not traces. Deterministic routing on the trace identifier is needed. The exporter README describes exactly this use case: consistent routing that enables stateful downstream processing, tail sampling included.
How the load_balancing exporter works
The exporter reads the routing key (routing_key) of each batch, computes which backend it goes to and creates one OTLP exporter per endpoint. A few facts from its README:
- Possible
routing_keyvalues areservice,traceID,metric,resource,streamID,attributesandrandomness. For traces,traceIDis the default; stating it explicitly still helps readability. - The source of backends is a resolver:
static(fixed list),dns(a name resolving to all addresses, for example a Kubernetes headless service),k8s(a Kubernetes service) oraws_cloud_map. - The backend choice depends on the identifier alone, not on actual load. The README states a distribution close to even, with a standard deviation under 5% in the current configuration.
- Declared stability: beta for traces and logs, alpha for metrics.
- The exporter type was renamed
load_balancing; the old nameloadbalancingis still accepted as a deprecated alias.
Here is the README excerpt showing the configuration structure, with the static resolver and, commented out, the DNS equivalent for Kubernetes:
Step-by-step setup
Step 1: the first tier, routing by traceID
# collector-routing.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 exporters: load_balancing: routing_key: traceID protocol: otlp: timeout: 1s tls: insecure: true resolver: dns: hostname: otel-sampler-headless.observability.svc.cluster.local port: 4317 service: pipelines: traces: receivers: [otlp] exporters: [load_balancing] Critical lines: routing_key: traceID guarantees one backend per trace; the dns resolver points at a headless service so that each second-tier pod appears as a distinct address. The DNS resolver re-queries the name periodically (5 s by default according to the README). tls.insecure: true suits a test; in production, use TLS between the two tiers.
If you prefer the k8s resolver, the README states that the first-tier pod must run with a service account allowed to get, list and watch EndpointSlice objects in the target namespace; otherwise the resolver cache stays empty and the exporter logs an error.
Step 2: the second tier, which samples
# collector-sampler.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: memory_limiter: check_interval: 1s limit_percentage: 80 spike_limit_percentage: 20 tail_sampling: decision_wait: 10s num_traces: 100000 policies: - name: errors type: status_code status_code: status_codes: [ERROR] - name: slow type: latency latency: threshold_ms: 1000 - name: normal-sample type: probabilistic probabilistic: sampling_percentage: 5 batch: {} exporters: otlphttp/dynatrace: endpoint: "https://<environment-id>.live.dynatrace.com/api/v2/otlp" headers: Authorization: "Api-Token ${env:DT_API_TOKEN}" service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, tail_sampling, batch] exporters: [otlphttp/dynatrace] Deploy this tier as a StatefulSet or Deployment behind the headless service named in step 1, with at least two replicas. The Dynatrace token needs the openTelemetryTrace.ingest scope; on Managed, the endpoint takes the form https://<your-domain>/e/<environment-id>/api/v2/otlp. For deploying the Collector on Kubernetes, see the OpenTelemetry Collector on Kubernetes.
Why two tiers rather than two pipelines in the same instance? The processor README says it is technically possible, but separating the layers gives better failure isolation. It describes the same architecture, one layer with the load balancing exporter and another with the tail sampling processor:
Step 3: tune resilience
By default, according to the README, the exporter does not try to re-route data to a healthy backend on delivery failure: queue, retry and timeout are disabled at the exporter level itself. Two levels of settings exist:
- Under
protocol.otlp(options 2):timeout,retry_on_failureandsending_queuefor temporary problems with one backend, such as a brief network glitch. Enabled by default. - Directly in the
load_balancingsection (options 1): the same settings, useful in an elastic environment such as Kubernetes where the endpoint list changes on every rollout. Disabled by default.
The README also states that data loss remains possible if all targets stay unavailable once retries are exhausted. Size the queues accordingly and alert on export failures.
What happens when the number of pods changes
When the backend list changes, part of the routes are reassigned: roughly R/N, where R is the number of routes (here, trace IDs) and N the number of backends. The more backends, the smaller the disruption. For a trace in flight during a rollout, its spans may therefore end up on two instances and produce an incomplete trace. The README notes that the dns and k8s resolvers reflect changes with a delay, the k8s resolver updating faster than dns.
What this means for a platform team:
- Avoid restarting the whole second tier at once: roll out gradually and watch the rate of traces without a root span.
- If the backend list changes constantly and stability matters, the README suggests considering the
groupbytraceprocessor upstream, which dispatches traces atomically to the exporter. - Set a
decision_waitlonger than your longest traces. Decisions already taken can be kept by the decision caches (decision_cache), disabled by default.
How to check that it works
- A local test: the exporter README offers a configuration where the load balancer and four backends run on the same machine, each backend exporting to the
debugexporter. Send multi-span traces and check that eachtraceIDappears in a single backend only. - First-tier logs: an error such as couldn’t find the exporter for the endpoint points, according to the README, to a
k8sresolver lacking RBAC permissions. - Collector internal metrics: on the second tier, follow the tail sampling processor counters. Exact names vary by version; list them on the internal telemetry endpoint rather than copying them from a tutorial.
-
Dynatrace SaaS: look for traces without a root span, a sign of scattered spans or too short a
decision_wait:
fetch spans, from: now() - 1h | summarize spans = count(), roots = countIf(isNull(span.parent_id)), by: {trace.id} | filter roots == 0 | limit 20 Dynatrace Managed: in the distributed traces view, filter on failed requests and check that traces are not truncated (DQL queries do not apply to Managed).
To correlate logs and traces after sampling, see OpenTelemetry logs and the trace ID. Routing by traceID also works for logs: the README states that logs without a trace ID are distributed randomly.
Best practices
- Separate the two tiers: the first is stateless, the second holds traces in memory.
- Use a headless service and a
dnsork8sresolver rather than a static list, which exposes you to loss if a backend goes down. - Size second-tier memory from throughput,
decision_waitandnum_traces, and putmemory_limiterfirst. - Compute RED metrics before sampling, on the first tier; with
traceIDrouting, every Collector sees all service and operation combinations, which can cause label collisions (the README recommendsservicerouting for that case). - Treat a truncated trace as a configuration defect, never as one team’s fault.
Frequently asked questions
Is trace ID load balancing needed with a single Collector?
No. With a single instance receiving every span, the processor sees each trace in full. Routing becomes mandatory as soon as several replicas sample.
What is the difference between routing_key traceID and service?
With traceID, all spans of a trace go to the same backend, which tail sampling requires. With service, all spans of a service go to the same backend, which suits metrics computed per service.
Is an ordinary Kubernetes load balancer enough?
No: a classic Service spreads connections, with no link to the traceID. You need the load_balancing exporter and a headless service so that each pod is individually addressable.
Conclusion
Tail sampling is only reliable at scale if every trace arrives whole on a single instance. The load_balancing exporter with routing_key: traceID is the condition, at the price of an extra Collector tier and some care during rollouts. Next, see context propagation with Kafka, which prevents other broken traces upstream. References: the load_balancing exporter README and the tail_sampling processor README.
span.observer is an independent community blog, not affiliated with Dynatrace LLC. Dynatrace is a trademark of its owner.
Originally published on span.observer. French version: https://span.observer/fr/load-balancing-trace-id-tail-sampling/.

