Skip to content

Commit 375a4d5

Browse files
galzilberclaude
andauthored
fix(openai): instrument responses.parse() for structured-output tracing (#4198)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b39151b commit 375a4d5

4 files changed

Lines changed: 317 additions & 0 deletions

File tree

packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/v1/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ def _instrument(self, **kwargs):
312312
"Responses.retrieve",
313313
responses_get_or_create_wrapper(tracer),
314314
)
315+
self._try_wrap(
316+
"openai.resources.responses",
317+
"Responses.parse",
318+
responses_get_or_create_wrapper(tracer),
319+
)
315320
self._try_wrap(
316321
"openai.resources.responses",
317322
"Responses.cancel",
@@ -327,6 +332,11 @@ def _instrument(self, **kwargs):
327332
"AsyncResponses.retrieve",
328333
async_responses_get_or_create_wrapper(tracer),
329334
)
335+
self._try_wrap(
336+
"openai.resources.responses",
337+
"AsyncResponses.parse",
338+
async_responses_get_or_create_wrapper(tracer),
339+
)
330340
self._try_wrap(
331341
"openai.resources.responses",
332342
"AsyncResponses.cancel",
@@ -364,9 +374,11 @@ def _uninstrument(self, **kwargs):
364374
unwrap("openai.resources.beta.threads.messages", "Messages.list")
365375
unwrap("openai.resources.responses", "Responses.create")
366376
unwrap("openai.resources.responses", "Responses.retrieve")
377+
unwrap("openai.resources.responses", "Responses.parse")
367378
unwrap("openai.resources.responses", "Responses.cancel")
368379
unwrap("openai.resources.responses", "AsyncResponses.create")
369380
unwrap("openai.resources.responses", "AsyncResponses.retrieve")
381+
unwrap("openai.resources.responses", "AsyncResponses.parse")
370382
unwrap("openai.resources.responses", "AsyncResponses.cancel")
371383
unwrap("openai.resources.beta.realtime.realtime", "Realtime.connect")
372384
unwrap("openai.resources.beta.realtime.realtime", "AsyncRealtime.connect")
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
interactions:
2+
- request:
3+
body: '{"input":"Extract: Alice is 30 years old.","model":"gpt-4.1-nano","text":{"format":{"type":"json_schema","strict":true,"name":"Person","schema":{"properties":{"name":{"title":"Name","type":"string"},"age":{"title":"Age","type":"integer"}},"required":["name","age"],"title":"Person","type":"object","additionalProperties":false}}}}'
4+
headers:
5+
Accept:
6+
- application/json
7+
Accept-Encoding:
8+
- gzip, deflate
9+
Connection:
10+
- keep-alive
11+
Content-Length:
12+
- '330'
13+
Content-Type:
14+
- application/json
15+
Host:
16+
- api.openai.com
17+
User-Agent:
18+
- OpenAI/Python 1.99.7
19+
X-Stainless-Arch:
20+
- arm64
21+
X-Stainless-Async:
22+
- 'false'
23+
X-Stainless-Lang:
24+
- python
25+
X-Stainless-OS:
26+
- MacOS
27+
X-Stainless-Package-Version:
28+
- 1.99.7
29+
X-Stainless-Runtime:
30+
- CPython
31+
X-Stainless-Runtime-Version:
32+
- 3.10.20
33+
x-stainless-read-timeout:
34+
- '600'
35+
x-stainless-retry-count:
36+
- '0'
37+
method: POST
38+
uri: https://api.openai.com/v1/responses
39+
response:
40+
body:
41+
string: !!binary |
42+
H4sIAAAAAAAA/31Vy3KjMBC8+ytSnO0t4eCAc8sPbOUeb1GyGIgSIbF6uOJK+d93JAwIYu8NpjWt
43+
efSMvlcPDwmvkueHRIPpSrKtH4unakfTPctytifkiaZFdqxgT4EU6Z5QhrYiLdKMbVN2zJK1p1DH
44+
D2B2oFHSQG9nGqiFqqQeS/N8v8/ztNgGzFhqnfE+TLWdADzXOx0p+2y0ctLHVVNhoDdzIbhs0PaN
45+
v2jo6Bm096/gBEJ1+IPApb94oFxcnQUUtFbeUzohgqHW8NeBZOeyA0mFPSNIfpGAcTmQlRVYyoWJ
46+
Pbk0VjtmOSYd21v6VSpnO2dLqz7hJ2iVEiWjYk7XqgqEz6np7Cb7lW4klWqzJdvdhmSb9Fpuf0pT
47+
f2fs21+HlrdQn75KY39b09xtLxBS1KG9BaHZDgijj0/5Ltv39wUWe+4g8IAxtIEJuNfHADIlLcgp
48+
qDiwGe1QK/iyo3c4QKVUlg71ffszA4VqOq2ON5BAhLzfh0TSFg7J8yF5EZzh1/qQYAJoeSSXZPS5
49+
XL9GmkQrEUKjxnBMErNYDQfDIRSgxv6BmPcS5dALtsNZQE3BDU0hdOLKmXIYlzI0aWwlJtV2FinZ
50+
O5SfcL6LafDl7YWAWixbaJU+9yrB2TNKzkYmtCMUZqDz01DXSi9sxrUt1cO941gZWoM9Y7D+0prD
51+
bIgM6BMWuLR8GMuaOtG3E1WiNMTVsdB2XsQumNNrYa7RXcPFuFo6/Udy+cDMSoMVaOkktgoM07xb
52+
zEWAvAa83yto9IzE21M8R6L0BcbIMAszs08ssc3HxG0vlN8eXi/Aa8C4JXwnIvAyl3nzP+aX5i4x
53+
x442/epbSnl2R+JXHNdQzWZxSOpHLON/NFZTPIsyxnPcvwQRQquK+5ZQ8RoXNiz21SLMUKbwkHid
54+
rCIsOYE+KsPDGOESqrhrp3XfD+C7QvmFiXVWJSMwbQf87cpoZ5DR2MUa1E6yYbsmFTf0KIa3yZm4
55+
UVj82YLf5euf9ujVGGUchreaHMks1eW7kZJbwC3eceTvUVvcpWICn/KxhM7Mp7lF9oraMBuX1eUf
56+
rsQ+qioIAAA=
57+
headers:
58+
Access-Control-Expose-Headers:
59+
- CF-Ray
60+
CF-Cache-Status:
61+
- DYNAMIC
62+
CF-Ray:
63+
- a02dd1c7bb56ae7c-TLV
64+
Connection:
65+
- keep-alive
66+
Content-Encoding:
67+
- gzip
68+
Content-Type:
69+
- application/json
70+
Date:
71+
- Thu, 28 May 2026 14:06:24 GMT
72+
Server:
73+
- cloudflare
74+
Strict-Transport-Security:
75+
- max-age=31536000; includeSubDomains; preload
76+
Transfer-Encoding:
77+
- chunked
78+
X-Content-Type-Options:
79+
- nosniff
80+
access-control-expose-headers:
81+
- X-Request-ID
82+
- CF-Ray
83+
alt-svc:
84+
- h3=":443"; ma=86400
85+
openai-organization:
86+
- traceloop
87+
openai-processing-ms:
88+
- '1846'
89+
openai-project:
90+
- proj_tzz1TbPPOXaf6j9tEkVUBIAa
91+
openai-version:
92+
- '2020-10-01'
93+
set-cookie:
94+
- __cf_bm=ze2YJE7X1Sq.Cjr_Fk8dIg2ja.e5CkcNyga3DsAhsMU-1779977181.391377-1.0.1.1-7s8F8JKzp7dG15jArIJ6k9j3_ywIDbNprDFJQvgcCRU9WheM_rXhmpDeNnoz6T.KQnW2GOG1reXTFCyyhXu6mn5dAoYoFt1iWdd4BC07o0Zs6FfYJDg59_gDxJW5Xhmc;
95+
HttpOnly; SameSite=None; Secure; Path=/; Domain=api.openai.com; Expires=Thu,
96+
28 May 2026 14:36:24 GMT
97+
x-ratelimit-limit-requests:
98+
- '30000'
99+
x-ratelimit-limit-tokens:
100+
- '150000000'
101+
x-ratelimit-remaining-requests:
102+
- '29999'
103+
x-ratelimit-remaining-tokens:
104+
- '149999925'
105+
x-ratelimit-reset-requests:
106+
- 2ms
107+
x-ratelimit-reset-tokens:
108+
- 0s
109+
x-request-id:
110+
- req_7b54d269740449c1a63e79934079420a
111+
status:
112+
code: 200
113+
message: OK
114+
version: 1
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
interactions:
2+
- request:
3+
body: '{"input":"Extract: Bob is 42 years old.","model":"gpt-4.1-nano","text":{"format":{"type":"json_schema","strict":true,"name":"Person","schema":{"properties":{"name":{"title":"Name","type":"string"},"age":{"title":"Age","type":"integer"}},"required":["name","age"],"title":"Person","type":"object","additionalProperties":false}}}}'
4+
headers:
5+
Accept:
6+
- application/json
7+
Accept-Encoding:
8+
- gzip, deflate
9+
Connection:
10+
- keep-alive
11+
Content-Length:
12+
- '328'
13+
Content-Type:
14+
- application/json
15+
Host:
16+
- api.openai.com
17+
User-Agent:
18+
- AsyncOpenAI/Python 1.99.7
19+
X-Stainless-Arch:
20+
- arm64
21+
X-Stainless-Async:
22+
- async:asyncio
23+
X-Stainless-Lang:
24+
- python
25+
X-Stainless-OS:
26+
- MacOS
27+
X-Stainless-Package-Version:
28+
- 1.99.7
29+
X-Stainless-Runtime:
30+
- CPython
31+
X-Stainless-Runtime-Version:
32+
- 3.10.20
33+
x-stainless-read-timeout:
34+
- '600'
35+
x-stainless-retry-count:
36+
- '0'
37+
method: POST
38+
uri: https://api.openai.com/v1/responses
39+
response:
40+
body:
41+
string: !!binary |
42+
H4sIAAAAAAAA/31VTXObMBC9+1dkdLY74OBgcmt/QCf3uMMssBAlQqKS8MST8X/vShgQjt0b2sc+
43+
7cfb1dfq4YHxij0/MI2my6Nt/bit4h2WdZbEO4iiJ4j3SYERwFO5j7MyKyB5zDJIk7IosAa2dhSq
44+
eMfSjjRKGhzspUawWOXgsDhNsyxNic9jxoLtjfMpVdsJpP8GpwLKj0arXrq4ahAGBzMXgsuGbF90
45+
JEMHJ9TOv8IjCtXRgYDzcPFIeXX1zqOotXKeshfCG2qNf3uU5SnvUIKwJwKjH5HHuBzJ8gotcGFC
46+
Ty6N1X1pOSUd2lv4zFVvu97mVn3gd9AqJfISxJKuVRUKl1PT2U3yI95IkGqzjba7TZRs4oRNf2lw
47+
d4a+w3VkefX1Gao09bc1zf32xtU+S4b2Zk9JscviYp89psXe3+dZ7KlDz4PGQIMzcK+PHiyVtCjn
48+
oMLAFrRjrfDTTt7+B5BSWRjr+/pnAQrVdFoVNxBPRLxfByahxQN7PrBfqjiw9YFR+HROtmc2eZwv
49+
XxMJ00r4wMAYTilSDqvxR/8TyU9T91AsO0liGOTa0SSQovCGogg6ctWbfByW3LdoaiSl1HaWKMs3
50+
zD/wdBfT6Io7yICUmLfYKn0aNEKTZ5RcDIxvhi/LSOdmoa6VvrKZvm1Bj/dOQ2WgRnuiYN2lNcfF
51+
CBnUR07pWj4OZQ29GJpJGlEaw+pYbDsn4d6b40thLtFdwqW4WpjPgVjeKbPcUAVamKVWoSk1766m
52+
wkNOAc7vBTV5BtIdKJ4DSboCU2SUhVnYZ5bQ5mLidhDKbwevr8BLwLQjXCcC8LwUefM/5p/NXWJO
53+
HW2GxXct5cUdzC04rrFaTOKY1LdYpnMwVHM8V2UMp3h4BwIEqoq7loB4CQvr1/rqKkxfJv+MOJ2s
54+
AowdURfKcD9GtIIq3rfzsh8G8E2R/PzE9laxCZh3Ax27PNgY0WTsQg3qXpbjbmUVN1CI8WXqTdgo
55+
Kv5ive/S9Xd78GZMMvbDW82O0SLV61cjjm4Bt3inkb9HbWmTihl8SqcS9mY5zS2xV2D9bJxX539H
56+
CWiCKAgAAA==
57+
headers:
58+
Access-Control-Expose-Headers:
59+
- CF-Ray
60+
CF-Cache-Status:
61+
- DYNAMIC
62+
CF-Ray:
63+
- a02dd1db6b6e36f8-TLV
64+
Connection:
65+
- keep-alive
66+
Content-Encoding:
67+
- gzip
68+
Content-Type:
69+
- application/json
70+
Date:
71+
- Thu, 28 May 2026 14:06:26 GMT
72+
Server:
73+
- cloudflare
74+
Strict-Transport-Security:
75+
- max-age=31536000; includeSubDomains; preload
76+
Transfer-Encoding:
77+
- chunked
78+
X-Content-Type-Options:
79+
- nosniff
80+
access-control-expose-headers:
81+
- X-Request-ID
82+
- CF-Ray
83+
alt-svc:
84+
- h3=":443"; ma=86400
85+
openai-organization:
86+
- traceloop
87+
openai-processing-ms:
88+
- '1424'
89+
openai-project:
90+
- proj_tzz1TbPPOXaf6j9tEkVUBIAa
91+
openai-version:
92+
- '2020-10-01'
93+
set-cookie:
94+
- __cf_bm=QnIHGVWXeYNMsGeR1BuZ28Ux60SAzc5t6Qpj0evXPI4-1779977184.5411139-1.0.1.1-3SZdXI.2.O6Cq6gDmmpmU5ZqHn.8Sb6eRJiQdFRJDz4wd9e_DiDNSJpY7t2aQ0pZhuOn9oh001bCMQYvpyAOKbMhYOLiNm8qF1bkr02208KkHG.SGIesp6H0yfh9..Va;
95+
HttpOnly; SameSite=None; Secure; Path=/; Domain=api.openai.com; Expires=Thu,
96+
28 May 2026 14:36:26 GMT
97+
x-ratelimit-limit-requests:
98+
- '30000'
99+
x-ratelimit-limit-tokens:
100+
- '150000000'
101+
x-ratelimit-remaining-requests:
102+
- '29999'
103+
x-ratelimit-remaining-tokens:
104+
- '149999925'
105+
x-ratelimit-reset-requests:
106+
- 2ms
107+
x-ratelimit-reset-tokens:
108+
- 0s
109+
x-request-id:
110+
- req_77a806b441db4b71803efb3c2fd7c284
111+
status:
112+
code: 200
113+
message: OK
114+
version: 1

packages/opentelemetry-instrumentation-openai/tests/traces/test_responses.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import pytest
3+
from pydantic import BaseModel
34

45
from openai import AsyncOpenAI, OpenAI
56
from opentelemetry.instrumentation.openai.utils import is_reasoning_supported
@@ -11,6 +12,11 @@
1112
from .utils import get_input_messages, get_output_messages
1213

1314

15+
class Person(BaseModel):
16+
name: str
17+
age: int
18+
19+
1420
@pytest.mark.vcr
1521
def test_responses(
1622
instrument_legacy, span_exporter: InMemorySpanExporter, openai_client: OpenAI
@@ -406,6 +412,77 @@ async def test_responses_streaming_async_with_context_manager(
406412
assert output_messages[0]["parts"][0]["content"] == full_text
407413

408414

415+
@pytest.mark.vcr
416+
def test_responses_parse(
417+
instrument_legacy, span_exporter: InMemorySpanExporter, openai_client: OpenAI
418+
):
419+
"""Structured-output via responses.parse() must produce an LLM span with usage
420+
and capture the prompt + structured response on the span."""
421+
input_text = "Extract: Alice is 30 years old."
422+
response = openai_client.responses.parse(
423+
model="gpt-4.1-nano",
424+
input=input_text,
425+
text_format=Person,
426+
)
427+
spans = span_exporter.get_finished_spans()
428+
assert len(spans) == 1, f"expected one openai.response span, got {len(spans)}"
429+
span = spans[0]
430+
assert span.name == "openai.response"
431+
assert span.attributes["gen_ai.provider.name"] == "openai"
432+
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
433+
assert span.attributes["gen_ai.usage.input_tokens"] > 0
434+
assert span.attributes["gen_ai.usage.output_tokens"] > 0
435+
436+
input_messages = get_input_messages(span)
437+
assert input_messages[0]["role"] == "user"
438+
assert input_messages[0]["parts"][0]["content"] == input_text
439+
440+
output_messages = get_output_messages(span)
441+
assert output_messages[0]["role"] == "assistant"
442+
output_text = output_messages[0]["parts"][0]["content"]
443+
parsed = Person.model_validate_json(output_text)
444+
assert parsed == response.output_parsed
445+
assert parsed.name == "Alice"
446+
assert parsed.age == 30
447+
448+
449+
@pytest.mark.vcr
450+
@pytest.mark.asyncio
451+
async def test_responses_parse_async(
452+
instrument_legacy,
453+
span_exporter: InMemorySpanExporter,
454+
async_openai_client: AsyncOpenAI,
455+
):
456+
"""Async structured-output via responses.parse() must produce an LLM span with usage
457+
and capture the prompt + structured response on the span."""
458+
input_text = "Extract: Bob is 42 years old."
459+
response = await async_openai_client.responses.parse(
460+
model="gpt-4.1-nano",
461+
input=input_text,
462+
text_format=Person,
463+
)
464+
spans = span_exporter.get_finished_spans()
465+
assert len(spans) == 1, f"expected one openai.response span, got {len(spans)}"
466+
span = spans[0]
467+
assert span.name == "openai.response"
468+
assert span.attributes["gen_ai.provider.name"] == "openai"
469+
assert span.attributes["gen_ai.request.model"] == "gpt-4.1-nano"
470+
assert span.attributes["gen_ai.usage.input_tokens"] > 0
471+
assert span.attributes["gen_ai.usage.output_tokens"] > 0
472+
473+
input_messages = get_input_messages(span)
474+
assert input_messages[0]["role"] == "user"
475+
assert input_messages[0]["parts"][0]["content"] == input_text
476+
477+
output_messages = get_output_messages(span)
478+
assert output_messages[0]["role"] == "assistant"
479+
output_text = output_messages[0]["parts"][0]["content"]
480+
parsed = Person.model_validate_json(output_text)
481+
assert parsed == response.output_parsed
482+
assert parsed.name == "Bob"
483+
assert parsed.age == 42
484+
485+
409486
def test_get_tools_from_kwargs_with_none():
410487
"""Test that get_tools_from_kwargs handles None tools value correctly.
411488

0 commit comments

Comments
 (0)