Tail sampling con OpenTelemetry, medido: conserva lo que importa hasta que lo escalas o lo achicas
Logs, métricas y trazas no son tres pilares. Son una sola petición. terminaba con un número incómodo. Un muestreo en cabecera (head sampling) del 10% decide si guarda una traza cuando la petición llega, antes de que nadie sepa si va a ser lenta. De 20 checkouts lentos guardó unos 2, y el histograma que te avisaba de los lentos señalaba trazas que ya no existían. Prometí el arreglo en ese artículo, y otra vez en el de logging estructurado: decidir al final de la traza. Eso es el muestreo en cola (tail sampling), y de eso trata este artículo.
El tail sampling cumple lo que promete. En mis pruebas conservó todas las trazas lentas y todas las fallidas, y aun así descartó el 90% del resto. También tiene dos formas de fallar que una demo con un solo Collector nunca enseña, y las dos pierden justo las trazas por las que lo pusiste: un buffer demasiado pequeño, y un segundo Collector.
Todo corre en local: tres servicios de Node, el OpenTelemetry Collector (contrib 0.164.0) y Jaeger 2.22, en Docker.
El sistema, y cómo saber qué se conservó
El checkout de Aurora Coffee Co. llama a pricing y después a inventory. Dos cosas salen mal a propósito, con frecuencias conocidas, para poder comparar cada resultado con la realidad:
- Uno de cada 50 pedidos es lento. Los SKU de temporada hacen que pricing espere 600 ms a sus reglas de promoción.
- Uno de cada 100 pedidos falla. Un fragmento del almacén de inventory responde 503, y checkout responde 502.
Los servicios son node:http sin nada más. Node 24 y posteriores ejecutan estos archivos TypeScript directamente, sin paso de compilación. Los textos y los registros están en inglés: son la salida real de las pruebas, y traducirlos los convertiría en otra cosa.
{
"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"
}
}
// pricing.ts — calcula el precio. Los SKU de temporada esperan una consulta lenta de reglas de promoción.
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') ?? ''
// Representa el costo real: 2.400 reglas de promoción frente a 12 de una mezcla de la casa.
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)
// inventory.ts — reserva inventario. Un fragmento del almacén no responde en 1 de cada 100 pedidos.
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)
// checkout.ts — la puerta de entrada: calcula el precio y después reserva el inventario.
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'))
// Uno de cada 50 pedidos es un SKU de temporada, el camino lento de pricing.
const sku = order % 50 === 0 ? `seasonal-${order}` : `house-${order % 7}`
// Se devuelve para que el generador de carga pueda buscar la traza en Jaeger después.
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)
La instrumentación son dos archivos pequeños que se cargan antes del servicio con dos --import. El primero instala el hook de carga que permite a la autoinstrumentación modificar los módulos ES:
// register.ts — se carga primero: `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+ y 26 ejecutan el hook en el mismo hilo con module.registerHooks(). module.register(),
// el cargador anterior en otro hilo, está obsoleto en Node 26 y queda solo como respaldo.
if (supportsSyncHooks()) registerSyncHooks()
else register('@opentelemetry/instrumentation/hook.mjs', import.meta.url)
El segundo arranca el SDK:
// telemetry.ts — se carga en segundo lugar, después de register.ts y antes del código del servicio.
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',
// Sin nombres de host, nombres de usuario ni PID en ningún 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))
})
}
El hook es lo que cambió desde septiembre, y ahora va en su propio archivo por una razón. Sin hook, la autoinstrumentación nunca ve node:http en ESM y cada servicio empieza su propia traza. El artículo de septiembre usaba module.register(), que Node 26 marca como obsoleto con un aviso en cada arranque. import-in-the-middle, la librería detrás del hook de OpenTelemetry, trae una versión que corre en el mismo hilo, sobre module.registerHooks(), y supportsSyncHooks() dice si la versión de Node que estás usando la admite. Tiene que ir en su propio --import: las sentencias import estáticas se evalúan antes que el código del archivo que las contiene, así que un hook registrado al principio de telemetry.ts se ejecutaría después de que se cargaran los imports de ese mismo archivo.
La referencia: qué pasó de verdad
Para evaluar un muestreador hace falta saber qué peticiones fueron lentas o fallaron sin mirar las trazas. El generador de carga lo registra desde el lado del cliente, junto con el trace ID de cada petición, que checkout devuelve en una cabecera:
// load.ts — envía N checkouts y registra lo que de verdad le pasó a cada uno.
// Uso: node load.ts <peticiones> <concurrencia> <salida.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`)
Después, un segundo script le pregunta a Jaeger, trace ID por trace ID, si tiene cada traza y si está completa. Un checkout en este sistema son exactamente cinco spans: el de servidor de checkout, sus dos llamadas salientes y los de servidor de pricing e inventory. Esa segunda pregunta importa más adelante.
// measure.ts — le pregunta a Jaeger cuáles de las trazas registradas tiene de verdad, y si están completas.
// Uso: 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'
// El span de servidor de checkout, sus dos llamadas fetch y los spans de servidor de pricing e inventory.
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)}`)
}
El stack
Un solo archivo de Compose cubre todos los escenarios del artículo. Cada uno es un perfil, así que levantas exactamente los Collectors que necesita:
# Una imagen del Collector, cinco papeles. `docker compose --profile <nombre> up -d` elige el escenario.
x-collector: &collector
image: otel/opentelemetry-collector-contrib:0.164.0
command: ["--config=/etc/otelcol/config.yaml"]
# memory_limiter cuenta el heap de Go; este límite cuenta todo el proceso, que en estas pruebas
# ocupó unas 1,35 veces el heap. 2 GiB dejan margen sobre limit_mib para esa diferencia.
mem_limit: 2048m
services:
jaeger:
image: jaegertracing/jaeger:2.22.0
ports:
- "16686:16686"
# head: decide el SDK; este Collector solo reenvía.
forward:
<<: *collector
profiles: [head]
volumes: ["./collector-forward.yaml:/etc/otelcol/config.yaml:ro"]
ports: ["4318:4318"]
# tail: un Collector decide después de ver la traza completa.
tail:
<<: *collector
profiles: [tail]
volumes: ["${TAIL_CONFIG:-./collector-tail.yaml}:/etc/otelcol/config.yaml:ro"]
ports: ["4318:4318", "8888:8888"]
# split: dos samplers de cola, cada uno alimentado directamente por los servicios que apuntan a él.
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: dos agentes enrutan por trace ID hacia dos 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"]
Cada escenario se ejecuta igual: levantas su perfil, arrancas los tres servicios, envías la carga, esperas a que los Collectors decidan y mides.
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 # el SDK agrupa durante 5 s, el sampler espera 10 s, más margen
node measure.ts outcomes.json
Head sampling, el punto de partida
Primero, el montaje de septiembre: el SDK conserva el 10% de las trazas, elegidas cuando llega la petición, y el Collector solo reenvía lo que recibe:
# Reenvía todo lo que recibe. Se usa con head sampling, donde el SDK ya decidió.
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]
Con docker compose --profile head up -d, y cada servicio arrancado con 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
Es el número de septiembre otra vez, con la otra mitad: de 20 checkouts lentos, 2 tienen traza, y de los 10 fallidos, 1. Con un muestreo del 10%, nueve de cada diez quejas de clientes apuntan a una traza que nunca se guardó. El head sampling conserva un 10% al azar de todo, y las peticiones que te importan son una parte pequeña, y nada azarosa, de ese todo.
Tail sampling: decidir cuando la traza está completa
El procesador tail_sampling guarda en memoria los spans de cada traza, espera y después aplica sus políticas a la traza completa. Una traza se conserva si alguna política dice que sí:
# Conserva todas las trazas lentas, todas las fallidas y el 10% del resto.
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
# Primero en el pipeline: rechaza datos antes de que el Collector se quede sin memoria y pierda
# todas las trazas que tiene retenidas.
memory_limiter:
check_interval: 1s
limit_mib: 1024
spike_limit_mib: 256
tail_sampling:
# Cuánto esperar los spans de una traza antes de decidir. Más que la petición más lenta
# más el retraso de exportación de los SDK (5 s por defecto), o los spans tardíos llegan tarde a la decisión.
decision_wait: 10s
# Cuántas trazas seguir. Pasado ese número se expulsan las más antiguas, decididas o no.
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]
Ahora los servicios envían todos los spans (el muestreador por defecto del SDK lo conserva todo), con 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
Las 20 trazas lentas y las 10 fallidas, cada una con sus cinco spans, mientras la política de base sigue descartando cerca del 90% del tráfico sano. Jaeger recibió un número de trazas parecido al del head sampling (129 frente a 98, un tercio más), y ahora son las que importan.
Dos cosas de esa configuración merecen una segunda mirada. La política failed funciona sin tocar el código de los servicios porque la instrumentación marca los spans como error por su cuenta: un span de servidor HTTP cuando responde 5xx, y un span de fetch saliente cuando la respuesta es 400 o más. Esa segunda regla importa en producción: un 404 de un servicio que tu código maneja a propósito convierte igual toda la traza en "fallida", y status_code la va a conservar. Y exporter: otlp_grpc no es una errata: el Collector renombró el exportador otlp en la 0.144, y el nombre antiguo sigue funcionando, pero deja un aviso de obsolescencia en cada arranque.
Lo que cuesta: ahora todos los spans salen de todos los servicios, así que la red y el Collector cargan con el 100% del tráfico que el head sampling recortaba en el origen. En septiembre medí unos 889 bytes por span en la red. El tail sampling cambia el ahorro de la red por el ahorro en el almacenamiento.
Lo que cuesta el buffer, y lo que pierde uno pequeño
decision_wait y num_traces parecen dos tamaños del mismo buffer. No lo son, y solo uno de los dos es el que gasta memoria.
Para ver qué retiene, leí las métricas propias del Collector (el puerto 8888 de la configuración de arriba) cada medio segundo con este script:
// monitor.ts — lee las métricas propias del Collector de tail sampling cada 500 ms hasta terminar,
// y después imprime los picos. Uso: node monitor.ts <segundos>
const seconds = Number(process.argv[2] ?? 60)
const peaks = { tracesInMemory: 0, rssMiB: 0, heapMiB: 0 }
let droppedTooEarly = 0
let refusedSpans = 0
// Suma todas las series de una métrica, tengan las etiquetas que tengan.
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 que memory_limiter rechazó en el receptor.
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}`)
Dos pruebas con el mismo tráfico, 60.000 checkouts a poco más de 1.000 por segundo. Primero con 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
Después con decision_wait: 30s (TAIL_CONFIG=./collector-tail-30s.yaml, el mismo archivo con una línea cambiada):
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
(La línea del generador de carga cuenta la lentitud tal como la vio el cliente. Con los servicios, el Collector y el generador en el mismo portátil, unas decenas de peticiones de más pueden pasar de 500 ms en una prueba; el diseño produce exactamente 1.200.)
Dos números se mueven y uno no. El Collector siguió 50.000 trazas en las dos pruebas: es num_traces, y el buffer es circular, así que se llena hasta ese tamaño y se queda lleno. Pero seguir una traza cuesta menos que guardarla. Cuando una traza ya está decidida, el procesador libera sus spans y solo guarda un registro de la decisión, para que los spans que lleguen tarde la hereden hasta que una traza más nueva saque ese registro. Lo caro son las trazas que todavía esperan, guardadas con todos sus spans. Las dos pruebas fueron a unos 1.150 y 1.020 checkouts por segundo, así que en cada momento esperaban unas 11.500 trazas completas con 10 segundos de espera, y 30.500 con 30. El heap pasó de 333 MiB a 687 MiB.
Así que el modelo de memoria es: trazas por segundo × decision_wait × el tamaño de una traza, más un registro por cada hueco de num_traces. Haciendo cuentas a partir de estas pruebas, sin que sea una medición aparte: unos 19 KB de heap por cada traza de cinco spans en espera, y unos pocos KB por hueco. Si los huecos te importan, la opción decision_cache del procesador guarda las decisiones en una caché aparte, con su propio tamaño, y libera el hueco.
Lo que pagas es la espera, y por eso la configuración pone memory_limiter el primero y deja al contenedor margen por encima. spans refused: 0 en las dos pruebas significa que el limitador nunca intervino, pero la de 30 segundos llegó a 687 MiB de heap frente a un límite blando de 768: con un 12% más de tráfico habría intervenido. Cuando interviene, no puede reducir las trazas que ya esperan. Rechaza spans nuevos, los SDK reintentan y los descartan cuando se llenan sus colas, y lo que obtienes son trazas incompletas en lugar de un cierre por falta de memoria que lo pierde todo. Configura una alerta para cuando otelcol_receiver_refused_spans pase de cero: significa que ya estás perdiendo datos.
El otro parámetro tiene su propia trampa. La forma tentadora de ahorrar memoria es un buffer más pequeño. Esto es num_traces: 1000 (TAIL_CONFIG=./collector-tail-small.yaml) con 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
Se descartaron 19.000 trazas antes de que ninguna política las viera, que es exactamente 20.000 menos el tamaño del buffer. Un buffer de 1.000 guarda alrededor de un segundo de este tráfico, así que todas las trazas salieron antes de cumplir sus 10 segundos, salvo las últimas mil en llegar, que se salvaron solo porque la carga terminó y no llegó nada detrás. Eso no equivale exactamente a los últimos mil pedidos. La traza de un checkout lento empieza a llegar 600 ms más tarde que la de uno rápido enviado en el mismo momento, así que las 25 trazas lentas que sobrevivieron se remontan hasta el pedido 18.750, y desplazaron al primer fallo de los últimos mil pedidos. Con tráfico constante, el mismo mecanismo no dejaría casi nada, y la única señal es un contador.
Las reglas que salen de aquí:
-
num_tracestiene que cubrir como mínimo el pico de trazas nuevas por segundo ×decision_wait, con margen. Configura una alerta para cuandootelcol_processor_tail_sampling_sampling_trace_dropped_too_earlypase de cero, y vigilaotelcol_processor_tail_sampling_sampling_trace_removal_age: cuando se acerca adecision_wait, estás a punto de empezar a perder trazas. -
decision_waittiene que cubrir la petición más lenta más lo que tarda el último span en llegar al Collector. El SDK de JS agrupa los spans hasta 5 segundos antes de enviarlos, así que una petición de 600 ms puede tener spans que llegan más de 5 segundos después del primero. Por eso esta configuración espera 10. - Si la memoria que eso exige es demasiada, acorta la espera solo hasta donde lo permitan esos dos retrasos. Por debajo de eso, la política de latencia empieza a decidir sobre trazas incompletas.
El segundo Collector
Un solo Collector acaba quedándose corto, así que el siguiente paso son dos. La forma habitual de tener dos es un agente por nodo: cada servicio envía al Collector que tiene al lado. Aquí, checkout e inventory envían a tail-a, y pricing a tail-b (la misma configuración de tail sampling, arrancada con --profile split, y pricing arrancado con 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
Todas las trazas fallidas llegaron a Jaeger, y ninguna de las 10 estaba completa. Las diez llegaron sin su span de pricing.
Cada Collector aplica sus políticas a la parte de la traza que recibió. tail-a vio checkout e inventory: el 503 y el 502, así que failed las conservó. tail-b solo vio pricing, donde nada falló, así que la política de errores dijo que no, y solo el 10% de base podía salvar ese span. En esta prueba no salvó ninguno; en una anterior, con la misma configuración, salvó tres.
Las trazas lentas sobrevivieron completas, pero no porque este montaje funcione. El span lento está en pricing, y checkout lo esperó, así que las dos mitades pasaban de 500 ms y los dos Collectors conservaron la suya. Las trazas sanas también llegaron completas, por una razón más sutil: la política probabilística decide con un hash del trace ID, así que los dos Collectors toman la misma decisión para la misma traza. Cualquier política que mire los spans en sí (latencia, estado, atributos) puede no coincidir entre las dos mitades de una traza, y lo que ves en Jaeger es la mitad que cumplió la condición.
El arreglo: enrutar por trace ID
Todos los spans de una traza tienen que llegar al mismo sampler. El Collector lo resuelve con un segundo nivel. El primer nivel no muestrea nada: solo enruta, con el exportador load_balancing usando el trace ID como clave:
# Primer nivel: recibe spans y envía todos los spans de una traza al mismo 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]
Los servicios mantienen exactamente el mismo reparto que antes, pricing en un agente y el resto en el otro (--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
Diez trazas fallidas, diez completas. Los agentes no guardan estado, así que puedes poner tantos como quieras. El estado vive en los samplers, y el enrutamiento es lo que hace que cada uno vea trazas completas.
Esto arregla la escala, no la alta disponibilidad. Cada sampler se queda con una parte de los trace IDs y la guarda en memoria, así que un sampler que se reinicia pierde su parte. Y con el resolver static y esta configuración, los agentes no tienen a dónde mandar esa parte mientras el sampler está caído: la cola de envío y los reintentos del exportador están desactivados si no los activas.
En producción, el resolver static pasa a ser dns o k8s, para poder añadir samplers sin tocar los agentes. El enrutamiento usa hashing consistente, así que añadir o quitar un sampler mueve solo una parte de los trace IDs, no todos. Pero el README del exportador advierte de un intervalo en el que lo que los agentes creen sobre los samplers no coincide con la realidad, y una traza en curso mientras sus IDs se mueven todavía puede partirse. Escala los samplers a propósito, no con un autoescalado por CPU que cambia la lista cada pocos minutos.
Cuándo el head sampling sigue siendo la opción correcta
El tail sampling no es gratis, y no siempre es la respuesta:
-
Con mucho volumen, el costo cambia de sitio en lugar de desaparecer. Todos los spans salen de todos los servicios, y cada span espera en la memoria de un sampler durante todo el
decision_wait. Con cientos de miles de peticiones por segundo, un head sampler delante (por ejemplo, conservar el 25% en el SDK) con tail sampling detrás puede ser la única forma asumible. Pierdes algunas trazas lentas, y lo sabes. - Si nadie mira trazas individuales, y tus preguntas se responden con métricas y logs, un head sampling bajo es más barato y más simple.
- Si no puedes mantener un nivel con estado, un único Collector de tail sampling sin redundancia es un riesgo real: cuando se reinicia, todas las trazas que tenía retenidas desaparecen.
Varias plataformas de observabilidad de pago ofrecen tail sampling como funcionalidad, ejecutado de su lado. Si cobran por span ingerido, sigues pagando cada span que envías, que es el mismo compromiso en la factura de otro.
Puntos clave
- El head sampling pierde justo las peticiones que te importan. Un head sampler del 10% conservó 2 de 20 trazas lentas y 1 de 10 fallidas. El tail sampling, que decide cuando la traza está completa, conservó las 30 y aun así descartó cerca del 90% del tráfico sano.
-
La memoria es tráfico ×
decision_wait. El Collector guarda todos los spans de cada traza sin decidir y solo un registro de las decididas: con un tráfico similar, 30 segundos de espera llevaron el heap de 333 MiB a 687 MiB. Ponmemory_limiterel primero, deja margen en el contenedor y vigila los spans rechazados. -
Un buffer demasiado pequeño lo descarta todo. Con
num_traces: 1000, 19.000 de 20.000 trazas salieron antes de cualquier decisión; solo sobrevivieron las últimas mil en llegar, porque la carga terminó. Cubre el pico de trazas por segundo ×decision_wait, y configura una alerta sobresampling_trace_dropped_too_early. - Dos tail samplers sin enrutamiento rompen tus trazas. Cada uno juzga la parte que ve: las 10 trazas fallidas perdieron su span de pricing. Solo coinciden las políticas que deciden por trace ID, y solo porque ignoran los spans.
-
Enruta por trace ID antes de muestrear. Un nivel sin estado con el exportador
load_balancingdelante de los samplers volvió a dejar completas las diez trazas fallidas.




Top comments (0)