Observability for Agents
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page’s examples target Python 3.12 with LangChain 1.4.2 / langchain-core 1.6.5 / LangGraph 1.2.12,
langchain-ollama 1.1.0 and the OpenTelemetry Python API/SDK, with LangSmith SDK 0.14.1, Langfuse 4.15.6 and
Arize Phoenix (arize-phoenix-otel 0.17.1, openinference-instrumentation-langchain 0.1.76), Java 21 with
LangChain4j 1.20.2, and Spring Boot 4.1 with Spring AI 2.0.1, using DocsAssistant's Ollama llama3.1:8b, as
checked on 2026-09-28 (see the AI Agents baseline). The OpenTelemetry GenAI semantic
conventions cited are those in the OpenTelemetry GenAI semantic-conventions repository on that date, where the
agent and framework spans still have Development status.
A single LLM call can be debugged by reading its input and output. An agent run cannot: it is a tree of model calls, tool calls, sub-agent calls and retries, and the interesting failure is usually several steps before the visible one ("the answer is wrong because the second search used the wrong component filter"). Observability for agents means recording that tree for every run, in a form you can search, compare and aggregate. This page covers the tracing and the rollout techniques; running agents in production — dashboards, alerting, drift, cost management, online evaluation — is the subject of the LLMOps & Evaluation sub-section of this AI section.
Traces and spans per agent step
Model an agent run as a trace and each step as a span, nested the way the work nests:
Record on each span what you need to answer "why did it do that?": for model spans, the model, token usage, latency and (subject to privacy rules) the messages; for tool spans, the tool name, the call ID, the arguments and the result, and an error status on failure; for agent spans, the agent name and the conversation ID that ties runs of one thread together. Aggregated over many runs, the same spans give steps per task, tokens and cost per task, tool error rates and latency by step — the efficiency metrics on Agent Evaluation.
OpenTelemetry GenAI semantic conventions
OpenTelemetry’s GenAI semantic conventions define standard names so that traces from different frameworks and
vendors line up. For agents and tools, the key operation names (the gen_ai.operation.name attribute) and span
names are:
| Operation | Span name | Typical attributes |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
invoke_workflow covers workflow runs. Message content (gen_ai.input.messages, gen_ai.output.messages,
gen_ai.tool.call.arguments, gen_ai.tool.call.result) is opt-in, because it can contain personal data. The
conventions have moved from the main OpenTelemetry semantic-conventions repository into a dedicated GenAI
repository, and are still marked Development, so pin the version your dashboards depend on. See
OpenTelemetry — Semantic conventions for generative AI systems
and OpenTelemetry GenAI semantic conventions
repository.
When a framework does not emit these spans itself, a few lines of middleware do:
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain_ollama import ChatOllama
from opentelemetry import trace
tracer = trace.get_tracer("docsassistant")
AGENT_NAME = "docs_assistant"
@wrap_tool_call
def trace_tool_calls(request, handler):
call = request.tool_call
with tracer.start_as_current_span(
f"execute_tool {call['name']}",
attributes={
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": call["name"],
"gen_ai.tool.call.id": call["id"],
},
) as span:
result = handler(request)
if getattr(result, "status", None) == "error":
span.set_status(trace.StatusCode.ERROR)
return result
agent = create_agent(ChatOllama(model="llama3.1:8b", temperature=0),
tools=[docs_search], middleware=[trace_tool_calls], name=AGENT_NAME)
def ask(question: str, conversation_id: str) -> str:
with tracer.start_as_current_span(
f"invoke_agent {AGENT_NAME}",
attributes={"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": AGENT_NAME,
"gen_ai.conversation.id": conversation_id},
):
result = agent.invoke({"messages": [("user", question)]})
return result["messages"][-1].content
The tool span is created inside the agent span, so the trace nests exactly as in the diagram above. With an OTLP
exporter configured on the TracerProvider, these spans go to any OpenTelemetry backend (Jaeger, Grafana Tempo, a
vendor APM). See OpenTelemetry — Python and
LangChain — Agents (middleware).
LangSmith, Langfuse and Phoenix for LangChain and LangGraph
Three widely used tracing backends integrate with LangChain and LangGraph with almost no code; all of them read their credentials from environment variables, never from source.
-
LangSmith (LangChain’s own platform) — set
LANGSMITH_TRACING=trueandLANGSMITH_API_KEYin the environment and everycreate_agent/ LangGraph run is traced, including each node, model call and tool call; the same traces feed LangSmith’s datasets and evaluations. See LangSmith — Observability. -
Langfuse (open source, self-hostable) — pass its LangChain callback handler; keys come from
LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEYand the host from the environment. See Langfuse. -
Arize Phoenix (open source) — built on OpenTelemetry and the OpenInference conventions: register a tracer provider pointing at Phoenix and instrument LangChain once. See Phoenix and OpenInference.
# Langfuse: a callback handler per run
from langfuse.langchain import CallbackHandler
agent.invoke({"messages": [("user", "What does ef_search control?")]},
config={"callbacks": [CallbackHandler()]})
# Phoenix: OpenTelemetry-based auto-instrumentation, once at startup
from openinference.instrumentation.langchain import LangChainInstrumentor
from phoenix.otel import register
tracer_provider = register(project_name="docsassistant")
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
Choose on hosting (managed vs. self-hosted), on whether you want an OpenTelemetry-native pipeline (Phoenix, or plain OTel as above) or a LangChain-native one (LangSmith), and on how the traces connect to your evaluation workflow.
Spring AI: Micrometer observations
Spring AI instruments itself with Micrometer’s Observation API, so with Spring Boot Actuator and a tracing bridge (Micrometer Tracing with OpenTelemetry) every call already produces spans and metrics:
| Observation name | What it covers |
|---|---|
|
A |
|
Each advisor in the chain (memory, safeguard, tool calling) |
|
The underlying chat or embedding model call, with GenAI-convention attributes and token usage |
|
Each tool execution, with the tool’s name and call ID |
Prompt and completion content are excluded by default. Opt in deliberately — and only where logging them is
allowed — with spring.ai.chat.client.observations.log-prompt, spring.ai.chat.client.observations.log-completion
and spring.ai.tools.observations.include-content:
management.tracing.sampling.probability=1.0
spring.ai.chat.client.observations.log-prompt=false
spring.ai.chat.client.observations.log-completion=false
spring.ai.tools.observations.include-content=true
Here tool arguments and results are recorded (they carry page paths, not personal data), while user prompts and answers are not. See Spring AI — Observability and Spring Boot — Observability.
The observations are ordinary Micrometer ones, so a custom ObservationHandler bean — which Spring Boot registers
with the ObservationRegistry automatically — can react to them, for example to log token usage per model call
without any tracing backend:
ObservationHandler for chat model callsimport io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.observation.ChatModelObservationContext;
import org.springframework.stereotype.Component;
@Component
class TokenUsageHandler implements ObservationHandler<ChatModelObservationContext> {
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof ChatModelObservationContext; // only gen_ai.client.operation
}
@Override
public void onStop(ChatModelObservationContext context) {
ChatResponse response = context.getResponse();
if (response != null) {
Usage usage = response.getMetadata().getUsage();
System.out.printf("model=%s in=%d out=%d%n", response.getMetadata().getModel(),
usage.getPromptTokens(), usage.getCompletionTokens());
}
}
}
LangChain4j exposes the same hooks as listeners: ChatModelListener on chat models (onRequest, onResponse,
onError) and AgentListener on agentic systems (beforeAgentInvocation, afterAgentInvocation,
onAgentInvocationError, plus hooks around agent tool executions), which are the place to create spans or record
metrics; the langchain4j-micrometer-metrics integration and framework integrations (Quarkus, Spring Boot) add
ready-made instrumentation. A ChatModelListener is passed to the model builder, and its attributes() map carries
data from onRequest to onResponse or onError of the same call:
ChatModelListener measuring latency and tokensimport dev.langchain4j.model.chat.listener.ChatModelErrorContext;
import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.ollama.OllamaChatModel;
import java.util.List;
class TracingListener implements ChatModelListener {
@Override
public void onRequest(ChatModelRequestContext ctx) {
ctx.attributes().put("start", System.nanoTime());
}
@Override
public void onResponse(ChatModelResponseContext ctx) {
long ms = (System.nanoTime() - (long) ctx.attributes().get("start")) / 1_000_000;
ChatResponse response = ctx.chatResponse();
System.out.printf("model=%s ms=%d in=%d out=%d%n", response.modelName(), ms,
response.tokenUsage().inputTokenCount(), response.tokenUsage().outputTokenCount());
}
@Override
public void onError(ChatModelErrorContext ctx) {
System.out.printf("model call failed: %s%n", ctx.error().getMessage());
}
}
OllamaChatModel model = OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.listeners(List.of(new TracingListener()))
.build();
In production, replace the printf calls with span or metric calls. See
LangChain4j — Observability.
Shadow deploys and canaries
Agents change in many small ways — a new model, a reworded system prompt, a new tool description — and each can shift behaviour in ways offline evaluation does not fully predict. Two rollout techniques reduce the risk:
-
Shadow deployment — run the new version on a copy of real traffic, in parallel with the current one, without showing its output to users and with every write tool stubbed. Compare the two sets of traces: tool-call patterns, steps, tokens, latency, and judge scores on the answers.
-
Canary release — route a small share of real users (say 5%) to the new version, watch the same metrics plus user signals (thumbs down, escalations, abandoned sessions), and widen or roll back.
Both depend on the tracing above: without per-step spans you can see that the new version is worse, but not why. The full treatment — online evaluation, alerting, drift detection — belongs to LLMOps: Online Evaluation & Feedback and LLMOps: Drift & Regression Monitoring.
In other frameworks
| Framework | Observability support |
|---|---|
OpenAI Agents SDK |
Tracing is on by default: agent, generation, function, handoff and guardrail spans, exportable to custom
processors ( |
Claude Agent SDK |
Streams hook events and per-run usage/cost in result messages; Claude Code’s OpenTelemetry support can export telemetry. |
Microsoft Agent Framework |
Built-in OpenTelemetry instrumentation following the GenAI conventions for agents, chats and tools. |
CrewAI |
An event bus for crew, agent, task and tool events, plus integrations with tracing backends. |