DEV Community

Cover image for Tail Sampling with OpenTelemetry, Measured: It Keeps What Matters Until You Scale It or Shrink It
Juan Gómez
Juan Gómez

Posted on

Tail Sampling with OpenTelemetry, Measured: It Keeps What Matters Until You Scale It or Shrink It

Tail Sampling with OpenTelemetry, Measured: It Keeps What Matters Until You Scale It or Shrink It

Logs, Metrics and Traces Are Not Three Pillars. They're One Request. ended on an uncomfortable number. A 10% head sampler decides whether to keep a trace when the request arrives, before anyone knows it will be slow. So of 20 slow checkouts, it kept about 2, and the histogram that told you about the slow ones pointed at traces that no longer existed. I promised the fix in that post, and again in the structured logging one: decide at the end of the trace instead. That is tail sampling, and this is the post.

Tail sampling does what it promises. On my run it kept every slow trace and every failed one, while dropping 90% of the rest. It also has two failure modes that a single-Collector demo never shows, and both lose exactly the traces you added it for: a buffer that is too small, and a second Collector.

Everything here runs locally: three Node services, the OpenTelemetry Collector (contrib 0.164.0) and Jaeger 2.22, in Docker.

Two request timelines: in the top one a coin flip at the start decides and scissors cut the bar before its long red slow segment; in the bottom one the whole bar, red segment included, is inspected with a magnifying glass and approved at the end

The system, and how to know what was kept

Aurora Coffee Co.'s checkout calls pricing, then inventory. Two things go wrong on purpose, at known rates, so every result can be checked against the truth:

  • 1 order in 50 is slow. Seasonal SKUs make pricing wait 600 ms on its promotion rules.
  • 1 order in 100 fails. One inventory warehouse shard answers 503, and checkout answers 502.

The services are plain node:http. Node 24 and later run these TypeScript files directly, with no build step:

{
  "name": "aurora-tail-sampling",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "dependencies": {
    "@opentelemetry/api": "1.10.0",
    "@opentelemetry/auto-instrumentations-node": "0.82.0",
    "@opentelemetry/exporter-trace-otlp-http": "0.300.0",
    "@opentelemetry/sdk-node": "0.300.0",
    "import-in-the-middle": "3.5.3"
  }
}
Enter fullscreen mode Exit fullscreen mode
// pricing.ts — quotes a price. Seasonal SKUs wait on a slow promotion-rules lookup.
import { createServer } from 'node:http'
import { setTimeout as sleep } from 'node:timers/promises'

const port = Number(process.env.PORT ?? 3001)

createServer(async (req, res) => {
  const sku = new URL(req.url ?? '/', 'http://pricing').searchParams.get('sku') ?? ''
  // Stands in for the real cost: 2,400 promotion rules against 12 for a house blend.
  if (sku.startsWith('seasonal-')) await sleep(600)
  res.writeHead(200, { 'content-type': 'application/json' })
  res.end(JSON.stringify({ sku, cents: sku.startsWith('seasonal-') ? 2100 : 1600 }))
}).listen(port)
Enter fullscreen mode Exit fullscreen mode
// inventory.ts — reserves stock. One warehouse shard is unavailable for 1 order in 100.
import { createServer } from 'node:http'

const port = Number(process.env.PORT ?? 3002)

createServer((req, res) => {
  const order = Number(new URL(req.url ?? '/', 'http://inventory').searchParams.get('order'))
  if (order % 100 === 37) {
    res.writeHead(503, { 'content-type': 'application/json' })
    res.end(JSON.stringify({ error: 'warehouse shard unavailable' }))
    return
  }
  res.writeHead(200, { 'content-type': 'application/json' })
  res.end(JSON.stringify({ order, reserved: true }))
}).listen(port)
Enter fullscreen mode Exit fullscreen mode
// checkout.ts — the front door: prices the order, then reserves stock.
import { createServer } from 'node:http'
import { trace } from '@opentelemetry/api'

const port = Number(process.env.PORT ?? 3000)
const pricingUrl = process.env.PRICING_URL ?? 'http://localhost:3001'
const inventoryUrl = process.env.INVENTORY_URL ?? 'http://localhost:3002'

