Instrumenting Agent Loops with OpenTelemetry Tracing
An agent loop that calls Claude multiple times and invokes tools in between is hard to debug from logs alone, because logs don't preserve the shape of the run.
Search across all documentation pages
An agent loop that calls Claude multiple times and invokes tools in between is hard to debug from logs alone, because logs don't preserve the shape of the run.
OpenTelemetry (OTel) tracing fixes that by wrapping each model call and tool invocation in a span, nested under one root span per agent run, so you can see exactly where time went and where things broke.
OpenTelemetry is a vendor-neutral standard for emitting traces, made of spans that represent units of work, with a parent-child relationship that mirrors your call structure.
For a Claude agent loop, the natural mapping is one root span per user request, with a child span for each model call and each tool invocation.
Each span carries attributes: model name, token counts, stop reason, tool name, and duration, which is enough to answer "why was this run slow" without opening a log viewer.
Most OTel setups export spans to a backend (Jaeger, Datadog, Honeycomb, or a local console exporter for development) via the OTel SDK's exporter and processor pipeline.
This page builds a working tracer for a Claude agent loop from scratch, using the console exporter for local development.
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")
# ... make the Claude API call here ...When to reach for this:
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
# --- One-time tracer setup ---
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"):
"""Make one Claude call wrapped in a child span."""
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:
"""Run a tool wrapped in a child span."""
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:
"""One root span per run, containing one child span per step."""
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)What this demonstrates:
agent_run span per user request, with model_call and tool_call spans nested inside it as children.gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.tool.name) that let you query traces by model, tool, or token cost later.set_status(Status(StatusCode.ERROR, ...)) instead of only logging them elsewhere.tools and tool_use handling flow that mirrors how a real Claude agent loop invokes and resolves tool calls.ConsoleSpanExporter for local development, swapped for a real backend exporter in production.TracerProvider is the OTel SDK's factory for tracers; you configure it once at process startup with a Resource (service metadata) and one or more span processors.BatchSpanProcessor batches finished spans and hands them to an exporter asynchronously, which keeps tracing overhead off your request's critical path.tracer.start_as_current_span(...) opens a span and makes it the "current" span in a context variable, so any span started inside that with block is automatically nested as its child. This is what produces the parent-child tree without manually passing span objects around.with block) records its duration automatically; you never set duration manually.| Attribute | Meaning |
|---|---|
gen_ai.system | The provider, e.g. anthropic |
gen_ai.request.model | The model requested, e.g. claude-sonnet-5 |
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens | Token counts from the response |
gen_ai.response.stop_reason | end_turn, tool_use, max_tokens, etc. |
gen_ai.tool.name | The tool invoked, for tool-call spans |
gen_ai.agent.steps_taken | How many loop iterations the run took |
These follow the emerging gen_ai.* semantic convention used across OTel-instrumented LLM tooling, which keeps your spans queryable the same way regardless of which model provider a given span came from.
Tracing every span at 100% is fine at low volume but gets expensive and noisy once an agent product has real traffic.
A common pattern is a ParentBased sampler at the root span combined with a higher fixed rate for error spans, so you keep full visibility into failures while sampling down routine successful runs.
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
# Sample 20% of root spans; every child span inherits the parent's decision.
provider = TracerProvider(
resource=resource,
sampler=ParentBased(root=TraceIdRatioBased(0.2)),
)start_as_current_span is also usable as a decorator (@tracer.start_as_current_span("name")) if you prefer wrapping whole functions instead of with blocks.set_status on error, not just record_exception; a span with an unset status can still render as "successful" in some trace viewers even if you logged an exception on it.provider.shutdown() (or rely on the SDK's atexit hook) in short-lived scripts so buffered spans in the BatchSpanProcessor actually flush before the process exits.BatchSpanProcessor can be lost if the process exits before the next batch flush. Fix: call trace.get_tracer_provider().force_flush() before returning, or use a SimpleSpanProcessor for short-lived processes.span.record_exception(exc) alone does not mark the span as failed. Fix: always pair it with span.set_status(Status(StatusCode.ERROR, str(exc))).start_as_current_span relies on context propagation; spawning a new thread or asyncio.create_task without propagating context can produce orphaned spans. Fix: use opentelemetry.context.attach/detach to carry context across thread or task boundaries, or use the OTel async instrumentation helpers.ParentBased sampler with a lower root rate, keeping 100% for error and cost-outlier spans.model_call and another calls it llm_request, cross-service traces become unqueryable as a set. Fix: agree on naming in an ADR before multiple teams start instrumenting independently.| Alternative | Use When | Don't Use When |
|---|---|---|
| Structured logging only (no tracing) | A single-call integration with no multi-step loop | You have a multi-step agent loop where call ordering and nesting matter |
| A hosted LLM observability platform's built-in SDK | You want tracing plus a ready-made UI with minimal setup | You need vendor-neutral traces that also flow into your existing non-LLM dashboards |
| Custom in-house span format (no OTel) | A tiny prototype with no plans to integrate with other tooling | You already use OTel elsewhere, or plan to export to Datadog/Jaeger/Honeycomb later |
| OTel auto-instrumentation libraries for LLM SDKs | You want spans with minimal manual code and accept the library's attribute choices | You need custom attributes specific to your agent's tool set or business logic |
No. The ConsoleSpanExporter used in this article's examples prints spans to stdout, which is enough for local development. A real backend (Jaeger, Datadog, Honeycomb, or any OTLP-compatible collector) is only needed once you want persistent storage, search, and a trace UI.
A trace is the full tree of spans for one operation (in this article, one agent_run). A span is a single node in that tree, representing one unit of work like a model call or tool invocation. Spans are linked by parent-child relationships, and together they form the trace.
Generally no. Keep spans focused on short, structured metadata (model name, token counts, stop reason) and put full prompt/response text in your structured logs instead, joined to the trace by a shared request ID. This keeps trace storage lean and avoids leaking sensitive content into a backend with different retention policies.
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)))
raiseRecording the exception and setting the span status both are needed; the exception event gives you the stack trace, and the status is what marks the span as failed in most trace viewers.
Yes, but the span should stay open for the duration of the stream rather than closing after the initial request. Open the span before starting the stream, consume stream.text_stream, call stream.get_final_message() for the final usage numbers, set those as span attributes, and only then let the with block close the span.
With a BatchSpanProcessor, span export happens asynchronously off the request path, so the overhead per call is typically the cost of a few attribute writes, not a network round trip. A SimpleSpanProcessor, which exports synchronously, adds more overhead and should be reserved for short-lived scripts, not production request paths.
One root span per run, plus one child span per model call and per tool invocation. A four-step agent loop with two tool calls would typically produce one root span, two model_call spans, and two tool_call spans, six spans total, all nested under the root.
Tracing everything (100% sampling) is fine at low volume and is the simplest place to start. As traffic grows, full sampling becomes expensive in the trace backend, so most teams shift to a ParentBased sampler with a reduced root rate, while keeping error and outlier spans at full sampling.
Resource attaches service-level metadata (like service.name) to every span the provider produces, which is how a trace backend distinguishes spans coming from your agent service versus spans from other services in the same trace or dashboard.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, pricing, and SDK versions move quickly - verify current specifics at platform.claude.com/docs before relying on them.
Reviewed by Chris St. John·Last updated Jul 13, 2026