Tracing a run with spans
A trace is a record of one workflow, and a span is a timed step inside it, so you can see what an agent did and how long each part took.
Last updated: 28 Sep, 2026 · openai-agents 0.22.3
The SDK traces every run for you. You can group several runs under one named trace with trace(...), and mark your own steps inside it with custom_span(...). Sending a trace to the OpenAI dashboard needs an API key; without one the SDK builds the ids but skips the export.
The trace and custom_span context managers
from agents import trace, custom_span
with trace("shop support"): # one named trace around the work
with custom_span("lookup step"): # a step you name yourself
result = Runner.run_sync(agent, "hello")Generating a trace id
gen_trace_id returns an id in the shape trace_ followed by 32 hex characters. You can pass it to trace(...) so you know the id ahead of the run.
from agents import gen_trace_id
trace_id = gen_trace_id()
print("id starts with:", trace_id.split("_")[0])
print("id is 'trace_' + 32 hex:", trace_id.startswith("trace_") and len(trace_id) == 38)Wrapping runs in a trace and a span
Pass the id into trace(...) and put the run inside a custom_span. The run works the same; the trace and span record it.
agent = Agent(name="Shop", instructions="Help with orders.", model=ShopModel())
with trace("shop support", trace_id=trace_id):
with custom_span("lookup step"):
result = Runner.run_sync(agent, "hello")
print("ran inside the trace:", result.final_output)View the code here
"""A deterministic stand-in Model for the OpenAI Agents SDK course.
It implements the Model interface so an Agent runs with no API key. It reads the
last user message and the tool results out of `input`, and returns either a tool
call, a handoff, or a final message. Swap it for a real model at the end.
"""
from agents.models.interface import Model
from agents.items import ModelResponse
from agents.usage import Usage
from openai.types.responses import (
ResponseOutputMessage, ResponseOutputText, ResponseFunctionToolCall,
ResponseCompletedEvent, ResponseTextDeltaEvent, Response,
)
def _message(text):
return ResponseOutputMessage(
id="msg", role="assistant", type="message", status="completed",
content=[ResponseOutputText(text=text, type="output_text", annotations=[])],
)
def _tool_call(name, arguments, call_id="call_1"):
return ResponseFunctionToolCall(
id="fc", call_id=call_id, name=name, arguments=arguments, type="function_call",
)
def last_user_text(input):
if isinstance(input, str):
return input
for item in reversed(input):
d = item if isinstance(item, dict) else item.__dict__
if d.get("role") == "user":
content = d.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
for part in content:
pd = part if isinstance(part, dict) else part.__dict__
if pd.get("text"):
return pd["text"]
return ""
def tool_output(input):
if isinstance(input, str):
return None
for item in reversed(input):
d = item if isinstance(item, dict) else item.__dict__
if d.get("type") == "function_call_output":
return d.get("output")
return None
class ShopModel(Model):
async def get_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, *, previous_response_id=None,
conversation_id=None, prompt=None):
result = tool_output(input)
if result is not None:
# A handoff transfer looks like {"assistant": "..."}; the specialist answers for real.
if result.strip().startswith('{"assistant"'):
if "refund" in (system_instructions or "").lower():
return ModelResponse(
output=[_message(
"Your refund is approved and will be processed in 5 to 7 days.")],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message("Handled by the specialist.")],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message(result)], usage=Usage(), response_id=None)
text = last_user_text(input).lower()
if handoffs and "refund" in text:
return ModelResponse(output=[_tool_call(handoffs[0].tool_name, "{}")],
usage=Usage(), response_id=None)
if tools and "order" in text:
return ModelResponse(output=[_tool_call("lookup_order", '{"order_id": "A17"}')],
usage=Usage(), response_id=None)
return ModelResponse(output=[_message("How can I help with your order?")],
usage=Usage(), response_id=None)
async def stream_response(self, system_instructions, input, model_settings, tools,
output_schema, handoffs, tracing, *, previous_response_id=None,
conversation_id=None, prompt=None):
text = "How can I help with your order?"
for i, word in enumerate(text.split()):
yield ResponseTextDeltaEvent(
type="response.output_text.delta", delta=word + " ",
content_index=0, item_id="msg", output_index=0,
sequence_number=i, logprobs=[],
)
response = Response(
id="r", created_at=0.0, model="shop-standin", object="response",
output=[_message(text)], parallel_tool_calls=False,
tool_choice="auto", tools=[],
)
yield ResponseCompletedEvent(type="response.completed", response=response,
sequence_number=99)
Trace ids from one wrapped run
The whole program in one file. It prints the id it made and the answer from the wrapped run. The export step needs a key, so nothing is sent to the dashboard here.
from agents import Agent, Runner, trace, custom_span, gen_trace_id, set_tracing_disabled
from shop_model import ShopModel
# tracing left ON here so trace()/custom_span() build real ids; export still needs a key.
trace_id = gen_trace_id()
print("id starts with:", trace_id.split("_")[0])
print("id is 'trace_' + 32 hex:", trace_id.startswith("trace_") and len(trace_id) == 38)
agent = Agent(name="Shop", instructions="Help with orders.", model=ShopModel())
with trace("shop support", trace_id=trace_id):
with custom_span("lookup step"):
result = Runner.run_sync(agent, "hello")
print("ran inside the trace:", result.final_output)id starts with: trace id is 'trace_' + 32 hex: True ran inside the trace: How can I help with your order?
What the ids and the wrapped run show
- The id has the fixed
trace_prefix and a 38-character length (the trace_ prefix plus 32 hex characters), which is how the SDK tags one workflow. - The run answers exactly as it would without a trace, so wrapping it changes nothing about the result.
- The export is the part that needs
OPENAI_API_KEY; the trace and span exist in the process either way. - The span timing shows in the OpenAI dashboard after the trace is exported, which needs a key; this run proves the id shape, not the span durations.
Automatic tracing vs a named trace
| Approach | Groups | You control the name and id |
|---|---|---|
| Default (per run) | One trace per run | No, the SDK names it |
with trace(...) | Every run in the block under one trace | Yes, through the arguments |
When to add your own spans
- Grouping a multi-run workflow so it reads as one trace in the dashboard.
- Marking a slow step, such as a lookup, with a
custom_spanto time it. - Tagging a trace with an id you keep, so you can find that exact run later.
OPENAI_API_KEY when you want the trace sent.Related
- Previous: Streaming a run with run_streamed
- Next: Shaping the model with ModelSettings
- Reference: Tracing
- Add a second
custom_span("format step")inside the same trace. - Print
trace_idin full and read its 32 hex characters. - Wrap two runs in one
trace(...)block and note they share the id.
You understood something today that you didn't yesterday.