async function call(url: string): Promise<unknown> {
  const response = await fetch(url, { signal: AbortSignal.timeout(5_000) })
  if (!response.ok) throw new Error(`${url} answered ${response.status}`)
  return response.json()
}

createServer(async (req, res) => {
  const order = Number(new URL(req.url ?? '/', 'http://checkout').searchParams.get('order'))
  // One order in 50 is a seasonal SKU, the slow path in pricing.
  const sku = order % 50 === 0 ? `seasonal-${order}` : `house-${order % 7}`
  // Returned so the load generator can look the trace up in Jaeger afterwards.
  res.setHeader('x-trace-id', trace.getActiveSpan()?.spanContext().traceId ?? '')

  try {
    const price = await call(`${pricingUrl}/quote?sku=${sku}`)
    await call(`${inventoryUrl}/reserve?order=${order}`)
    res.writeHead(200, { 'content-type': 'application/json' })
    res.end(JSON.stringify({ order, price }))
  } catch (error) {
    res.writeHead(502, { 'content-type': 'application/json' })
    res.end(JSON.stringify({ order, error: error instanceof Error ? error.message : 'checkout failed' }))
  }
}).listen(port)
Enter fullscreen mode Exit fullscreen mode

The tracing setup is two small files, loaded before the service with two --import flags. The first installs the loader hook that lets auto-instrumentation patch ES modules:

// register.ts — loaded first: `node --import ./register.ts --import ./telemetry.ts service.ts`.
import { register } from 'node:module'
import { register as registerSyncHooks, supportsSyncHooks } from 'import-in-the-middle/register-hooks.mjs'

// Node 24.11.1+ and 26 run the hook in-thread with module.registerHooks(). module.register(),
// the older off-thread loader, is deprecated in Node 26 and kept only as the fallback.
if (supportsSyncHooks()) registerSyncHooks()
else register('@opentelemetry/instrumentation/hook.mjs', import.meta.url)
Enter fullscreen mode Exit fullscreen mode

The second starts the SDK:

// telemetry.ts — loaded second, after register.ts, and before any service code.
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'

const sdk = new NodeSDK({
  serviceName: process.env.OTEL_SERVICE_NAME ?? 'unnamed-service',
  // Keep host names, user names and PIDs out of every span.
  autoDetectResources: false,
  traceExporter: new OTLPTraceExporter({
    url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'}/v1/traces`,
  }),
  instrumentations: [getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false } })],
})

sdk.start()

for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.once(signal, () => {
    void sdk.shutdown().finally(() => process.exit(0))
  })
}
Enter fullscreen mode Exit fullscreen mode

The hook is the part that changed since September, and it is now a file of its own for a reason. Without a hook, auto-instrumentation never sees node:http under ESM and every service starts its own trace. The September post used module.register(), which Node 26 deprecates with a warning on every start. import-in-the-middle, the library behind OpenTelemetry's hook, ships an in-thread version built on module.registerHooks(), and supportsSyncHooks() says whether the running Node can use it. It has to run in its own --import: static import statements are evaluated before the code of the file that contains them, so a hook registered at the top of telemetry.ts would run after that file's imports had already loaded.

The ground truth

To score a sampler you need to know which requests were slow or failed, independently of the traces. The load generator records that from the client side, along with each request's trace ID, which checkout returns in a header:

// load.ts — sends N checkouts and records what really happened to each one.
// Usage: node load.ts <requests> <concurrency> <out.json>
import { writeFileSync } from 'node:fs'

type Outcome = { order: number; traceId: string; status: number; ms: number }

const [requests = '1000', concurrency = '10', out = 'outcomes.json'] = process.argv.slice(2)
const total = Number(requests)
const outcomes: Outcome[] = []
let next = 1

async function worker(): Promise<void> {
  while (next <= total) {
    const order = next++
    const started = performance.now()
    const response = await fetch(`http://localhost:3000/checkout?order=${order}`)
    await response.arrayBuffer()
    outcomes.push({ order, traceId: response.headers.get('x-trace-id') ?? '', status: response.status, ms: Math.round(performance.now() - started) })
  }
}

