← Back to DevBytes

Observability and Tracing with OpenAI Agents SDK: Complete Guide

Observability and Tracing with OpenAI Agents SDK: Complete Guide

Building AI agents that work reliably in production requires more than just good prompts and well-structured tools. Once your agent starts chaining multiple LLM calls, invoking tools, and delegating tasks to sub-agents, understanding what actually happened during a run becomes a serious challenge. This is where observability and tracing come in. The OpenAI Agents SDK provides built-in tracing capabilities that give you deep visibility into every step your agents take, from the initial user message to the final response.

In this guide, we'll explore what tracing means in the context of the Agents SDK, why it matters for production systems, how to configure and customize it, and the best practices that will keep your observability strategy scalable as your application grows.

What Is Tracing in the OpenAI Agents SDK

Tracing in the Agents SDK is a structured logging mechanism that records the full execution tree of an agent run. Every time you call Runner.run() or Runner.run_sync(), the SDK automatically creates a trace — a hierarchical record of spans that captures each meaningful operation: LLM generations, tool calls, handoffs between agents, guardrail checks, and custom spans you define yourself.

A trace represents a complete logical operation, typically a single end-to-end agent run. A span represents a unit of work within that trace. Spans can be nested, which means a parent span (like an agent invocation) can contain child spans (like an LLM call followed by a tool execution). This tree structure mirrors how your agent actually executes, making it easy to pinpoint where latency, errors, or unexpected behavior originate.

The SDK sends traces to OpenAI's tracing backend by default, where you can visualize them in the OpenAI dashboard. You can also export traces to other observability platforms or process them locally.

Why Observability Matters for AI Agents

Traditional web applications are relatively deterministic: given the same input, you expect the same output, and debugging usually means inspecting logs and stack traces. AI agents break this model. They make decisions based on LLM outputs, they choose which tools to call, they may loop through multiple reasoning steps, and they can hand off work to other agents. This non-determinism makes observability not just useful but essential.

Getting Started with Default Tracing

The good news is that tracing is enabled by default. When you run an agent, the SDK automatically captures a trace without any configuration. Let's look at a minimal example.

import asyncio
from agents import Agent, Runner

async def main():
    agent = Agent(
        name="Research Assistant",
        instructions="You are a helpful research assistant. Answer concisely.",
    )

    result = await Runner.run(
        agent,
        "What are the main causes of inflation?"
    )
    print(result.final_output)

asyncio.run(main())

When this code executes, the SDK creates a trace containing the agent span, the LLM generation span, and metadata about the run. You can view this trace in the OpenAI platform under the Tracing section. Each span includes timing, input, output, and usage information.

If you want to disable tracing for a specific run — perhaps during local development or testing — you can pass tracing_disabled=True to the runner.

result = await Runner.run(
    agent,
    "What are the main causes of inflation?",
    tracing_disabled=True
)

Understanding Trace Structure and Span Types

The SDK defines several built-in span types that correspond to the core concepts of the framework. Understanding these will help you read traces effectively.

Each span has a start time, end time, and structured data payload. The nesting of spans within a trace forms a tree that you can inspect visually in the dashboard or programmatically via the API.

Adding Custom Spans

Built-in spans cover the framework's core operations, but real applications often have custom logic that deserves its own observability. The SDK lets you add custom spans to mark important sections of your code. This is invaluable for correlating agent behavior with your application's business logic.

import asyncio
from agents import Agent, Runner, custom_span, gen_trace_id

async def fetch_market_data(symbol: str) -> dict:
    # Simulate an external API call
    await asyncio.sleep(0.5)
    return {"symbol": symbol, "price": 182.45, "change": 2.3}

async def main():
    agent = Agent(
        name="Stock Analyst",
        instructions="You analyze stock data and provide insights.",
    )

    trace_id = gen_trace_id()

    with custom_span(
        name="fetch_market_data",
        data={"symbol": "AAPL"},
    ):
        market_data = await fetch_market_data("AAPL")

    result = await Runner.run(
        agent,
        f"Analyze this stock data: {market_data}",
        trace_id=trace_id,
    )
    print(result.final_output)

asyncio.run(main())

In this example, the custom_span context manager creates a span that records the market data fetch operation. By passing the same trace_id to the runner, both the custom span and the agent's built-in spans appear in the same trace, giving you a unified view of the entire workflow.

Working with Trace Processors

