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@versionaparecí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
La analogía: la caja negra del avión
Una caja negra de avión hace tres cosas a la vez:
- 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.
- 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.
- 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:
- 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—.
- 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. - 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.
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).
| Atributo | Significado | Ejemplo |
|---|---|---|
gen_ai.system | Familia del proveedor | openai, anthropic, vllm, huggingface |
gen_ai.request.model | Modelo solicitado | gpt-4o, meta-llama/Llama-3.1-8B-Instruct |
gen_ai.request.temperature | Sampling temp | 0.7 |
gen_ai.request.max_tokens | Cap de output | 1024 |
gen_ai.request.top_p | Nucleus sampling | 0.95 |
gen_ai.response.model | Modelo que respondió (puede diferir) | gpt-4o-2024-08-06 |
gen_ai.response.id | ID de la respuesta del proveedor | chatcmpl-9xY... |
gen_ai.response.finish_reasons | Razones de fin | ["stop"], ["length"], ["tool_calls"] |
gen_ai.usage.input_tokens | Tokens entrada | 1247 |
gen_ai.usage.output_tokens | Tokens salida | 412 |
gen_ai.operation.name | Tipo de operación | chat, text_completion, embeddings |
gen_ai.prompt.id | ID del prompt versionado | customer_support_v3 |
gen_ai.prompt.version | Versión específica | 14 |
gen_ai.prompt.label | Etiqueta semántica | production, staging, canary |
gen_ai.conversation.id | ID de conversación multiturn | session_abc123 |
Y para tool calls (función desde el modelo), los atributos gen_ai.tool.*:
| Atributo | Significado |
|---|---|
gen_ai.tool.call.id | ID del tool call concreto |
gen_ai.tool.name | Nombre de la herramienta |
gen_ai.tool.type | function, mcp, etc. |
gen_ai.tool.description | Descripció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?”.
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+versionviajando: si en un mes notas que la calidad ha caído, filtras porprompt.versiony 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=blocky razón. Auditoría ENS satisfecha. - Tool calls correlacionados:
tool.get_balancees un span hijo delgen_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ónicos —
gen_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
traceparentdesde el cliente LLM hasta el servidor MCP, atributosmcp.*, 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
- Prompt versioning con Langfuse y MLflow — el
prompt_id@versionque viaja comogen_ai.prompt.iden los spans de aquí. - Evals para LLMs: la capa después del tracing — el patrón “el judge corre sobre una muestra de las trazas” se apoya en este pipeline.
- LLM-as-judge: el corrector de oposiciones — el judge consume trazas para evaluar la calidad continua. Las trazas conservadas por tail-sampling son su input.
- Fine-tuning continuo en producción — las trazas son el origen del dataset de fine-tuning. Cuando un usuario regenera, ese span con
gen_ai.response.finish_reasons=["stop"]rejected entra al pipeline DPO. - El pipeline LLMOps de seis etapas — el mapa maestro donde OBSERVE es la etapa 5.
Referencias
- OpenTelemetry GenAI Semantic Conventions: https://opentelemetry.io/docs/specs/semconv/gen-ai/.
- OpenLLMetry (Traceloop): https://github.com/traceloop/openllmetry.
- openinference (Arize): https://github.com/Arize-ai/openinference.
- Langfuse OTel integration: https://langfuse.com/docs/opentelemetry/get-started.
- OTel Collector tail-sampling processor: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor.
- Phoenix (Arize) docs: https://docs.arize.com/phoenix.
- Model Context Protocol observability (
mcp.*semconv): https://modelcontextprotocol.io/docs/observability.