Tracing & Performance

Head vs Tail Sampling: Choosing the Right Trace Strategy

Head vs tail sampling explained: Choose the right distributed tracing strategy to control costs without losing visibility into errors and slow requests.

In distributed systems, you generate traces constantly—thousands per second. But keeping every single trace gets expensive fast. That's where sampling comes in: you choose which traces to store and analyze. The timing of that choice, however, fundamentally changes how you debug and understand your system. Understanding head sampling vs tail sampling is the key to building a cost-effective observability strategy that doesn't miss the failures that matter most.

The difference is simple: head sampling decides whether to keep a trace at its start, before you know anything about it. Tail sampling waits until the trace completes, then decides based on what actually happened. Each strategy has real tradeoffs, and picking the wrong one means either burning money on data you don't need or losing visibility into your slowest requests and rare failures.

What Is Head Sampling?

Head sampling makes the sampling decision at the entry point of a request, before execution completes. When a request arrives, you flip a coin: keep this trace or drop it? If your sample rate is 10%, you keep one in ten traces, regardless of what happens inside.

The implementation is straightforward. Early in request handling, you generate a random number or hash the trace ID against your configured rate. No waiting, no buffering. If the trace is dropped, you never allocate memory for spans or send data to your backend. This simplicity is the main win—minimal overhead, predictable memory usage, and easy to coordinate across distributed services.

import random

def should_sample_head(trace_id: str, sample_rate: float = 0.1):
    # Deterministic: same trace_id always gets same decision
    return hash(trace_id) % 100 < (sample_rate * 100)

# Early in request handling:
if should_sample_head(trace_id):
    tracer.start_span("request", attributes={"sampled": True})
else:
    # Don't allocate resources for this trace
    pass

The downside: you're blind to failures. If a request errors but wasn't sampled, you'll never see it. A rare slow transaction happening once a day has a low chance of landing in your sample set if your rate is conservative.

What Is Tail Sampling?

Tail sampling inverts the decision to the end of a request. You initially collect all spans, then inspect the completed trace and decide: is this trace worth keeping? Error? Keep it. Latency above your threshold? Keep it. Normal, fast, successful request? Probably drop it.

Because the decision happens after execution, you can condition on actual behavior. An error always makes it through. A five-second database query triggers keep. A successful 10ms request gets sampled out. Distributed tracing benefits enormously from this visibility.

The trade-off is buffering and backend load. During execution, spans live in memory. When the request finishes, you ship all spans to a collector, which applies filtering logic. If your tail sample rules are too aggressive, you'll miss interesting traces; too lenient, and storage costs spike.

// Collect spans during request
tracer := NewTracer()
defer func() {
    trace := tracer.GetCompletedTrace()
    
    // Tail sampling decision
    if trace.HasError || trace.Duration > 500*time.Millisecond {
        backend.Send(trace)
    } else if rand.Float64() < 0.01 { // 1% of successful requests
        backend.Send(trace)
    }
}()

// ... execute request ...

Head vs Tail: When to Use Each

Head sampling excels when:

  • Volume is extremely high and uniform. You're running 10,000+ requests/second with predictable latency. Sampling 1% still captures thousands of traces.
  • Latency sensitivity is critical. Financial trading systems, real-time APIs, and payment processors can't afford buffering. Head sampling is instant.
  • Errors are rare. If your error rate is less than 1% and your head sample rate is 10%, you'll catch most failures naturally.
  • You want minimal complexity. Configure a percentage, apply it uniformly, done.

Tail sampling excels when:

  • Errors are non-negotiable. One missed payment failure or auth bug is unacceptable. Tail sampling guarantees every error is captured.
  • Workloads are heterogeneous. Some requests are fast; others are slow. Some are business-critical; others are routine. You can tag and filter accordingly.
  • Web performance monitoring matters. Keeping all transactions above your p95 latency threshold while dropping fast requests balances debugging insight with cost.
  • You need full request context. With APM, tail sampling lets you collect spans across all databases, API calls, and async jobs, then decide if the complete trace is worth storing.

Most teams use both. Apply head sampling at 20-50% to trim raw volume at the source, then use tail sampling rules at your backend to keep all errors, all slow transactions, and a small percentage of normal traffic. This gives you cost efficiency plus correctness.

The Two-Stage Pipeline

A proven pattern combines both strategies:

  1. Head sampling at ingestion — Each service applies a modest head sample rate (e.g., 25%) to reduce data sent to your backend.
  2. Tail sampling at the backend — Your tracing backend applies intelligent rules: keep all errors, keep traces above latency thresholds, keep critical-path traces.
# Two-stage sampling config
head_sampling:
  enabled: true
  rate: 0.25  # Keep 25% of all traces at source

tail_sampling:
  enabled: true
  rules:
    - if: status == error
      action: keep
    - if: duration_ms > 1000
      action: keep
    - if: tags.service == "payments"
      action: keep
    - else: keep 0.05  # 5% of normal traffic

This avoids buffering all traces in each service while still catching errors and anomalies. You get the efficiency of head sampling plus the visibility of tail sampling.

Sampling in Your Observability Stack

When you're building for distributed tracing, sampling strategy directly impacts debuggability. A slow database query is gold for optimization, but head sampling alone might discard it. If you're using LightTrace to monitor performance and reduce API latency, ensure your sampling keeps the interesting traces visible.

LightTrace ingests traces via Sentry-compatible SDKs. You control head-sampling in your SDK config:

import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: "https://<key>@light-trace.robomiri.com/1",
  tracesSampleRate: 0.1, // 10% head sampling
});

From there, trace data flows into LightTrace's dashboard with full context: stack traces, breadcrumbs, affected users, and performance waterfall. Your sampling strategy determines what data arrives; LightTrace stores and surfaces what matters.

Practical Guidance

Start with head sampling if you're building a new system or have simple, uniform traffic. It's cheap, requires no backend smarts, and scales automatically.

Switch to tail sampling—or add it—if you're losing visibility into errors, struggling to debug production incidents, or need to guarantee that every failure gets captured.

If you're running microservices with variable latency and business-critical paths, hybrid sampling (head + tail) is the sweet spot. It controls costs without sacrificing the debuggability you need.

The goal isn't to keep every trace. It's to keep the traces that teach you something: failures, anomalies, and slow requests. Tune your sample rates as your traffic grows and your observability needs evolve.

Start tracking errors in minutes

Start capturing distributed traces with LightTrace — point your Sentry SDK at LightTrace and watch your system's behavior land in a fast, mobile-first dashboard. Start free and see every failure in full context.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.