Distributed Tracing for Multi-Agent Systems
Implement distributed tracing for multi-agent systems using OpenTelemetry. Track tool calls, reasoning chains, and inter-agent communication across your fleet.
Published on • September 8, 2026
AI Assistant

When a single agent fails, debugging is straightforward — read the logs. When five agents collaborate on a task and the result is wrong, finding the failure point is like finding a needle in a haystack. Distributed tracing gives you a map: every decision, tool call, and handoff visualized in a single trace.
Why Multi-Agent Systems Need Tracing
Multi-agent systems introduce complexity that traditional logging can’t handle:
- Cross-agent communication — Agent A calls Agent B which calls Agent C
- Parallel execution — Multiple agents working simultaneously
- Tool call chains — Agent uses Tool A which triggers Tool B
- State propagation — Understanding how state flows between agents
- Error attribution — Knowing which agent caused a failure
Without tracing, you’re debugging blind.
OpenTelemetry for Agents
OpenTelemetry (OTel) is the industry standard for distributed tracing. It works perfectly for agent systems:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Set up tracing
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("agent-system")
Instrumenting Agent Execution
Basic Agent Span
from opentelemetry.trace import StatusCode
class TracedAgent:
def __init__(self, name: str, llm, tools):
self.name = name
self.llm = llm
self.tools = tools
def execute(self, task: str, parent_context=None) -> str:
with tracer.start_as_current_span(
"agent.execute",
attributes={
"agent.name": self.name,
"agent.task": task,
}
) as span:
try:
# Reasoning step
with tracer.start_as_current_span("agent.reason") as reason_span:
plan = self._plan(task)
reason_span.set_attribute("agent.plan", str(plan))
# Tool execution
results = []
for tool_call in plan.get("tool_calls", []):
result = self._execute_tool(tool_call)
results.append(result)
# Synthesis
with tracer.start_as_current_span("agent.synthesize") as synth_span:
response = self._synthesize(task, results)
synth_span.set_attribute("agent.response_length", len(response))
span.set_status(StatusCode.OK)
return response
except Exception as e:
span.set_status(StatusCode.ERROR, str(e))
span.record_exception(e)
raise
Tracing Tool Calls
class TracedToolExecutor:
def execute(self, tool_name: str, arguments: dict) -> dict:
with tracer.start_as_current_span(
"tool.execute",
attributes={
"tool.name": tool_name,
"tool.arguments": json.dumps(arguments)[:500],
}
) as span:
start_time = time.time()
try:
result = self._run_tool(tool_name, arguments)
duration_ms = (time.time() - start_time) * 1000
span.set_attribute("tool.result_length", len(str(result)))
span.set_attribute("tool.duration_ms", duration_ms)
span.set_attribute("tool.success", True)
span.set_status(StatusCode.OK)
return {"success": True, "result": result, "duration_ms": duration_ms}
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
span.set_attribute("tool.success", False)
span.set_attribute("tool.error", str(e))
span.set_status(StatusCode.ERROR, str(e))
span.record_exception(e)
return {"success": False, "error": str(e), "duration_ms": duration_ms}
Tracing Agent-to-Agent Handoffs
class TracedMultiAgentSystem:
def __init__(self, agents: dict):
self.agents = agents
def orchestrate(self, task: str) -> str:
with tracer.start_as_current_span(
"orchestration.run",
attributes={"orchestration.task": task}
) as span:
current_agent = "supervisor"
context = {"task": task}
while current_agent != "done":
with tracer.start_as_current_span(
f"agent.{current_agent}",
attributes={"agent.id": current_agent}
) as agent_span:
# Execute agent
result = self.agents[current_agent].execute(
task=task,
context=context
)
# Record handoff
next_agent = result.get("next_agent", "done")
if next_agent != "done":
with tracer.start_as_current_span(
"agent.handoff",
attributes={
"handoff.from": current_agent,
"handoff.to": next_agent,
"handoff.context": json.dumps(result.get("context", {}))[:200]
}
):
pass # Handoff span records the transition
agent_span.set_attribute("agent.next", next_agent)
current_agent = next_agent
context.update(result.get("context", {}))
return context.get("final_answer", "")
Adding Semantic Attributes
Define consistent attributes across your traces:
# Semantic conventions for agent traces
AGENT_ATTRIBUTES = {
"agent.name": "Name of the agent",
"agent.task": "Input task/query",
"agent.plan": "Agent's execution plan",
"agent.response_length": "Length of agent response",
"tool.name": "Name of tool called",
"tool.arguments": "Tool input arguments",
"tool.result_length": "Length of tool result",
"tool.success": "Whether tool call succeeded",
"tool.duration_ms": "Tool execution time",
"handoff.from": "Source agent",
"handoff.to": "Destination agent",
"handoff.context": "Context passed between agents",
"error.type": "Error class name",
"error.message": "Error message",
}
Visualizing Traces
With Jaeger
# Export to Jaeger
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
jaeger_exporter = JaegerExporter(
agent_host_name="localhost",
agent_port=6831,
)
provider.add_span_processor(BatchSpanProcessor(jaeger_exporter))
With LangSmith
import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = "your-api-key"
# LangSmith automatically traces LangChain/LangGraph calls
Custom Dashboard
from fastapi import FastAPI
from opentelemetry.trace import format_span_id
app = FastAPI()
@app.get("/traces/{trace_id}")
async def get_trace(trace_id: str):
"""Get all spans for a trace."""
spans = await query_spans(trace_id)
# Build timeline
timeline = []
for span in sorted(spans, key=lambda s: s.start_time):
timeline.append({
"id": format_span_id(span.span_id),
"name": span.name,
"start_ms": (span.start_time - spans[0].start_time) / 1e6,
"duration_ms": span.duration / 1e6,
"attributes": dict(span.attributes),
"parent": format_span_id(span.parent.span_id) if span.parent else None,
})
return {"trace_id": trace_id, "spans": timeline}
Analyzing Agent Performance
Use traces to identify bottlenecks:
class AgentPerformanceAnalyzer:
def analyze_trace(self, trace_id: str) -> dict:
spans = self._get_spans(trace_id)
# Find slowest operations
tool_spans = [s for s in spans if s.name.startswith("tool.")]
tool_spans.sort(key=lambda s: s.duration, reverse=True)
# Calculate agent time distribution
agent_spans = [s for s in spans if s.name.startswith("agent.")]
agent_times = {}
for span in agent_spans:
agent_name = span.attributes.get("agent.name", "unknown")
agent_times[agent_name] = agent_times.get(agent_name, 0) + span.duration
# Find error patterns
error_spans = [s for s in spans if s.status == StatusCode.ERROR]
return {
"total_duration_ms": sum(s.duration for s in spans) / 1e6,
"slowest_tools": [
{"name": s.attributes.get("tool.name"), "duration_ms": s.duration / 1e6}
for s in tool_spans[:5]
],
"agent_time_distribution": {
k: v / 1e6 for k, v in agent_times.items()
},
"error_count": len(error_spans),
"span_count": len(spans),
}
Sampling Strategies
In production, you can’t trace everything:
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
# Sample 10% of traces in production
sampler = TraceIdRatioBased(0.1)
# Always trace errors
# (Use custom sampler for this)
provider = TracerProvider(sampler=sampler)
Conclusion
Distributed tracing transforms multi-agent debugging from guesswork into science. With OpenTelemetry, you get vendor-neutral instrumentation that works across frameworks. Start by instrumenting agent execution and tool calls, add semantic attributes for consistency, export to Jaeger or LangSmith for visualization, and use trace analysis to identify performance bottlenecks.