const started = performance.now()
await Promise.all(Array.from({ length: Number(concurrency) }, worker))
writeFileSync(out, JSON.stringify(outcomes))
const slow = outcomes.filter((o) => o.ms >= 500).length
const failed = outcomes.filter((o) => o.status >= 500).length
console.log(`${outcomes.length} checkouts in ${((performance.now() - started) / 1000).toFixed(1)} s: ${slow} over 500 ms, ${failed} failed`)
Enter fullscreen mode Exit fullscreen mode

Then a second script asks Jaeger, trace ID by trace ID, whether it has each trace, and whether the trace is whole. A checkout in this system is exactly five spans: checkout's server span, its two outgoing calls, and the pricing and inventory server spans. That second question matters later.

// measure.ts — asks Jaeger which of the recorded traces it actually has, and whether they are whole.
// Usage: node measure.ts <outcomes.json>
import { readFileSync } from 'node:fs'

type Outcome = { order: number; traceId: string; status: number; ms: number }
type JaegerTrace = { spans: unknown[] }

const outcomes: Outcome[] = JSON.parse(readFileSync(process.argv[2] ?? 'outcomes.json', 'utf8'))
const jaeger = process.env.JAEGER_URL ?? 'http://localhost:16686'
// Checkout's server span, its two fetch calls, and the pricing and inventory server spans.
const spansPerCheckout = 5

async function lookup(traceId: string): Promise<JaegerTrace | null> {
  const response = await fetch(`${jaeger}/api/traces/${traceId}`)
  if (response.status === 404) return null
  const body = (await response.json()) as { data?: JaegerTrace[] }
  return body.data?.[0] ?? null
}

const rows = new Map<string, { requests: number; kept: number; whole: number }>()
for (const outcome of outcomes) {
  const kind = outcome.status >= 500 ? 'failed' : outcome.ms >= 500 ? 'slow' : 'normal'
  const row = rows.get(kind) ?? { requests: 0, kept: 0, whole: 0 }
  row.requests++
  const found = await lookup(outcome.traceId)
  if (found) {
    row.kept++
    if (found.spans.length === spansPerCheckout) row.whole++
  }
  rows.set(kind, row)
}

console.log('kind      requests  in Jaeger  whole')
for (const kind of ['slow', 'failed', 'normal']) {
  const row = rows.get(kind) ?? { requests: 0, kept: 0, whole: 0 }
  console.log(`${kind.padEnd(9)} ${String(row.requests).padStart(8)}  ${String(row.kept).padStart(9)}  ${String(row.whole).padStart(5)}`)
}
Enter fullscreen mode Exit fullscreen mode

The stack

One Compose file covers every scenario in this post. Each one is a profile, so you bring up exactly the Collectors it needs:

# One Collector image, five roles. `docker compose --profile <name> up -d` picks the scenario.
x-collector: &collector
  image: otel/opentelemetry-collector-contrib:0.164.0
  command: ["--config=/etc/otelcol/config.yaml"]
  # memory_limiter counts Go heap; this limit counts the whole process, which ran at about
  # 1.35x the heap in these runs. 2 GiB leaves room above limit_mib for that difference.
  mem_limit: 2048m

services:
  jaeger:
    image: jaegertracing/jaeger:2.22.0
    ports:
      - "16686:16686"

  # head: the SDK decides, this Collector only forwards.
  forward:
    <<: *collector
    profiles: [head]
    volumes: ["./collector-forward.yaml:/etc/otelcol/config.yaml:ro"]
    ports: ["4318:4318"]

  # tail: one Collector decides after seeing the whole trace.
  tail:
    <<: *collector
    profiles: [tail]
    volumes: ["${TAIL_CONFIG:-./collector-tail.yaml}:/etc/otelcol/config.yaml:ro"]
    ports: ["4318:4318", "8888:8888"]

  # split: two tail samplers, each fed directly by the services that point at it.
  tail-a:
    <<: *collector
    profiles: [split]
    volumes: ["./collector-tail.yaml:/etc/otelcol/config.yaml:ro"]
    ports: ["4318:4318"]
  tail-b:
    <<: *collector
    profiles: [split]
    volumes: ["./collector-tail.yaml:/etc/otelcol/config.yaml:ro"]
    ports: ["4319:4318"]

  # routed: two agents route by trace ID to two samplers.
  agent-a:
    <<: *collector
    profiles: [routed]
    volumes: ["./collector-agent.yaml:/etc/otelcol/config.yaml:ro"]
    ports: ["4318:4318"]
  agent-b:
    <<: *collector
    profiles: [routed]
    volumes: ["./collector-agent.yaml:/etc/otelcol/config.yaml:ro"]
    ports: ["4319:4318"]
  sampler-1:
    <<: *collector
    profiles: [routed]
    volumes: ["./collector-tail.yaml:/etc/otelcol/config.yaml:ro"]
  sampler-2:
    <<: *collector
    profiles: [routed]
    volumes: ["./collector-tail.yaml:/etc/otelcol/config.yaml:ro"]
