Skip to content

Commit 0125626

Browse files
committed
Add workflow root span support and handoff example
1 parent 407fdfb commit 0125626

File tree

6 files changed

+253
-4
lines changed

6 files changed

+253
-4
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Update this with your real OpenAI API key
2+
OPENAI_API_KEY=sk-YOUR_API_KEY
3+
4+
# Uncomment and adjust if you use a non-default OTLP collector endpoint
5+
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
6+
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
7+
8+
OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-handoffs
9+
10+
# Optionally override the agent name reported on spans
11+
# OTEL_GENAI_AGENT_NAME=Travel Concierge
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
OpenTelemetry OpenAI Agents Handoff Example
2+
==========================================
3+
4+
This example shows how the OpenTelemetry OpenAI Agents instrumentation captures
5+
spans in a small multi-agent workflow. Three agents collaborate: a primary
6+
concierge, a concise assistant with a random-number tool, and a Spanish
7+
specialist reached through a handoff. Running the sample produces
8+
``invoke_agent`` spans for each agent as well as an ``execute_tool`` span for
9+
the random-number function.
10+
11+
Setup
12+
-----
13+
14+
1. Copy `.env.example <.env.example>`_ to `.env` and populate it with your real
15+
``OPENAI_API_KEY``. Adjust the OTLP exporter settings if your collector does
16+
not listen on ``http://localhost:4317``.
17+
2. Create a virtual environment and install the dependencies:
18+
19+
::
20+
21+
python3 -m venv .venv
22+
source .venv/bin/activate
23+
pip install "python-dotenv[cli]"
24+
pip install -r requirements.txt
25+
26+
Run
27+
---
28+
29+
Execute the workflow with ``dotenv`` so the environment variables from ``.env``
30+
are loaded automatically:
31+
32+
::
33+
34+
dotenv run -- python main.py
35+
36+
The script emits a short transcript to stdout while spans stream to the OTLP
37+
endpoint defined in your environment. You should see multiple
38+
``invoke_agent`` spans (one per agent) and an ``execute_tool`` span for the
39+
random-number helper triggered during the run.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# pylint: skip-file
2+
"""Multi-agent handoff example instrumented with OpenTelemetry."""
3+
4+
from __future__ import annotations
5+
6+
import asyncio
7+
import json
8+
import random
9+
10+
from agents import Agent, HandoffInputData, Runner, function_tool, handoff
11+
from agents import trace as agent_trace
12+
from agents.extensions import handoff_filters
13+
from agents.models import is_gpt_5_default
14+
from dotenv import load_dotenv
15+
16+
from opentelemetry import trace as otel_trace
17+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
18+
OTLPSpanExporter,
19+
)
20+
from opentelemetry.instrumentation.openai_agents import (
21+
OpenAIAgentsInstrumentor,
22+
)
23+
from opentelemetry.sdk.trace import TracerProvider
24+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
25+
26+
27+
def configure_otel() -> None:
28+
"""Configure the OpenTelemetry SDK and enable the Agents instrumentation."""
29+
30+
provider = TracerProvider()
31+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
32+
otel_trace.set_tracer_provider(provider)
33+
34+
OpenAIAgentsInstrumentor().instrument(tracer_provider=provider)
35+
36+
37+
@function_tool
38+
def random_number_tool(maximum: int) -> int:
39+
"""Return a random integer between 0 and ``maximum``."""
40+
41+
return random.randint(0, maximum)
42+
43+
44+
def spanish_handoff_message_filter(
45+
handoff_message_data: HandoffInputData,
46+
) -> HandoffInputData:
47+
"""Trim the message history forwarded to the Spanish-speaking agent."""
48+
49+
if is_gpt_5_default():
50+
# When GPT-5 is enabled we skip additional filtering.
51+
return HandoffInputData(
52+
input_history=handoff_message_data.input_history,
53+
pre_handoff_items=tuple(handoff_message_data.pre_handoff_items),
54+
new_items=tuple(handoff_message_data.new_items),
55+
)
56+
57+
filtered = handoff_filters.remove_all_tools(handoff_message_data)
58+
history = (
59+
tuple(filtered.input_history[2:])
60+
if isinstance(filtered.input_history, tuple)
61+
else filtered.input_history[2:]
62+
)
63+
64+
return HandoffInputData(
65+
input_history=history,
66+
pre_handoff_items=tuple(filtered.pre_handoff_items),
67+
new_items=tuple(filtered.new_items),
68+
)
69+
70+
71+
assistant = Agent(
72+
name="Assistant",
73+
instructions="Be extremely concise.",
74+
tools=[random_number_tool],
75+
)
76+
77+
spanish_assistant = Agent(
78+
name="Spanish Assistant",
79+
instructions="You only speak Spanish and are extremely concise.",
80+
handoff_description="A Spanish-speaking assistant.",
81+
)
82+
83+
concierge = Agent(
84+
name="Concierge",
85+
instructions=(
86+
"Be a helpful assistant. If the traveler switches to Spanish, handoff to"
87+
" the Spanish specialist. Use the random number tool when asked for"
88+
" numbers."
89+
),
90+
handoffs=[
91+
handoff(spanish_assistant, input_filter=spanish_handoff_message_filter)
92+
],
93+
)
94+
95+
96+
async def run_workflow() -> None:
97+
"""Execute a conversation that triggers tool calls and handoffs."""
98+
99+
with agent_trace(workflow_name="Travel concierge handoff"):
100+
# Step 1: Basic conversation with the initial assistant.
101+
result = await Runner.run(
102+
assistant,
103+
input="I'm planning a trip to Madrid. Can you help?",
104+
)
105+
106+
print("Step 1 complete")
107+
108+
# Step 2: Ask for a random number to exercise the tool span.
109+
result = await Runner.run(
110+
assistant,
111+
input=result.to_input_list()
112+
+ [
113+
{
114+
"content": "Pick a lucky number between 0 and 20",
115+
"role": "user",
116+
}
117+
],
118+
)
119+
120+
print("Step 2 complete")
121+
122+
# Step 3: Continue the conversation with the concierge agent.
123+
result = await Runner.run(
124+
concierge,
125+
input=result.to_input_list()
126+
+ [
127+
{
128+
"content": "Recommend some sights in Madrid for a weekend trip.",
129+
"role": "user",
130+
}
131+
],
132+
)
133+
134+
print("Step 3 complete")
135+
136+
# Step 4: Switch to Spanish to cause a handoff to the specialist.
137+
result = await Runner.run(
138+
concierge,
139+
input=result.to_input_list()
140+
+ [
141+
{
142+
"content": "Por favor habla en español. ¿Puedes resumir el plan?",
143+
"role": "user",
144+
}
145+
],
146+
)
147+
148+
print("Step 4 complete")
149+
150+
print("\n=== Conversation Transcript ===\n")
151+
for message in result.to_input_list():
152+
print(json.dumps(message, indent=2, ensure_ascii=False))
153+
154+
155+
def main() -> None:
156+
load_dotenv()
157+
configure_otel()
158+
asyncio.run(run_workflow())
159+
160+
161+
if __name__ == "__main__":
162+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
openai-agents~=0.3.3
2+
python-dotenv~=1.0
3+
4+
opentelemetry-sdk~=1.36.0
5+
opentelemetry-exporter-otlp-proto-grpc~=1.36.0
6+
opentelemetry-instrumentation-openai-agents~=0.1.0.dev

instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ def __init__(
160160
and agent_name_override.strip()
161161
else None
162162
)
163-
self._root_spans: dict[str, Span] = {}
164163
self._spans: dict[str, _SpanContext] = {}
164+
self._root_spans: dict[str, Span] = {}
165165
self._lock = RLock()
166166

167167
def _operation_name(self, span_data: Any) -> str:

instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,15 @@ def test_generation_span_creates_client_span():
7575
pass
7676

7777
spans = exporter.get_finished_spans()
78-
client_span = next(
79-
span for span in spans if span.kind is SpanKind.CLIENT
80-
)
78+
client_spans = [span for span in spans if span.kind is SpanKind.CLIENT]
79+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
80+
81+
assert len(server_spans) == 1
82+
server_span = server_spans[0]
83+
assert server_span.name == "workflow"
84+
assert server_span.attributes["gen_ai.provider.name"] == "openai"
85+
assert client_spans
86+
client_span = next(iter(client_spans))
8187

8288
assert client_span.attributes["gen_ai.provider.name"] == "openai"
8389
assert client_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "chat"
@@ -115,6 +121,12 @@ def test_generation_span_without_roles_uses_text_completion():
115121
if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
116122
== GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value
117123
)
124+
assert completion_span.kind is SpanKind.CLIENT
125+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
126+
assert len(server_spans) == 1
127+
assert server_spans[0].name == "workflow"
128+
assert server_spans[0].attributes["gen_ai.provider.name"] == "openai"
129+
assert [span for span in spans if span.kind is SpanKind.CLIENT]
118130

119131
assert completion_span.kind is SpanKind.CLIENT
120132
assert completion_span.name == "text_completion gpt-4o-mini"
@@ -142,6 +154,11 @@ def test_function_span_records_tool_attributes():
142154
span for span in spans if span.kind is SpanKind.INTERNAL
143155
)
144156

157+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
158+
assert len(server_spans) == 1
159+
assert server_spans[0].name == "workflow"
160+
assert server_spans[0].attributes["gen_ai.provider.name"] == "openai"
161+
145162
assert (
146163
tool_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "execute_tool"
147164
)
@@ -174,6 +191,11 @@ def test_agent_create_span_records_attributes():
174191
if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
175192
== GenAI.GenAiOperationNameValues.CREATE_AGENT.value
176193
)
194+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
195+
assert len(server_spans) == 1
196+
assert server_spans[0].name == "workflow"
197+
assert server_spans[0].attributes["gen_ai.provider.name"] == "openai"
198+
assert [span for span in spans if span.kind is SpanKind.CLIENT]
177199

178200
assert create_span.kind is SpanKind.CLIENT
179201
assert create_span.name == "create_agent support_bot"
@@ -209,6 +231,11 @@ def test_agent_name_override_applied_to_agent_spans():
209231
if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
210232
== GenAI.GenAiOperationNameValues.INVOKE_AGENT.value
211233
)
234+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
235+
assert len(server_spans) == 1
236+
assert server_spans[0].name == "workflow"
237+
assert server_spans[0].attributes["gen_ai.provider.name"] == "openai"
238+
assert [span for span in spans if span.kind is SpanKind.CLIENT]
212239

213240
assert agent_span_record.kind is SpanKind.CLIENT
214241
assert agent_span_record.name == "invoke_agent Travel Concierge"
@@ -261,6 +288,10 @@ def __init__(self) -> None:
261288
assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == (
262289
"stop",
263290
)
291+
server_spans = [span for span in spans if span.kind is SpanKind.SERVER]
292+
assert len(server_spans) == 1
293+
assert server_spans[0].name == "workflow"
294+
assert server_spans[0].attributes["gen_ai.provider.name"] == "openai"
264295
finally:
265296
instrumentor.uninstrument()
266297
exporter.clear()

0 commit comments

Comments
 (0)