DEV Community

Cover image for Tail sampling con OpenTelemetry, medido: conserva lo que importa hasta que lo escalas o lo achicas
Juan Gómez
Juan Gómez

Posted on

Tail sampling con OpenTelemetry, medido: conserva lo que importa hasta que lo escalas o lo achicas

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.

Dos líneas de tiempo de una petición: en la de arriba una moneda decide al principio y unas tijeras cortan la barra antes de su tramo rojo lento; en la de abajo la barra entera, tramo rojo incluido, se revisa con una lupa y se aprueba al final

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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)
Enter fullscreen mode Exit fullscreen mode
// 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)
Enter fullscreen mode Exit fullscreen mode
// 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
  })
}
Enter fullscreen mode Exit fullscreen mode

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`)
Enter fullscreen mode Exit fullscreen mode

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)}`)
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Un buffer circular de huecos para trazas, con una traza nueva que entra por un punto y otra antigua que sale expulsada por el otro, algunos huecos marcados como ya decididos y un indicador de memoria casi lleno al lado

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}`)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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_traces tiene que cubrir como mínimo el pico de trazas nuevas por segundo × decision_wait, con margen. Configura una alerta para cuando otelcol_processor_tail_sampling_sampling_trace_dropped_too_early pase de cero, y vigila otelcol_processor_tail_sampling_sampling_trace_removal_age: cuando se acerca a decision_wait, estás a punto de empezar a perder trazas.
  • decision_wait tiene 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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Tres servicios envían spans a dos agentes, que mandan cada traza, identificada por su color, siempre al mismo de dos samplers con relojes de arena, y los dos samplers exportan a un mismo almacenamiento

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.

Una balanza con paquetes de red y un chip de memoria en un platillo y una lupa sobre una sola traza marcada con una advertencia en el otro, casi equilibrada

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. Pon memory_limiter el 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 sobre sampling_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_balancing delante de los samplers volvió a dejar completas las diez trazas fallidas.

Top comments (0)