Tracing LLM con OpenTelemetry GenAI: la caja negra del avión que el campo estabilizó en 2026

Este post complementa el de Prompt versioning con Langfuse y MLflow, donde el prompt_id@version aparecía como atributo de span sin explicar el pipeline entero, y el de Evals para LLMs, que distingue tracing de eval. Aquí entramos al dentro del tracing.

TL;DR

OpenTelemetry GenAI, las semantic conventions gen_ai.* y mcp.*, son estables desde finales de 2025 y a mayo de 2026 son el sustrato sobre el que todo el ecosistema —Langfuse, Phoenix, LangSmith, Braintrust, Arize, Honeycomb, Datadog, New Relic— construye su observabilidad LLM. Eso significa que tu equipo instrumenta una sola vez, contra OTel, y elige backend después; no al revés. El pipeline canónico es aplicación → OTel SDK (OpenLLMetry o openinference) → OTel Collector → backend(s), con un sampling de dos capas (head-based del 1-5 % para mantener volumen viable, tail-based al 100 % sobre errores y latencias altas). Este post desmonta los atributos exactos que hay que rellenar en cada span (gen_ai.system, request.model, usage.input_tokens, etc.), la diferencia operativa entre los tres SDKs habituales, y la anatomía de una traza real de chat-with-RAG-and-tool con todos los spans hijos.

Estás aquí: OBSERVE

Estás aquí: OBSERVE · tracing con OpenTelemetry GenAI1 · Data2 · Tune3 · Eval4 · Deploy5 · Observe6 · Retrain

La analogía: la caja negra del avión

Una caja negra de avión hace tres cosas a la vez:

  1. Graba todo sin necesidad de que alguien lo pida explícitamente. Cada parámetro de vuelo, cada conversación de cabina, cada acción de los pilotos queda registrada con marca temporal precisa y formato estandarizado.
  2. Usa un formato que conoce todo el mundo. Cualquier investigador de accidentes aéreos del mundo puede leer la caja negra de cualquier fabricante, porque el schema está acordado entre todos. No hay “caja negra Boeing” y “caja negra Airbus”: hay una sola caja negra.
  3. Sobrevive al evento: se diseña asumiendo que la nave puede caer. No depende del propio avión para almacenarse ni para ser leída.

El tracing LLM con OTel GenAI hace exactamente esas tres cosas:

  1. Graba todo automáticamente. Cada llamada al modelo, cada paso de un agente, cada tool call, cada retrieval de RAG, queda como span —una unidad de trabajo con inicio, fin, atributos y relación parent/child con otros spans—.
  2. Usa un formato común. Los atributos gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.response.finish_reasons, etc., son acordados por el grupo de trabajo OpenTelemetry GenAI y los respetan todos los backends.
  3. Sobrevive al backend. Los spans no viajan directamente a Langfuse: viajan a un OTel Collector intermedio que se encarga de buffering, sampling y fan-out a 1, 2 o N backends. Si Langfuse cae, el Collector retiene los spans.

La analogía no es decorativa: es la única forma de entender por qué OTel GenAI ganó al ecosistema de SDKs propietarios que cada vendor traía en 2023-2024. La caja negra estandarizada gana sobre la grabadora propietaria, siempre.

Por qué OTel GenAI existe (y por qué ganó)

El estado de las cosas a finales de 2024 era roto: cada vendor traía su propio SDK con sus propios nombres de atributos. LangSmith llamaba model_name, OpenLLMetry llamaba llm.model, Helicone llamaba model. Si querías cambiar de proveedor de observabilidad, re-instrumentabas la aplicación entera. Para equipos serios, eso era inaceptable.

OpenTelemetry tenía ya un marco para resolver esto: las semantic conventions. Un grupo de trabajo (OpenTelemetry GenAI SIG) propuso un schema. En 2025 alcanzó “Stable” para los atributos de la modalidad básica (chat completions + tool calls), y en 2026 cubre también modalidades multimodales, embeddings y reasoning.

Pipeline OTel GenAI: instrumentas una vez, eliges backend despuésAplicaciónPython / Node / Go+ OpenLLMetryo openinference SDKOTel Collectorbuffering + sampling +batching + retry +routing por exporterLangfuseUI LLM-first, eval, promptsTempo / Jaegerdistributed tracing infraPrometheusmétricas agregadasClickHouse / OpenSearchstorage de logs y eventosPhoenixopcional, eval-firstDatadog/NRinfra-sideSDK añade spans automáticamentecon gen_ai.* attributescambias backend tocando soloel exporter del CollectorSi Langfuse cae, el Collector retiene los spans y los re-emite cuando vuelve.

