Tracing and Observability
On this page
Updated November 3, 2026. Trace processors previously read
trace.start_time,trace.end_time, andspan.name, which do not exist in the SDK: a trace has no timestamp, and a span carriesstarted_at,ended_at, andspan_data. The export processor did not implementon_trace_start, an abstract method: instantiating it raised aTypeError. “Nested traces” created a new trace inside a trace, which the SDK flags as a likely error: steps now usecustom_span(). Additions:flush_traces(), sensitive data handling, and Zero Data Retention. Code verified withopenai-agents0.24.0. Sources: Tracing (SDK documentation) · openai-agents-python.
Tracing and Observability
An agent in production is a black box if you do not trace its actions. When a customer complains about an absurd response, you have neither the exact request, nor the tools called, nor the values they returned. The SDK’s tracing records every step of execution: model calls, tools used, handoffs, and guardrails. This is your primary tool for debugging, optimizing, and auditing your agents.
Built-in Tracing
Good news: you do not need to enable anything. The SDK automatically traces every execution, and by default, sends traces to the OpenAI dashboard at platform.openai.com. The agent below, written without a single tracing instruction, already produces a usable trace.
from agents import Agent, Runner, function_tool
@function_tool
def search_product(name: str) -> str:
"""Searches for a product in the catalog."""
return f"Product found: {name}, price €29.99"
agent = Agent(
name="Shop Agent",
instructions="You help customers find products.",
tools=[search_product],
model="gpt-5.6-terra",
)
# Every execution automatically creates a trace
result = Runner.run_sync(agent, "Search for the pro laptop")
Visit the dashboard to see the complete trace: duration, tokens consumed, tools called, and the final response.
Naming Traces
After a few hundred executions, the dashboard becomes an undifferentiated list. Give your traces meaningful names so you can find them easily—a business journey name is better than a technical ID.
from agents import trace
with trace("premium-customer-order"):
result = await Runner.run(
agent,
"I want to order 5 laptops for my team",
)
The trace name appears in the dashboard and makes filtering easier.
Breaking a Workflow into Steps
For a workflow with multiple calls, open one trace around the whole thing, then mark each step with custom_span(). Do not nest a trace() inside another: the SDK then logs the warning Trace already exists. Creating a new trace, but this is probably a mistake. Spans, however, are designed to nest: each one sits under the current span.
from agents import Runner, custom_span, trace
async def process_order(message: str, conversation_id: str):
with trace("order-workflow", group_id=conversation_id):
with custom_span("step-1-understanding"):
result_understanding = await Runner.run(triage_agent, message)
with custom_span("step-2-processing"):
result_processing = await Runner.run(
order_agent,
result_understanding.to_input_list(),
)
with custom_span("step-3-confirmation", data={"step": 3}):
result_final = await Runner.run(
confirmation_agent,
result_processing.to_input_list(),
)
return result_final.final_output
In the dashboard, the order-workflow trace shows the three steps, and under each, the automatic spans of execution: agent, model calls, tools. The group_id links traces from the same conversation, for example using your thread ID.
By default, the SDK wraps every Runner.run() in a trace named Agent workflow, and inside traces the agent, each model generation, each function call, guardrails, and handoffs. For a more compact hierarchy, the documentation suggests cutting automatic task and turn spans:
from agents import RunConfig
result = await Runner.run(
agent,
"Hello",
run_config=RunConfig(tracing={"include_task_and_turn_spans": False}),
)
Custom Trace Processors
The OpenAI dashboard is convenient, but your operations team probably monitors everything else elsewhere. To send traces to your own system, write a processor: it receives start and end events, for the complete trace as well as for each span. All six methods of TracingProcessor are abstract: implement them all, even if empty, or instantiation will fail.
Here is what each object actually exposes: a trace has a trace_id and a name, and its export() method also returns group_id and metadata; it has no timestamp. A span has span_id, trace_id, parent_id, started_at and ended_at (ISO 8601 strings), and span_data, whose type attribute is agent, generation, function, handoff, guardrail, custom, and so on. Trace duration is therefore measured in the processor.
import time
from datetime import datetime
from typing import Any
from agents import add_trace_processor
from agents.tracing import Span, Trace, TracingProcessor
class MyTraceProcessor(TracingProcessor):
def __init__(self) -> None:
self._starts: dict[str, float] = {}
def on_trace_start(self, trace: Trace) -> None:
self._starts[trace.trace_id] = time.monotonic()
print(f"[TRACE START] {trace.name} - {trace.trace_id}")
def on_trace_end(self, trace: Trace) -> None:
duration = time.monotonic() - self._starts.pop(trace.trace_id, time.monotonic())
print(f"[TRACE END] {trace.name} - duration: {duration:.2f} s")
def on_span_start(self, span: Span[Any]) -> None:
print(f" [SPAN START] {span.span_data.type}")
def on_span_end(self, span: Span[Any]) -> None:
if span.started_at and span.ended_at:
duration = datetime.fromisoformat(span.ended_at) - datetime.fromisoformat(span.started_at)
print(f" [SPAN END] {span.span_data.type} - {duration.total_seconds():.2f} s")
def shutdown(self) -> None:
pass
def force_flush(self) -> None:
pass
add_trace_processor(MyTraceProcessor())
add_trace_processor() adds your processor to the original one: traces continue to go to the OpenAI dashboard. set_trace_processors() replaces the default processors: nothing goes to OpenAI anymore, unless you put its exporter back in the list yourself.
Exporting to External Systems
For an observability tool, you just need to reformat the spans. The example below writes one JSON line per finished span, a format most log collectors know how to ingest; span.export() already provides a complete dictionary.
import json
import logging
from typing import Any
from agents.tracing import Span, Trace, TracingProcessor
logger = logging.getLogger("agent-traces")
class JsonExportProcessor(TracingProcessor):
"""Writes each finished span as JSON, for a log collector."""
def on_trace_start(self, trace: Trace) -> None:
pass
def on_trace_end(self, trace: Trace) -> None:
logger.info(json.dumps(trace.export(), default=str))
def on_span_start(self, span: Span[Any]) -> None:
pass
def on_span_end(self, span: Span[Any]) -> None:
logger.info(json.dumps(span.export(), default=str))
def shutdown(self) -> None:
pass
def force_flush(self) -> None:
pass
Watch what you export: by default, generation spans contain model inputs and outputs, and function spans contain tool arguments and results. To exclude them, pass RunConfig(trace_include_sensitive_data=False) or set the environment variable OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA=false. For ready-made integrations, the SDK documentation lists many third-party tools that provide their own processor.
Do Not Lose the Last Traces
The default exporter sends traces in the background, every few seconds. In a worker that processes tasks and does not stop, or in a short script, call flush_traces() after closing the trace to force sending:
from agents import flush_traces
with trace("nightly-processing"):
await Runner.run(agent, "Summarize today's tickets")
flush_traces() # after the trace ends, never in the middle
Disabling Tracing
There is one case where tracing hinders more than it helps: a test suite that runs hundreds of mock agents and pollutes your dashboard. In tests or during local development, you can therefore disable it, either globally or for a single execution.
from agents import RunConfig, set_tracing_disabled
# Disable completely (or environment variable OPENAI_AGENTS_DISABLE_TRACING=1)
set_tracing_disabled(True)
# Or for a single execution
result = await Runner.run(agent, "Test message", run_config=RunConfig(tracing_disabled=True))
One last point, this one contractual: according to the documentation, tracing to OpenAI is not available for organizations subject to a Zero Data Retention policy. In this case, only your own processor, installed with set_trace_processors(), will give you traces.
Key Metrics to Monitor
Now, what should you look at? In production, six indicators are enough to qualify an agent’s health. Total latency—the time between request and final response—is what your users feel; it is most often explained by the number of turns, that is, the number of times the agent loop executed. Tokens consumed, both input and output per execution, drive your bill.
The other three relate to quality. The tool failure rate, the percentage of tools that return an error, reveals an unstable third-party API or poorly generated arguments. The guardrail trigger rate, visible in guardrail spans, measures the frequency of blocks: a sudden spike signals either an attack or a guardrail that has become too strict. Finally, the handoff distribution tells you which agent responds most often—and thus whether your triage is sending requests where they should go.
Key Takeaways
- The SDK automatically traces every execution to the OpenAI dashboard (except for organizations on Zero Data Retention)
- One
trace()per workflow; mark steps withcustom_span(), and usegroup_idto link traces from a conversation - A trace has no timestamp; a span has
started_at,ended_at, andspan_data.type - A
TracingProcessorimplements its six methods;add_trace_processor()adds,set_trace_processors()replaces trace_include_sensitive_data=Falseexcludes inputs and outputs;flush_traces()forces sending- Disable tracing in tests with
set_tracing_disabled(True)orRunConfig(tracing_disabled=True)