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.
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.
Resumo
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.
Receita
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:
- Seu aplicativo executa um loop de agente com várias etapas (chamada de modelo, chamada de ferramenta, chamada de modelo, ...) e você precisa ver onde a latência ou as falhas se concentram.
- Você já exporta traces para um backend (Jaeger, Datadog, Honeycomb) para outros serviços e deseja que as chamadas Claude apareçam na mesma visualização.
- Você está depurando uma execução específica lenta ou com falha e precisa da forma da ordem de chamada, não apenas de métricas agregadas.
- Você está prestes a escrever um ADR para o esquema de spans da sua equipe e precisa primeiro de uma implementação de referência funcional.
Exemplo 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
# --- 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:
- Um span raiz
agent_runpor solicitação do usuário, com spansmodel_calletool_callaninhados dentro dele como filhos. - Atributos de span (
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. - Registro de falhas no próprio span via
set_status(Status(StatusCode.ERROR, ...))em vez de apenas logá-las em outro lugar. - Um fluxo de tratamento de
toolsetool_useque espelha como um loop de agente Claude real invoca e resolve chamadas de ferramenta. - Um
ConsoleSpanExporterpara desenvolvimento local, trocado por um exportador de backend real em produção.
Mergulho Profundo
Como Funciona
TracerProvideré a fábrica do SDK do OTel para tracers; você o configura uma vez no início do processo com umResource(metadados do serviço) e um ou mais processadores de span.BatchSpanProcessoragrupa 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 blocowithseja automaticamente aninhado como seu filho. É isso que produz a árvore pai-filho sem passar objetos de span manualmente.- Encerrar um span (sair do bloco
with) registra sua duração automaticamente; você nunca define a duração manualmente. - Atributos são apenas pares chave-valor anexados a um span; não há imposição de esquema no nível do SDK, que é exatamente por que as equipes escrevem um ADR para manter os nomes consistentes (veja Relacionados).
Convenção de Nomenclatura de Atributos
| 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.
Considerações de Amostragem
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)),
)Notas do Python
start_as_current_spantambém pode ser usado como um decorador (@tracer.start_as_current_span("name")) se você preferir encapsular funções inteiras em vez de blocoswith.- Sempre chame
set_statusem caso de erro, não apenasrecord_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. - Use
provider.shutdown()(ou confie no hookatexitdo SDK) em scripts de curta duração para que os spans em buffer noBatchSpanProcessorrealmente sejam enviados antes que o processo saia.
Armadilhas
- Esquecer de enviar antes da saída do processo. Em um script curto ou manipulador estilo Lambda, spans em buffer em um
BatchSpanProcessorpodem ser perdidos se o processo sair antes do próximo envio em lote. Correção: chametrace.get_tracer_provider().force_flush()antes de retornar, ou use umSimpleSpanProcessorpara processos de curta duração. - Registrar exceções sem definir o status do span. Chamar
span.record_exception(exc)sozinho não marca o span como falho. Correção: sempre combine-o comspan.set_status(Status(StatusCode.ERROR, str(exc))). - Registrar o texto completo do prompt como um atributo de span. Atributos de span são destinados a metadados curtos e estruturados; grandes blocos de texto incham seu armazenamento de trace e podem vazar dados sensíveis para um backend de trace com regras de retenção diferentes das de seus logs. Correção: mantenha o texto do prompt/resposta em seus logs estruturados e coloque apenas contagens de tokens, nome do modelo e identificadores curtos nos spans.
- Perder o relacionamento pai-filho entre threads ou tarefas assíncronas.
start_as_current_spandepende da propagação de contexto; criar uma nova thread ouasyncio.create_tasksem propagar o contexto pode produzir spans órfãos. Correção: useopentelemetry.context.attach/detachpara carregar o contexto entre limites de thread ou tarefa, ou use os helpers de instrumentação assíncrona do OTel. - Amostragem de 100% em produção. Rastrear cada span em volume total à medida que o tráfego cresce adiciona custo real e ruído no backend de trace. Correção: aplique um amostrador
ParentBasedcom uma taxa raiz reduzida, mantendo 100% para spans de erro e outliers de custo. - Nomes de span e atributos inconsistentes entre serviços. Se um serviço o chama de
model_calle outro o chama dellm_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.
Alternativas
| 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 |
FAQs
Preciso de um backend separado como Jaeger ou Datadog para usar OpenTelemetry?
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.
Qual é a diferença entre um span e um 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.
Devo colocar o texto completo do prompt e da resposta no span como atributos?
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.
Como correlaciono um span de trace com uma linha de log estruturada?
- Gere ou capture um ID de solicitação antes da chamada.
- Defina-o como um atributo de span (por exemplo,
gen_ai.request.id). - Inclua o mesmo ID em sua entrada de log estruturada para essa chamada.
- Consulte qualquer um dos sistemas por esse ID para pular entre eles.
O que acontece se uma chamada de ferramenta levantar uma exceção dentro de um span rastreado?
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.
Posso rastrear respostas Claude em streaming da mesma forma?
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.
Por que usar nomes de atributos `gen_ai.*` em vez da minha própria nomenclatura?
- Segue a convenção semântica OTel emergente para spans de IA generativa, que mantém os atributos consistentes entre provedores e serviços.
- Backends de trace e painéis que entendem essa convenção podem renderizá-los sem configuração personalizada.
- Dá à sua equipe um ponto de partida documentado em vez de inventar nomes do zero, que é também o que o modelo ADR nesta seção formaliza.
Adicionar tracing retarda visivelmente meu loop de agente?
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.
Quantos spans um único loop de agente deve produzir?
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.
A amostragem é necessária ou posso rastrear tudo?
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.
Para que serve `Resource` em `TracerProvider(resource=resource)`?
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.
Relacionados
- Como a Observabilidade Funciona para Aplicações LLM - o modelo mental em que o tracing se encaixa.
- Noções Básicas de Observabilidade - o padrão de logging plano e pré-tracing no qual este artigo se baseia.
- Logging Estruturado de Prompts, Respostas e Contagens de Tokens - o esquema de log que se emparelha com spans via ID de solicitação.
- Modelo ADR: Definindo seu Esquema de Spans OTel para Chamadas de Ferramentas - documentando as decisões de nomenclatura e amostragem feitas aqui.
- Correlação de Latência e Erros com Implantações de Código - usando dados de trace para atribuir uma regressão a uma implantação.
- Melhores Práticas de Observabilidade - uma lista de verificação consolidada nesta seção.
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.