Lo que cambia en la práctica:

  • Antes (SDKs propietarios): cambias de Langfuse a Phoenix → re-instrumentas, re-deployas todo, pierdes el histórico.
  • Ahora (OTel): cambias de Langfuse a Phoenix → editas el exporter en el otel-collector-config.yaml, recargas el Collector, los spans nuevos van al destino nuevo sin tocar una línea de código de la aplicación.

Los atributos canónicos gen_ai.*

A mayo de 2026 los atributos estables —es decir, que no van a cambiar— son los que aparecen en la tabla. Hay más en estado “experimental” (multimodal, reasoning) y otros en “deprecated” (que se mantienen por compatibilidad).

AtributoSignificadoEjemplo
gen_ai.systemFamilia del proveedoropenai, anthropic, vllm, huggingface
gen_ai.request.modelModelo solicitadogpt-4o, meta-llama/Llama-3.1-8B-Instruct
gen_ai.request.temperatureSampling temp0.7
gen_ai.request.max_tokensCap de output1024
gen_ai.request.top_pNucleus sampling0.95
gen_ai.response.modelModelo que respondió (puede diferir)gpt-4o-2024-08-06
gen_ai.response.idID de la respuesta del proveedorchatcmpl-9xY...
gen_ai.response.finish_reasonsRazones de fin["stop"], ["length"], ["tool_calls"]
gen_ai.usage.input_tokensTokens entrada1247
gen_ai.usage.output_tokensTokens salida412
gen_ai.operation.nameTipo de operaciónchat, text_completion, embeddings
gen_ai.prompt.idID del prompt versionadocustomer_support_v3
gen_ai.prompt.versionVersión específica14
gen_ai.prompt.labelEtiqueta semánticaproduction, staging, canary
gen_ai.conversation.idID de conversación multiturnsession_abc123

Y para tool calls (función desde el modelo), los atributos gen_ai.tool.*:

AtributoSignificado
gen_ai.tool.call.idID del tool call concreto
gen_ai.tool.nameNombre de la herramienta
gen_ai.tool.typefunction, mcp, etc.
gen_ai.tool.descriptionDescripción breve

Y para tool calls vía MCP (Model Context Protocol), los atributos mcp.* añaden capa específica (mcp.server.name, mcp.method, mcp.session.id, etc.) que conviven con los gen_ai.*.

La regla práctica: si tu instrumentación rellena estos atributos en cada span, el dashboard de Langfuse, el grafo de Tempo y los métricas de Prometheus salen “gratis” porque los tres saben leer la convención.

El pipeline canónico: SDK → Collector → backends

Tres capas, cada una con su responsabilidad:

1 · El SDK

Tres opciones dominantes en 2026:

OpenLLMetry (Traceloop) — el más extendido. Instrumenta automáticamente OpenAI Python SDK, Anthropic SDK, LangChain, LlamaIndex, Haystack, Cohere, Mistral, vLLM y casi cualquier librería del ecosistema. Una sola línea (Traceloop.init()) instrumenta todo. Licencia Apache 2.0. Mantiene su propio fork con extensiones que aún no están en OTel core, pero exporta cumpliendo la convención.

openinference (Arize) — competidor directo, también Apache 2.0. Más cercano a Phoenix (mismo vendor) pero exporta OTel estándar. Mejor instrumentación para LangChain/LlamaIndex/DSPy en algunas versiones; peor para vLLM directo.

Langfuse SDK — propietario en la forma (la API es de Langfuse), pero por dentro emite spans OTel. Lo natural si Langfuse es el backend principal. Tiene el mejor soporte para “session” y “user” linking, conceptos que no están aún en OTel core pero que Langfuse mapea a gen_ai.conversation.id para compatibilidad.

Recomendación práctica para mayo 2026: OpenLLMetry si quieres backend-agnostic (cambiarás de proveedor); Langfuse SDK si Langfuse ya es tu apuesta (te ahorras un mapeo). Los dos producen spans válidos que cualquier Collector consume.

2 · El OTel Collector

El Collector es la pieza más importante del pipeline y la menos hablada. Tres responsabilidades:

Buffering: si Langfuse o Tempo tienen un problema, el Collector retiene los spans en memoria (o en disco con persistencia activada) y los re-emite cuando el backend vuelve.