By default, traces are sent to OpenAI's backend using a BatchTraceProcessor. However, you may want to send traces to other destinations — Datadog, Langfuse, Honeycomb, or your own logging infrastructure. The SDK supports this through trace processors.

A trace processor implements two methods: on_trace_start and on_trace_end, plus on_span_start and on_span_end for real-time span processing. Here's how to build a custom processor that logs spans to the console.

from agents import Agent, Runner
from agents.tracing import TraceProcessor, Span, Trace
from agents.tracing.processor import get_trace_processor
import json

class ConsoleTraceProcessor(TraceProcessor):
    def on_trace_start(self, trace: Trace) -> None:
        print(f"[TRACE START] {trace.trace_id}")

    def on_trace_end(self, trace: Trace) -> None:
        print(f"[TRACE END] {trace.trace_id}")
        for span in trace.spans:
            print(f"  Span: {span.span_data}")
            print(f"    Started: {span.started_at}")
            print(f"    Ended: {span.ended_at}")

    def on_span_start(self, span: Span) -> None:
        print(f"[SPAN START] {span.span_data}")

    def on_span_end(self, span: Span) -> None:
        duration = None
        if span.started_at and span.ended_at:
            duration = span.ended_at - span.started_at
        print(f"[SPAN END] duration={duration}s")

    def shutdown(self) -> None:
        print("[TRACE PROCESSOR] Shutting down")

    def force_flush(self) -> None:
        pass

To use your custom processor, you add it to the global trace processor chain. You can also remove the default processor if you don't want traces sent to OpenAI.

from agents import add_trace_processor, set_trace_processors

# Replace all processors with only the console processor
set_trace_processors([ConsoleTraceProcessor()])

# Or add alongside the default processor
# add_trace_processor(ConsoleTraceProcessor())

Integrating with External Observability Platforms

In production, you'll typically want traces in a dedicated observability platform. The pattern is the same as the console processor above, but instead of printing, you forward the span data to your platform's API. Here's a sketch of a processor that sends spans to an HTTP endpoint.

import httpx
import asyncio
from agents.tracing import TraceProcessor, Span, Trace

class HTTPObservabilityProcessor(TraceProcessor):
    def __init__(self, endpoint: str, api_key: str):
        self.endpoint = endpoint
        self.api_key = api_key
        self._spans: list[dict] = []
        self._client = httpx.AsyncClient()

    def on_trace_start(self, trace: Trace) -> None:
        self._current_trace_id = trace.trace_id

    def on_span_end(self, span: Span) -> None:
        span_data = {
            "trace_id": self._current_trace_id,
            "span_id": span.span_id,
            "parent_id": span.parent_id,
            "name": getattr(span.span_data, "type", "unknown"),
            "started_at": span.started_at,
            "ended_at": span.ended_at,
            "data": span.export(),
        }
        self._spans.append(span_data)

    def on_trace_end(self, trace: Trace) -> None:
        asyncio.create_task(self._flush())

    async def _flush(self) -> None:
        try:
            await self.client.post(
                self.endpoint,
                json={"spans": self._spans},
                headers={"Authorization": f"Bearer {self.api_key}"},
            )
            self._spans.clear()
        except Exception as e:
            print(f"Failed to send traces: {e}")

    def shutdown(self) -> None:
        asyncio.create_task(self._client.aclose())

    def force_flush(self) -> None:
        asyncio.create_task(self._flush())

Many observability vendors also provide official integrations for the Agents SDK, so check whether your platform has a ready-made processor before building your own.

Tracing Multi-Agent Workflows

One of the most powerful features of the Agents SDK is agent handoffs, where one agent delegates work to another. Tracing makes these workflows transparent. Each handoff creates a HandoffSpan, and the receiving agent gets its own AgentSpan nested within the same trace.

import asyncio
from agents import Agent, Runner, handoff

# Define a specialist agent
billing_agent = Agent(
    name="Billing Specialist",
    instructions="You handle billing questions. Be precise and reference policies.",
)

# Define a triage agent that can hand off
triage_agent = Agent(
    name="Triage Agent",
    instructions=(
        "You are a customer support triage agent. "
        "For billing questions, hand off to the billing specialist."
    ),
    handoffs=[billing_agent],
)

async def main():
    result = await Runner.run(
        triage_agent,
        "I was charged twice for my subscription last month."
    )
    print(result.final_output)

asyncio.run(main())

