volver al blog

Observabilidad de agentes: tu traza se rompe justo donde importa

ia-generativaagentesobservabilidadopentelemetry

El span que no explica nada

Un agente en producción devuelve una respuesta absurda. Abres la traza esperando entender qué pasó y lo que encuentras es esto: un span de 14 segundos llamado POST /v1/messages, código 200, sin más. Técnicamente todo funcionó. Operativamente no sabes nada.

El problema no es tu instrumentación: es que la estabas aplicando al sistema equivocado. La observabilidad clásica se diseñó para peticiones que hacen una cosa. Un agente hace 20, decide sobre la marcha cuáles y se contradice a sí mismo por el camino. http.request.method y db.system.name no tienen nada que decir sobre eso.

Desde abril de 2024 hay un grupo de trabajo en OpenTelemetry, el GenAI SIG, dedicado exactamente a este hueco. Lo que ha salido de ahí ya es utilizable, con matices que conviene conocer antes de montar nada encima.

El árbol de spans es la idea entera

Todo lo demás son detalles. La aportación central de las convenciones GenAI es dejar de modelar la llamada al modelo como una petición HTTP suelta y modelar la ejecución completa del agente como un árbol.

invoke_agent soporte-nivel-1 (INTERNAL)
├── chat claude-sonnet (CLIENT)          ← el modelo decide consultar pedidos
│     gen_ai.usage.input_tokens = 1523
│     gen_ai.response.finish_reasons = ["tool_calls"]
├── execute_tool consultar_pedido (INTERNAL)
├── chat claude-sonnet (CLIENT)          ← razona con el resultado
└── chat claude-sonnet (CLIENT)          ← redacta la respuesta final
      gen_ai.usage.input_tokens = 2841
      gen_ai.usage.cache_read.input_tokens = 1523
      gen_ai.response.finish_reasons = ["stop"]

Eso, en un visor de trazas, deja de ser una caja negra. Ves en qué turno el modelo llamó a la herramienta y con qué parámetros, no que «el agente tardó 14 segundos».

gen_ai.operation.name es el discriminador y cubre el ciclo completo: create_agent, invoke_agent, invoke_workflow, execute_tool, chat, embeddings o retrieval, entre otros valores. La versión 1.41 dividió invoke_agent en dos tipos de span según dónde se ejecute el agente: CLIENT si llamas a un servicio remoto, INTERNAL si el framework lo corre en tu proceso. Es una distinción aburrida hasta que intentas separar tu latencia de la del proveedor.

La distinción entre invoke_agent e invoke_workflow también tiene criterio detrás: un agente razona y elige el camino, un flujo de trabajo sigue uno predeterminado. Si los mides con el mismo span, no puedes preguntarles a tus datos cuánto te está costando la autonomía.

Dos métricas responden al 80 % de las preguntas

De todo el catálogo de métricas, estas dos son las que vas a mirar cada semana:

MétricaQué contesta
gen_ai.client.operation.durationDónde está la latencia, por modelo y operación
gen_ai.client.token.usageQué te está costando dinero y cómo evoluciona

Con las dimensiones gen_ai.request.model y gen_ai.provider.name ya puedes responder qué modelo es el caro, qué paso del agente es el lento y si el consumo de tokens de la semana pasada fue un pico o una tendencia.

Hay dos reglas de conteo que evitan discusiones incómodas con finanzas. Cuando el proveedor informa de tokens consumidos y tokens facturables, la instrumentación debe registrar los facturables. Y cuando el conteo no se puede obtener de forma fiable, debe omitirlo en lugar de estimarlo. Un dato ausente es honesto; uno inventado contamina el panel durante meses.

Ojo con las convenciones específicas por proveedor: gen_ai.usage.cache_read.input_tokens y gen_ai.usage.reasoning.output_tokens no son adorno. Si tu agente usa prompt caching o un modelo de razonamiento y no separas esos tokens, tu coste por interacción está mal calculado.

El agujero de MCP

Este merece un apartado propio porque es el fallo que más he visto y de los más frustrantes de diagnosticar.