Sampling: aplica las dos capas de sampling (siguiente sección) sin que la aplicación se entere.

Routing: con la config exporters apunta a uno o varios backends a la vez. La práctica habitual es enviar a Langfuse + Tempo + Prometheus simultáneamente:

  • Langfuse para UI LLM-first (prompts, evals, sesiones de usuario).
  • Tempo (o Jaeger) para el contexto distribuido completo (un span LLM dentro de un request HTTP que ha tocado 12 microservicios).
  • Prometheus para métricas agregadas (latencia P95 por modelo, tokens/segundo, error rate).

Un fragmento de otel-collector-config.yaml representativo en producción:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow_traces
        type: latency
        latency: { threshold_ms: 5000 }
      - name: head_5pct
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

exporters:
  otlphttp/langfuse:
    endpoint: https://langfuse.internal:3000/api/public/otel/v1
    headers: { Authorization: "Basic ${LANGFUSE_AUTH}" }
  otlp/tempo:
    endpoint: tempo:4317
    tls: { insecure: true }
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling, batch]
      exporters: [otlphttp/langfuse, otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus]

3 · Los backends

Langfuse es el dominante para LLM observability OSS (licencia MIT, suite completa: tracing + evals + prompts + datasets). Su modelo asume trace_id y span_id de OTel y los pinta en UI con el contexto LLM expandido (mensajes formateados, tokens contados, costes calculados con un mapeo de modelo→precio).

Phoenix (Arize, licencia ELv2) tiene un foco más eval-first. Útil si tu equipo viene de NLP clásico y quiere visualización de embeddings UMAP por defecto.

LangSmith (LangChain) es propietario, no OSS. Excelente integración con LangChain/LangGraph; menos relevante si no usas ese stack.

Tempo / Jaeger muestran las trazas LLM dentro del contexto del request distribuido. Es lo que necesitas cuando un cliente reporta que “la página tarda 8 segundos” y quieres ver si los 8 segundos son el modelo, el retrieval o el microservicio aguas abajo.

Sampling: las dos capas que conviven

Sin sampling, un sistema con 10 req/s genera ~26 millones de spans/mes. El backend se ahoga, el coste explota y la mayoría de los spans son redundantes (200 requests parecidas no informan más que 20).

El patrón canónico en 2026 son dos capas combinadas:

Head-based sampling (al entrar)

Se decide antes de que el span exista si vas a trazarlo o no, basado en una decisión probabilística (1-5 % típico) o en una regla determinista (tracear siempre si el usuario es premium, si el modelo es el nuevo en canary, etc.).

Pros: barato (no se genera el span en absoluto), determinista.

Contras: te puedes perder errores poco frecuentes. Si un error pasa 1 vez de cada 1000 y trazas el 1 %, lo verás 1 vez de cada 100 000 — ya no es debuggable.

Tail-based sampling (al salir)

Se genera el span completo y se decide al final si conservarlo o tirarlo. La decisión puede ser:

  • Conservar el 100 % de los errores.
  • Conservar el 100 % de las trazas con latencia > X ms.
  • Conservar el 100 % de las trazas que activaron un guardrail.
  • Conservar una muestra aleatoria del resto (1-5 %).

Pros: garantiza que ves los errores y los outliers de latencia. Sin él, los problemas más interesantes son los que más probabilidad tienen de quedarse fuera.

Contras: necesitas un buffer porque hasta que la traza no termina no puedes decidir. El procesador tail_sampling del Collector mantiene los spans 5-30 segundos en memoria hasta tomar la decisión.

La regla práctica en 2026 es combinar ambos: head-based al 5 % + tail-based capturando el 100 % de errores y latencias > 5s. Eso te da ~5 % de tráfico baseline + el 100 % de lo interesante.

Anatomía de una traza real

Vamos a desmontar la traza de una sola pregunta de usuario a un sistema típico chat-with-RAG-and-tool. La pregunta es “¿cuál es el saldo de mi cuenta principal?”.

