OpenAI Agents SDKopenai-agents 0.22 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

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

python
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.

python
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.

python
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)
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_model.py
"""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.

Example
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)

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

ApproachGroupsYou control the name and id
Default (per run)One trace per runNo, the SDK names it
with trace(...)Every run in the block under one traceYes, 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_span to time it.
  • Tagging a trace with an id you keep, so you can find that exact run later.
Watch out. Without an API key the SDK skips the export, so the ids exist but nothing reaches the dashboard. Set OPENAI_API_KEY when you want the trace sent.
Try it yourself
  • Add a second custom_span("format step") inside the same trace.
  • Print trace_id in 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.