The resulting trace will show the triage agent's LLM call, the handoff decision, and then the billing specialist's processing — all in a single coherent tree. This makes it straightforward to audit which agent made which decision and how long each step took.

Tracing Tool Executions

Tools are where agents interact with the real world, and they're also where most production issues occur. The SDK automatically creates spans for function tool calls, but you can add additional context by wrapping expensive or critical tool logic in custom spans.

from agents import Agent, Runner, function_tool, custom_span
import asyncio

@function_tool
async def search_database(query: str) -> str:
    with custom_span(
        name="database_search",
        data={"query": query},
    ):
        await asyncio.sleep(0.3)  # simulate DB query
        return f"Results for: {query}"

agent = Agent(
    name="Data Agent",
    instructions="You search the database and summarize results.",
    tools=[search_database],
)

async def main():
    result = await Runner.run(agent, "Find records about Q3 revenue")
    print(result.final_output)

asyncio.run(main())

The trace will contain a FunctionToolSpan for the tool call itself, plus your custom database_search span nested inside it. This lets you distinguish between the framework's overhead and your actual tool logic when analyzing performance.

Adding Metadata to Traces

Traces are most useful when they carry context about the business operation they represent. You can attach metadata such as user IDs, session IDs, and request tags using the run method's parameters or by setting trace metadata directly.

from agents import Runner, gen_trace_id
from agents.tracing import trace, get_current_trace

async def handle_user_request(user_id: str, session_id: str, message: str):
    trace_id = gen_trace_id()

    with trace(
        workflow_name="customer_support",
        trace_id=trace_id,
        group_id=session_id,
        metadata={
            "user_id": user_id,
            "environment": "production",
            "version": "1.2.0",
        },
    ):
        result = await Runner.run(
            triage_agent,
            message,
            trace_id=trace_id,
        )
        return result.final_output

The workflow_name helps you filter traces in the dashboard, group_id lets you correlate traces from the same session, and metadata carries arbitrary key-value pairs that you can use for searching and analysis.

Best Practices for Observability

As you build out your tracing strategy, keep these principles in mind to get the most value without overwhelming your systems.

Performance Considerations

Tracing adds overhead, but the SDK is designed to minimize it. Spans are created in memory and processed asynchronously by the BatchTraceProcessor, which buffers spans and sends them in batches. This means tracing should not noticeably affect your agent's latency in most cases.

However, if you write a custom processor that performs synchronous I/O in on_span_end, you can introduce latency. Always use asynchronous operations in processors and never block the event loop. If your processor needs to call an external API, queue the data and flush it in the background.

import asyncio
from agents.tracing import TraceProcessor, Span, Trace

class AsyncBatchProcessor(TraceProcessor):
    def __init__(self):
        self._queue: asyncio.Queue = asyncio.Queue()
        self._task: asyncio.Task | None = None

    def on_trace_start(self, trace: Trace) -> None:
        if self._task is None:
            self._task = asyncio.create_task(self._worker())

    def on_span_end(self, span: Span) -> None:
        try:
            self._queue.put_nowait(span.export())
        except asyncio.QueueFull:
            pass  # drop span if queue is full

    async def _worker(self):
        while True:
            batch = []
            try:
                item = await asyncio.wait_for(self._queue.get(), timeout=5.0)
                batch.append(item)
                while not self._queue.empty() and len(batch) < 100:
                    batch.append(self._queue.get_nowait())
            except asyncio.TimeoutError:
                continue

            if batch:
                try:
                    await self._send_batch(batch)
                except Exception as e:
                    print(f"Trace send failed: {e}")

    async def _send_batch(self, batch: list[dict]):
        # Send to your observability backend
        pass

    def on_trace_end(self, trace: Trace) -> None:
        pass

    def shutdown(self) -> None:
        if self._task:
            self._task.cancel()

    def force_flush(self) -> None:
        pass

Conclusion

Observability is the foundation of running AI agents in production. The OpenAI Agents SDK's built-in tracing gives you a detailed, hierarchical view of every agent run out of the box, and its extensible processor architecture lets you route that data to any observability platform. By combining the SDK's automatic spans with custom spans for your business logic, correlating traces with user context, and following sound practices around sensitive data and performance, you can build agent systems that are not only powerful but also transparent, debuggable, and trustworthy. Start with the default tracing to understand your agents' behavior, then layer in custom processors and metadata as your production needs grow — your future self, debugging a 2 AM incident, will thank you.

— Ad —

Google AdSense will appear here after approval

← Back to all articles