Skip to content

Commit 2add6a8

Browse files
authored
fix(openai-agents): propagate gen_ai.agent.name through an agent flow + set workflow name to fast mcp (#3388)
1 parent fd9626b commit 2add6a8

File tree

10 files changed

+312
-4
lines changed

10 files changed

+312
-4
lines changed

packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/fastmcp_instrumentation.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class FastMCPInstrumentor:
1717

1818
def __init__(self):
1919
self._tracer = None
20+
self._server_name = None
2021

2122
def instrument(self, tracer: Tracer):
2223
"""Apply FastMCP-specific instrumentation."""
@@ -30,12 +31,35 @@ def instrument(self, tracer: Tracer):
3031
"fastmcp.tools.tool_manager",
3132
)
3233

34+
# Instrument FastMCP __init__ to capture server name
35+
register_post_import_hook(
36+
lambda _: wrap_function_wrapper(
37+
"fastmcp", "FastMCP.__init__", self._fastmcp_init_wrapper()
38+
),
39+
"fastmcp",
40+
)
41+
3342
def uninstrument(self):
3443
"""Remove FastMCP-specific instrumentation."""
3544
# Note: wrapt doesn't provide a clean way to unwrap post-import hooks
3645
# This is a limitation we'll need to document
3746
pass
3847

48+
def _fastmcp_init_wrapper(self):
49+
"""Create wrapper for FastMCP initialization to capture server name."""
50+
@dont_throw
51+
def traced_method(wrapped, instance, args, kwargs):
52+
# Call the original __init__ first
53+
result = wrapped(*args, **kwargs)
54+
55+
if args and len(args) > 0:
56+
self._server_name = f"{args[0]}.mcp"
57+
elif 'name' in kwargs:
58+
self._server_name = f"{kwargs['name']}.mcp"
59+
60+
return result
61+
return traced_method
62+
3963
def _fastmcp_tool_wrapper(self):
4064
"""Create wrapper for FastMCP tool execution."""
4165
@dont_throw
@@ -62,12 +86,16 @@ async def traced_method(wrapped, instance, args, kwargs):
6286
with self._tracer.start_as_current_span("mcp.server") as mcp_span:
6387
mcp_span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, "server")
6488
mcp_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, "mcp.server")
89+
if self._server_name:
90+
mcp_span.set_attribute(SpanAttributes.TRACELOOP_WORKFLOW_NAME, self._server_name)
6591

6692
# Create nested tool span
6793
span_name = f"{entity_name}.tool"
6894
with self._tracer.start_as_current_span(span_name) as tool_span:
6995
tool_span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND, TraceloopSpanKindValues.TOOL.value)
7096
tool_span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, entity_name)
97+
if self._server_name:
98+
tool_span.set_attribute(SpanAttributes.TRACELOOP_WORKFLOW_NAME, self._server_name)
7199

72100
if self._should_send_prompts():
73101
try:

packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,29 @@ def get_greeting() -> str:
121121
assert len(request_writer_spans) == 0, (
122122
f"RequestStreamWriter spans should be removed, found {len(request_writer_spans)}"
123123
)
124+
125+
# Verify TRACELOOP_WORKFLOW_NAME is set correctly on server spans
126+
mcp_server_spans = [span for span in spans if span.name == 'mcp.server']
127+
assert len(mcp_server_spans) >= 1, (
128+
f"Expected at least 1 mcp.server span, found {len(mcp_server_spans)}"
129+
)
130+
131+
for server_span in mcp_server_spans:
132+
workflow_name = server_span.attributes.get('traceloop.workflow.name')
133+
assert workflow_name == 'test-server.mcp', (
134+
f"Expected workflow name 'test-server.mcp', got '{workflow_name}'"
135+
)
136+
137+
# Verify TRACELOOP_WORKFLOW_NAME is also set on tool spans
138+
server_tool_spans = [span for span in spans if span.name == 'add_numbers.tool'
139+
and span.attributes.get('traceloop.span.kind') == 'tool'
140+
and 'traceloop.workflow.name' in span.attributes]
141+
assert len(server_tool_spans) >= 1, (
142+
f"Expected at least 1 server-side tool span with workflow name, found {len(server_tool_spans)}"
143+
)
144+
145+
for tool_span in server_tool_spans:
146+
workflow_name = tool_span.attributes.get('traceloop.workflow.name')
147+
assert workflow_name == 'test-server.mcp', (
148+
f"Expected workflow name 'test-server.mcp' on tool span, got '{workflow_name}'"
149+
)

packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp_attributes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def get_test_config() -> dict:
6262
# Test 2: Verify traceloop attributes
6363
assert tool_span.attributes.get("traceloop.span.kind") == "tool"
6464
assert tool_span.attributes.get("traceloop.entity.name") == "process_data"
65+
assert tool_span.attributes.get("traceloop.workflow.name") == "attribute-test-server.mcp"
6566

6667
# Test 3: Verify span status
6768
assert tool_span.status.status_code.name == "OK"
@@ -166,4 +167,7 @@ async def failing_tool(should_fail: bool = True) -> str:
166167
assert error_span.attributes.get("traceloop.span.kind") == "tool"
167168
assert error_span.attributes.get("traceloop.entity.name") == "failing_tool"
168169

170+
# Verify workflow name is set correctly even on error spans
171+
assert error_span.attributes.get("traceloop.workflow.name") == "error-test-server.mcp"
172+
169173
print("✅ Error handling validated")

packages/opentelemetry-instrumentation-openai-agents/opentelemetry/instrumentation/openai_agents/_hooks.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from opentelemetry.trace import Tracer, Status, StatusCode, SpanKind, get_current_span, set_span_in_context
88
from opentelemetry import context
99
from opentelemetry.semconv_ai import SpanAttributes, TraceloopSpanKindValues
10-
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_COMPLETION
10+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_COMPLETION, GEN_AI_AGENT_NAME
1111
from agents.tracing.processors import TracingProcessor
1212
from .utils import dont_throw
1313

14+
from traceloop.sdk.tracing import set_agent_name
15+
1416

