Trazas de agente sin precio: auditoría del semconv GenAI de OpenTelemetry
Una corrida de agente ya tiene forma estándar en OpenTelemetry: un span de workflow sobre spans de agente sobre plan, inferencia y tools. Lo que no tiene es un precio.
Clonamos open-telemetry/semantic-conventions-genai el 14 de agosto de 2026, en HEAD 30182ac (13-ago-2026), y leímos los archivos del modelo y los reportes de referencia en vez de los posts que hablan de ellos. Las convenciones están más avanzadas de lo que se asume: cinco tipos distintos de span de agente, un capítulo completo de tracing para MCP, una matriz de conformance contra 28 librerías reales. También les falta algo estructural. No hay ningún atributo en todo el registry para lo que costó una operación.
Ese hueco importa más para un agente que paga por llamada que para un chatbot que le factura a un humano cada mes. Esto es una auditoría de qué existe, qué se agregó en las últimas dos semanas, y dónde la capa de telemetría deja de poder responder la pregunta que un agente autónomo realmente hace: cuánto de mi balance consumió eso.
Un repo propio, de cuatro meses
Hasta mayo las convenciones GenAI vivían dentro del repo principal semantic-conventions. El commit ebe3d1f, "Prepare standalone GenAI semantic conventions repo", tiene fecha 2026-05-04, y la API de GitHub reporta el repositorio creado el 2026-05-05. La historia se arrastró en vez de reiniciarse: los commits previos al split todavía referencian números de PR del repo principal, y el último heredado es el chore de release v1.41.0 del 27-abr-2026. Desde el split hubo 198 commits.
El resto de la metadata conviene decirlo sin adornos, porque los badges son toda la historia. Apache-2.0. 249 stars, 77 forks, 175 issues abiertas al momento del clone. Sin releases: la lista de tags está vacía. Y en model/manifest.yaml:
# model/manifest.yaml
name: semantic-conventions-genai
schema_url: https://opentelemetry.io/schemas/gen-ai-dev/1.42.0-dev
stability: development
dependencies:
- schema_url: https://opentelemetry.io/schemas/1.44.0
registry_path: https://github.com/open-telemetry/[email protected][model]
stability: development, y un schema URL con -dev dos veces adentro. Cada atributo gen_ai.* y mcp.* de los docs lleva el badge azul de Development. Los badges Stable que aparecen en las tablas generadas —error.type, server.address, server.port, network.transport— vienen todos del registry core del que depende, pinneado en v1.44.0. Nada específico de GenAI se graduó.
gen_ai.workflow.duration pasó a gen_ai.invoke_workflow.duration el 4-ago-2026, y se agregó una operación fetch_response el 31-jul-2026. Pinnea la versión contra la que instrumentas.
La forma de una corrida de agente
El registry define 63 atributos gen_ai.*. La estructura interesante está en los spans, y en particular en una distinción que el repo hizo en abril: invoke_agent se partió en un span de cliente y un span interno.
Un invoke agent client span describe llamar a un agente que corre en otro lado — los ejemplos documentados son la Assistants API de OpenAI y AWS Bedrock Agents. Span kind CLIENT. Un invoke agent internal span describe un agente que corre en tu propio proceso — LangChain, CrewAI. Span kind INTERNAL. Mismo valor de gen_ai.operation.name, invoke_agent, distinto span kind, distintos sets de atributos. Ese split es la primera admisión en las convenciones de que "agente" es a la vez un recurso remoto que pagas por llamar y un loop que corres tú.
Arriba está invoke_workflow, INTERNAL, con nombre invoke_workflow {gen_ai.workflow.name}. Los criterios se ajustaron el 10-ago-2026: el span de workflow es para un proceso coordinado sobre múltiples agentes o llamadas GenAI, NO debería reportarse para invocaciones de un agente suelto, y NO debería reportarse cuando el workflow es un detalle interno de implementación de otra operación — un agente que levanta un runner para delegar en un sub-agente no genera uno. Los workflows definidos por la aplicación sí lo generan, incluso anidados. El doc nombra puntos de entrada concretos por framework: Crew.kickoff(), el invoke de LangGraph, Runner.run de ADK, Runner.run de OpenAI Agents con handoffs.
Abajo, dos más. plan, INTERNAL, "la fase de decisión donde un agente formula una estrategia antes de ejecutarla" — la llamada al LLM que genera el plan es hija del span de plan, y los spans de tool resultantes son hermanos bajo el mismo invoke_agent. Y execute_tool, INTERNAL, con nombre execute_tool {gen_ai.tool.name}. Los spans de inferencia mantienen la forma anterior: {gen_ai.operation.name} {gen_ai.request.model}, kind CLIENT.
El enum de gen_ai.operation.name es donde se ve el alcance del esfuerzo. Además de chat, embeddings y text_completion, ahora lleva create_agent, invoke_agent, invoke_workflow, plan, execute_tool, retrieval, fetch_response, y ocho operaciones de memoria: search_memory, create_memory, update_memory, upsert_memory, delete_memory, create_memory_store, delete_memory_store. La memoria consiguió vocabulario de primera clase antes que el dinero — coherente con dónde puso la atención el ecosistema, como cubrimos en la actualización de arquitecturas de memoria.
Dos detalles operativos que vale copiar a cualquier instrumentación que escribas. Primero, hay un set de atributos marcado como relevante para sampling que DEBERÍA setearse al momento de crear el span: gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.agent.name, server.address, server.port. Si los seteas tarde, el head sampling no los ve. Segundo, gen_ai.agent.id se especifica como el identificador estable asignado por el provider — un ARN de agente de Bedrock, un id del Agent Registry de GCP — y el doc dice explícitamente que NO se recomienda registrar ahí ids de instancias en memoria, por su naturaleza transitoria.
MCP se mudó al mismo repo
Las convenciones de MCP viven ahora en este repo, lo que significa que las llamadas MCP y el agente que las hace comparten un solo vocabulario. El capítulo de MCP abre rechazando las dos alternativas obvias: no uses las convenciones de RPC, no te apoyes en las de HTTP, porque múltiples requests MCP pueden compartir un request HTTP y un request MCP puede abarcar varios.
La propagación de contexto es la parte con filo. Las instrumentaciones DEBERÍAN inyectar el trace context en el bag params._meta del request MCP, no en el transporte. MCP normalmente exige claves con prefijo DNS en _meta; el SEP-414 hace una excepción explícita para las claves W3C, así que van sin prefijo:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get-weather",
"_meta": {
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"tracestate": "rojo=00f067aa0ba902b7"
}
},
"id": 1
}
La consecuencia está dicha directamente en la spec: el span del cliente MCP pasa a ser el padre del span del servidor MCP sin importar el transporte, y el contexto de transporte —el request HTTP que casualmente lo llevó— se registra como span link. Es la decisión correcta para un protocolo cuyo núcleo ahora es stateless, como lo dejó la revisión 2026-07-28. La sesión pasa a ser un atributo (mcp.session.id), no un supuesto estructural.
Solo hay cuatro atributos mcp.*: mcp.method.name, mcp.protocol.version, mcp.resource.uri, mcp.session.id. Todo lo demás es prestado — gen_ai.tool.name, gen_ai.prompt.name, jsonrpc.request.id, rpc.response.status_code. Los nombres de span siguen {mcp.method.name} {target} donde target es el nombre de la tool o del prompt; mcp.resource.uri se deja fuera del nombre por defecto por cardinalidad. Y hay una regla anti-duplicación explícita: si la instrumentación de MCP puede detectar de forma confiable que una instrumentación GenAI externa ya está trazando la ejecución de la tool, NO debería crear un segundo span, sino decorar el existente.
Qué se emite en la práctica
Lo más útil del repo no es el texto de la spec. Es reference/: 28 escenarios, uno por librería, cada uno ejercitando un SDK real contra un mock server determinista, con la telemetría capturada validada por un conformance runner y los resultados commiteados como reportes por señal. Eso convierte "las convenciones soportan X" en una afirmación contable.
Contando desde esos reportes: el span de inferencia lo emiten 13 librerías, entre ellas openai, anthropic, litellm, aws-bedrock, vertexai y claude-agent-sdk. Las 13 setean gen_ai.usage.input_tokens y gen_ai.usage.output_tokens en el span. El invoke-agent interno lo emiten 7 frameworks: agent-framework, autogen, crewai, google-adk, langchain, openai-agents, pydantic-ai. El span de workflow, 4. El de plan, 2 (crewai, langchain). Los de memoria, 2 (aws-bedrock-agentcore, google-adk).
Y ahí la columna de métricas colapsa. gen_ai.client.token.usage —el histograma en unidades {token}, con gen_ai.operation.name, gen_ai.provider.name y gen_ai.token.type (input u output) como atributos requeridos— lo emiten exactamente dos de los 28 escenarios: agent-framework y anthropic. gen_ai.invoke_agent.tool_calls y gen_ai.invoke_agent.inference_calls, uno cada uno (google-adk).
Esa asimetría es el hallazgo práctico. Los conteos de tokens están ampliamente disponibles como atributos de span y casi en ningún lado como métricas. Si tu contabilidad de consumo lee del pipeline de métricas, lee de un instrumento en el que las librerías de instrumentación de hoy casi no escriben. Si lee de spans, depende de una decisión de sampling. Ninguno de los dos es un feed de facturación, que es el punto que hicimos en el post sobre el oficio de la observabilidad y que la matriz de conformance ahora vuelve medible.
La columna que no está
Haz grep en todo el directorio del modelo por cost, price, billing, payment o usd. Sale un solo hit, y es la palabra "costs" dentro de una nota en prosa sobre almacenamiento. No hay ningún atributo monetario en las convenciones semánticas de GenAI.
No es un descuido que nadie notó. La issue #287, "Add convention for operation costs", está abierta desde el 30-may-2025. Lo que cambió es el PR #443, abierto el 9-ago-2026 y todavía abierto con 7 comentarios, que propone el esquema concreto:
// propuesto en el PR #443 — sin mergear
gen_ai.usage.cost.amount double // costo monetario de la operación
gen_ai.usage.cost.currency string // ISO 4217, requerido si amount está seteado
gen_ai.usage.cost.source enum // provider | pricing_table | estimate
// más una métrica histograma
gen_ai.client.operation.cost
Las notas de diseño son más interesantes que la lista de campos. Los atributos van en el grupo compartido attributes.gen_ai.usage, así aterrizan en spans de inferencia, embeddings, agente y workflow sin duplicación. Las tablas de precios hardcodeadas dentro de la instrumentación están explícitamente prohibidas: el valor viene del provider o de una tabla que provee el usuario. El enum source existe para que los dashboards nunca mezclen valores facturados reales con estimaciones en el mismo gráfico. Y el tipo es float, justificado en el PR con una frase que vale citar: "This is observability, not accounting."
Un PR hermano, el #439 (abierto el 7-ago-2026), agrega governance de presupuesto al span invoke_agent: gen_ai.agent.token_budget.limit y .consumed, gen_ai.agent.iteration_budget.limit y .consumed, más un histograma gen_ai.invoke_agent.token_budget.utilization. Los tokens consumidos excluyen explícitamente el consumo de sub-agentes, para que los valores sigan siendo sumables sobre el árbol. Un PR te dice qué tan cerca del tope estás; el otro, cuánto costó ese consumo.
Los dos están abiertos. Ninguno mergeado. A día de hoy, un framework de agentes que quiera emitir gasto no tiene una clave estándar bajo la cual emitirlo.
Un float no es un settlement
"Observabilidad, no contabilidad" es la decisión de alcance correcta para OpenTelemetry, y es exactamente la razón por la que un agente que paga por llamada no puede tratar el pipeline de telemetría como su ledger.
Compara las formas. En la especificación x402 v2 —volvimos a clonar x402-foundation/x402 hoy en a1af647 para verificarlo— el amount de PaymentRequirements es un string en unidades atómicas del token, y el SettleResponse que devuelve el facilitator tiene campos requeridos success, transaction (el hash de la transacción on-chain) y network (CAIP-2), con un amount opcional que lleva el monto realmente liquidado, otra vez en unidades atómicas como string.
Cada decisión de diseño ahí es la opuesta a la del #443, y con razón. Enteros en unidades base, porque un float que pierde un redondeo en el sexto decimal es un redondeo que no puedes reconciliar contra USDC. Strings, porque los números de JSON son IEEE-754 y 10000 unidades atómicas tienen que sobrevivir el round trip. Un hash de transacción, porque el registro autoritativo está en una cadena y no en un span sampleado. Las respuestas de verify y settle del facilitator, que recorrimos en el deep dive de su API, son la contabilidad; un span es el relato de lo que pasó alrededor.
Así que un operador de agentes termina con dos ledgers, y la disciplina útil es mantenerlos separados y unidos por un identificador, no fusionados:
Settlement — autoritativo, entero, on-chain
Unidades atómicas, autorización EIP-3009 o el equivalente por red, un hash de transacción, un id de red CAIP-2. De acá sale el balance. Nunca viene de un span, y nunca se pierde por sampling.
Telemetría — explicativo, float, sampleado
Conteos de tokens, latencias, nombres de modelo y provider, árboles de llamadas a tools y MCP, y —cuando entre el #443— un costo estimado con un source explícito. Esto responde "por qué esta corrida costó 3x la anterior".
La clave de join es el problema de ingeniería interesante. Un span ya lleva gen_ai.conversation.id, gen_ai.response.id y mcp.session.id. Lo que no tiene es lugar para el identificador de settlement: el hash de la transacción, o el id de reserva que un gateway emite antes de proxear la llamada. Hasta que las convenciones tengan uno, ese enlace vive en un atributo custom — y cada operador le pondrá un nombre distinto, que es justamente el modo de falla que las convenciones semánticas existen para evitar.
Qué significa para LLM4Agents
Tres consecuencias concretas.
Primera, el gateway está en la posición rara de poder emitir los dos ledgers desde un mismo lugar. Un endpoint compatible con OpenAI que reserva, proxea y liquida —el flujo que documentamos en el post de internals de facturación— ya conoce el modelo, el provider al que ruteó, los tokens que devolvió el upstream y el monto liquidado. Emitir spans gen_ai.* desde ese camino cuesta casi nada incremental y vuelve la plataforma legible para cualquier backend OTel que el cliente ya corra. La alternativa, un formato de telemetría propio, garantiza una migración después y no interopera con nada mientras tanto.
Segunda, el routing de modelos y el fallback solo son depurables con estos atributos seteados con honestidad. La convención dice que gen_ai.provider.name es un discriminador del sabor de telemetría y PUEDE diferir del provider upstream real cuando hay un proxy de por medio, mientras que gen_ai.request.model es lo que se pidió y gen_ai.response.model es lo que respondió. Para un gateway con cadenas de fallback, ese trío es todo el audit trail de una degradación. Setearlos flojo convierte un fallback en un evento invisible.
Tercera, el hueco de costo es una oportunidad, no solo un agujero. LLM4Agents liquida en stablecoins contra una autorización on-chain, así que tiene algo que casi ningún participante de ese hilo tiene: un valor de source más fuerte que provider. No una estimación, no una tabla de precios: un monto liquidado con un hash de transacción. Si las convenciones aterrizan gen_ai.usage.cost.* como float para estimaciones, el complemento natural es un hermano de grado settlement, y quien tiene datos reales de settlement es quien debería proponerlo.
Cómo mantenerse en la frontera
En orden, de lo más barato a lo más ambicioso.
Empezar emitiendo los spans que ya tienen consenso, pinneados a una versión. Spans de inferencia con gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens y gen_ai.usage.output_tokens, más los atributos relevantes para sampling seteados al crear el span. Pinnear al schema URL de manifest.yaml y tratar los renames como mantenimiento agendado, no como sorpresa: entraron dos en las últimas tres semanas.
Después, emitir gen_ai.client.token.usage como métrica, no solo como atributo de span. Hoy lo hacen dos de 28 escenarios de referencia. Es un histograma con tres atributos requeridos, sobrevive al sampling, y es el instrumento que todo backend OTel va a graficar por defecto. Es la forma más barata de ir adelante del ecosistema en vez de atrás.
Tercero, instrumentar la superficie MCP con propagación por params._meta. Un gateway que expone tools MCP y además sirve inferencia es exactamente el lugar donde una traza se conecta o se rompe; inyectar y extraer ahí hace que la traza del agente del cliente entre a la plataforma y salga sin un hueco en el medio. Respetar la regla anti-duplicación para no contar dos veces las ejecuciones de tools.
Cuarto, definir la clave de join ahora y publicarla. Un solo atributo que lleve la referencia de settlement —id de reserva en el span del request, hash de transacción en el liquidado— hace reconciliables los dos ledgers. Usar un nombre con prefijo de vendor, documentarlo, y estar listo para migrar a una clave estándar si aparece.
Quinto, participar upstream. Los PRs #443 y #439 están abiertos y con review activo. Un participante que pueda decir "así se ve un costo liquidado y no estimado en el cable, en unidades atómicas, con un hash de transacción" aporta algo que al hilo hoy le falta. La ventana para dar forma a una convención se cierra cuando se estabiliza, y esta todavía no sacó ni un release.
Las convenciones se van a estabilizar sobre el modelo que alguien tenga de qué es una corrida de agente. Hoy ese modelo tiene operaciones de memoria, fases de planificación y árboles de workflow, y ningún concepto de un balance que baja. Para agentes que pagan por llamada, eso no es un detalle. Es la mitad faltante de la traza.
Corre agentes en un gateway que sabe cuánto costó cada llamada
Endpoint compatible con OpenAI, settlement en stablecoins por request, y consumo que puedes reconciliar contra un hash de transacción.
Registra tu agente