From b47362da5166e62e755e5f6461e2657a2d10804b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Apr 2026 15:32:20 -0400 Subject: [PATCH 1/6] feat(httpx): Migrate to span first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sentry_sdk/integrations/httpx.py | 241 ++++++++++---- sentry_sdk/traces.py | 8 + sentry_sdk/tracing_utils.py | 69 +++- tests/integrations/httpx/test_httpx.py | 443 ++++++++++++++++++++++++- 4 files changed, 672 insertions(+), 89 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 9e3de63ed1..1c7c953fe4 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -1,12 +1,15 @@ import sentry_sdk -from sentry_sdk import start_span +from sentry_sdk import start_span as legacy_start_span from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.traces import start_span from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( + add_http_request_source_for_streamed_span, should_propagate_trace, add_http_request_source, add_sentry_baggage_to_headers, + has_span_streaming_enabled, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, @@ -20,6 +23,7 @@ if TYPE_CHECKING: from typing import Any + from sentry_sdk._types import Attributes try: @@ -49,48 +53,99 @@ def _install_httpx_client() -> None: @ensure_integration_enabled(HttpxIntegration, real_send) def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": + client = sentry_sdk.get_client() + is_span_streaming_enabled = has_span_streaming_enabled(client.options) + parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % ( - request.method, - parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, - ), - origin=HttpxIntegration.origin, - ) as span: - span.set_data(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - - if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): - for ( - key, - value, - ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): - logger.debug( - "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( - key=key, value=value, url=request.url + if is_span_streaming_enabled: + with start_span( + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + attributes={ + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": HttpxIntegration.origin, + SPANDATA.HTTP_METHOD: request.method, + }, + ) as segment: + attributes: "Attributes" = {} + + if parsed_url is not None: + attributes["url"] = parsed_url.url + attributes[SPANDATA.HTTP_QUERY] = parsed_url.query + attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + + if should_propagate_trace(client, str(request.url)): + for ( + key, + value, + ) in ( + sentry_sdk.get_current_scope().iter_trace_propagation_headers() + ): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." ) - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value - rv = real_send(self, request, **kwargs) + rv = real_send(self, request, **kwargs) - span.set_http_status(rv.status_code) - span.set_data("reason", rv.reason_phrase) + segment.status = "error" if rv.status_code >= 400 else "ok" + attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code - with capture_internal_exceptions(): - add_http_request_source(span) + segment.set_attributes(attributes) + + # Needs to happen within the context manager as we want to attach the + # final data before the span finishes and is sent for ingesting. + with capture_internal_exceptions(): + add_http_request_source_for_streamed_span(segment) + else: + with legacy_start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + origin=HttpxIntegration.origin, + ) as span: + span.set_data(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + if should_propagate_trace(client, str(request.url)): + for ( + key, + value, + ) in ( + sentry_sdk.get_current_scope().iter_trace_propagation_headers() + ): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." + ) + + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value + + rv = real_send(self, request, **kwargs) + + span.set_http_status(rv.status_code) + span.set_data("reason", rv.reason_phrase) + + with capture_internal_exceptions(): + add_http_request_source(span) return rv @@ -103,50 +158,100 @@ def _install_httpx_async_client() -> None: async def send( self: "AsyncClient", request: "Request", **kwargs: "Any" ) -> "Response": - if sentry_sdk.get_client().get_integration(HttpxIntegration) is None: + client = sentry_sdk.get_client() + if client.get_integration(HttpxIntegration) is None: return await real_send(self, request, **kwargs) + is_span_streaming_enabled = has_span_streaming_enabled(client.options) parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(str(request.url), sanitize=False) - with start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % ( - request.method, - parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, - ), - origin=HttpxIntegration.origin, - ) as span: - span.set_data(SPANDATA.HTTP_METHOD, request.method) - if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) - - if should_propagate_trace(sentry_sdk.get_client(), str(request.url)): - for ( - key, - value, - ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): - logger.debug( - "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format( - key=key, value=value, url=request.url + if is_span_streaming_enabled: + with start_span( + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + attributes={ + "sentry.op": OP.HTTP_CLIENT, + "sentry.origin": HttpxIntegration.origin, + SPANDATA.HTTP_METHOD: request.method, + }, + ) as segment: + attributes: "Attributes" = {} + + if parsed_url is not None: + attributes["url"] = parsed_url.url + attributes[SPANDATA.HTTP_QUERY] = parsed_url.query + attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + + if should_propagate_trace(client, str(request.url)): + for ( + key, + value, + ) in ( + sentry_sdk.get_current_scope().iter_trace_propagation_headers() + ): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." ) - ) - if key == BAGGAGE_HEADER_NAME: - add_sentry_baggage_to_headers(request.headers, value) - else: - request.headers[key] = value - rv = await real_send(self, request, **kwargs) + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value - span.set_http_status(rv.status_code) - span.set_data("reason", rv.reason_phrase) + rv = await real_send(self, request, **kwargs) - with capture_internal_exceptions(): - add_http_request_source(span) + segment.status = "error" if rv.status_code >= 400 else "ok" + attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code + + segment.set_attributes(attributes) + + # Needs to happen within the context manager as we want to attach the + # final data before the span finishes and is sent for ingesting. + with capture_internal_exceptions(): + add_http_request_source_for_streamed_span(segment) + else: + with legacy_start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % ( + request.method, + parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, + ), + origin=HttpxIntegration.origin, + ) as span: + span.set_data(SPANDATA.HTTP_METHOD, request.method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + if should_propagate_trace(client, str(request.url)): + for ( + key, + value, + ) in ( + sentry_sdk.get_current_scope().iter_trace_propagation_headers() + ): + logger.debug( + f"[Tracing] Adding `{key}` header {value} to outgoing request to {request.url}." + ) + if key == BAGGAGE_HEADER_NAME: + add_sentry_baggage_to_headers(request.headers, value) + else: + request.headers[key] = value + + rv = await real_send(self, request, **kwargs) + + span.set_http_status(rv.status_code) + span.set_data("reason", rv.reason_phrase) + + with capture_internal_exceptions(): + add_http_request_source(span) return rv diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index f44ef71f5b..a10d8199e0 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -467,6 +467,10 @@ def sampled(self) -> "Optional[bool]": def start_timestamp(self) -> "Optional[datetime]": return self._start_timestamp + @property + def start_timestamp_monotonic_ns(self) -> "Optional[int]": + return self._start_timestamp_monotonic_ns + @property def timestamp(self) -> "Optional[datetime]": return self._timestamp @@ -681,6 +685,10 @@ def sampled(self) -> "Optional[bool]": def start_timestamp(self) -> "Optional[datetime]": return None + @property + def start_timestamp_monotonic_ns(self) -> "Optional[int]": + return None + @property def timestamp(self) -> "Optional[datetime]": return None diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 9efb01000c..71e42a714d 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -6,7 +6,7 @@ import sys import warnings from collections.abc import Mapping, MutableMapping -from datetime import timedelta +from datetime import datetime, timedelta, timezone from random import Random from urllib.parse import quote, unquote import uuid @@ -24,6 +24,7 @@ filename_for_module, logger, match_regex_list, + nanosecond_time, qualname_from_function, safe_repr, to_string, @@ -34,6 +35,7 @@ _is_in_project_root, _module_in_list, ) +from sentry_sdk.tracing import Span as LegacySpan from typing import TYPE_CHECKING @@ -229,7 +231,7 @@ def _should_be_included( def add_source( - span: "sentry_sdk.tracing.Span", + span: Union["sentry_sdk.tracing.Span", "sentry_sdk.traces.StreamedSpan"], project_root: "Optional[str]", in_app_include: "Optional[list[str]]", in_app_exclude: "Optional[list[str]]", @@ -273,14 +275,20 @@ def add_source( except Exception: lineno = None if lineno is not None: - span.set_data(SPANDATA.CODE_LINENO, frame.f_lineno) + if isinstance(span, LegacySpan): + span.set_data(SPANDATA.CODE_LINENO, lineno) + else: + span.set_attribute(SPANDATA.CODE_LINENO, lineno) try: namespace = frame.f_globals.get("__name__") except Exception: namespace = None if namespace is not None: - span.set_data(SPANDATA.CODE_NAMESPACE, namespace) + if isinstance(span, LegacySpan): + span.set_data(SPANDATA.CODE_NAMESPACE, namespace) + else: + span.set_attribute(SPANDATA.CODE_NAMESPACE, namespace) filepath = _get_frame_module_abs_path(frame) if filepath is not None: @@ -290,7 +298,12 @@ def add_source( in_app_path = filepath.replace(project_root, "").lstrip(os.sep) else: in_app_path = filepath - span.set_data(SPANDATA.CODE_FILEPATH, in_app_path) + + if isinstance(span, LegacySpan): + span.set_data(SPANDATA.CODE_FILEPATH, in_app_path) + else: + if in_app_path is not None: + span.set_attribute(SPANDATA.CODE_FILEPATH, in_app_path) try: code_function = frame.f_code.co_name @@ -298,7 +311,10 @@ def add_source( code_function = None if code_function is not None: - span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) + if isinstance(span, LegacySpan): + span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) + else: + span.set_attribute(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) def add_query_source(span: "sentry_sdk.tracing.Span") -> None: @@ -331,6 +347,47 @@ def add_query_source(span: "sentry_sdk.tracing.Span") -> None: ) +def add_http_request_source_for_streamed_span( + span: "sentry_sdk.traces.StreamedSpan", +) -> None: + """ + Adds OTel compatible source code information to a span for an outgoing HTTP request. + + This is intended to be used with StreamedSpans, not legacy Spans. + + StreamedSpans need to have this information added before the span finishes, which + is why some of the checks that exist in `add_http_request_source` are not present here. + """ + client = sentry_sdk.get_client() + + if span.start_timestamp is None: + return + + should_add_request_source = client.options.get("enable_http_request_source", True) + if not should_add_request_source: + return + + try: + elapsed = nanosecond_time() - span.start_timestamp_monotonic_ns + end_timestamp = span.start_timestamp + timedelta(microseconds=elapsed / 1000) + except (AttributeError, TypeError): + end_timestamp = datetime.now(timezone.utc) + + duration = end_timestamp - span.start_timestamp + threshold = client.options.get("http_request_source_threshold_ms", 0) + slow_query = duration / timedelta(milliseconds=1) > threshold + + if not slow_query: + return + + add_source( + span=span, + project_root=client.options["project_root"], + in_app_include=client.options.get("in_app_include"), + in_app_exclude=client.options.get("in_app_exclude"), + ) + + def add_http_request_source(span: "sentry_sdk.tracing.Span") -> None: """ Adds OTel compatible source code information to a span for an outgoing HTTP request diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 33bdc93c73..534ce705d4 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -9,7 +9,7 @@ import sentry_sdk from sentry_sdk import capture_message, start_transaction -from sentry_sdk.consts import MATCH_ALL, SPANDATA +from sentry_sdk.consts import MATCH_ALL, OP, SPANDATA from sentry_sdk.integrations.httpx import HttpxIntegration from tests.conftest import ApproxDict @@ -122,7 +122,7 @@ def test_crumb_capture_client_error( "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_outgoing_trace_headers(sentry_init, httpx_client, httpx_mock): +def test_outgoing_trace_headers_legacy(sentry_init, httpx_client, httpx_mock): httpx_mock.add_response() sentry_init( @@ -158,7 +158,7 @@ def test_outgoing_trace_headers(sentry_init, httpx_client, httpx_mock): "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_outgoing_trace_headers_append_to_baggage( +def test_outgoing_trace_headers_append_to_baggage_legacy( sentry_init, httpx_client, httpx_mock, @@ -400,7 +400,9 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events, httpx_mock) "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_request_source_disabled(sentry_init, capture_events, httpx_client, httpx_mock): +def test_request_source_disabled_legacy( + sentry_init, capture_events, httpx_client, httpx_mock +): httpx_mock.add_response() sentry_options = { "integrations": [HttpxIntegration()], @@ -439,8 +441,12 @@ def test_request_source_disabled(sentry_init, capture_events, httpx_client, http "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_request_source_enabled( - sentry_init, capture_events, enable_http_request_source, httpx_client, httpx_mock +def test_request_source_enabled_legacy( + sentry_init, + capture_events, + enable_http_request_source, + httpx_client, + httpx_mock, ): httpx_mock.add_response() sentry_options = { @@ -480,7 +486,7 @@ def test_request_source_enabled( "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_request_source(sentry_init, capture_events, httpx_client, httpx_mock): +def test_request_source_legacy(sentry_init, capture_events, httpx_client, httpx_mock): httpx_mock.add_response() sentry_init( @@ -522,14 +528,14 @@ def test_request_source(sentry_init, capture_events, httpx_client, httpx_mock): is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep assert is_relative_path - assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source" + assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source_legacy" @pytest.mark.parametrize( "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_request_source_with_module_in_search_path( +def test_request_source_with_module_in_search_path_legacy( sentry_init, capture_events, httpx_client, httpx_mock ): """ @@ -589,7 +595,7 @@ def test_request_source_with_module_in_search_path( "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_no_request_source_if_duration_too_short( +def test_no_request_source_if_duration_too_short_legacy( sentry_init, capture_events, httpx_client, httpx_mock ): httpx_mock.add_response() @@ -616,7 +622,7 @@ def fake_start_span(*args, **kwargs): yield span with mock.patch( - "sentry_sdk.integrations.httpx.start_span", + "sentry_sdk.integrations.httpx.legacy_start_span", fake_start_span, ): if asyncio.iscoroutinefunction(httpx_client.get): @@ -641,7 +647,7 @@ def fake_start_span(*args, **kwargs): "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_request_source_if_duration_over_threshold( +def test_request_source_if_duration_over_threshold_legacy( sentry_init, capture_events, httpx_client, httpx_mock ): httpx_mock.add_response() @@ -668,7 +674,7 @@ def fake_start_span(*args, **kwargs): yield span with mock.patch( - "sentry_sdk.integrations.httpx.start_span", + "sentry_sdk.integrations.httpx.legacy_start_span", fake_start_span, ): if asyncio.iscoroutinefunction(httpx_client.get): @@ -700,7 +706,7 @@ def fake_start_span(*args, **kwargs): assert ( data.get(SPANDATA.CODE_FUNCTION) - == "test_request_source_if_duration_over_threshold" + == "test_request_source_if_duration_over_threshold_legacy" ) @@ -708,7 +714,7 @@ def fake_start_span(*args, **kwargs): "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_span_origin(sentry_init, capture_events, httpx_client, httpx_mock): +def test_span_origin_legacy(sentry_init, capture_events, httpx_client, httpx_mock): httpx_mock.add_response() sentry_init( @@ -730,3 +736,410 @@ def test_span_origin(sentry_init, capture_events, httpx_client, httpx_mock): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.http.httpx" + + +def _get_http_client_span(items): + return next( + item.payload + for item in items + if item.payload.get("attributes", {}).get("sentry.op") == OP.HTTP_CLIENT + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_outgoing_trace_headers_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + integrations=[HttpxIntegration()], + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + items = capture_items("span") + + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + response = httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert response.request.headers[ + "sentry-trace" + ] == "{trace_id}-{span_id}-{sampled}".format( + trace_id=http_span["trace_id"], + span_id=http_span["span_id"], + sampled=1, + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_outgoing_trace_headers_append_to_baggage_span_streaming( + sentry_init, + capture_items, + httpx_client, + httpx_mock, +): + httpx_mock.add_response() + + sentry_init( + traces_sample_rate=1.0, + integrations=[HttpxIntegration()], + release="d08ebdb9309e1b004c6f52202de58a09c2268e42", + _experiments={"trace_lifecycle": "stream"}, + ) + + url = "http://example.com/" + + items = capture_items("span") + + with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=500000): + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, headers={"baGGage": "custom=data"}) + ) + else: + response = httpx_client.get(url, headers={"baGGage": "custom=data"}) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + baggage = response.request.headers["baggage"] + assert baggage.startswith("custom=data,") + assert f"sentry-trace_id={http_span['trace_id']}" in baggage + assert "sentry-sample_rand=0.500000" in baggage + assert "sentry-sampled=true" in baggage + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_request_source_disabled_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + enable_http_request_source=False, + http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO not in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE not in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH not in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION not in http_span["attributes"] + + +@pytest.mark.parametrize("enable_http_request_source", [None, True]) +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_request_source_enabled_span_streaming( + sentry_init, + capture_items, + enable_http_request_source, + httpx_client, + httpx_mock, +): + httpx_mock.add_response() + + sentry_options = { + "integrations": [HttpxIntegration()], + "traces_sample_rate": 1.0, + "http_request_source_threshold_ms": 0, + "_experiments": {"trace_lifecycle": "stream"}, + } + if enable_http_request_source is not None: + sentry_options["enable_http_request_source"] = enable_http_request_source + + sentry_init(**sentry_options) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_request_source_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + + assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int + assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 + assert ( + http_span["attributes"][SPANDATA.CODE_NAMESPACE] + == "tests.integrations.httpx.test_httpx" + ) + assert http_span["attributes"][SPANDATA.CODE_FILEPATH].endswith( + "tests/integrations/httpx/test_httpx.py" + ) + + is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + assert is_relative_path + + assert ( + http_span["attributes"][SPANDATA.CODE_FUNCTION] + == "test_request_source_span_streaming" + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_request_source_with_module_in_search_path_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + """ + Test that request source is relative to the path of the module it ran in + """ + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + from httpx_helpers.helpers import async_get_request_with_client + + asyncio.get_event_loop().run_until_complete( + async_get_request_with_client(httpx_client, url) + ) + else: + from httpx_helpers.helpers import get_request_with_client + + get_request_with_client(httpx_client, url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + + assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int + assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 + assert http_span["attributes"][SPANDATA.CODE_NAMESPACE] == "httpx_helpers.helpers" + assert http_span["attributes"][SPANDATA.CODE_FILEPATH] == "httpx_helpers/helpers.py" + + is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + assert is_relative_path + + if asyncio.iscoroutinefunction(httpx_client.get): + assert ( + http_span["attributes"][SPANDATA.CODE_FUNCTION] + == "async_get_request_with_client" + ) + else: + assert ( + http_span["attributes"][SPANDATA.CODE_FUNCTION] == "get_request_with_client" + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_no_request_source_if_duration_too_short_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + enable_http_request_source=True, + # Threshold so high no real request will ever exceed it + http_request_source_threshold_ms=9999999, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO not in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE not in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH not in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION not in http_span["attributes"] + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_request_source_if_duration_over_threshold_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + enable_http_request_source=True, + # Threshold of 0 means any non-zero duration qualifies + http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert SPANDATA.CODE_LINENO in http_span["attributes"] + assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] + assert SPANDATA.CODE_FILEPATH in http_span["attributes"] + assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + + assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int + assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 + assert ( + http_span["attributes"][SPANDATA.CODE_NAMESPACE] + == "tests.integrations.httpx.test_httpx" + ) + assert http_span["attributes"][SPANDATA.CODE_FILEPATH].endswith( + "tests/integrations/httpx/test_httpx.py" + ) + + is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + assert is_relative_path + + assert ( + http_span["attributes"][SPANDATA.CODE_FUNCTION] + == "test_request_source_if_duration_over_threshold_span_streaming" + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_span_origin_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert http_span["attributes"]["sentry.origin"] == "auto.http.httpx" From 019fa478c279f0c10c6fc0ac9179e752722860d6 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Apr 2026 15:45:09 -0400 Subject: [PATCH 2/6] fix typing issue --- sentry_sdk/tracing_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 71e42a714d..c3309ebb7a 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -231,7 +231,7 @@ def _should_be_included( def add_source( - span: Union["sentry_sdk.tracing.Span", "sentry_sdk.traces.StreamedSpan"], + span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]", project_root: "Optional[str]", in_app_include: "Optional[list[str]]", in_app_exclude: "Optional[list[str]]", @@ -367,10 +367,10 @@ def add_http_request_source_for_streamed_span( if not should_add_request_source: return - try: + if span.start_timestamp_monotonic_ns is not None: elapsed = nanosecond_time() - span.start_timestamp_monotonic_ns end_timestamp = span.start_timestamp + timedelta(microseconds=elapsed / 1000) - except (AttributeError, TypeError): + else: end_timestamp = datetime.now(timezone.utc) duration = end_timestamp - span.start_timestamp From 6815bc621e36ceba1e468df0d395dc9444921067 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Apr 2026 16:17:35 -0400 Subject: [PATCH 3/6] Fix initialization issue raised by cursor --- sentry_sdk/traces.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index a10d8199e0..b210600b2a 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -283,7 +283,7 @@ def __init__( # it is measured in nanoseconds self._start_timestamp_monotonic_ns = nanosecond_time() except AttributeError: - pass + self._start_timestamp_monotonic_ns = None self._span_id: "Optional[str]" = None @@ -385,12 +385,12 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None ) if self._timestamp is None: - try: + if self._start_timestamp_monotonic_ns is not None: elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns self._timestamp = self._start_timestamp + timedelta( microseconds=elapsed / 1000 ) - except AttributeError: + else: self._timestamp = datetime.now(timezone.utc) client = sentry_sdk.get_client() From bf39690444330a21f4504759e6e3eee5f91f6183 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 16 Apr 2026 16:28:57 -0400 Subject: [PATCH 4/6] Address issue raised by linter --- sentry_sdk/traces.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index b210600b2a..9527927e50 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -278,12 +278,9 @@ def __init__( self._start_timestamp = datetime.now(timezone.utc) self._timestamp: "Optional[datetime]" = None - try: - # profiling depends on this value and requires that - # it is measured in nanoseconds - self._start_timestamp_monotonic_ns = nanosecond_time() - except AttributeError: - self._start_timestamp_monotonic_ns = None + # profiling depends on this value and requires that + # it is measured in nanoseconds + self._start_timestamp_monotonic_ns = nanosecond_time() self._span_id: "Optional[str]" = None From 25a953e9399d56118480620a5ab5218fe5bf1907 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 17 Apr 2026 11:30:46 -0400 Subject: [PATCH 5/6] Address code review comments --- sentry_sdk/integrations/httpx.py | 51 ++++----- sentry_sdk/tracing_utils.py | 80 +++++-------- tests/integrations/httpx/test_httpx.py | 153 +++++++++++++++++-------- 3 files changed, 159 insertions(+), 125 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 1c7c953fe4..c86a924f5a 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -1,13 +1,10 @@ import sentry_sdk -from sentry_sdk import start_span as legacy_start_span from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk.traces import start_span from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( - add_http_request_source_for_streamed_span, - should_propagate_trace, add_http_request_source, + should_propagate_trace, add_sentry_baggage_to_headers, has_span_streaming_enabled, ) @@ -61,7 +58,7 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: - with start_span( + with sentry_sdk.traces.start_span( name="%s %s" % ( request.method, @@ -70,15 +67,15 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": attributes={ "sentry.op": OP.HTTP_CLIENT, "sentry.origin": HttpxIntegration.origin, - SPANDATA.HTTP_METHOD: request.method, + "http.request.method": request.method, }, - ) as segment: + ) as streamed_span: attributes: "Attributes" = {} if parsed_url is not None: - attributes["url"] = parsed_url.url - attributes[SPANDATA.HTTP_QUERY] = parsed_url.query - attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + attributes["url.full"] = parsed_url.url + attributes["url.query"] = parsed_url.query + attributes["url.fragment"] = parsed_url.fragment if should_propagate_trace(client, str(request.url)): for ( @@ -98,17 +95,17 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": rv = real_send(self, request, **kwargs) - segment.status = "error" if rv.status_code >= 400 else "ok" - attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code + streamed_span.status = "error" if rv.status_code >= 400 else "ok" + attributes["http.response.status_code"] = rv.status_code - segment.set_attributes(attributes) + streamed_span.set_attributes(attributes) # Needs to happen within the context manager as we want to attach the # final data before the span finishes and is sent for ingesting. with capture_internal_exceptions(): - add_http_request_source_for_streamed_span(segment) + add_http_request_source(streamed_span) else: - with legacy_start_span( + with sentry_sdk.start_span( op=OP.HTTP_CLIENT, name="%s %s" % ( @@ -168,7 +165,7 @@ async def send( parsed_url = parse_url(str(request.url), sanitize=False) if is_span_streaming_enabled: - with start_span( + with sentry_sdk.traces.start_span( name="%s %s" % ( request.method, @@ -177,15 +174,17 @@ async def send( attributes={ "sentry.op": OP.HTTP_CLIENT, "sentry.origin": HttpxIntegration.origin, - SPANDATA.HTTP_METHOD: request.method, + "http.request.method": request.method, }, - ) as segment: + ) as streamed_span: attributes: "Attributes" = {} if parsed_url is not None: - attributes["url"] = parsed_url.url - attributes[SPANDATA.HTTP_QUERY] = parsed_url.query - attributes[SPANDATA.HTTP_FRAGMENT] = parsed_url.fragment + attributes["url.full"] = parsed_url.url + if parsed_url.query: + attributes["url.query"] = parsed_url.query + if parsed_url.fragment: + attributes["url.fragment"] = parsed_url.fragment if should_propagate_trace(client, str(request.url)): for ( @@ -205,17 +204,17 @@ async def send( rv = await real_send(self, request, **kwargs) - segment.status = "error" if rv.status_code >= 400 else "ok" - attributes[SPANDATA.HTTP_STATUS_CODE] = rv.status_code + streamed_span.status = "error" if rv.status_code >= 400 else "ok" + attributes["http.response.status_code"] = rv.status_code - segment.set_attributes(attributes) + streamed_span.set_attributes(attributes) # Needs to happen within the context manager as we want to attach the # final data before the span finishes and is sent for ingesting. with capture_internal_exceptions(): - add_http_request_source_for_streamed_span(segment) + add_http_request_source(streamed_span) else: - with legacy_start_span( + with sentry_sdk.start_span( op=OP.HTTP_CLIENT, name="%s %s" % ( diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index c3309ebb7a..19e8ef6d41 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -278,17 +278,14 @@ def add_source( if isinstance(span, LegacySpan): span.set_data(SPANDATA.CODE_LINENO, lineno) else: - span.set_attribute(SPANDATA.CODE_LINENO, lineno) + span.set_attribute("code.line.number", lineno) try: namespace = frame.f_globals.get("__name__") except Exception: namespace = None - if namespace is not None: - if isinstance(span, LegacySpan): - span.set_data(SPANDATA.CODE_NAMESPACE, namespace) - else: - span.set_attribute(SPANDATA.CODE_NAMESPACE, namespace) + if namespace is not None and isinstance(span, LegacySpan): + span.set_data(SPANDATA.CODE_NAMESPACE, namespace) filepath = _get_frame_module_abs_path(frame) if filepath is not None: @@ -303,7 +300,7 @@ def add_source( span.set_data(SPANDATA.CODE_FILEPATH, in_app_path) else: if in_app_path is not None: - span.set_attribute(SPANDATA.CODE_FILEPATH, in_app_path) + span.set_attribute("code.file.path", in_app_path) try: code_function = frame.f_code.co_name @@ -314,7 +311,7 @@ def add_source( if isinstance(span, LegacySpan): span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) else: - span.set_attribute(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) + span.set_attribute("code.function.name", frame.f_code.co_name) def add_query_source(span: "sentry_sdk.tracing.Span") -> None: @@ -347,19 +344,24 @@ def add_query_source(span: "sentry_sdk.tracing.Span") -> None: ) -def add_http_request_source_for_streamed_span( - span: "sentry_sdk.traces.StreamedSpan", +def add_http_request_source( + span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]", ) -> None: """ - Adds OTel compatible source code information to a span for an outgoing HTTP request. - - This is intended to be used with StreamedSpans, not legacy Spans. - - StreamedSpans need to have this information added before the span finishes, which - is why some of the checks that exist in `add_http_request_source` are not present here. + Adds OTel compatible source code information to a span for an outgoing HTTP request """ client = sentry_sdk.get_client() + if isinstance(span, LegacySpan): + if not client.is_active(): + return + + # In the StreamedSpan case, we need to add the extra span information before + # the span finishes, so it's expected that this will be None. In the LegacySpan case, + # it should already be finished. + if span.timestamp is None: + return + if span.start_timestamp is None: return @@ -367,11 +369,19 @@ def add_http_request_source_for_streamed_span( if not should_add_request_source: return - if span.start_timestamp_monotonic_ns is not None: - elapsed = nanosecond_time() - span.start_timestamp_monotonic_ns - end_timestamp = span.start_timestamp + timedelta(microseconds=elapsed / 1000) + if span.timestamp is None: + if ( + isinstance(span, sentry_sdk.traces.StreamedSpan) + and span.start_timestamp_monotonic_ns is not None + ): + elapsed = nanosecond_time() - span.start_timestamp_monotonic_ns + end_timestamp = span.start_timestamp + timedelta( + microseconds=elapsed / 1000 + ) + else: + end_timestamp = datetime.now(timezone.utc) else: - end_timestamp = datetime.now(timezone.utc) + end_timestamp = span.timestamp duration = end_timestamp - span.start_timestamp threshold = client.options.get("http_request_source_threshold_ms", 0) @@ -388,36 +398,6 @@ def add_http_request_source_for_streamed_span( ) -def add_http_request_source(span: "sentry_sdk.tracing.Span") -> None: - """ - Adds OTel compatible source code information to a span for an outgoing HTTP request - """ - client = sentry_sdk.get_client() - if not client.is_active(): - return - - if span.timestamp is None or span.start_timestamp is None: - return - - should_add_request_source = client.options.get("enable_http_request_source", True) - if not should_add_request_source: - return - - duration = span.timestamp - span.start_timestamp - threshold = client.options.get("http_request_source_threshold_ms", 0) - slow_query = duration / timedelta(milliseconds=1) > threshold - - if not slow_query: - return - - add_source( - span=span, - project_root=client.options["project_root"], - in_app_include=client.options.get("in_app_include"), - in_app_exclude=client.options.get("in_app_exclude"), - ) - - def extract_sentrytrace_data( header: "Optional[str]", ) -> "Optional[Dict[str, Union[str, bool, None]]]": diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 534ce705d4..31d10f7f70 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -855,10 +855,9 @@ def test_request_source_disabled_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO not in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE not in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH not in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION not in http_span["attributes"] + assert "code.line.number" not in http_span["attributes"] + assert "code.file.path" not in http_span["attributes"] + assert "code.function.name" not in http_span["attributes"] @pytest.mark.parametrize("enable_http_request_source", [None, True]) @@ -899,10 +898,9 @@ def test_request_source_enabled_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + assert "code.line.number" in http_span["attributes"] + assert "code.file.path" in http_span["attributes"] + assert "code.function.name" in http_span["attributes"] @pytest.mark.parametrize( @@ -935,26 +933,21 @@ def test_request_source_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + assert "code.line.number" in http_span["attributes"] + assert "code.file.path" in http_span["attributes"] + assert "code.function.name" in http_span["attributes"] - assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int - assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 - assert ( - http_span["attributes"][SPANDATA.CODE_NAMESPACE] - == "tests.integrations.httpx.test_httpx" - ) - assert http_span["attributes"][SPANDATA.CODE_FILEPATH].endswith( + assert type(http_span["attributes"]["code.line.number"]) == int + assert http_span["attributes"]["code.line.number"] > 0 + assert http_span["attributes"]["code.file.path"].endswith( "tests/integrations/httpx/test_httpx.py" ) - is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + is_relative_path = http_span["attributes"]["code.file.path"][0] != os.sep assert is_relative_path assert ( - http_span["attributes"][SPANDATA.CODE_FUNCTION] + http_span["attributes"]["code.function.name"] == "test_request_source_span_streaming" ) @@ -998,27 +991,25 @@ def test_request_source_with_module_in_search_path_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + assert "code.line.number" in http_span["attributes"] + assert "code.file.path" in http_span["attributes"] + assert "code.function.name" in http_span["attributes"] - assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int - assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 - assert http_span["attributes"][SPANDATA.CODE_NAMESPACE] == "httpx_helpers.helpers" - assert http_span["attributes"][SPANDATA.CODE_FILEPATH] == "httpx_helpers/helpers.py" + assert type(http_span["attributes"]["code.line.number"]) == int + assert http_span["attributes"]["code.line.number"] > 0 + assert http_span["attributes"]["code.file.path"] == "httpx_helpers/helpers.py" - is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + is_relative_path = http_span["attributes"]["code.file.path"][0] != os.sep assert is_relative_path if asyncio.iscoroutinefunction(httpx_client.get): assert ( - http_span["attributes"][SPANDATA.CODE_FUNCTION] + http_span["attributes"]["code.function.name"] == "async_get_request_with_client" ) else: assert ( - http_span["attributes"][SPANDATA.CODE_FUNCTION] == "get_request_with_client" + http_span["attributes"]["code.function.name"] == "get_request_with_client" ) @@ -1053,10 +1044,9 @@ def test_no_request_source_if_duration_too_short_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO not in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE not in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH not in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION not in http_span["attributes"] + assert "code.line.number" not in http_span["attributes"] + assert "code.file.path" not in http_span["attributes"] + assert "code.function.name" not in http_span["attributes"] @pytest.mark.parametrize( @@ -1090,26 +1080,21 @@ def test_request_source_if_duration_over_threshold_span_streaming( http_span = _get_http_client_span(items) - assert SPANDATA.CODE_LINENO in http_span["attributes"] - assert SPANDATA.CODE_NAMESPACE in http_span["attributes"] - assert SPANDATA.CODE_FILEPATH in http_span["attributes"] - assert SPANDATA.CODE_FUNCTION in http_span["attributes"] + assert "code.line.number" in http_span["attributes"] + assert "code.file.path" in http_span["attributes"] + assert "code.function.name" in http_span["attributes"] - assert type(http_span["attributes"][SPANDATA.CODE_LINENO]) == int - assert http_span["attributes"][SPANDATA.CODE_LINENO] > 0 - assert ( - http_span["attributes"][SPANDATA.CODE_NAMESPACE] - == "tests.integrations.httpx.test_httpx" - ) - assert http_span["attributes"][SPANDATA.CODE_FILEPATH].endswith( + assert type(http_span["attributes"]["code.line.number"]) == int + assert http_span["attributes"]["code.line.number"] > 0 + assert http_span["attributes"]["code.file.path"].endswith( "tests/integrations/httpx/test_httpx.py" ) - is_relative_path = http_span["attributes"][SPANDATA.CODE_FILEPATH][0] != os.sep + is_relative_path = http_span["attributes"]["code.file.path"][0] != os.sep assert is_relative_path assert ( - http_span["attributes"][SPANDATA.CODE_FUNCTION] + http_span["attributes"]["code.function.name"] == "test_request_source_if_duration_over_threshold_span_streaming" ) @@ -1143,3 +1128,73 @@ def test_span_origin_span_streaming( http_span = _get_http_client_span(items) assert http_span["attributes"]["sentry.origin"] == "auto.http.httpx" + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_http_url_attributes_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/?foo=bar#frag" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert http_span["attributes"]["http.request.method"] == "GET" + assert http_span["attributes"]["url.full"] == "http://example.com/" + assert http_span["attributes"]["url.query"] == "foo=bar" + assert http_span["attributes"]["url.fragment"] == "frag" + assert http_span["attributes"]["http.response.status_code"] == 200 + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_http_url_attributes_no_query_or_fragment_span_streaming( + sentry_init, capture_items, httpx_client, httpx_mock +): + httpx_mock.add_response() + + sentry_init( + integrations=[HttpxIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + items = capture_items("span") + + url = "http://example.com/" + + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) + + sentry_sdk.flush() + + http_span = _get_http_client_span(items) + + assert http_span["attributes"]["http.request.method"] == "GET" + assert http_span["attributes"]["url.full"] == "http://example.com/" + assert "url.query" not in http_span["attributes"] + assert "url.fragment" not in http_span["attributes"] + assert http_span["attributes"]["http.response.status_code"] == 200 From 9d2546f142f6b77edced61ead6ad15c3659a0a2d Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 17 Apr 2026 14:26:38 -0400 Subject: [PATCH 6/6] Update tests. Forgot to add matching checks in other client --- sentry_sdk/integrations/httpx.py | 6 ++-- tests/integrations/httpx/test_httpx.py | 50 +++++++------------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index c86a924f5a..b3ca478b90 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -74,8 +74,10 @@ def send(self: "Client", request: "Request", **kwargs: "Any") -> "Response": if parsed_url is not None: attributes["url.full"] = parsed_url.url - attributes["url.query"] = parsed_url.query - attributes["url.fragment"] = parsed_url.fragment + if parsed_url.query: + attributes["url.query"] = parsed_url.query + if parsed_url.fragment: + attributes["url.fragment"] = parsed_url.fragment if should_propagate_trace(client, str(request.url)): for ( diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 31d10f7f70..f10716f81d 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -1,11 +1,9 @@ import os -import datetime import asyncio from unittest import mock import httpx import pytest -from contextlib import contextmanager import sentry_sdk from sentry_sdk import capture_message, start_transaction @@ -604,7 +602,8 @@ def test_no_request_source_if_duration_too_short_legacy( integrations=[HttpxIntegration()], traces_sample_rate=1.0, enable_http_request_source=True, - http_request_source_threshold_ms=100, + # Threshold so high no real request will ever exceed it + http_request_source_threshold_ms=9999999, ) events = capture_events() @@ -612,23 +611,10 @@ def test_no_request_source_if_duration_too_short_legacy( url = "http://example.com/" with start_transaction(name="test_transaction"): - - @contextmanager - def fake_start_span(*args, **kwargs): - with sentry_sdk.start_span(*args, **kwargs) as span: - pass - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - yield span - - with mock.patch( - "sentry_sdk.integrations.httpx.legacy_start_span", - fake_start_span, - ): - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) (event,) = events @@ -656,7 +642,8 @@ def test_request_source_if_duration_over_threshold_legacy( integrations=[HttpxIntegration()], traces_sample_rate=1.0, enable_http_request_source=True, - http_request_source_threshold_ms=100, + # Threshold is low so any request will exceed it + http_request_source_threshold_ms=0, ) events = capture_events() @@ -664,23 +651,10 @@ def test_request_source_if_duration_over_threshold_legacy( url = "http://example.com/" with start_transaction(name="test_transaction"): - - @contextmanager - def fake_start_span(*args, **kwargs): - with sentry_sdk.start_span(*args, **kwargs) as span: - pass - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - yield span - - with mock.patch( - "sentry_sdk.integrations.httpx.legacy_start_span", - fake_start_span, - ): - if asyncio.iscoroutinefunction(httpx_client.get): - asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) - else: - httpx_client.get(url) + if asyncio.iscoroutinefunction(httpx_client.get): + asyncio.get_event_loop().run_until_complete(httpx_client.get(url)) + else: + httpx_client.get(url) (event,) = events