Skip to content

Commit b47362d

Browse files
ericapisaniclaude
andcommitted
feat(httpx): Migrate to span first
Adds span streaming support to the httpx integration by adding a new code path that uses `start_span` from `sentry_sdk.traces` when `_experiments={"trace_lifecycle": "stream"}` is enabled. The existing legacy path is preserved unchanged. Test coverage is updated to: - Rename span-data-asserting tests with a `_legacy` suffix - Fix broken mock targets in duration threshold tests (`start_span` → `legacy_start_span` after the rename introduced two names) - Add `_span_streaming` variants for all tests that assert on span data, using `capture_items("span")` and `span["attributes"]` instead of `event["spans"][*]["data"]` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5d26c2f commit b47362d

4 files changed

Lines changed: 672 additions & 89 deletions

File tree

sentry_sdk/integrations/httpx.py

Lines changed: 173 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import sentry_sdk
2-
from sentry_sdk import start_span
2+
from sentry_sdk import start_span as legacy_start_span
33
from sentry_sdk.consts import OP, SPANDATA
44
from sentry_sdk.integrations import Integration, DidNotEnable
5+
from sentry_sdk.traces import start_span
56
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
67
from sentry_sdk.tracing_utils import (
8+
add_http_request_source_for_streamed_span,
79
should_propagate_trace,
810
add_http_request_source,
911
add_sentry_baggage_to_headers,
12+
has_span_streaming_enabled,
1013
)
1114
from sentry_sdk.utils import (
1215
SENSITIVE_DATA_SUBSTITUTE,
@@ -20,6 +23,7 @@
2023

2124
if TYPE_CHECKING:
2225
from typing import Any
26+
from sentry_sdk._types import Attributes
2327

2428

2529
try:
@@ -49,48 +53,99 @@ def _install_httpx_client() -> None:
4953

5054
@ensure_integration_enabled(HttpxIntegration, real_send)
5155
def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response":
56+
client = sentry_sdk.get_client()
57+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
58+
5259
parsed_url = None
5360
with capture_internal_exceptions():
5461
parsed_url = parse_url(str(request.url), sanitize=False)
5562

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
63+
if is_span_streaming_enabled:
64+
with start_span(
65+
name="%s %s"
66+
% (
67+
request.method,
68+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
69+
),
70+
attributes={
71+
"sentry.op": OP.HTTP_CLIENT,
72+
"sentry.origin": HttpxIntegration.origin,
73+
SPANDATA.HTTP_METHOD: request.method,
74+
},
75+
) as segment:
76+
attributes: "Attributes" = {}
77+
78+
if parsed_url is not None:
79+
attributes["url"] = parsed_url.url
80+
attributes[SPANDATA.HTTP_QUERY] = parsed_url.query
81+
attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment
82+
83+
if should_propagate_trace(client, str(request.url)):
84+
for (
85+
key,
86+
value,
87+
) in (
88+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
89+
):
90+
logger.debug(
91+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
7992
)
80-
)
8193

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

87-
rv = real_send(self, request, **kwargs)
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+
segment.status = "error" if rv.status_code >= 400 else "ok"
102+
attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code
91103

92-
with capture_internal_exceptions():
93-
add_http_request_source(span)
104+
segment.set_attributes(attributes)
105+
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_for_streamed_span(segment)
110+
else:
111+
with legacy_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,100 @@ 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 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+
SPANDATA.HTTP_METHOD: request.method,
181+
},
182+
) as segment:
183+
attributes: "Attributes" = {}
184+
185+
if parsed_url is not None:
186+
attributes["url"] = parsed_url.url
187+
attributes[SPANDATA.HTTP_QUERY] = parsed_url.query
188+
attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment
189+
190+
if should_propagate_trace(client, str(request.url)):
191+
for (
192+
key,
193+
value,
194+
) in (
195+
sentry_sdk.get_current_scope().iter_trace_propagation_headers()
196+
):
197+
logger.debug(
198+
f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}."
136199
)
137-
)
138-
if key == BAGGAGE_HEADER_NAME:
139-
add_sentry_baggage_to_headers(request.headers, value)
140-
else:
141-
request.headers[key] = value
142200

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

145-
span.set_http_status(rv.status_code)
146-
span.set_data("reason", rv.reason_phrase)
206+
rv = await real_send(self, request, **kwargs)
147207

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

151256
return rv
152257

sentry_sdk/traces.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ def sampled(self) -> "Optional[bool]":
467467
def start_timestamp(self) -> "Optional[datetime]":
468468
return self._start_timestamp
469469

470+
@property
471+
def start_timestamp_monotonic_ns(self) -> "Optional[int]":
472+
return self._start_timestamp_monotonic_ns
473+
470474
@property
471475
def timestamp(self) -> "Optional[datetime]":
472476
return self._timestamp
@@ -681,6 +685,10 @@ def sampled(self) -> "Optional[bool]":
681685
def start_timestamp(self) -> "Optional[datetime]":
682686
return None
683687

688+
@property
689+
def start_timestamp_monotonic_ns(self) -> "Optional[int]":
690+
return None
691+
684692
@property
685693
def timestamp(self) -> "Optional[datetime]":
686694
return None

0 commit comments

Comments
 (0)