Agent Observability: Tracing, Debugging, and Monitoring in Production
An agent that took fourteen steps and cost $0.80 instead of two steps and $0.02 is failing in a specific way — and without span-level tracing you'll never see it. OpenTelemetry, LangSmith, Langfuse, and Arize Phoenix give you X-ray vision into every LLM call, every tool call, every retry. Instrument from day one; retroactive tracing helps no one at 3 AM.
Learning Objectives
After this lesson, you will be able to:
Add OpenTelemetry tracing to your agents so every LLM call and tool call is captured in a visual tree you can inspect
Read a LangSmith trace to diagnose exactly where an agent failed -- which tool call went wrong, which argument was bad, how many tokens were wasted
Build a monitoring dashboard that tracks cost and speed for every agent run, so cost surprises never happen again
Thread a trace ID through multi-agent handoffs so you can follow one task across multiple agents in your logs
Set up token budget alerts that warn you (or auto-kill the agent) before a runaway loop burns through your API credits
Observability is your X-ray vision into what your agent is actually doing. Without it, your agent is a black box -- you send in a question and get back an answer, with no idea what happened in between. This lesson gives you the tools to see everything.
Try it! Next time you use an AI chatbot, count how many "steps" it seems to take for a complex query. Now imagine each step had a timestamp, token count, and cost. That is what tracing gives you. If you have access to LangSmith (free tier available), try running a simple LangChain agent and clicking on the trace -- the visual tree of every step is incredibly illuminating.
Agents fail silently. An LLM returns JSON with a wrong field name. The tool fails with a validation error. The agent retries with a slightly different argument — and the retry works. Your user sees the correct answer. Your monitoring sees "success." But somewhere in the logs, hidden across 12 tool calls, is a $0.60 retry spiral that should have cost $0.12.
A trace is the complete record of one agent execution from start to finish. It is a tree structure: one root span representing the overall task, with child spans for each LLM call, tool call, and retrieval operation. If an agent takes 8 tool calls to complete a task, the trace has 9 spans: 1 root + 8 children (or more, if tool calls have their own children like retrieval followed by reranking).
The trace answers the question: "What happened during this specific run?" You can look at any failed run by its trace_id and see exactly what the agent received, decided, and executed.
A span represents one unit of work within a trace. Each span has:
Name: What operation this is (e.g., "anthropic.messages.create", "tool_call.search_web")
Start and end timestamps: Used to calculate latency
Attributes: Metadata specific to this operation (model name, temperature, input token count, output token count)
Status: Success, error, or timeout
Events: Notable moments within the span (cache hit, retry triggered, fallback activated)
Spans nest to form the trace tree. An LLM call span might have child spans for the token streaming events, or for a tool call the LLM decided to make during that response.
Events are lightweight markers within a span. They do not have their own duration — they are just timestamps with a label. Use events to mark moments like:
"retry.triggered" — the tool failed and the agent is retrying
"cache.hit" — a cached tool result was used instead of a fresh API call
"fallback.activated" — the primary tool failed, switching to backup
"context.truncated" — the conversation history was trimmed to fit context window
Events let you ask questions like "how many retries happened across all runs last week?" without having to parse through full span logs.
OpenTelemetry (OTel) is the vendor-neutral standard for instrumentation. Once you instrument your agent with OTel spans, you can send the data to any backend: LangSmith, Arize Phoenix, Datadog, Jaeger, Honeycomb, or your own Grafana stack.
For production, auto-instrumentation patches the library at import time — you do not need to manually add spans everywhere. The openinference library provides auto-instrumentation for Anthropic, OpenAI, LangChain, and more:
pythonreference · read-only
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from openinference.instrumentation.anthropic import AnthropicInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Send traces to Phoenix (self-hosted) or any OTLP-compatible backend
exporter = OTLPSpanExporter(endpoint="http://localhost:6006/v1/traces")
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(exporter))
# This patches anthropic.Anthropic() at import time
AnthropicInstrumentor().instrument(tracer_provider=provider)
# From here on, every client.messages.create() call is automatically traced
# No code changes needed in your agent logic
Auto-instrumentation is the recommended approach for existing codebases. You get traces for every API call with zero changes to your agent code.
Waterfall chart: Each span as a horizontal bar, showing start time and duration. LLM calls are wide (1-3 seconds). Tool calls vary (50ms for cache hits, 2+ seconds for slow APIs). Retries show as duplicate spans at the same nesting level.
Token cost breakdown: Each LLM call span shows input tokens, output tokens, and estimated cost. The root span aggregates totals. You can see at a glance which step consumed the most tokens.
Input/output viewer: Click any span to see the exact text sent to the model and the exact text returned. This is where you find the "wrong tool argument" bug — you see the model output {"tool": "search_products", "arguments": {"query": "order_id: 12345"}} when it should have called lookup_order.
Latency heatmap: Across many runs, which spans are consistently slow? Which tool calls have high P95 latency?
LangSmith supports attaching LLM-as-judge evaluators to traces. After each run, a second LLM call automatically scores the output:
pythonreference · read-only
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from langsmith import Client
client = Client()
# Create an evaluator that scores tool call efficiency
def efficiency_evaluator(run, example):
tool_call_count = sum(
1 for step in run.child_runs
if step.run_type == "tool"
)
# Flag runs that used more than 10 tool calls as inefficient
score = 1.0 if tool_call_count <= 10 else max(0, 1.0 - (tool_call_count - 10) * 0.1)
return {"key": "tool_efficiency", "score": score}
# Attach to a dataset and run evaluations
client.evaluate(
target=run_agent,
data="my-agent-test-dataset",
evaluators=[efficiency_evaluator],
experiment_prefix="v2-prompt-update"
)
In production, you will have thousands of traces. Use tags and metadata to filter:
run_id: Find a specific execution (log the run_id when a user reports a bug)
session_id: Find all traces for a specific user session
tags: Label traces by feature ("flight-booking", "code-generation")
latency > 10s: Find slow outliers
cost > $0.50: Find expensive runs
status = error: Find all failed runs
What Do You Think?
An agent that books flights costs $0.02 per run on average but occasionally costs $0.80. What should you add to your tracing to find the root cause?
The expensive runs are caused by retry spirals. To find them: add retry_count as a span attribute on every tool call span. Track input_tokens per LLM call — a $0.80 run likely has a context window inflation event where a large tool result was not truncated. Add a context_length attribute at each step so you can see the conversation growing. Filter traces by cost > $0.20, then look for spans with retry_count > 0 or LLM call spans with unusually high input_tokens.
A user asks the travel booking agent: "Book a round-trip flight from SFO to JFK for March 15–22, under $400." The agent runs for 47 seconds and returns: "I was unable to complete your booking. Please try again." The run_id is run_4f7a2b. You open it in LangSmith.
The second LLM call span shows the model selecting flight option #3 and calling the booking tool. But look at the arguments: {"flight_id": "UA-4821", "passenger_name": "...", "card_num": "..."}. The actual tool schema requires "card_number", not "card_num". The tool call span shows status: error, error: "ValidationError: 'card_num' is not a valid field".
The model receives the validation error and retries. But it still generates card_num. The tool description says card_number but the model trained on a slightly different schema. Three retries, all with the same wrong field name. Each retry: 1 LLM call + 1 failed tool call = ~2,000 tokens. Three retries = 6,000 extra tokens.
By the fourth LLM call, the context window has grown: the original search results (3,200 tokens) + the booking attempts + the error messages. Input token count on this LLM call: 12,400. The model is now reasoning about all three previous failures simultaneously. It starts hedging and eventually generates: "I was unable to complete your booking."
Two fixes revealed by the trace: (1) The tool schema description was wrong — update it to say card_number explicitly with an example. (2) Add Pydantic validation before the tool executes — catch the card_num vs card_number mismatch and return a structured error with the correct field name: {"error": "WrongFieldName", "expected": "card_number", "received": "card_num"}. This gives the model a clear correction signal instead of a generic ValidationError.
When your orchestrator spawns sub-agents, each sub-agent needs to create spans as children of the orchestrator's trace, not as independent top-level traces:
pythonreference · read-only
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from opentelemetry import trace, context
from opentelemetry.propagate import inject, extract
import asyncio
tracer = trace.get_tracer("multi-agent")
async def orchestrator(task: str):
with tracer.start_as_current_span("orchestrator.run") as root_span:
root_span.set_attribute("task", task)
# Inject trace context into carrier dict to pass to sub-agents
carrier = {}
inject(carrier) # carrier now contains trace_id and span_id
# Spawn sub-agents in parallel, passing the trace context
results = await asyncio.gather(
sub_agent("research", task, carrier),
sub_agent("planning", task, carrier),
sub_agent("execution", task, carrier),
)
root_span.set_attribute("sub_agent_count", 3)
return results
async def sub_agent(name: str, task: str, parent_carrier: dict):
# Extract parent context from carrier — links this span as a child
parent_context = extract(parent_carrier)
with tracer.start_as_current_span(
f"sub_agent.{name}",
context=parent_context
) as span:
span.set_attribute("sub_agent.name", name)
span.set_attribute("sub_agent.task", task)
# This sub-agent's LLM calls and tool calls will nest here
result = await run_sub_agent_loop(name, task)
span.set_attribute("sub_agent.result_length", len(result))
return result
In LangSmith or Arize, the trace tree now shows: orchestrator.run → 3 children (sub_agent.research, sub_agent.planning, sub_agent.execution), each with their own LLM call and tool call children. You can see all 4 agents' work in one unified waterfall.
The trace_id is the most important string in production agent debugging. It is the single key that lets you reconstruct any execution:
Log it everywhere: in your application logs, in your error tracker (Sentry), in your database with the task record
Include it in user-facing error messages: "Error reference: run_4f7a2b" — users can report it, you can find the exact trace
Use it for cost attribution: aggregate total cost by trace_id to know exactly what each user task cost
What Do You Think?
A multi-agent system: orchestrator spawns 3 sub-agents in parallel. Sub-agent #2 fails. How do you correlate its logs with the orchestrator's trace?
The answer is context propagation. Passing the parent carrier (containing trace_id and span_id) to each sub-agent at spawn time means all sub-agents' spans appear as children under the orchestrator's span in the same trace. You open one trace in LangSmith and see the full picture: orchestrator → sub-agent-1 (success) → sub-agent-2 (error, 3 retries) → sub-agent-3 (success, waiting for sub-agent-2). The failure is immediately visible.
Token costs are not uniform. The same agent task can cost $0.02 on a fast day and $0.80 on a bad one. Cost monitoring tells you which tasks, which tools, and which users are expensive.
Tests · The root span should have estimated_cost_usd, total_input_tokens, and total_output_tokens attributes. Each tool call should have a tool.name attribute and tool.status='success'. The trace should have at least 5 spans total.
Traces are the unit of agent debugging. A trace captures the complete execution tree of one agent run; every LLM call, every tool call, every retry is a span in that tree; the trace_id is the single key to reconstruct any failure
Cost surprises require per-span token tracking. Aggregate token counts at run completion misses retry spirals; tracking input_tokens and output_tokens per LLM call span reveals exactly which step caused a cost spike
Distributed tracing requires explicit context propagation. When spawning sub-agents, inject the parent trace context into the message; the sub-agent extracts it and creates child spans; without this, multi-agent systems produce disconnected log streams you cannot correlate
Seven metrics constitute a minimal production dashboard. Success rate, P95 latency, cost per run, token budget hit rate, retry rate, context overflow rate, and tool error rate; alert on any that cross a baseline threshold