1517
class OpenTelemetryTracingProcessor(TracingProcessor):
1618
"""
@@ -73,6 +75,8 @@ def on_span_start(self, span):
7375

7476
if isinstance(span_data, AgentSpanData):
7577
agent_name = getattr(span_data, 'name', None) or "unknown_agent"
78+
# Set agent name in OpenTelemetry context for propagation to child spans
79+
set_agent_name(agent_name)
7680

7781
handoff_parent = None
7882
trace_id = getattr(span, 'trace_id', None)
@@ -83,7 +87,7 @@ def on_span_start(self, span):
8387

8488
attributes = {
8589
SpanAttributes.TRACELOOP_SPAN_KIND: TraceloopSpanKindValues.AGENT.value,
86-
"gen_ai.agent.name": agent_name,
90+
GEN_AI_AGENT_NAME: agent_name,
8791
"gen_ai.system": "openai_agents"
8892
}
8993

@@ -132,6 +136,7 @@ def on_span_start(self, span):
132136

133137
if from_agent and from_agent != 'unknown':
134138
handoff_attributes["gen_ai.handoff.from_agent"] = from_agent
139+
handoff_attributes[GEN_AI_AGENT_NAME] = from_agent
135140
if to_agent and to_agent != 'unknown':
136141
handoff_attributes["gen_ai.handoff.to_agent"] = to_agent
137142

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
interactions:
2+
- request:
3+
body: '{"data":[{"object":"trace.span","id":"span_1682072c4a874ee68e5ab580","trace_id":"trace_2dc4a148df4c45ed8b309c32cc5c11a9","parent_id":"span_c0ea12fef2fa41949a7e3aa4","started_at":"2025-08-15T17:56:02.384852+00:00","ended_at":"2025-08-15T17:56:03.612731+00:00","span_data":{"type":"response","response_id":"resp_689f74b2f1e0819088b30c8105dc8b290a33650d0eca9a46"},"error":null},{"object":"trace.span","id":"span_f69f0eed2a614f6ca2486935","trace_id":"trace_2dc4a148df4c45ed8b309c32cc5c11a9","parent_id":"span_c0ea12fef2fa41949a7e3aa4","started_at":"2025-08-15T17:56:03.613427+00:00","ended_at":"2025-08-15T17:56:03.613997+00:00","span_data":{"type":"function","name":"generate_report","input":"{\"processed_data\":\"Processed
4+
results: Analyzed data patterns for: Sales data from last quarter\"}","output":"Generated
5+
report: Processed results: Analyzed data patterns for: Sales data from last
6+
quarter","mcp_data":null},"error":null},{"object":"trace.span","id":"span_40705905ad4149d79d4419ad","trace_id":"trace_2dc4a148df4c45ed8b309c32cc5c11a9","parent_id":"span_c0ea12fef2fa41949a7e3aa4","started_at":"2025-08-15T17:56:03.614647+00:00","ended_at":"2025-08-15T17:56:08.170805+00:00","span_data":{"type":"response","response_id":"resp_689f74b439dc81909f83fedf09751dfe0a33650d0eca9a46"},"error":null},{"object":"trace.span","id":"span_c0ea12fef2fa41949a7e3aa4","trace_id":"trace_2dc4a148df4c45ed8b309c32cc5c11a9","parent_id":null,"started_at":"2025-08-15T17:55:59.725264+00:00","ended_at":"2025-08-15T17:56:08.171797+00:00","span_data":{"type":"agent","name":"Analytics
7+
Agent","handoffs":[],"tools":["analyze_data","process_results","generate_report"],"output_type":"str"},"error":null},{"object":"trace","id":"trace_6a430ad653c745b78c89622b8e61fccc","workflow_name":"Agent
8+
workflow","group_id":null,"metadata":null}]}'
9+
headers:
10+
accept:
11+
- '*/*'
12+
accept-encoding:
13+
- gzip, deflate
14+
connection:
15+
- keep-alive
16+
content-length:
17+
- '1811'
18+
content-type:
19+
- application/json
20+
cookie:
21+
- __cf_bm=UhrfEFws9O_ZBKuSryCKFovrTxciXL8p2WJuM1K2dN8-1755280562-1.0.1.1-dIIsnsWKGJtA9W6u0MbXjq7UUseSGAthIGNSZMriLzkecTBUlPjjJFr6r0QnteF8Ul.liPTWhJI6mlCKQBREwPTAAOYdCC2ZirAu9ZrwIWA;
22+
_cfuvid=zDtlMy4g5CGjInt8L2ecM4HeWcHtz0bFgxVbfE5vSqk-1755280562683-0.0.1.1-604800000
23+
host:
24+
- api.openai.com
25+
openai-beta:
26+
- traces=v1
27+
user-agent:
28+
- python-httpx/0.28.1
29+
method: POST
30+
uri: https://api.openai.com/v1/traces/ingest
31+
response:
32+
body:
33+
string: ''
34+
headers:
35+
CF-RAY:
36+
- 96fa91223a586901-FRA
37+
Connection:
38+
- keep-alive
39+
Date:
40+
- Fri, 15 Aug 2025 17:56:09 GMT
41+
Server:
42+
- cloudflare
43+
X-Content-Type-Options:
44+
- nosniff
45+
alt-svc:
46+
- h3=":443"; ma=86400
47+
cf-cache-status:
48+
- DYNAMIC
49+
openai-organization:
50+
- traceloop
51+
openai-processing-ms:
52+
- '242'
53+
openai-project:
54+
- proj_tzz1TbPPOXaf6j9tEkVUBIAa
55+
openai-version:
56+
- '2020-10-01'
57+
strict-transport-security:
58+
- max-age=31536000; includeSubDomains; preload
59+
x-envoy-upstream-service-time:
60+
- '253'
61+
x-request-id:
62+
- req_874f73c01f3025bae3501d414db98cdf
63+
status:
64+
code: 204
65+
message: No Content
66+
- request:
67+
body: '{"include":[],"input":[{"content":"What is AI?","role":"user"}],"instructions":"You
68+
are a helpful assistant that answers all questions","max_output_tokens":1024,"model":"gpt-4.1","stream":false,"temperature":0.3,"tools":[],"top_p":0.2}'
69+
headers:
70+
accept:
71+
- application/json
72+
accept-encoding:
73+
- gzip, deflate
74+
connection:
75+
- keep-alive
76+
content-length:
77+
- '235'
78+
content-type:
79+
- application/json
80+
cookie:
81+
- __cf_bm=WwDHl7j6.dqwOcLIJAXqGOLTR6ZUq3JCq47vW3LBIBs-1755280559-1.0.1.1-na9dmQo.4u4zv1vUQ7SN457JVcBR1ifes3cOUutsLuVtLSfo_sZ1I8fRayi6NDR2VKiwUFBhrUYM85dJ8BB7Ior2pM9Ng5MfNJwvGRd3lgE;
82+
_cfuvid=PWHn6CD5_OXbE3jv9HT7E4FDlSvoTN5AciqTl4Chslg-1755280559217-0.0.1.1-604800000
83+
host:
84+
- api.openai.com
85+
user-agent:
86+
- Agents/Python 0.2.7
87+
x-stainless-arch:
88+
- arm64
89+
x-stainless-async:
90+
- async:asyncio
91+
x-stainless-lang:
92+
- python
93+
x-stainless-os:
94+
- MacOS
95+
x-stainless-package-version:
96+
- 1.99.9
97+
x-stainless-read-timeout:
98+
- '600'
99+
x-stainless-retry-count:
100+
- '1'
101+
x-stainless-runtime:
102+
- CPython
103+
x-stainless-runtime-version:
104+
- 3.10.13
105+
method: POST
106+
uri: https://api.openai.com/v1/responses
107+
response:
108+
body:
109+
string: !!binary |
110+
H4sIAAAAAAAAA3RV227jNhB9z1cM9LQ14kB2nET2m1EURYCiKLrdAsWmEEbUSGLNi5YcOtEu8u8F
111+
KVmx2+yLYc3lcOYczvDbFUAm62wHmSPfl/fFtnnYVNuioqJYbQnrYlutxHYl1sVqW61zLKpNIYrb
112+
bZFvmhVl1xHAVv+Q4BOINX6yC0fIVJcYfauHu7t1kd/db5PPM3LwMUdY3StiqsekCsWhdTaYWFWD
113+
ylMyk3PWZTswQalkkOaUWNbEKJW/9Hp2QbC0Jh3ylw2AjgChI9U3QQF6Lz2jYeAOGdD4Z3IeUCn4
114+
EsiPmQlL40tpA/eBS7YHSoCrfL2ZnWytKgWqyxK0rUnFs9uel5ub1XKdr++W+Wa52ky0JcxsB5+v
115+
AAC+pd9ZD+3bkxyY00pEOaoiL27Xm/z+oagbcbt9V46EwUNPCYW8x/bM8T3ek1NYw2TeSjov6wL2
116+
RAe98JydAtAYy3ii/fPfF05l297Z6h1PAtpBtljsHxcLiLrUHhrrYLHYO5aNFBIVPBompWRLRtBi
117+
cQOPDI6aKBtb4I6gpiMp22syDLaB2GJgcuAHz6Q9WAfeNvwcr0KSXaCBnlxjnQZGf/DAQy+jlgM4
118+
+hKkk6aFLmg0IM9Ov4E/OvI05UgjVKhp92SezBI+mZpcaiHmGuTgUIFC0wZsCT4oeSD4ybRK+i5W
119+
ZLmLJfbxbs1h/ocI9TsJ2xr5NQJJHc2phZ5IdNH/C6Ez0dk4q4FeenIylgcfNIpOGgI1RSS4j1Yd
120+
Y3RUQUU+0NSg8RBtNQnpo24x8DeFQzS2qMcjaydTpkDnY5f7x0RdRdDEUQVpQKMZgI7khhoHYBKd
121+
scq2kvw1+CA6QD8R9Kd0HPBsBP1Eykfp5DXsFb3gdTz1Z2tbRbA/xc2caE2mTtdslnZE4M56Amvg
122+
V+JGyZeIstf41ZqRAFLN8rKXJfzYIVeWRzZE8Gx11IPcUQqCONyp5UcDPmiNbriG/SNID1jZwCf+
123+
JsI9PGVeo+OnDHy6lENiKq4dCB6eJXcwjl4qzlFPLFkep7t0DbZhMlANoKWW4vB2/7iTJn3GOk+6
124+
3mTzHL1O/+bRypxVaVxnosfgGJiCsh4dKkXqcoWxC+PS7R0dpQ2+PO31Mu2mecX1zuqeS4Gio/JA
125+
w7nPEXobK8x20w7JqGms47OguI9GSifjFcDr+DxgQzyUsiYTp58uVv8kTcmjPaupwaDGTZR5to7O
126+
m2DSPbk4hdGc39xO1rRxpsri/OPb99mmS3Eja1PFR3KV9ZKHcb/WMuhsrnvksbNSjMQHttnseFt8
127+
Gdu+PFuH+WzsU43r8dsFI9ItT11Kj5U6PZMhrfW5AWkunqfxcfqP/eydnNtM0tVviflFq/979e7u
128+
3/O8BzzL/z1stozqDLrIZxKDv9RbE2ONjBH/9er1XwAAAP//AwB79dv3tQgAAA==
129+
headers:
130+
CF-RAY:
131+
- 96fa9123bba209c9-HFA
132+
Connection:
133+
- keep-alive
134+
Content-Encoding:
135+
- gzip
136+
Content-Type:
137+
- application/json
138+
Date:
139+
- Fri, 15 Aug 2025 17:56:12 GMT
140+
Server:
141+
- cloudflare
142+
Transfer-Encoding:
143+
- chunked
144+
X-Content-Type-Options:
145+
- nosniff
146+
alt-svc:
147+
- h3=":443"; ma=86400
148+
cf-cache-status:
149+
- DYNAMIC
150+
openai-organization:
151+
- traceloop
152+
openai-processing-ms:
153+
- '2970'
154+
openai-project:
155+
- proj_tzz1TbPPOXaf6j9tEkVUBIAa
156+
openai-version:
157+
- '2020-10-01'
158+
strict-transport-security:
159+
- max-age=31536000; includeSubDomains; preload
160+
x-envoy-upstream-service-time:
161+
- '2974'
162+
x-ratelimit-limit-requests:
163+
- '10000'
164+
x-ratelimit-limit-tokens:
165+
- '30000000'
166+
x-ratelimit-remaining-requests:
167+
- '9999'
168+
x-ratelimit-remaining-tokens:
169+
- '29999957'
170+
x-ratelimit-reset-requests:
171+
- 6ms
172+
x-ratelimit-reset-tokens:
173+
- 0s
174+
x-request-id:
175+
- req_75dd7627c6cae3f69948923a1d3a4850
176+
status:
177+
code: 200
178+
message: OK
179+
version: 1

packages/opentelemetry-instrumentation-openai-agents/tests/conftest.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Unit tests configuration module."""
22

