Skip to content

Commit 55cf2aa

Browse files
authored
Handle NonRecordingSpans correctly in OpenAI instrumentation (#1145)
1 parent 0fab7fe commit 55cf2aa

File tree

4 files changed

+160
-37
lines changed

4 files changed

+160
-37
lines changed

logfire/_internal/integrations/llm_providers/llm_provider.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import TYPE_CHECKING, Any, Callable, cast
66

77
from ...constants import ONE_SECOND_IN_NANOSECONDS
8-
from ...utils import is_instrumentation_suppressed, suppress_instrumentation
8+
from ...utils import is_instrumentation_suppressed, log_internal_error, suppress_instrumentation
99

1010
if TYPE_CHECKING:
1111
from ...main import Logfire, LogfireSpan
@@ -80,44 +80,48 @@ def uninstrument_context():
8080
is_async = is_async_client_fn(client if isinstance(client, type) else type(client))
8181

8282
def _instrumentation_setup(*args: Any, **kwargs: Any) -> Any:
83-
if is_instrumentation_suppressed():
84-
return None, None, kwargs
83+
try:
84+
if is_instrumentation_suppressed():
85+
return None, None, kwargs
8586

86-
options = kwargs.get('options') or args[-1]
87-
message_template, span_data, stream_state_cls = get_endpoint_config_fn(options)
88-
if not message_template:
89-
return None, None, kwargs
87+
options = kwargs.get('options') or args[-1]
88+
message_template, span_data, stream_state_cls = get_endpoint_config_fn(options)
89+
if not message_template:
90+
return None, None, kwargs
9091

91-
span_data['async'] = is_async
92+
span_data['async'] = is_async
9293

93-
stream = kwargs['stream']
94+
stream = kwargs['stream']
9495

95-
if stream and stream_state_cls:
96-
stream_cls = kwargs['stream_cls']
97-
assert stream_cls is not None, 'Expected `stream_cls` when streaming'
96+
if stream and stream_state_cls:
97+
stream_cls = kwargs['stream_cls']
98+
assert stream_cls is not None, 'Expected `stream_cls` when streaming'
9899

99-
if is_async:
100+
if is_async:
100101

101-
class LogfireInstrumentedAsyncStream(stream_cls):
102-
async def __stream__(self) -> AsyncIterator[Any]:
103-
with record_streaming(logfire_llm, span_data, stream_state_cls) as record_chunk:
104-
async for chunk in super().__stream__(): # type: ignore
105-
record_chunk(chunk)
106-
yield chunk
102+
class LogfireInstrumentedAsyncStream(stream_cls):
103+
async def __stream__(self) -> AsyncIterator[Any]:
104+
with record_streaming(logfire_llm, span_data, stream_state_cls) as record_chunk:
105+
async for chunk in super().__stream__(): # type: ignore
106+
record_chunk(chunk)
107+
yield chunk
107108

108-
kwargs['stream_cls'] = LogfireInstrumentedAsyncStream
109-
else:
109+
kwargs['stream_cls'] = LogfireInstrumentedAsyncStream
110+
else:
110111

111-
class LogfireInstrumentedStream(stream_cls):
112-
def __stream__(self) -> Iterator[Any]:
113-
with record_streaming(logfire_llm, span_data, stream_state_cls) as record_chunk:
114-
for chunk in super().__stream__(): # type: ignore
115-
record_chunk(chunk)
116-
yield chunk
112+
class LogfireInstrumentedStream(stream_cls):
113+
def __stream__(self) -> Iterator[Any]:
114+
with record_streaming(logfire_llm, span_data, stream_state_cls) as record_chunk:
115+
for chunk in super().__stream__(): # type: ignore
116+
record_chunk(chunk)
117+
yield chunk
117118

118-
kwargs['stream_cls'] = LogfireInstrumentedStream
119+
kwargs['stream_cls'] = LogfireInstrumentedStream
119120

120-
return message_template, span_data, kwargs
121+
return message_template, span_data, kwargs
122+
except Exception: # pragma: no cover
123+
log_internal_error()
124+
return None, None, kwargs
121125

122126
# In these methods, `*args` is only expected to be `(self,)`
123127
# in the case where we instrument classes rather than client instances.

logfire/_internal/integrations/llm_providers/openai.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from openai.types.create_embedding_response import CreateEmbeddingResponse
1313
from openai.types.images_response import ImagesResponse
1414
from openai.types.responses import Response
15-
from opentelemetry.sdk.trace import ReadableSpan
1615
from opentelemetry.trace import get_current_span
1716

1817
from logfire import LogfireSpan
@@ -94,10 +93,10 @@ def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig:
9493
def is_current_agent_span(*span_names: str):
9594
current_span = get_current_span()
9695
return (
97-
isinstance(current_span, ReadableSpan)
98-
and current_span.instrumentation_scope
99-
and current_span.instrumentation_scope.name == 'logfire.openai_agents'
100-
and current_span.name in span_names
96+
current_span.is_recording()
97+
and (instrumentation_scope := getattr(current_span, 'instrumentation_scope', None))
98+
and instrumentation_scope.name == 'logfire.openai_agents'
99+
and getattr(current_span, 'name', None) in span_names
101100
)
102101

103102

@@ -187,9 +186,13 @@ def on_response(response: ResponseT, span: LogfireSpan) -> ResponseT:
187186
elif isinstance(response, ImagesResponse):
188187
span.set_attribute('response_data', {'images': response.data})
189188
elif isinstance(response, Response): # pragma: no branch
190-
events = json.loads(span.attributes['events']) # type: ignore
191-
events += responses_output_events(response)
192-
span.set_attribute('events', events)
189+
try:
190+
events = json.loads(span.attributes['events']) # type: ignore
191+
except Exception:
192+
pass
193+
else:
194+
events += responses_output_events(response)
195+
span.set_attribute('events', events)
193196

194197
return response
195198

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
interactions:
2+
- request:
3+
body: '{"input":"hi","model":"gpt-4.1"}'
4+
headers:
5+
accept:
6+
- application/json
7+
accept-encoding:
8+
- gzip, deflate, zstd
9+
connection:
10+
- keep-alive
11+
content-length:
12+
- '32'
13+
content-type:
14+
- application/json
15+
host:
16+
- api.openai.com
17+
user-agent:
18+
- OpenAI/Python 1.86.0
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.86.0
29+
x-stainless-read-timeout:
30+
- '600'
31+
x-stainless-retry-count:
32+
- '0'
33+
x-stainless-runtime:
34+
- CPython
35+
x-stainless-runtime-version:
36+
- 3.12.6
37+
method: POST
38+
uri: https://api.openai.com/v1/responses
39+
response:
40+
body:
41+
string: !!binary |
42+
H4sIAAAAAAAAA3RUwXKjMAy95yu8Pjcd00BCctlr9xvaHUaxRcLWWIwtd5vp5N93MISQbnphQE96
43+
fnqS+VwIIRsjd0J6DF21LgulcGVKhXmZgYJ1saqhwD3kWwBcq40p1Lpe56YGgGwlH3oC2v9BzRcS
44+
cgGHuPYIjKaCHss2hVKb9bbcJiwwcAx9jaa2s8hohqI96LeDp+h6VTXYgCmM3pOXO+GitSnQuEth
45+
ZZChseEWDeyj5obcTbyFj4oid5Erpjf8ApJB20s6dLzMH7Plk3oqlipfZvnYaKqUO/GyEEKIz/Sc
46+
HGzDYTIQTKl7A0vY1DVqDeUm325qvGtg4uBTh4kFQ4ADXoHvnEqgJsforpLmsm5oL03jB0/VKQGc
47+
I4aLUS+/b8CUvhPyGa2lH+KZ/goNTvwSR7SdOFEUTAZOP8VrNOXKvEaDCuTEcB7fJlLpySY5EEIT
48+
GBwPyX1iSpIdeLAWbcVEttJg01zZx2ENOo/vDcVQXTatSt5PI/QIgVzjDnI3+iCxrsnzLKn3NLYt
49+
+NMYXAhxHpYS/XujseIG+12TBmuIdjBMBiaPcy2MbYceOKZw9qjGaLJsPLwm38L1ezaQlDc1P5w/
50+
9HykRg8mRSY5AdfxSKau6uZn+uh0mmFS3QTY28t9immbJkGNu9n98uH/8Ow+Tao16COaa50alI/V
51+
X29Ult0D7vFO8/qOmonBzpi3k1kx4M0PoUUGAww9/Xlx/gcAAP//AwChpk6j2wQAAA==
52+
headers:
53+
CF-RAY:
54+
- 950a509f8f060714-CPT
55+
Connection:
56+
- keep-alive
57+
Content-Encoding:
58+
- gzip
59+
Content-Type:
60+
- application/json
61+
Date:
62+
- Mon, 16 Jun 2025 12:29:49 GMT
63+
Server:
64+
- cloudflare
65+
Set-Cookie:
66+
- __cf_bm=jeuQbol7J8yslwqEiMFKQOGwqn_sSNjE10ScC9yGaZM-1750076989-1.0.1.1-18DSSIpXUsRzxtm2_m5_5KeVD3l4lGJj77S_rsi0E3DfDhLeSOTgpu.aR6hCIJkjOzqzNeF03iPxXdTihETti4O7RuQBPChu7xmbqC2eGdM;
67+
path=/; expires=Mon, 16-Jun-25 12:59:49 GMT; domain=.api.openai.com; HttpOnly;
68+
Secure; SameSite=None
69+
- _cfuvid=AfHPPG8IeRnW5Ieut_5AqKANl_AZEK61zM8VqZiePwg-1750076989962-0.0.1.1-604800000;
70+
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
71+
Transfer-Encoding:
72+
- chunked
73+
X-Content-Type-Options:
74+
- nosniff
75+
alt-svc:
76+
- h3=":443"; ma=86400
77+
cf-cache-status:
78+
- DYNAMIC
79+
openai-organization:
80+
- pydantic-28gund
81+
openai-processing-ms:
82+
- '361'
83+
openai-version:
84+
- '2020-10-01'
85+
strict-transport-security:
86+
- max-age=31536000; includeSubDomains; preload
87+
x-ratelimit-limit-requests:
88+
- '10000'
89+
x-ratelimit-limit-tokens:
90+
- '2000000'
91+
x-ratelimit-remaining-requests:
92+
- '9999'
93+
x-ratelimit-remaining-tokens:
94+
- '1999972'
95+
x-ratelimit-reset-requests:
96+
- 6ms
97+
x-ratelimit-reset-tokens:
98+
- 0s
99+
x-request-id:
100+
- req_6058ae08c495d2f122fcd029965b4e3a
101+
status:
102+
code: 200
103+
message: OK
104+
version: 1

tests/otel_integrations/test_openai.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,3 +2057,15 @@ def test_responses_api(exporter: TestExporter) -> None:
20572057
},
20582058
]
20592059
)
2060+
2061+
2062+
@pytest.mark.vcr()
2063+
def test_responses_api_nonrecording(exporter: TestExporter, config_kwargs: dict[str, Any]) -> None:
2064+
client = openai.Client()
2065+
logfire.instrument_openai(client)
2066+
logfire.configure(**config_kwargs, sampling=logfire.SamplingOptions(head=0))
2067+
with logfire.span('span'):
2068+
response = client.responses.create(model='gpt-4.1', input='hi')
2069+
assert response.output_text == snapshot('Hello! How can I help you today? 😊')
2070+
2071+
assert exporter.exported_spans_as_dict() == []

0 commit comments

Comments
 (0)