Instrumentar Bucles de Agente con Trazas de OpenTelemetry
Un bucle de agente que llama a Claude múltiples veces e invoca herramientas entre medias es difícil de depurar solo con registros, porque los registros no conservan la forma de la ejecución.
OpenTelemetry (OTel) tracing soluciona eso envolviendo cada llamada al modelo y cada invocación de herramienta en un span, anidado bajo un span raíz por ejecución de agente, para que puedas ver exactamente dónde se fue el tiempo y dónde se rompieron las cosas.
Resumen
OpenTelemetry es un estándar neutral de proveedor para emitir trazas, compuestas por spans que representan unidades de trabajo, con una relación padre-hijo que refleja la estructura de tus llamadas.
Para un bucle de agente de Claude, el mapeo natural es un span raíz por solicitud de usuario, con un span hijo por cada llamada al modelo y cada invocación de herramienta.
Cada span transporta atributos: nombre del modelo, recuentos de tokens, razón de parada, nombre de la herramienta y duración, lo cual es suficiente para responder "¿por qué esta ejecución fue lenta" sin abrir un visor de registros.
La mayoría de las configuraciones de OTel exportan spans a un backend (Jaeger, Datadog, Honeycomb o un exportador de consola local para desarrollo) a través del pipeline de exportador y procesador del SDK de OTel.
Esta página construye un trazador funcional para un bucle de agente de Claude desde cero, utilizando el exportador de consola para desarrollo local.
Receta
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(ConsoleSpanExporter())
)
tracer = trace.get_tracer("claude.agent")
with tracer.start_as_current_span("agent_run") as run_span:
with tracer.start_as_current_span("model_call") as call_span:
call_span.set_attribute("gen_ai.request.model", "claude-sonnet-5")
# ... haz la llamada a la API de Claude aquí ...Cuándo usar esto:
- Tu aplicación ejecuta un bucle de agente de varios pasos (llamada al modelo, llamada a herramienta, llamada al modelo, ...) y necesitas ver dónde se concentran la latencia o los fallos.
- Ya exportas trazas a un backend (Jaeger, Datadog, Honeycomb) para otros servicios y quieres que las llamadas a Claude aparezcan en la misma vista.
- Estás depurando una ejecución lenta o fallida específica y necesitas la forma del orden de las llamadas, no solo métricas agregadas.
- Estás a punto de escribir un ADR para el esquema de spans de tu equipo y necesitas primero una implementación de referencia funcional.
Ejemplo Funcional
import time
import anthropic
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace import Status, StatusCode
# --- Configuración del trazador de una sola vez ---
resource = Resource.create({"service.name": "claude-agent-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("claude.agent")
client = anthropic.Anthropic()
def traced_model_call(messages: list[dict], model: str = "claude-sonnet-5"):
"""Realiza una llamada a Claude envuelta en un span hijo."""
with tracer.start_as_current_span("model_call") as span:
span.set_attribute("gen_ai.system", "anthropic")
span.set_attribute("gen_ai.request.model", model)
try:
response = client.messages.create(
model=model,
max_tokens=500,
messages=messages,
)
except anthropic.APIStatusError as exc:
span.set_status(Status(StatusCode.ERROR, str(exc)))
span.set_attribute("error.type", exc.__class__.__name__)
raise
span.set_attribute("gen_ai.usage.input_tokens", response.usage.input_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.usage.output_tokens)
span.set_attribute("gen_ai.response.stop_reason", response.stop_reason)
return response
def traced_tool_call(tool_name: str, tool_input: dict) -> str:
"""Ejecuta una herramienta envuelta en un span hijo."""
with tracer.start_as_current_span("tool_call") as span:
span.set_attribute("gen_ai.tool.name", tool_name)
start = time.monotonic()
if tool_name == "get_weather":
result = f"Weather for {tool_input.get('city', 'unknown')}: sunny, 72F"
else:
span.set_status(Status(StatusCode.ERROR, "unknown tool"))
raise ValueError(f"Unknown tool: {tool_name}")
span.set_attribute("gen_ai.tool.duration_ms", round((time.monotonic() - start) * 1000, 1))
return result
def run_agent_loop(user_prompt: str, max_steps: int = 4) -> str:
"""Un span raíz por ejecución, que contiene un span hijo por paso."""
with tracer.start_as_current_span("agent_run") as run_span:
run_span.set_attribute("gen_ai.agent.max_steps", max_steps)
messages = [{"role": "user", "content": user_prompt}]
tools = [{
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}]
for step in range(max_steps):
response = traced_model_call(messages)
if response.stop_reason != "tool_use":
run_span.set_attribute("gen_ai.agent.steps_taken", step + 1)
return response.content[0].text
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result_text = traced_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result_text,
})
messages.append({"role": "user", "content": tool_results})
run_span.set_attribute("gen_ai.agent.steps_taken", max_steps)
return "Max steps reached without a final answer."
if __name__ == "__main__":
answer = run_agent_loop("What is the weather in Denver right now?")
print(answer)Lo que esto demuestra:
- Un span raíz
agent_runpor solicitud de usuario, con spansmodel_callytool_callanidados dentro como hijos. - Atributos de span (
gen_ai.request.model,gen_ai.usage.input_tokens,gen_ai.tool.name) que te permiten consultar trazas por modelo, herramienta o costo de tokens más tarde. - Registro de fallos en el propio span a través de
set_status(Status(StatusCode.ERROR, ...))en lugar de solo registrarlos en otro lugar. - Un flujo de manejo de
toolsytool_useque refleja cómo un bucle de agente de Claude real invoca y resuelve llamadas a herramientas. - Un
ConsoleSpanExporterpara desarrollo local, que se sustituye por un exportador de backend real en producción.
Análisis Profundo
Cómo Funciona
TracerProvideres la fábrica de trazadores del SDK de OTel; lo configuras una vez al inicio del proceso con unResource(metadatos del servicio) y uno o más procesadores de spans.BatchSpanProcessoragrupa los spans finalizados y los entrega a un exportador de forma asíncrona, lo que mantiene la sobrecarga del trazado fuera de la ruta crítica de tu solicitud.tracer.start_as_current_span(...)abre un span y lo convierte en el span "actual" en una variable de contexto, de modo que cualquier span iniciado dentro de ese bloquewithse anida automáticamente como su hijo. Esto es lo que produce el árbol padre-hijo sin pasar manualmente objetos span.- Al finalizar un span (salir del bloque
with), su duración se registra automáticamente; nunca estableces la duración manualmente. - Los atributos son simplemente pares clave-valor adjuntos a un span; no hay aplicación de esquema a nivel del SDK, que es exactamente por qué los equipos escriben un ADR para mantener la consistencia de los nombres (ver Relacionados).
Convención de Nombres de Atributos
| Atributo | Significado |
|---|---|
gen_ai.system | El proveedor, p. ej., anthropic |
gen_ai.request.model | El modelo solicitado, p. ej., claude-sonnet-5 |
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens | Recuentos de tokens de la respuesta |
gen_ai.response.stop_reason | end_turn, tool_use, max_tokens, etc. |
gen_ai.tool.name | La herramienta invocada, para spans de llamada a herramienta |
gen_ai.agent.steps_taken | Cuántas iteraciones del bucle tomó la ejecución |
Estos siguen la convención semántica emergente gen_ai.* utilizada en herramientas de LLM instrumentadas con OTel, lo que mantiene tus spans consultables de la misma manera independientemente del proveedor de modelo del que provenga un span determinado.
Consideraciones de Muestreo
Trazar cada span al 100% está bien con bajo volumen, pero se vuelve caro y ruidoso una vez que un producto de agente tiene tráfico real.
Un patrón común es un muestreador ParentBased en el span raíz combinado con una tasa fija más alta para los spans de error, para que mantengas visibilidad completa de los fallos mientras muestras las ejecuciones exitosas de rutina.
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
# Muestra el 20% de los spans raíz; cada span hijo hereda la decisión del padre.
provider = TracerProvider(
resource=resource,
sampler=ParentBased(root=TraceIdRatioBased(0.2)),
)Notas de Python
start_as_current_spantambién se puede usar como decorador (@tracer.start_as_current_span("name")) si prefieres envolver funciones completas en lugar de bloqueswith.- Llama siempre a
set_statusen caso de error, no solo arecord_exception; un span con un estado no establecido aún puede renderizarse como "exitoso" en algunos visores de trazas, incluso si registraste una excepción en él. - Usa
provider.shutdown()(o confía en el hookatexitdel SDK) en scripts de corta duración para que los spans en búfer en elBatchSpanProcessorse envíen realmente antes de que el proceso salga.
Trampas
- Olvidar enviar antes de la salida del proceso. En un script corto o un manejador tipo Lambda, los spans en búfer en un
BatchSpanProcessorpueden perderse si el proceso sale antes de la próxima sincronización del lote. Solución: llama atrace.get_tracer_provider().force_flush()antes de devolver, o usa unSimpleSpanProcessorpara procesos de corta duración. - Registrar excepciones sin establecer el estado del span. Llamar solo a
span.record_exception(exc)no marca el span como fallido. Solución: siempre combínalo conspan.set_status(Status(StatusCode.ERROR, str(exc))). - Registrar el texto completo del prompt como atributo de span. Los atributos de span están destinados a metadatos estructurados y cortos, y los bloques de texto grandes hinchan tu almacenamiento de trazas y pueden filtrar datos sensibles a un backend de trazas con reglas de retención diferentes a las de tus registros. Solución: mantén el texto del prompt/respuesta en tus registros estructurados, y pon solo recuentos de tokens, nombre del modelo e identificadores cortos en los spans.
- Perder la relación padre-hijo entre hilos o tareas asíncronas.
start_as_current_spandepende de la propagación del contexto; crear un nuevo hilo oasyncio.create_tasksin propagar el contexto puede producir spans huérfanos. Solución: usaopentelemetry.context.attach/detachpara transportar el contexto entre límites de hilos o tareas, o usa los ayudantes de instrumentación asíncrona de OTel. - Muestreo del 100% en producción. Trazar cada span a volumen completo una vez que el tráfico crece añade costos reales y ruido en el backend de trazas. Solución: aplica un muestreador
ParentBasedcon una tasa raíz más baja, manteniendo el 100% para los spans de error y los valores atípicos de costo. - Nombres de spans y atributos inconsistentes entre servicios. Si un servicio lo llama
model_cally otro lo llamallm_request, las trazas entre servicios se vuelven imposibles de consultar como un conjunto. Solución: acuerda los nombres en un ADR antes de que varios equipos comiencen a instrumentar de forma independiente.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Solo registro estructurado (sin trazas) | Una integración de llamada única sin bucle de varios pasos | Tienes un bucle de agente de varios pasos donde el orden y la anidación de las llamadas importan |
| Un SDK integrado de una plataforma de observabilidad LLM alojada | Quieres trazado además de una UI lista para usar con una configuración mínima | Necesitas trazas neutrales de proveedor que también fluyan a tus paneles existentes no LLM |
| Formato de span interno personalizado (sin OTel) | Un prototipo pequeño sin planes de integrarse con otras herramientas | Ya usas OTel en otro lugar, o planeas exportar a Datadog/Jaeger/Honeycomb más tarde |
| Bibliotecas de auto-instrumentación de OTel para SDKs de LLM | Quieres spans con código manual mínimo y aceptas las opciones de atributos de la biblioteca | Necesitas atributos personalizados específicos para el conjunto de herramientas de tu agente o lógica de negocio |
Preguntas Frecuentes
¿Necesito un backend separado como Jaeger o Datadog para usar OpenTelemetry?
No. El ConsoleSpanExporter utilizado en los ejemplos de este artículo imprime spans en stdout, lo cual es suficiente para el desarrollo local. Un backend real (Jaeger, Datadog, Honeycomb, o cualquier colector compatible con OTLP) solo es necesario una vez que deseas almacenamiento persistente, búsqueda y una UI de trazas.
¿Cuál es la diferencia entre un span y una traza?
Una traza es el árbol completo de spans para una operación (en este artículo, una agent_run). Un span es un nodo único en ese árbol, que representa una unidad de trabajo como una llamada al modelo o una invocación de herramienta. Los spans están vinculados por relaciones padre-hijo, y juntos forman la traza.
¿Debo poner el texto completo del prompt y la respuesta en el span como atributos?
Generalmente no. Mantén los spans enfocados en metadatos cortos y estructurados (nombre del modelo, recuentos de tokens, razón de parada) y pon el texto completo del prompt/respuesta en tus registros estructurados en su lugar, unidos a la traza por un ID de solicitud compartido. Esto mantiene el almacenamiento de trazas ligero y evita filtrar contenido sensible a un backend con políticas de retención diferentes.
¿Cómo correlaciono un span de traza con una línea de registro estructurado?
- Genera o captura un ID de solicitud antes de la llamada.
- Establécelo como un atributo de span (p. ej.,
gen_ai.request.id). - Incluye el mismo ID en tu entrada de registro estructurado para esa llamada.
- Consulta cualquiera de los sistemas por ese ID para saltar entre ellos.
¿Qué sucede si una llamada a herramienta genera una excepción dentro de un span trazado?
with tracer.start_as_current_span("tool_call") as span:
try:
result = run_tool(name, args)
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raiseSe necesita tanto registrar la excepción como establecer el estado del span; el evento de excepción te da el rastreo de la pila, y el estado es lo que marca el span como fallido en la mayoría de los visores de trazas.
¿Puedo trazar respuestas de Claude en streaming de la misma manera?
Sí, pero el span debe permanecer abierto durante toda la duración del stream en lugar de cerrarse después de la solicitud inicial. Abre el span antes de iniciar el stream, consume stream.text_stream, llama a stream.get_final_message() para los números de uso finales, establécelos como atributos del span, y solo entonces deja que el bloque with cierre el span.
¿Por qué usar nombres de atributos `gen_ai.*` en lugar de mi propio nombre?
- Sigue la convención semántica emergente de OTel para spans de IA generativa, lo que mantiene los atributos consistentes entre proveedores y servicios.
- Los backends de trazas y los paneles que entienden esta convención pueden renderizarlos sin configuración personalizada.
- Da a tu equipo un punto de partida documentado en lugar de inventar nombres desde cero, que es también lo que el plantilla ADR en esta sección formaliza.
¿Añadir trazado ralentiza notablemente mi bucle de agente?
Con un BatchSpanProcessor, la exportación de spans ocurre asíncronamente fuera de la ruta de la solicitud, por lo que la sobrecarga por llamada suele ser el costo de unas pocas escrituras de atributos, no un viaje de ida y vuelta de red. Un SimpleSpanProcessor, que exporta de forma síncrona, añade más sobrecarga y debe reservarse para scripts de corta duración, no para rutas de solicitud de producción.
¿Cuántos spans debería producir una ejecución de agente?
Un span raíz por ejecución, más un span hijo por llamada al modelo y por invocación de herramienta. Un bucle de agente de cuatro pasos con dos llamadas a herramientas produciría típicamente un span raíz, dos spans model_call y dos spans tool_call, seis spans en total, todos anidados bajo el raíz.
¿Es necesario el muestreo, o puedo trazar todo?
Trazar todo (muestreo del 100%) está bien con bajo volumen y es el punto de partida más simple. A medida que el tráfico crece, el muestreo completo se vuelve costoso en el backend de trazas, por lo que la mayoría de los equipos cambian a un muestreador ParentBased con una tasa raíz reducida, manteniendo el 100% para los spans de error y los valores atípicos.
¿Para qué sirve `Resource` en `TracerProvider(resource=resource)`?
Resource adjunta metadatos a nivel de servicio (como service.name) a cada span que produce el proveedor, que es cómo un backend de trazas distingue los spans que provienen de tu servicio de agente de los spans de otros servicios en la misma traza o panel.
Relacionados
- Cómo Funciona la Observabilidad para Aplicaciones LLM - el modelo mental en el que encaja el trazado.
- Conceptos Básicos de Observabilidad - el patrón de registro plano y pre-trazado sobre el que se construye este artículo.
- Registro Estructurado de Prompts, Respuestas y Recuentos de Tokens - el esquema de registro que se empareja con los spans a través del ID de solicitud.
- Plantilla ADR: Definición del Esquema de Spans OTel para Llamadas a Herramientas - documentación de las decisiones de nomenclatura y muestreo tomadas aquí.
- Correlación de Latencia y Errores con Despliegues de Código - uso de datos de trazas para atribuir una regresión a un despliegue.
- Mejores Prácticas de Observabilidad - una lista de verificación consolidada de esta sección.
Versiones de Stack: Escrito contra la línea de modelos de Claude actual alrededor de junio de 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (el predeterminado), y Claude Haiku 4.5 - y el SDK oficial de Python
anthropic(última versión 0.x). Los nombres de modelos, precios y versiones de SDK cambian rápidamente; verifica los detalles actuales en platform.claude.com/docs antes de confiar en ellos.