33
import os
4+
import sys
5+
import types
46
import pytest
7+
from unittest.mock import MagicMock
58
from opentelemetry.sdk.trace import TracerProvider
69
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
710
InMemorySpanExporter,
@@ -22,6 +25,31 @@
2225

2326
pytest_plugins = []
2427

28+
# Mock traceloop modules before any imports using proper ModuleType
29+
SET_AGENT_NAME_MOCK = MagicMock()
30+
31+
# Create proper module mocks using types.ModuleType for better type safety
32+
mock_traceloop = types.ModuleType('traceloop')
33+
mock_sdk = types.ModuleType('traceloop.sdk')
34+
mock_tracing = types.ModuleType('traceloop.sdk.tracing')
35+
36+
# Set up the module hierarchy and add our mock function
37+
mock_tracing.set_agent_name = SET_AGENT_NAME_MOCK
38+
mock_sdk.tracing = mock_tracing
39+
mock_traceloop.sdk = mock_sdk
40+
41+
# Install mocks in sys.modules before any imports occur
42+
sys.modules['traceloop'] = mock_traceloop
43+
sys.modules['traceloop.sdk'] = mock_sdk
44+
sys.modules['traceloop.sdk.tracing'] = mock_tracing
45+
46+
47+
@pytest.fixture
48+
def mock_set_agent_name():
49+
"""Provide access to the mocked set_agent_name function for test assertions."""
50+
SET_AGENT_NAME_MOCK.reset_mock() # Reset mock between tests
51+
return SET_AGENT_NAME_MOCK
52+
2553