Montas un servidor MCP, el agente lo llama, algo va mal. Miras la traza del agente: acaba en la llamada. Miras la del servidor: empieza de la nada. Son dos trazas distintas sin nada que las una, porque nadie propaga el contexto a través de JSON-RPC.

Las convenciones MCP entraron en la versión 1.39 precisamente para eso. Con W3C Trace Context propagado en ambos lados, el span del servidor cuelga del span del cliente y la cadena se mantiene:

invoke_agent agente-meteo (INTERNAL)
├── chat {modelo} (CLIENT)
├── tools/call get-weather (CLIENT)        ← cliente MCP
│     mcp.method.name = tools/call
│     mcp.session.id = sess-abc
│     gen_ai.tool.name = get-weather
│   └── tools/call get-weather (SERVER)    ← servidor MCP
└── chat {modelo} (CLIENT)

La especificación contempla además la deduplicación: si detecta que una instrumentación GenAI externa ya está registrando la ejecución de la herramienta, enriquece ese span con los atributos MCP en lugar de crear uno duplicado. Detalle pequeño, ahorra horas de confusión.

Contenido: el conflicto real

Aquí está la tensión que la especificación resuelve mejor de lo que esperaba. El prompt y la respuesta son a la vez el dato más útil para depurar y el más sensible que vas a mover. Hay tres modos y no son equivalentes.

ModoCuándo tiene sentido
Sin registrar (por defecto)Producción con datos sensibles y sin necesidad de depurar
En atributos del spanDesarrollo y preproducción; limitado en tamaño y visible para cualquiera con acceso a trazas
Almacenamiento externo + referencia en el spanProducción con volumen o datos personales: IAM y retención independientes

La captura de contenido está desactivada por defecto y suele activarse con OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true. La segunda opción es la tentadora y la que te mete en un problema de cumplimiento: si tus trazas las ve todo el equipo de plataforma, acabas de darle acceso a las conversaciones de tus clientes. Para producción, la tercera.

La letra pequeña: nada de esto es estable

Y ahora la parte honesta, porque construir sobre esto sin saberlo duele.

A agosto de 2026, cada atributo, span, métrica y evento gen_ai.* del registro oficial sigue marcado como Development (lo que antes se llamaba experimental). Ninguno es estable. No hay 1.0 ni fecha comprometida. Los nombres pueden cambiar entre versiones, y de hecho han cambiado: la versión 1.37 sustituyó gen_ai.system por gen_ai.provider.name y rehízo por completo cómo se registra el historial de conversación.

En la versión 1.42, publicada el 12 de junio de 2026, todo gen_ai.* salió del repositorio principal de convenciones semánticas a uno propio. Es un cambio organizativo para que el trabajo de GenAI tenga su propia cadencia, no una graduación a estable.

¿Vale la pena igualmente? Sí, con dos precauciones. La primera: fija la versión de la instrumentación y usa OTEL_SEMCONV_STABILITY_OPT_IN para gestionar las transiciones en lugar de sufrirlas. La segunda: no consultes atributos crudos desde el código de tus alertas. Mete una capa fina en medio, un mapeo tuyo de nombre lógico a atributo, y cuando la especificación se mueva cambias un fichero en vez de 18 paneles.

La alternativa es peor: instrumentación propietaria de un proveedor de observabilidad, con el mismo riesgo de cambio y, además, sin puerta de salida.

Por dónde empezar el lunes

Si tu agente no está instrumentado, el orden que más devuelve por el esfuerzo que pide:

  1. Instrumenta el SDK con el contenido desactivado. Sin tocar tu código de negocio ya tienes los spans chat y las dos métricas.
  2. Envuelve tu bucle de agente en un span invoke_agent y tus herramientas en execute_tool. Son 10 líneas y es lo que convierte una lista de llamadas en una historia legible.
  3. Propaga el contexto en tus servidores MCP. Es donde tu traza se rompe hoy sin que lo sepas.
  4. Captura el contenido al final, y en almacenamiento externo. Cuando ya tengas la forma, no antes.

El objetivo no es un panel bonito. Es poder contestar algo mejor que «el modelo a veces se lía» cuando alguien pregunte por qué el agente respondió eso.

Fuentes