Enter fullscreen mode Exit fullscreen mode

Each scenario below runs the same way: bring up its profile, start the three services, send the load, wait for the Collectors to decide, measure.

docker compose --profile tail up -d

OTEL_SERVICE_NAME=pricing   PORT=3001 node --import ./register.ts --import ./telemetry.ts pricing.ts &
OTEL_SERVICE_NAME=inventory PORT=3002 node --import ./register.ts --import ./telemetry.ts inventory.ts &
OTEL_SERVICE_NAME=checkout  PORT=3000 node --import ./register.ts --import ./telemetry.ts checkout.ts &

node load.ts 1000 10 outcomes.json
sleep 25   # the SDK batches for 5 s, the sampler waits 10 s, plus margin
node measure.ts outcomes.json
Enter fullscreen mode Exit fullscreen mode

Head sampling, the baseline

First the September setup: the SDK keeps 10% of traces, chosen when the request arrives, and the Collector just forwards what it gets:

# Forwards everything it receives. Used with head sampling, where the SDK already decided.
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch: {}

exporters:
  otlp_grpc:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp_grpc]
Enter fullscreen mode Exit fullscreen mode

With docker compose --profile head up -d, and every service started with OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1:

1000 checkouts in 2.8 s: 20 over 500 ms, 10 failed
kind      requests  in Jaeger  whole
slow            20          2      2
failed          10          1      1
normal         970         95     95
Enter fullscreen mode Exit fullscreen mode

That is the September number again, with the other half of it: of 20 slow checkouts, 2 have a trace, and of the 10 failed checkouts, 1 does. With a 10% sampler, nine customer complaints out of ten point at a trace that was never kept. Head sampling keeps a random 10% of everything, and the requests you care about are a small, non-random slice of everything.

Tail sampling: decide after the trace is complete

The tail_sampling processor holds each trace's spans in memory, waits, and then runs its policies against the complete trace. A trace is kept if any policy says yes:

# Keeps every slow trace, every failed trace, and 10% of the rest.
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  # First in the pipeline: refuses data before the Collector runs out of memory and loses
  # every trace it is holding.
  memory_limiter:
    check_interval: 1s
    limit_mib: 1024
    spike_limit_mib: 256
  tail_sampling:
    # How long to wait for a trace's spans before deciding. Longer than the slowest request
    # plus the SDKs' export delay (5 s by default), or late spans miss the decision.
    decision_wait: 10s
    # How many traces to track. Past this, the oldest are evicted, decided or not.
    num_traces: 50000
    policies:
      - name: slow
        type: latency
        latency:
          threshold_ms: 500
      - name: failed
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: baseline
        type: probabilistic
        probabilistic:
          sampling_percentage: 10
  batch: {}

exporters:
  otlp_grpc:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  telemetry:
    metrics:
      readers:
        - pull:
            exporter:
              prometheus:
                host: 0.0.0.0
                port: 8888
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp_grpc]
Enter fullscreen mode Exit fullscreen mode

