Skip to content

Commit 409a779

Browse files
ericapisaniclaude
andauthored
feat(httpx): Migrate to span first (#6084)
Migrates the httpx integration to the span-first (streaming span) architecture. When `_experiments={"trace_lifecycle": "stream"}` is enabled, the integration now uses `StreamedSpan` via `sentry_sdk.traces.start_span` instead of the legacy `Span`-based (transactions) path. The legacy path remains unchanged for backwards compatibility. Part of the broader span-first integration migration. Fixes PY-2330 Fixes #6028 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f7ab39b commit 409a779

4 files changed

Lines changed: 714 additions & 144 deletions

File tree

sentry_sdk/integrations/httpx.py

Lines changed: 177 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import sentry_sdk
2-
from sentry_sdk import start_span
32
from sentry_sdk.consts import OP, SPANDATA
43
from sentry_sdk.integrations import Integration, DidNotEnable
54
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
65
from sentry_sdk.tracing_utils import (
7-
should_propagate_trace,
86
add_http_request_source,
7+
should_propagate_trace,
98
add_sentry_baggage_to_headers,
9+
has_span_streaming_enabled,
1010
)
1111
from sentry_sdk.utils import (
1212
SENSITIVE_DATA_SUBSTITUTE,
@@ -20,6 +20,7 @@
2020

2121
if TYPE_CHECKING:
2222
from typing import Any
23+
from sentry_sdk._types import Attributes
2324

2425

2526
try:
@@ -49,48 +50,102 @@ def _install_httpx_client() -> None:
4950

5051
@ensure_integration_enabled(HttpxIntegration, real_send)
5152
def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
53+
client = sentry_sdk.get_client()
54+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
55+
5256
parsed_url = None
5357
with capture_internal_exceptions():
5458
parsed_url = parse_url(str(request.url), sanitize=False)
5559

56-
with start_span(
57-
op=OP.HTTP_CLIENT,
58-
name="%s %s"
59-
% (
60-
request.method,
61-
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
62-
),
63-
origin=HttpxIntegration.origin,
64-
) as span:
65-
span.set_data(SPANDATA.HTTP_METHOD, request.method)
66-
if parsed_url is not None:
67-
span.set_data("url", parsed_url.url)
68-
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
69-
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
70-
71-
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
72-
for (
73-
key,
74-
value,
75-
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
76-
logger.debug(
77-
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
78-
key=key, value=value, url=request.url
60+
if is_span_streaming_enabled:
61+
with sentry_sdk.traces.start_span(
62+
name="%s %s"
63+
% (
64+
request.method,
65+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
66+
),
67+
attributes={
68+
"sentry.op": OP.HTTP_CLIENT,
69+
"sentry.origin": HttpxIntegration.origin,
70+
"http.request.method": request.method,
71+
},
72+
) as streamed_span:
73+
attributes: "Attributes" = {}
74+
75+
if parsed_url is not None:
76+
attributes["url.full"] = parsed_url.url
77+
if parsed_url.query:
78+
attributes["url.query"] = parsed_url.query
79+
if parsed_url.fragment:
80+
attributes["url.fragment"] = parsed_url.fragment
81+
82+
if should_propagate_trace(client, str(request.url)):
83+
for (
84+
key,
85+
value,
86+
) in (
87+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
88+
):
89+
logger.debug(
90+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
7991
)
80-
)
8192

82-
if key == BAGGAGE_HEADER_NAME:
83-
add_sentry_baggage_to_headers(request.headers, value)
84-
else:
85-
request.headers[key] = value
93+
if key == BAGGAGE_HEADER_NAME:
94+
add_sentry_baggage_to_headers(request.headers, value)
95+
else:
96+
request.headers[key] = value
8697

87-
rv = real_send(self, request, **kwargs)
98+
try:
99+
rv = real_send(self, request, **kwargs)
88100

89-
span.set_http_status(rv.status_code)
90-
span.set_data("reason", rv.reason_phrase)
101+
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
102+
attributes["http.response.status_code"] = rv.status_code
103+
finally:
104+
streamed_span.set_attributes(attributes)
91105

92-
with capture_internal_exceptions():
93-
add_http_request_source(span)
106+
# Needs to happen within the context manager as we want to attach the
107+
# final data before the span finishes and is sent for ingesting.
108+
with capture_internal_exceptions():
109+
add_http_request_source(streamed_span)
110+
else:
111+
with sentry_sdk.start_span(
112+
op=OP.HTTP_CLIENT,
113+
name="%s %s"
114+
% (
115+
request.method,
116+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
117+
),
118+
origin=HttpxIntegration.origin,
119+
) as span:
120+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
121+
if parsed_url is not None:
122+
span.set_data("url", parsed_url.url)
123+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
124+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
125+
126+
if should_propagate_trace(client, str(request.url)):
127+
for (
128+
key,
129+
value,
130+
) in (
131+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
132+
):
133+
logger.debug(
134+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
135+
)
136+
137+
if key == BAGGAGE_HEADER_NAME:
138+
add_sentry_baggage_to_headers(request.headers, value)
139+
else:
140+
request.headers[key] = value
141+
142+
rv = real_send(self, request, **kwargs)
143+
144+
span.set_http_status(rv.status_code)
145+
span.set_data("reason", rv.reason_phrase)
146+
147+
with capture_internal_exceptions():
148+
add_http_request_source(span)
94149

95150
return rv
96151

@@ -103,50 +158,103 @@ def _install_httpx_async_client() -> None:
103158
async def send(
104159
self: "AsyncClient", request: "Request", **kwargs: "Any"
105160
) -> "Response":
106-
if sentry_sdk.get_client().get_integration(HttpxIntegration) is None:
161+
client = sentry_sdk.get_client()
162+
if client.get_integration(HttpxIntegration) is None:
107163
return await real_send(self, request, **kwargs)
108164

165+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
109166
parsed_url = None
110167
with capture_internal_exceptions():
111168
parsed_url = parse_url(str(request.url), sanitize=False)
112169

113-
with start_span(
114-
op=OP.HTTP_CLIENT,
115-
name="%s %s"
116-
% (
117-
request.method,
118-
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
119-
),
120-
origin=HttpxIntegration.origin,
121-
) as span:
122-
span.set_data(SPANDATA.HTTP_METHOD, request.method)
123-
if parsed_url is not None:
124-
span.set_data("url", parsed_url.url)
125-
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
126-
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
127-
128-
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
129-
for (
130-
key,
131-
value,
132-
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
133-
logger.debug(
134-
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
135-
key=key, value=value, url=request.url
170+
if is_span_streaming_enabled:
171+
with sentry_sdk.traces.start_span(
172+
name="%s %s"
173+
% (
174+
request.method,
175+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
176+
),
177+
attributes={
178+
"sentry.op": OP.HTTP_CLIENT,
179+
"sentry.origin": HttpxIntegration.origin,
180+
"http.request.method": request.method,
181+
},
182+
) as streamed_span:
183+
attributes: "Attributes" = {}
184+
185+
if parsed_url is not None:
186+
attributes["url.full"] = parsed_url.url
187+
if parsed_url.query:
188+
attributes["url.query"] = parsed_url.query
189+
if parsed_url.fragment:
190+
attributes["url.fragment"] = parsed_url.fragment
191+
192+
if should_propagate_trace(client, str(request.url)):
193+
for (
194+
key,
195+
value,
196+
) in (
197+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
198+
):
199+
logger.debug(
200+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
136201
)
137-
)
138-
if key == BAGGAGE_HEADER_NAME:
139-
add_sentry_baggage_to_headers(request.headers, value)
140-
else:
141-
request.headers[key] = value
142202

143-
rv = await real_send(self, request, **kwargs)
203+
if key == BAGGAGE_HEADER_NAME:
204+
add_sentry_baggage_to_headers(request.headers, value)
205+
else:
206+
request.headers[key] = value
144207

145-
span.set_http_status(rv.status_code)
146-
span.set_data("reason", rv.reason_phrase)
208+
try:
209+
rv = await real_send(self, request, **kwargs)
147210

148-
with capture_internal_exceptions():
149-
add_http_request_source(span)
211+
streamed_span.status = "error" if rv.status_code >= 400 else "ok"
212+
attributes["http.response.status_code"] = rv.status_code
213+
finally:
214+
streamed_span.set_attributes(attributes)
215+
216+
# Needs to happen within the context manager as we want to attach the
217+
# final data before the span finishes and is sent for ingesting.
218+
with capture_internal_exceptions():
219+
add_http_request_source(streamed_span)
220+
else:
221+
with sentry_sdk.start_span(
222+
op=OP.HTTP_CLIENT,
223+
name="%s %s"
224+
% (
225+
request.method,
226+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
227+
),
228+
origin=HttpxIntegration.origin,
229+
) as span:
230+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
231+
if parsed_url is not None:
232+
span.set_data("url", parsed_url.url)
233+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
234+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
235+
236+
if should_propagate_trace(client, str(request.url)):
237+
for (
238+
key,
239+
value,
240+
) in (
241+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
242+
):
243+
logger.debug(
244+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
245+
)
246+
if key == BAGGAGE_HEADER_NAME:
247+
add_sentry_baggage_to_headers(request.headers, value)
248+
else:
249+
request.headers[key] = value
250+
251+
rv = await real_send(self, request, **kwargs)
252+
253+
span.set_http_status(rv.status_code)
254+
span.set_data("reason", rv.reason_phrase)
255+
256+
with capture_internal_exceptions():
257+
add_http_request_source(span)
150258

151259
return rv
152260

sentry_sdk/traces.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,9 @@ def __init__(
278278
self._start_timestamp = datetime.now(timezone.utc)
279279
self._timestamp: "Optional[datetime]" = None
280280

281-
try:
282-
# profiling depends on this value and requires that
283-
# it is measured in nanoseconds
284-
self._start_timestamp_monotonic_ns = nanosecond_time()
285-
except AttributeError:
286-
pass
281+
# profiling depends on this value and requires that
282+
# it is measured in nanoseconds
283+
self._start_timestamp_monotonic_ns = nanosecond_time()
287284

288285
self._span_id: "Optional[str]" = None
289286

@@ -385,13 +382,10 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None
385382
)
386383

387384
if self._timestamp is None:
388-
try:
389-
elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns
390-
self._timestamp = self._start_timestamp + timedelta(
391-
microseconds=elapsed / 1000
392-
)
393-
except AttributeError:
394-
self._timestamp = datetime.now(timezone.utc)
385+
elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns
386+
self._timestamp = self._start_timestamp + timedelta(
387+
microseconds=elapsed / 1000
388+
)
395389

396390
client = sentry_sdk.get_client()
397391
if not client.is_active():

0 commit comments

Comments
 (0)