Trace_id = abc123 · "¿cuál es el saldo de mi cuenta principal?"duración total: 2 847 ms · 7 spans · 1 trace0 ms2847 msHTTP POST /chat — span root (app)guardrail.input · 38msrag.retrieve · 218ms · BGE-M3 + Qdrantembed · 28msvector_search · 80msgen_ai.chat · 2104ms · llama-3.1-70b-instructtool.get_balance · 60msguardrail.output · 28msAtributos clave por spanroot:http.method=POST, user.id=42, conversation.id=session_abcguardrail.input:gen_ai.tool.name=llm_guard, gen_ai.tool.type=guardrail, gen_ai.guardrail.decision=allowrag.retrieve:gen_ai.operation.name=embeddings, gen_ai.request.model=bge-m3, db.system=qdrantvector_search:db.operation=query, db.collection=docs_prod, db.qdrant.top_k=20, db.qdrant.score=0.83gen_ai.chat:gen_ai.system=vllm, request.model=llama-3.1-70b, usage.input=1247, usage.output=412prompt.id=customer_support_v3, prompt.version=14, prompt.label=productiontool.get_balance:gen_ai.tool.name=get_balance, gen_ai.tool.type=function, gen_ai.tool.call.id=call_xyguardrail.output:gen_ai.tool.name=llama_guard_4, gen_ai.guardrail.decision=allowTodos los spans comparten trace_id=abc123. Langfuse los pinta como árbol; Tempo como timeline plana.

Siete spans, una sola traza. La información que se puede explotar:

  • Latencia descompuesta: 38 ms guardrail + 218 ms retrieval + 2104 ms LLM + 60 ms tool + 28 ms guardrail = 2 448 ms (el resto es overhead de orquestación). El cuello de botella es el LLM (74 % del tiempo). Si el usuario se queja de lentitud, sabes dónde mirar.
  • Tokens y coste: 1 247 input + 412 output. Con Llama 3.1 70B on-prem a ~12 W/token de coste energético equivalente, ~3.5 c€ por respuesta. Multiplicado por volumen, da factura de inferencia.
  • prompt.id+version viajando: si en un mes notas que la calidad ha caído, filtras por prompt.version y ves si coincide con un cambio del prompt.
  • Guardrail decisions trazadas: si un guardrail bloqueó la respuesta, queda en la traza con gen_ai.guardrail.decision=block y razón. Auditoría ENS satisfecha.
  • Tool calls correlacionados: tool.get_balance es un span hijo del gen_ai.chat. Si la herramienta falló, ves directamente el error en su span, no aparece sólo en logs separados.

Implicaciones en hardware on-premise

El tracing no es gratis, pero su overhead bien dimensionado es despreciable.

Coste por span

A nivel SDK, un span LLM añade ~50-200 µs de overhead (creación, atributos, serialización). Para un LLM call que dura 2 segundos, eso es < 0.01 % de la latencia.

A nivel red, un span típico OTLP comprimido pesa ~1-3 KB. Para un sistema con 100 req/s con 7 spans/request, son 700 spans/s × 2 KB ≈ 1.4 MB/s al Collector. Trivial.

Storage

Es donde se va el dinero. Sin sampling, un mes a 100 req/s ≈ 18 GB de spans. Con head-based 5 % + tail-based de errores ≈ 1-2 GB/mes. Manejable en cualquier ClickHouse o Loki autohospedado.

En una RTX 4090 (24 GB) + Collector y Langfuse on-prem

Para un servicio de demo o un single-tenant pequeño: Langfuse + Postgres + Clickhouse + Collector en el mismo host del modelo. Consume ~8 GB RAM y < 1 % de CPU continuo. La 4090 sirve el modelo, el resto vive en CPU. Setup mínimo viable para una startup o un equipo pequeño.

En un cluster genérico 4×H100 SXM + observabilidad central

Pod dedicado para el OTel Collector (DaemonSet en cada nodo + Gateway central). Langfuse + ClickHouse + Tempo + Prometheus + Grafana en un namespace observability separado del namespace serving. Es la arquitectura canónica de cualquier producción ENS/NIS2 seria: la observabilidad no comparte recursos con el modelo.

Lo que no hemos cubierto (próximos artículos)

  • Métricas LLM (Prometheus): los counters y histograms canónicosgen_ai_client_request_duration_seconds, gen_ai_client_input_tokens_total, etc. Cómo se construyen dashboards Grafana sobre esa base.
  • Guardrails como spans: cómo modelar Llama Guard 4, NeMo Guardrails y LLM Guard como spans hijos del span LLM, y qué atributos llevan.
  • Tracing distribuido con MCP — propagación traceparent desde el cliente LLM hasta el servidor MCP, atributos mcp.*, problemas conocidos.
  • eBPF para tracing automático sin SDK — Tetragon y Hubble extrayendo trazas LLM sin instrumentación explícita, para casos donde no se puede modificar el código.

Ver también

Referencias