The services now send every span (the SDK's default sampler keeps everything), and docker compose --profile tail up -d:

1000 checkouts in 2.2 s: 20 over 500 ms, 10 failed
kind      requests  in Jaeger  whole
slow            20         20     20
failed          10         10     10
normal         970         99     99
Enter fullscreen mode Exit fullscreen mode

All 20 slow traces and all 10 failed ones, each with all five spans, while the baseline policy still drops roughly 90% of the healthy traffic. Jaeger got about as many traces as with head sampling (129 against 98, a third more), and now they are the right ones.

Two things in that config are worth a second look. The failed policy works without touching the service code because the instrumentation marks spans as errors on its own: an HTTP server span when it answers 5xx, and an outgoing fetch span when the answer is 400 or above. That second rule matters in production: a downstream 404 that your code handles on purpose still makes the whole trace "failed", and status_code will keep it. And exporter: otlp_grpc is not a typo: the Collector renamed the otlp exporter in 0.144, and the old name still works but logs a deprecation warning on every start.

What it costs: every span now leaves every service, so the network and the Collector carry 100% of the traffic that head sampling used to cut at the source. In September I measured about 889 bytes per span on the wire. Tail sampling moves the savings from the network to the storage.

A ring buffer of trace slots, a new trace arriving at one point and an older one pushed out at another, some slots marked as already decided, and a memory gauge near full beside it

What the buffer costs, and what a small one loses

decision_wait and num_traces look like two sizes for the same buffer. They are not, and only one of them is what costs memory.

To see what it holds, I sampled the Collector's own metrics (port 8888 in the config above) every half second with this script:

// monitor.ts — samples the tail-sampling Collector's own metrics every 500 ms until stopped,
// then prints the peaks. Usage: node monitor.ts <seconds>
const seconds = Number(process.argv[2] ?? 60)
const peaks = { tracesInMemory: 0, rssMiB: 0, heapMiB: 0 }
let droppedTooEarly = 0
let refusedSpans = 0

// Sums every series of a metric, whatever its labels.
function read(text: string, name: string): number {
  return text
    .split('\n')
    .filter((l) => l.startsWith(name + ' ') || l.startsWith(name + '{'))
    .reduce((sum, l) => sum + Number(l.slice(l.lastIndexOf(' ') + 1)), 0)
}

const deadline = Date.now() + seconds * 1000
while (Date.now() < deadline) {
  const text = await (await fetch('http://localhost:8888/metrics')).text()
  peaks.tracesInMemory = Math.max(peaks.tracesInMemory, read(text, 'otelcol_processor_tail_sampling_sampling_traces_on_memory'))
  peaks.rssMiB = Math.max(peaks.rssMiB, read(text, 'otelcol_process_memory_rss') / 2 ** 20)
  peaks.heapMiB = Math.max(peaks.heapMiB, read(text, 'otelcol_process_runtime_heap_alloc_bytes') / 2 ** 20)
  droppedTooEarly = read(text, 'otelcol_processor_tail_sampling_sampling_trace_dropped_too_early')
  // Spans the memory_limiter turned away at the receiver.
  refusedSpans = read(text, 'otelcol_receiver_refused_spans')
  await new Promise((resolve) => setTimeout(resolve, 500))
}
console.log(`peak traces held: ${peaks.tracesInMemory}, peak RSS: ${peaks.rssMiB.toFixed(0)} MiB, peak heap: ${peaks.heapMiB.toFixed(0)} MiB, dropped too early: ${droppedTooEarly}, spans refused: ${refusedSpans}`)
Enter fullscreen mode Exit fullscreen mode

Two runs with the same traffic, 60,000 checkouts at a little over 1,000 a second, first with decision_wait: 10s:

60000 checkouts in 52.0 s: 1200 over 500 ms, 600 failed
peak traces held: 50000, peak RSS: 564 MiB, peak heap: 333 MiB, dropped too early: 0, spans refused: 0
Enter fullscreen mode Exit fullscreen mode

Then with decision_wait: 30s (TAIL_CONFIG=./collector-tail-30s.yaml, the same file with one line changed):

60000 checkouts in 59.1 s: 1200 over 500 ms, 600 failed
peak traces held: 50000, peak RSS: 913 MiB, peak heap: 687 MiB, dropped too early: 0, spans refused: 0
Enter fullscreen mode Exit fullscreen mode

(The load line counts slowness as the client saw it. With the services, the Collector and the load generator on one laptop, a few dozen extra requests can cross 500 ms in a run; the design produces exactly 1,200.)

Two numbers move and one does not. The Collector tracked 50,000 traces in both runs: that is num_traces, and the buffer is a circular buffer, so it fills to that size and stays full. But tracking is cheaper than holding. Once a trace is decided, the processor releases its spans and keeps only a record of the decision, so that spans arriving late can inherit it until a newer trace pushes the record out. The expensive part is the traces still waiting, held with every span. The two runs went at about 1,150 and 1,020 checkouts a second, so roughly 11,500 complete traces were waiting at any moment with a 10-second wait, and 30,500 with a 30-second one. The heap went from 333 MiB to 687 MiB.

So the memory model is: traces per second × decision_wait × the size of a trace, plus a record per num_traces slot. Rough arithmetic from these runs, not a measurement of its own: about 19 KB of heap per waiting five-span trace, and a few KB per slot. If the slots matter to you, the processor's decision_cache option keeps decisions in a separate cache sized on its own and releases the slot.

The wait is what you pay for, and that is why the config puts memory_limiter first and gives the container room above it. spans refused: 0 in both runs means the limiter never stepped in, but the 30-second run peaked at 687 MiB of heap against a soft limit of 768: about 12% more traffic and it would have. When it does, it cannot shrink the traces already waiting. It refuses new spans, the SDKs retry and then drop them once their queues fill, and you get incomplete traces instead of an out-of-memory kill that loses everything. Alert on otelcol_receiver_refused_spans above zero; it means you are already losing data.

The other knob has its own trap. The tempting way to save memory is a smaller buffer. Here is num_traces: 1000 (TAIL_CONFIG=./collector-tail-small.yaml) with 20,000 checkouts:

20000 checkouts in 20.8 s: 400 over 500 ms, 200 failed
peak traces held: 1000, peak RSS: 253 MiB, peak heap: 66 MiB, dropped too early: 19000, spans refused: 0
kind      requests  in Jaeger  whole
slow           400         25     25
failed         200          9      9
normal       19400         98     98
Enter fullscreen mode Exit fullscreen mode

19,000 traces were dropped before any policy saw them, which is exactly 20,000 minus the buffer. A buffer of 1,000 holds about a second of this traffic, so every trace was pushed out before its 10 seconds were up, except the last thousand to arrive, which survived only because the load stopped and nothing came after them. That is not quite the last thousand orders. A slow checkout's trace starts arriving 600 ms later than a fast one sent at the same moment, so the 25 slow traces that survived reach back to order 18,750, and they pushed out the first failure among the last thousand orders. Under steady traffic, the same mechanism would leave close to nothing, and the only sign of it is a counter.

The rules that fall out:

  • num_traces has to cover at least peak new traces per second × decision_wait, with margin. Alert on otelcol_processor_tail_sampling_sampling_trace_dropped_too_early above zero, and watch otelcol_processor_tail_sampling_sampling_trace_removal_age: when it gets close to decision_wait, you are about to start dropping.
  • decision_wait has to cover the slowest request plus the time the slowest span takes to reach the Collector. The JS SDK batches spans for up to 5 seconds before sending them, so a 600 ms request can have spans arriving more than 5 seconds after its first one. That is why this config waits 10.
  • If the memory for that is too much, shorten the wait only as far as those two delays allow. Past that, the latency policy starts deciding on incomplete traces.

The second Collector

One Collector runs out of capacity eventually, so the next step is two. The common way to get two is an agent per node: each service sends to the Collector next to it. Here, checkout and inventory send to tail-a, and pricing to tail-b (the same tail-sampling config, started with --profile split, and pricing started with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319):

1000 checkouts in 2.2 s: 20 over 500 ms, 10 failed
kind      requests  in Jaeger  whole
slow            20         20     20
failed          10         10      0
normal         970        103    103
Enter fullscreen mode Exit fullscreen mode

Every failed trace reached Jaeger, and none of the 10 was whole. All ten arrived without their pricing span.

Each Collector applies its policies to the part of the trace it received. tail-a saw checkout and inventory: the 503 and the 502, so failed kept them. tail-b saw only pricing, where nothing failed, so the error policy said no and only the 10% baseline could save that span. In this run it saved none; in a run before it, with the same config, it saved three.

The slow traces survived whole, but not because this setup works. The slow span is in pricing, and checkout waited for it, so both halves were over 500 ms and both Collectors kept their half. The healthy traces are whole too, for a subtler reason: the probabilistic policy decides by hashing the trace ID, so both Collectors make the same choice for the same trace. Any policy that looks at the spans themselves (latency, status, attributes) can disagree between the two halves of a trace, and the trace you get in Jaeger is the half that matched.

The fix: route by trace ID

Every span of a trace has to reach the same sampler. The Collector does that with a second tier. The first tier samples nothing and routes, with the load_balancing exporter keyed on trace ID:

# First tier: receives spans and routes every span of a trace to the same sampler.
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  load_balancing:
    routing_key: traceID
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      static:
        hostnames:
          - sampler-1:4317
          - sampler-2:4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [load_balancing]
Enter fullscreen mode Exit fullscreen mode

The services keep exactly the same split as before, pricing on one agent and the rest on the other (--profile routed):

1000 checkouts in 3.6 s: 20 over 500 ms, 10 failed
kind      requests  in Jaeger  whole
slow            20         20     20
failed          10         10     10
normal         970         99     99
Enter fullscreen mode Exit fullscreen mode

Ten failed traces, ten whole. The agents are stateless, so you can run as many as you like. The samplers are where the state lives, and the routing is what makes each one see whole traces.

This is a scaling fix, not a high-availability one. Each sampler owns a slice of the trace IDs and holds that slice in memory, so a sampler that restarts loses its slice. And with the static resolver and this config, the agents have nowhere else to send that slice while the sampler is down: the exporter's sending queue and retries are off unless you turn them on.

In production the static resolver becomes the dns or k8s resolver, so samplers can be added without editing the agents. Routing uses consistent hashing, so adding or removing a sampler moves only a share of the trace IDs, not all of them. But the exporter's README warns of a window in which the agents' view of the samplers does not match reality, and a trace in flight while its IDs move can still be split. Scale samplers deliberately, not on a CPU autoscaler that changes the list every few minutes.

Three services send spans to two agents, which route every trace by color to the same one of two samplers with hourglasses, and both samplers export to one storage cylinder

When head sampling is still the right call

Tail sampling is not free, and it is not always the answer:

  • At very high volume, the cost moves rather than disappears. Every span leaves every service, and every span waits in a sampler's memory for the whole decision_wait. If you run hundreds of thousands of requests per second, a head sampler in front (say, keep 25% at the SDK) with tail sampling behind it can be the only affordable shape. You lose some slow traces, knowingly.
  • If nobody looks at individual traces, and your questions are answered by metrics and logs, head sampling at a low rate is cheaper and simpler.
  • If you cannot run a stateful tier, a single tail-sampling Collector with no redundancy is a real risk: when it restarts, every trace it was holding is gone.

Several paid observability platforms offer tail-based sampling as a feature, run on their side. If they bill by ingested spans, you still pay for every span you send, which is the same trade-off on someone else's invoice.

A balance scale with network packets and a memory chip on one pan and a magnifying glass over one flagged trace on the other, almost level

Key Takeaways


  • Head sampling loses the requests you care about. A 10% head sampler kept 2 of 20 slow traces and 1 of 10 failed ones. Tail sampling, deciding after the trace is complete, kept all 30 while still dropping about 90% of healthy traffic.
  • Memory is traffic × decision_wait. The Collector holds every span of every undecided trace and only a stub of the decided ones: at the same traffic, a 30-second wait took the heap from 333 MiB to 687 MiB. Put memory_limiter first, give the container room above it, and alert on refused spans.
  • A buffer that is too small drops everything. With num_traces: 1000, 19,000 of 20,000 traces were evicted before any decision; only the last thousand to arrive survived, because the load stopped. Cover peak traces per second × decision_wait, and alert on sampling_trace_dropped_too_early.
  • Two tail samplers without routing break your traces. Each one judges the part it sees: all 10 failed traces lost their pricing span. Only policies that decide by trace ID agree, and only because they ignore the spans.
  • Route by trace ID before you sample. A stateless tier with the load_balancing exporter in front of the samplers made all ten failed traces whole again.

Top comments (0)