Skip to content
Blog

Observability for Agents: Tracing Tool Calls and Reasoning

An agent is a loop, not a single API call. Instrument it with OpenTelemetry AGENT/LLM/TOOL spans and a shared trace_id so you can attribute bad reasoning, slow dependencies, and tool failures.

Published on • August 3, 2026

AI Assistant

The single biggest lie you can tell yourself about an agent is that it is a single API call. It is a loop: model thinks → picks a tool → tool executes → result feeds back → model thinks again. When that loop returns a wrong answer, the proximate blame lands on “the model,” but the actual defect usually lives in the action layer — a tool that timed out, an argument the model generated badly, or a business error disguised as a successful RPC. — “Run AI like production infrastructure, not like an experiment. Trace workflows, log every call, and keep the system observable at all times.”

Logs of ping-pong can’t answer “why did the agent pick that tool, with those arguments?” You need traces, and the Model Context Protocol gives the tool boundary a stable, standard shape — spans that cross process and language boundaries via a shared trace_id.

Prerequisites

  • Python 3.10+, pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp.
  • An OTLP endpoint (Jaeger/Grafana Tempo, or local http://localhost:4318/v1/traces).
  • MCP standardizes tools/call; OpenTelemetry semantic conventions for agents define span kinds (AGENT, LLM, TOOL) and recommend capturing tool name + arguments + result with trace context propagated across the client/server boundary.

Step 1: A tracer and an AGENT root span

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("agent-runtime")

Wrap the whole run in a root span so every tool and model call nests underneath (one trace = one agent run).

Step 2 — Wrap each LLM reasoning round

def reason(agent, content, response):
    with tracer.start_as_current_span("llm.round") as span:
        span.set_attribute("gen_ai.operation.name", "generate")
        span.set_attribute("gen_ai.prompt", content)
        span.set_attribute("gen_ai.response", response)
        span.set_attribute("gen_ai.usage.total_tokens", response.usage_metadata.total_token_count)
        return response

Notice we attach token usage to the span — this is where cost and observability meet.

Step 3 — Wrap tool calls as TOOL spans

The tool span answers the questions logs cannot. tools/call <name> {args} spans make each execution a first-class, searchable event.

import json

def call_mcp_tool(name, args, call_id):
    with tracer.start_as_current_span(f"tools/call {name}") as span:
        span.set_attribute("mcp.method.name", "tools/call")
        span.set_attribute("gen_ai.operation.name", "execute_tool")
        span.set_attribute("tool_name", name)
        span.set_attribute("tool_call_id", call_id)
        span.set_attribute("gen_ai.tool.call.arguments", json.dumps(args))
        try:
            result = execute_mcp(name, args)   # your MCP client round trip
            span.set_attribute("gen_ai.tool.call.result", truncate(json.dumps(result)))
            return result
        except Exception as e:
            span.record_exception(e)
            span.set_status(trace.Status(trace.StatusCode.ERROR))
            raise

Capture arguments and result intentionally — payloads are sensitive, so only opt-in when the debugging value outweighs retention risk.

Putting It All Together — the agent loop

def run_agent(prompt):
    state = prompt
    with tracer.start_as_current_span("agent.run") as root:
        for round_no in range(MAX_ROUNDS):
            resp = client.models.generate_content(model=MODEL, contents=state)
            state = f"{state}\n{resp.text}"
            reason(agent, state, resp)
            action = choose_tool(resp)          # parse structured tool call
            if not action:
                return resp.text                # no tool -> answer ready
            result = call_mcp_tool(action.name, action.args, action.call_id)
            state += f"\nTool={action.name} -> {result}"
    raise RuntimeError("no answer within rounds")

Why this matters for debugging

  • Attribution — distinguish bad reasoning (LLM span high latency/rounds) from a slow dependency (TOOL span) from a tool that returned an error the model misread (TOOL Status ERROR).
  • Cross-service — MCP propagates trace context, so the MCP server’s own spans join the same trace_id.
  • Cost + latency on every LLM span answer “how many model rounds, how slow, how expensive.”

Conclusion & Next Steps

Observability for agents is distributed tracing plus LLM and tool semantic conventions. Next: sample spans at scale to bound cardinality, forward traces to Langfuse/LangSmith/Grafana, and alert on “TOOL span error rate by tool name” — the highest-signal agent health metric.

References / Sources