Sampling Strategies at Scale
Part 8 of an 8-part series on implementing observability in Java microservices
Observability at scale has a dirty secret: It is expensive.
Logging every request, tracing every function, and counting every packet will bankrupt you. As traffic scales, your Observability bill often grows faster than your infrastructure bill. In this final module, we learn how to keep the signal but drop the noise.
1. The Cost Equation
The cost of tracing is directly proportional to the volume of data you ingest.
In a moderate system with 1,000 requests/sec, generating 10 spans per request:
- 10,000 spans/sec
- ~5KB per span
- 50MB/sec → 4.3TB/day
You cannot store 4TB/day of trace data explicitly unless you have a massive budget. You must Sample.
Interactive: Sampling Cost Calculator
Adjust the sliders to see how quickly costs explode without sampling.
2. Sampling Strategies
1. Head Sampling (The “Coin Flip”)
Head sampling happens at the origin of the trace—usually in the SDK or Java Agent running inside your application. The decision is made before the first span is even exported.
How it Works
When a request hits your Frontend service, the SDK flips a coin (figuratively).
- Heads: Sample (Record) the trace. The SDK attaches a
sampled=trueflag to thetraceparentheader. - Tails: Drop the trace. The SDK attaches
sampled=false.
Crucially, this decision propagates downstream. If Frontend decides to drop a trace, Backend and Database will also drop it. This is called Parent-Based Sampling, and it ensures you don’t end up with “broken” traces where the child spans exist but the parent is missing.
Strategies
always_on: Keep 100%. Great for development or low-traffic services.always_off: Keep 0%. Disables tracing.traceidratio: Probabilistic. Keeps a fixed percentage (e.g., 10%) based on the TraceID hash.parentbased_traceidratio(Recommended): Respects the parent’s decision if one exists. If it’s a new root trace, it uses the ratio.
Configuration (Java Agent)
# 1. Set the Sampler Strategy
# "parentbased_traceidratio" is the gold standard for production
OTEL_TRACES_SAMPLER=parentbased_traceidratio
# 2. Set the Ratio (Probability)
# 1.0 = 100% (Keep all), 0.1 = 10% (Keep 1 in 10), 0.001 = 0.1%
OTEL_TRACES_SAMPLER_ARG=0.1
# 3. Environment Tagging (Critical for filtering)
OTEL_RESOURCE_ATTRIBUTES=service.name=payment-service,deployment.environment=production
[!NOTE] Head sampling is efficient because dropped traces incur zero network or storage cost. However, it is “dumb”—it doesn’t know if a request will eventually fail. You might drop the most critical error of the day simply because the coin flip came up “tails”.
2. Tail Sampling (The “Smart Way”)
Tail sampling moves the decision making to the end of the workflow—specifically, to the OpenTelemetry Collector.
Because the Collector receives all spans, it can wait for the trace to complete (or time out) before deciding whether to keep it. This allows for intelligent policies:
- Errors: “Keep any trace containing a span with
status=ERROR.” - Latency: “Keep any trace that took > 2 seconds.”
- Probabilistic: “Keep 1% of everything else.”
The Result: You keep 100% of the “interesting” data and drop 99% of the boring “success” data.
Interactive: The Sampling Duel
Watch how Head Sampling (random 10%) compares to Tail Sampling (smart policies) when handling a mix of success (green) and error (red) traffic.
3. Custom Samplers (Programmatic)
Sometimes, configuration isn’t enough. You might need a sampler that checks a feature flag, a customer tier (Gold/Silver), or a specific HTTP header. In these cases, you can write a Custom Sampler.
Java Implementation
In Java, you implement the Sampler interface.
package com.example.observability;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.trace.data.LinkData;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import io.opentelemetry.sdk.trace.samplers.SamplingResult;
import io.opentelemetry.api.trace.SpanKind;
import java.util.List;
public class PremiumUserSampler implements Sampler {
@Override
public SamplingResult shouldSample(
Context parentContext,
String traceId,
String name,
SpanKind spanKind,
Attributes attributes,
List<LinkData> parentLinks) {
// Check for a custom attribute or header
String userTier = attributes.get(AttributeKey.stringKey("user.tier"));
if ("gold".equals(userTier)) {
// Always sample Gold users
return SamplingResult.recordAndSample();
}
// Drop everyone else (or delegate to another sampler)
return SamplingResult.drop();
}
@Override
public String getDescription() {
return "PremiumUserSampler";
}
}
Go Implementation
In Go, you implement the sdktrace.Sampler interface.
package main
import (
"fmt"
"go.opentelemetry.io/otel/attribute"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
type PremiumUserSampler struct{}
func (s *PremiumUserSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
// iterate through attributes to find user.tier
for _, attr := range p.Attributes {
if attr.Key == attribute.Key("user.tier") && attr.Value.AsString() == "gold" {
return sdktrace.SamplingResult{
Decision: sdktrace.RecordAndSample,
Attributes: p.Attributes,
Tracestate: trace.SpanContextFromContext(p.ParentContext).TraceState(),
}
}
}
// Drop otherwise
return sdktrace.SamplingResult{
Decision: sdktrace.Drop,
Attributes: p.Attributes,
Tracestate: trace.SpanContextFromContext(p.ParentContext).TraceState(),
}
}
func (s *PremiumUserSampler) Description() string {
return "PremiumUserSampler"
}
4. The Architecture Challenge
Tail sampling implies state. To make a decision for TraceID: 0xABC, the Collector needs to see all spans for that trace.
If you have 5 Replica Collectors behind a standard Kubernetes Service (Round-Robin), spans for 0xABC will be scattered across different pods. No single collector has the full picture.
The 2-Tier Solution
You need a 2-Tier architecture to ensure Trace Affinity.
- Tier 1 (Gateway): Stateless. Receives spans from agents. Uses the
loadbalancingexporter to Consistent Hash the TraceID and route it to a specific Tier 2 pod. - Tier 2 (Sampler): Stateful. Receives all spans for
0xABC. Holds them in memory. Makes the decision.
Configuration
Tier 1: The Gateway (Load Balancer)
# otel-collector-gateway.yaml
exporters:
loadbalancing:
protocol:
port: 4317
resolver:
k8s: { service: "otel-collector-sampler" } # Points to Tier 2 Service
routing_key: "traceID" # Ensure spans with same TraceID go to same destination
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]
Tier 2: The Sampler (Decision Maker)
# otel-collector-sampler.yaml
processors:
tail_sampling:
decision_wait: 10s # Wait 10s for spans to arrive
num_traces: 50000 # Max traces in memory
expected_new_traces_per_sec: 1000
policies:
- name: keep_errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: keep_slow_requests
type: latency
latency: {threshold_ms: 2000}
- name: keep_1_percent
type: probabilistic
probabilistic: {sampling_percentage: 1}
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlp/honeycomb, logging] # Send to final backend
5. Remote Sampling (Jaeger Style)
What if you want the efficiency of Head Sampling but the control of centralized configuration?
Remote Sampling allows your application (SDK) to poll the Collector for sampling strategies. This means you can change sampling rates dynamically without redeploying your application.
- SDK: “Hey Collector, what’s the sampling rate for
payment-service?” - Collector: “It’s 5% right now.”
This is natively supported in the OpenTelemetry Jaeger Remote Sampler extension.
Configuration (Collector)
extensions:
jaegerremotesampling:
source:
remote:
endpoint: "jaeger-collector:14250"
strategies:
default_strategy:
type: probabilistic
param: 0.5
service_strategies:
- service: payment-service
type: probabilistic
param: 0.1 # 10% for payments
- service: fraud-detection
type: always_sample # 100% for fraud
service:
extensions: [jaegerremotesampling]
```
3. Production Checklist
Before you declare victory, verify these 5 items:
1. Unified Service Naming
Ensure service.name is consistent across Metrics, Logs, and Traces. If your metrics say order-svc but your traces say OrderService, you cannot correlate them.
OTEL_SERVICE_NAME=order-service
2. Environment Tags
You will regret not knowing if a trace is from prod or staging.
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.2.0
3. Queue Sizes
Monitor your Collector Queue. If it is always full, you are dropping data.
- Check the metric
otelcol_processor_batch_queue_length. - Increase
send_batch_sizeor add more Collector replicas.
4. Health Checks
Do not trace your Kubernetes Health Checks (/health, /readiness). They are spam that consumes your sampling budget.
- Java Agent:
OTEL_JAVAAGENT_EXCLUDE_CLASSES=org.springframework.boot.actuate.health.HealthEndpoint - Collector: Use the
filterprocessor to drop spans wherehttp.target == "/health".
5. Secure your Exporters
Never expose 0.0.0.0:4317 (gRPC) to the public internet.
- Use internal ClusterIPs in Kubernetes.
- Enable mTLS if communicating across clusters.
4. Conclusion
We have built a world-class Observability stack. We started with Java Agents, moved to Manual Spans, added Baggage, integrated Metrics & Logs, built a Collector Pipeline, and optimized it with Tail Sampling.
You are now ready to debug anything.
Next Steps
- Review the basics in Module 02: Zero to Tracing.
- Deep dive into Collector internals in Module 07: Collector Setup.
- Check the Glossary for term definitions.