Instrumentando Loops de Agente com OpenTelemetry Tracing
Um loop de agente que chama Claude várias vezes e invoca ferramentas entre elas é difícil de depurar apenas com logs, pois os logs não preservam a forma da execução.
Busque em todas as páginas da documentação
Um loop de agente que chama Claude várias vezes e invoca ferramentas entre elas é difícil de depurar apenas com logs, pois os logs não preservam a forma da execução.
O tracing do OpenTelemetry (OTel) corrige isso envolvendo cada chamada de modelo e invocação de ferramenta em um span, aninhado sob um span raiz por execução de agente, para que você possa ver exatamente onde o tempo foi gasto e onde as coisas falharam.
OpenTelemetry é um padrão neutro de fornecedor para emitir traces, compostos por spans que representam unidades de trabalho, com um relacionamento pai-filho que espelha sua estrutura de chamadas.
Para um loop de agente Claude, o mapeamento natural é um span raiz por solicitação do usuário, com um span filho para cada chamada de modelo e cada invocação de ferramenta.
Cada span carrega atributos: nome do modelo, contagens de tokens, motivo de parada, nome da ferramenta e duração, o que é suficiente para responder "por que esta execução foi lenta" sem abrir um visualizador de logs.
A maioria das configurações de OTel exporta spans para um backend (Jaeger, Datadog, Honeycomb ou um exportador de console local para desenvolvimento) por meio do pipeline de exportador e processador do SDK do OTel.
Esta página constrói um tracer funcional para um loop de agente Claude do zero, usando o exportador de console para desenvolvimento local.
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")
# ... faça a chamada da API Claude aqui ...Quando usar isso:
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
# --- Configuração única do tracer ---
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"):
"""Faz uma chamada Claude encapsulada em um span filho."""
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:
"""Executa uma ferramenta encapsulada em um span filho."""
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:
"""Um span raiz por execução, contendo um span filho por etapa."""
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)O que isso demonstra:
agent_run por solicitação do usuário, com spans model_call e tool_call aninhados dentro dele como filhos.gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.tool.name) que permitem consultar traces por modelo, ferramenta ou custo de token posteriormente.set_status(Status(StatusCode.ERROR, ...)) em vez de apenas logá-las em outro lugar.tools e tool_use que espelha como um loop de agente Claude real invoca e resolve chamadas de ferramenta.ConsoleSpanExporter para desenvolvimento local, trocado por um exportador de backend real em produção.TracerProvider é a fábrica do SDK do OTel para tracers; você o configura uma vez no início do processo com um Resource (metadados do serviço) e um ou mais processadores de span.BatchSpanProcessor agrupa spans finalizados e os entrega a um exportador de forma assíncrona, o que mantém a sobrecarga do tracing fora do caminho crítico da sua solicitação.tracer.start_as_current_span(...) abre um span e o torna o span "atual" em uma variável de contexto, de modo que qualquer span iniciado dentro desse bloco with seja automaticamente aninhado como seu filho. É isso que produz a árvore pai-filho sem passar objetos de span manualmente.with) registra sua duração automaticamente; você nunca define a duração manualmente.| Atributo | Significado |
|---|---|
gen_ai.system | O provedor, por exemplo, anthropic |
gen_ai.request.model | O modelo solicitado, por exemplo, claude-sonnet-5 |
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens | Contagens de tokens da resposta |
gen_ai.response.stop_reason | end_turn, tool_use, max_tokens, etc. |
gen_ai.tool.name | A ferramenta invocada, para spans de chamada de ferramenta |
gen_ai.agent.steps_taken | Quantas iterações do loop a execução levou |
Estes seguem a convenção semântica emergente gen_ai.* usada em ferramentas LLM instrumentadas com OTel, que mantém seus spans consultáveis da mesma forma, independentemente de qual provedor de modelo um determinado span veio.
Rastrear cada span em 100% está bom em baixo volume, mas fica caro e barulhento assim que um produto de agente tem tráfego real.
Um padrão comum é um amostrador ParentBased no span raiz combinado com uma taxa fixa mais alta para spans de erro, para que você mantenha visibilidade total das falhas enquanto amostra as execuções bem-sucedidas de rotina.
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
# Amostra 20% dos spans raiz; cada span filho herda a decisão do pai.
provider = TracerProvider(
resource=resource,
sampler=ParentBased(root=TraceIdRatioBased(0.2)),
)start_as_current_span também pode ser usado como um decorador (@tracer.start_as_current_span("name")) se você preferir encapsular funções inteiras em vez de blocos with.set_status em caso de erro, não apenas record_exception; um span com status não definido ainda pode ser renderizado como "bem-sucedido" em alguns visualizadores de trace, mesmo que você tenha registrado uma exceção nele.provider.shutdown() (ou confie no hook atexit do SDK) em scripts de curta duração para que os spans em buffer no BatchSpanProcessor realmente sejam enviados antes que o processo saia.BatchSpanProcessor podem ser perdidos se o processo sair antes do próximo envio em lote. Correção: chame trace.get_tracer_provider().force_flush() antes de retornar, ou use um SimpleSpanProcessor para processos de curta duração.span.record_exception(exc) sozinho não marca o span como falho. Correção: sempre combine-o com span.set_status(Status(StatusCode.ERROR, str(exc))).start_as_current_span depende da propagação de contexto; criar uma nova thread ou asyncio.create_task sem propagar o contexto pode produzir spans órfãos. Correção: use opentelemetry.context.attach/detach para carregar o contexto entre limites de thread ou tarefa, ou use os helpers de instrumentação assíncrona do OTel.ParentBased com uma taxa raiz reduzida, mantendo 100% para spans de erro e outliers de custo.model_call e outro o chama de llm_request, traces entre serviços se tornam não consultáveis como um conjunto. Correção: concorde com a nomenclatura em um ADR antes que várias equipes comecem a instrumentar independentemente.| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| Apenas logging estruturado (sem tracing) | Uma integração de chamada única sem loop multi-etapa | Você tem um loop de agente multi-etapa onde a ordem e o aninhamento das chamadas importam |
| Um SDK integrado de uma plataforma de observabilidade LLM hospedada | Você deseja tracing mais uma UI pronta com configuração mínima | Você precisa de traces neutros de fornecedor que também fluam para seus painéis não LLM existentes |
| Formato de span personalizado interno (sem OTel) | Um pequeno protótipo sem planos de integração com outras ferramentas | Você já usa OTel em outros lugares ou planeja exportar para Datadog/Jaeger/Honeycomb posteriormente |
| Bibliotecas de auto-instrumentação OTel para SDKs LLM | Você deseja spans com código manual mínimo e aceita as escolhas de atributos da biblioteca | Você precisa de atributos personalizados específicos para o conjunto de ferramentas do seu agente ou lógica de negócios |
Não. O ConsoleSpanExporter usado nos exemplos deste artigo imprime spans no stdout, o que é suficiente para desenvolvimento local. Um backend real (Jaeger, Datadog, Honeycomb ou qualquer coletor compatível com OTLP) só é necessário quando você deseja armazenamento persistente, pesquisa e uma UI de trace.
Um trace é a árvore completa de spans para uma operação (neste artigo, um agent_run). Um span é um único nó nessa árvore, representando uma unidade de trabalho como uma chamada de modelo ou invocação de ferramenta. Os spans são vinculados por relacionamentos pai-filho e, juntos, formam o trace.
Geralmente não. Mantenha os spans focados em metadados curtos e estruturados (nome do modelo, contagens de tokens, motivo de parada) e coloque o texto completo do prompt/resposta em seus logs estruturados, juntando-os ao trace por um ID de solicitação compartilhado. Isso mantém o armazenamento de trace enxuto e evita vazar conteúdo sensível para um backend com políticas de retenção diferentes.
gen_ai.request.id).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)))
raiseRegistrar a exceção e definir o status do span são necessários; o evento de exceção fornece o stack trace, e o status é o que marca o span como falho na maioria dos visualizadores de trace.
Sim, mas o span deve permanecer aberto durante toda a duração do stream em vez de fechar após a solicitação inicial. Abra o span antes de iniciar o stream, consuma stream.text_stream, chame stream.get_final_message() para os números de uso finais, defina-os como atributos de span e somente então deixe o bloco with fechar o span.
Com um BatchSpanProcessor, a exportação de span ocorre de forma assíncrona fora do caminho da solicitação, portanto, a sobrecarga por chamada é tipicamente o custo de algumas escritas de atributos, não uma viagem de ida e volta pela rede. Um SimpleSpanProcessor, que exporta de forma síncrona, adiciona mais sobrecarga e deve ser reservado para scripts de curta duração, não para caminhos de solicitação de produção.
Um span raiz por execução, mais um span filho por chamada de modelo e por invocação de ferramenta. Um loop de agente de quatro etapas com duas chamadas de ferramenta produziria tipicamente um span raiz, dois spans model_call e dois spans tool_call, seis spans no total, todos aninhados sob o raiz.
Rastrear tudo (amostragem de 100%) está bom em baixo volume e é o lugar mais simples para começar. À medida que o tráfego cresce, a amostragem completa se torna cara no backend de trace, então a maioria das equipes muda para um amostrador ParentBased com uma taxa raiz reduzida, mantendo a amostragem completa para erros e spans outliers.
Resource anexa metadados de nível de serviço (como service.name) a cada span que o provedor produz, que é como um backend de trace distingue spans vindos do seu serviço de agente de spans de outros serviços no mesmo trace ou painel.
Versões de Stack: Escrito contra a linha de modelos Claude atual em aproximadamente junho de 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (o padrão), e Claude Haiku 4.5 - e o SDK oficial
anthropicpara Python (última versão 0.x). Nomes de modelos, preços e versões de SDK mudam rapidamente - verifique os detalhes atuais em platform.claude.com/docs antes de confiar neles.
Revisado por Chris St. John·Última atualização: 13 de jul. de 2026