diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 9e3de63ed1..b3ca478b90 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -1,12 +1,12 @@ import sentry_sdk -from sentry_sdk import start_span from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import ( - should_propagate_trace, add_http_request_source, + should_propagate_trace, add_sentry_baggage_to_headers, + has_span_streaming_enabled, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, @@ -20,6 +20,7 @@ if TYPE_CHECKING: from typing import Any + from sentry_sdk._types import Attributes try: @@ -49,48 +50,101 @@ 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 sentry_sdk.traces.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, + "http.request.method": request.method, + }, + ) as streamed_span: + attributes: "Attributes" = {} + + if parsed_url is not None: + 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 ( + 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) + streamed_span.status = "error" if rv.status_code >= 400 else "ok" + attributes["http.response.status_code"] = rv.status_code - with capture_internal_exceptions(): - add_http_request_source(span) + 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(streamed_span) + else: + with sentry_sdk.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 +157,102 @@ 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 sentry_sdk.traces.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, + "http.request.method": request.method, + }, + ) as streamed_span: + attributes: "Attributes" = {} + + if parsed_url is not None: + 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 ( + 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) + streamed_span.status = "error" if rv.status_code >= 400 else "ok" + attributes["http.response.status_code"] = rv.status_code + + 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(streamed_span) + else: + with sentry_sdk.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..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: - pass + # 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 @@ -385,12 +382,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() @@ -467,6 +464,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 +682,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..19e8ef6d41 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,13 +275,16 @@ 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("code.line.number", lineno) try: namespace = frame.f_globals.get("__name__") except Exception: namespace = None - if namespace is not None: + if namespace is not None and isinstance(span, LegacySpan): span.set_data(SPANDATA.CODE_NAMESPACE, namespace) filepath = _get_frame_module_abs_path(frame) @@ -290,7 +295,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("code.file.path", in_app_path) try: code_function = frame.f_code.co_name @@ -298,7 +308,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("code.function.name", frame.f_code.co_name) def add_query_source(span: "sentry_sdk.tracing.Span") -> None: @@ -331,22 +344,46 @@ def add_query_source(span: "sentry_sdk.tracing.Span") -> None: ) -def add_http_request_source(span: "sentry_sdk.tracing.Span") -> None: +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 """ client = sentry_sdk.get_client() - if not client.is_active(): - return - if span.timestamp is None or span.start_timestamp is None: + 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 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 + 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 = span.timestamp + + duration = end_timestamp - span.start_timestamp threshold = client.options.get("http_request_source_threshold_ms", 0) slow_query = duration / timedelta(milliseconds=1) > threshold diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index 33bdc93c73..f10716f81d 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -1,15 +1,13 @@ 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 -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 +120,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 +156,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 +398,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 +439,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 +484,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 +526,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 +593,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() @@ -598,7 +602,8 @@ def test_no_request_source_if_duration_too_short( 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() @@ -606,23 +611,10 @@ def test_no_request_source_if_duration_too_short( 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.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 @@ -641,7 +633,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() @@ -650,7 +642,8 @@ def test_request_source_if_duration_over_threshold( 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() @@ -658,23 +651,10 @@ def test_request_source_if_duration_over_threshold( 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.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 @@ -700,7 +680,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 +688,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 +710,465 @@ 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 "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]) +@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 "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( + "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 "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"]["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"]["code.file.path"][0] != os.sep + assert is_relative_path + + assert ( + http_span["attributes"]["code.function.name"] + == "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 "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"]["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"]["code.file.path"][0] != os.sep + assert is_relative_path + + if asyncio.iscoroutinefunction(httpx_client.get): + assert ( + http_span["attributes"]["code.function.name"] + == "async_get_request_with_client" + ) + else: + assert ( + http_span["attributes"]["code.function.name"] == "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 "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( + "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 "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"]["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"]["code.file.path"][0] != os.sep + assert is_relative_path + + assert ( + http_span["attributes"]["code.function.name"] + == "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" + + +@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