2654
@pytest.fixture(scope="session")
2755
def exporter():

packages/opentelemetry-instrumentation-openai-agents/tests/test_openai_agents.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,27 @@ async def compose_music(style: str, key: str) -> str:
430430
f"Found unexpected root spans that should be child spans: {unexpected_root_spans}. "
431431
f"All spans should be children of 'Agent Workflow' root span."
432432
)
433+
434+
435+
@pytest.mark.vcr
436+
def test_agent_name_propagation_to_agent_spans(exporter, test_agent):
437+
"""Test that agent name is set into agent spans through the context propagation mechanism."""
438+
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_AGENT_NAME
439+
440+
query = "What is AI?"
441+
Runner.run_sync(
442+
test_agent,
443+
query,
444+
)
445+
spans = exporter.get_finished_spans()
446+
447+
# Find the agent span
448+
agent_spans = [s for s in spans if s.name == "testAgent.agent"]
449+
assert len(agent_spans) == 1, f"Expected 1 agent span, got {len(agent_spans)}"
450+
agent_span = agent_spans[0]
451+
452+
# Verify the agent span has the agent name attribute set via context propagation
453+
assert GEN_AI_AGENT_NAME in agent_span.attributes, f"Agent span should have {GEN_AI_AGENT_NAME} attribute"
454+
assert agent_span.attributes[GEN_AI_AGENT_NAME] == "testAgent", (
455+
f"Expected agent name 'testAgent', got '{agent_span.attributes[GEN_AI_AGENT_NAME]}'"
456+
)

0 commit comments

Comments
 (0)