Tracing Multi-Agent Workflows with OpenTelemetry
Source: mortalapps.com- Distributed tracing maps execution flows across decoupled, autonomous agent nodes by establishing parent-child span relationships.
- Solves the visibility gap in multi-agent handoffs, non-deterministic loops, and cross-framework execution paths.
- Enables precise step-level latency measurement, cascading failure isolation, and granular token cost attribution.
- Utilizes W3C Trace Context propagation to pass trace headers seamlessly across HTTP, gRPC, and message queue boundaries.
- Requires carefully tuned sampling strategies to manage the storage and performance overhead of large LLM payloads.
Why This Matters
Implementing a robust system for tracing multi agent workflow opentelemetry deployments is critical as organizations transition from single-agent prototypes to distributed multi-agent networks. In a multi-agent system, execution is non-deterministic, asynchronous, and often spans multiple frameworks (such as LangGraph, CrewAI, or custom engines) and cloud boundaries. Without structured tracing, engineers cannot diagnose why a workflow failed, which agent introduced a hallucination, or where latency bottlenecks reside.
This approach was invented to replace fragmented, framework-specific logging with a vendor-agnostic standard. When agents delegate tasks to other agents, traditional logging loses the execution context. If you ignore distributed tracing in production, cascading failures become impossible to debug, token cost attribution fails, and SLA breaches go unresolved because you cannot isolate the slow component.
Use OpenTelemetry-based tracing when your agents communicate over HTTP/gRPC, utilize asynchronous message queues, or hand off tasks across framework boundaries. Avoid custom logging wrappers, which create tight coupling and fail to scale as your agent ecosystem expands. By standardizing on OpenTelemetry, enterprises gain a unified view of the entire agentic call graph, from the initial user prompt down to individual tool executions and LLM completions. This ensures that every decision made by an autonomous agent is auditable, measurable, and debuggable at scale.
Core Concepts
To successfully implement distributed tracing across multi-agent systems, engineers must master several core OpenTelemetry and agent-specific concepts:
- Trace Context: The metadata packet containing a unique Trace ID, Span ID, and Trace Flags. This context must be passed along every execution path to correlate distributed actions.
- W3C Trace Context Standard: A universally accepted specification defining HTTP headers (
traceparentandtracestate) for context propagation across network boundaries. - GenAI Semantic Conventions: Standardized OpenTelemetry attributes (e.g.,
gen_ai.system,gen_ai.request.model,gen_ai.usage.input_tokens) that ensure LLM inputs, outputs, and metadata are recorded consistently. - Span Link: A pointer that associates independent traces (such as an offline evaluation run or an asynchronous background task) without creating a strict parent-child hierarchy.
- Baggage: A key-value store propagated alongside the trace context, used to pass runtime metadata like tenant IDs, user permissions, or token budgets down the call graph.
- Agent Handoff Span: A specialized span representing the transition of execution authority from a coordinator agent to a specialist agent.
How It Works
Tracing a multi-agent workflow involves injecting, propagating, and extracting trace context across network and framework boundaries. The lifecycle of a distributed trace during an agent-to-agent handoff follows a structured sequence:
1. Context Injection at the Source Agent
When the Coordinator Agent decides to delegate a task to a Specialist Agent, it initiates an outbound request (e.g., an HTTP POST or a message queue publish). Before sending the request, the Coordinator's tracing SDK injects the active span's context into the outgoing request's headers. This serializes the Trace ID and current Span ID into the standard traceparent header.
2. Context Extraction at the Destination Agent
Upon receiving the request, the Specialist Agent's entry-point middleware extracts the traceparent header. The local OpenTelemetry SDK parses this header and sets the extracted context as the active parent context for all subsequent operations executed by the Specialist Agent. This links the Specialist's local spans directly to the Coordinator's calling span.
3. Execution and Attribute Enrichment
As the Specialist Agent executes its internal loop, it generates child spans for each step, including LLM completions, database queries, and tool executions. Each span is enriched with GenAI semantic attributes, local variables, and performance metrics. If the Specialist calls external APIs or databases, the context continues to propagate downstream.
4. Failure Handling and Propagation
If an error occurs within the Specialist Agent (e.g., an LLM rate limit or a tool execution failure), the active span catches the exception, sets its status to ERROR, and records the stack trace. This error state is propagated back to the Coordinator Agent via the response payload, allowing the Coordinator's parent span to accurately reflect the downstream failure and initiate fallback logic.
Architecture
The architecture of a distributed tracing system for multi-agent workflows consists of decoupled agent services, a context propagation layer, an aggregation collector, and an observability backend. Execution begins when a user client sends a request to the Coordinator Agent. The Coordinator Agent initializes the root trace and generates parent spans.
When the Coordinator delegates a task, the trace context flows through HTTP/gRPC headers or message queue metadata to the Specialist Agent. The Specialist Agent extracts this context, executes its local agentic loop (generating child spans for LLM calls and tool executions), and returns the result.
All agents run an OpenTelemetry SDK configured with an OTLP (OpenTelemetry Protocol) exporter. Spans are pushed asynchronously from each agent instance over gRPC or HTTP/JSON to a centralized OpenTelemetry Collector. The Collector processes, filters, and batches the spans before exporting them to the Observability Backend (such as Jaeger, Honeycomb, or Datadog), where developers can query and visualize the complete, unified call graph.
Context Propagation across Agent-to-Agent (A2A) Boundaries
To maintain a continuous trace across independent agent services, you must explicitly inject and extract trace context. In Python, this is achieved using the opentelemetry.propagate API. When making an HTTP call, the traceparent header must conform to the W3C format:
version-trace_id-parent_id-trace_flags
For example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
00: The version of the specification.4bf92f3577b34da6a3ce929d0e0e4736: The 16-byte unique Trace ID.00f067aa0ba902b7: The 8-byte unique Span ID of the caller.01: Trace flags (e.g.,01indicates that the trace is sampled).
When the receiving agent extracts this header, it establishes a remote parent span context. Any spans created within the receiver's scope will automatically reference this remote parent, seamlessly joining the distributed trace.
Correlating Heterogeneous Frameworks
In production, you may deploy a Coordinator Agent built with LangGraph that calls a Specialist Agent built with PydanticAI. Because different frameworks wrap their internal execution loops in proprietary abstractions, auto-instrumentation often fails to bridge the gap. To correlate these frameworks, you must write a custom integration layer that extracts the active OpenTelemetry context from the framework's state and propagates it manually.
For instance, in LangGraph, you can pass the trace context within the graph's state dictionary. When a node executes a call to an external PydanticAI agent, it retrieves the context from the state, injects it into the outbound request headers, and ensures the downstream framework initializes its internal tracer with that extracted context.
Handling Asynchronous and Event-Driven Agent Handoffs
Many multi-agent systems rely on event-driven handoffs using message brokers like RabbitMQ, Apache Kafka, or AWS SQS. In these architectures, a direct parent-child span relationship can be misleading because the coordinator does not block waiting for the specialist to finish.
Instead, you should use Span Links. When the Coordinator publishes a task to a queue, it records a span and injects the context into the message attributes. When the Specialist consumes the message, it starts a *new* root trace but adds a Span Link pointing back to the Coordinator's publishing span. This preserves the historical relationship without implying synchronous execution, allowing observability tools to visualize the asynchronous flow correctly.
Cost and Token Attribution via Baggage
OpenTelemetry Baggage allows you to propagate arbitrary key-value pairs alongside your trace context. This is highly valuable for tracking token consumption and cost attribution across multi-agent chains.
By injecting metadata such as tenant_id, billing_tier, and budget_limit at the root of the trace, downstream agents can access these values without needing to query a database. When downstream agents make LLM calls, they write these baggage values along with the token usage metrics into the span attributes. The observability backend can then aggregate these metrics to provide real-time cost-per-tenant and cost-per-workflow dashboards.
Code Example
import os
import logging
import requests
from flask import Flask, request, jsonify
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("multi_agent_tracing")
# Initialize OpenTelemetry Tracer Provider
provider = TracerProvider()
processor = BatchSpanProcessor(ConsoleSpanExporter())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
# Initialize Flask App for the Specialist Agent
app = Flask(__name__)
@app.route("/execute-task", methods=["POST"])
def execute_task():
# Extract trace context from incoming HTTP headers
carrier = {key: value for key, value in request.headers.items()}
extracted_context = TraceContextTextMapPropagator().extract(carrier=carrier)
# Start a child span using the extracted context as parent
with tracer.start_as_current_span("specialist_agent_execution", context=extracted_context) as span:
try:
data = request.json or {}
task = data.get("task", "default_task")
span.set_attribute("agent.name", "SpecialistAgent")
span.set_attribute("agent.task", task)
logger.info(f"Specialist executing task: {task}")
# Simulate local tool execution
with tracer.start_as_current_span("local_tool_execution") as tool_span:
tool_span.set_attribute("tool.name", "DataLookup")
result = f"Processed: {task}"
tool_span.set_attribute("tool.output", result)
span.set_status(trace.StatusCode.OK)
return jsonify({"status": "success", "result": result})
except Exception as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
return jsonify({"status": "error", "message": str(e)}), 500
def run_coordinator_agent():
# Start the root trace for the Coordinator Agent
with tracer.start_as_current_span("coordinator_workflow") as span:
span.set_attribute("agent.name", "CoordinatorAgent")
# Prepare the payload and inject trace context
payload = {"task": "Analyze financial report"}
headers = {}
TraceContextTextMapPropagator().inject(carrier=headers)
# Retrieve specialist endpoint from environment
specialist_url = os.environ.get("SPECIALIST_AGENT_URL", "http://localhost:5000/execute-task")
try:
logger.info("Coordinator delegating task to Specialist...")
response = requests.post(specialist_url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
result = response.json()
span.set_attribute("workflow.result", result.get("result", ""))
span.set_status(trace.StatusCode.OK)
logger.info(f"Workflow completed successfully: {result}")
except Exception as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
logger.error(f"Workflow failed: {e}")
if __name__ == "__main__":
# In production, the Flask app and Coordinator run in separate processes.
# For this runnable example, we start the Flask server or run the coordinator.
import sys
if len(sys.argv) > 1 and sys.argv[1] == "server":
port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port)
else:
# Run the coordinator (expects server to be running on localhost:5000)
run_coordinator_agent()
# Force flush spans before exiting
provider.shutdown()
ConsoleSpanExporter output showing nested spans with matching Trace IDs but distinct Span IDs, linking the coordinator_workflow span to the specialist_agent_execution span and its child local_tool_execution span.