From a1386b0cb36f926ac0f45b97ffc53caac040b5fc Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 12:46:13 +0200 Subject: [PATCH 1/6] feat(stdlib): Add source information for slow outgoing HTTP requests --- sentry_sdk/consts.py | 9 + sentry_sdk/integrations/stdlib.py | 9 +- sentry_sdk/tracing_utils.py | 88 +++++-- tests/integrations/stdlib/__init__.py | 6 + .../stdlib/httplib_helpers/__init__.py | 0 .../stdlib/httplib_helpers/helpers.py | 3 + tests/integrations/stdlib/test_httplib.py | 231 ++++++++++++++++++ 7 files changed, 321 insertions(+), 25 deletions(-) create mode 100644 tests/integrations/stdlib/__init__.py create mode 100644 tests/integrations/stdlib/httplib_helpers/__init__.py create mode 100644 tests/integrations/stdlib/httplib_helpers/helpers.py diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 43c7e857ac..64be7a8872 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -913,6 +913,8 @@ def __init__( error_sampler=None, # type: Optional[Callable[[Event, Hint], Union[float, bool]]] enable_db_query_source=True, # type: bool db_query_source_threshold_ms=100, # type: int + enable_http_request_source=True, # type: bool + http_request_source_threshold_ms=100, # type: int spotlight=None, # type: Optional[Union[bool, str]] cert_file=None, # type: Optional[str] key_file=None, # type: Optional[str] @@ -1268,6 +1270,13 @@ def __init__( The query location will be added to the query for queries slower than the specified threshold. + :param enable_http_request_source: When enabled, the source location will be added to outgoing HTTP requests. + + :param http_request_source_threshold_ms: The threshold in milliseconds for adding the source location to an + outgoing HTTP request. + + The request location will be added to the request for requests slower than the specified threshold. + :param custom_repr: A custom `repr `_ function to run while serializing an object. diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index d388c5bca6..3db97e5685 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -8,7 +8,11 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.tracing_utils import EnvironHeaders, should_propagate_trace +from sentry_sdk.tracing_utils import ( + EnvironHeaders, + should_propagate_trace, + add_http_request_source, +) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, @@ -135,6 +139,9 @@ def getresponse(self, *args, **kwargs): finally: span.finish() + with capture_internal_exceptions(): + add_http_request_source(span) + return rv HTTPConnection.putrequest = putrequest # type: ignore[method-assign] diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index b81d647c6d..acbfe86ff3 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -218,33 +218,10 @@ def _should_be_included( ) -def add_query_source(span): - # type: (sentry_sdk.tracing.Span) -> None +def add_source(span, project_root, in_app_include, in_app_exclude): """ Adds OTel compatible source code information to the span """ - 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_query_source = client.options.get("enable_db_query_source", True) - if not should_add_query_source: - return - - duration = span.timestamp - span.start_timestamp - threshold = client.options.get("db_query_source_threshold_ms", 0) - slow_query = duration / timedelta(milliseconds=1) > threshold - - if not slow_query: - return - - project_root = client.options["project_root"] - in_app_include = client.options.get("in_app_include") - in_app_exclude = client.options.get("in_app_exclude") - # Find the correct frame frame = sys._getframe() # type: Union[FrameType, None] while frame is not None: @@ -309,6 +286,69 @@ def add_query_source(span): span.set_data(SPANDATA.CODE_FUNCTION, frame.f_code.co_name) +def add_query_source(span): + # type: (sentry_sdk.tracing.Span) -> None + """ + Adds OTel compatible source code information to a database query span + """ + 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_query_source = client.options.get("enable_db_query_source", True) + if not should_add_query_source: + return + + duration = span.timestamp - span.start_timestamp + threshold = client.options.get("db_query_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): + # type: (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) + print("division: ", duration / timedelta(milliseconds=1), threshold) + 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): # type: (Optional[str]) -> Optional[Dict[str, Union[str, bool, None]]] """ diff --git a/tests/integrations/stdlib/__init__.py b/tests/integrations/stdlib/__init__.py new file mode 100644 index 0000000000..247198726c --- /dev/null +++ b/tests/integrations/stdlib/__init__.py @@ -0,0 +1,6 @@ +import os +import sys + +# Load `asyncpg_helpers` into the module search path to test query source path names relative to module. See +# `test_query_source_with_module_in_search_path` +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) diff --git a/tests/integrations/stdlib/httplib_helpers/__init__.py b/tests/integrations/stdlib/httplib_helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/stdlib/httplib_helpers/helpers.py b/tests/integrations/stdlib/httplib_helpers/helpers.py new file mode 100644 index 0000000000..875052e7b5 --- /dev/null +++ b/tests/integrations/stdlib/httplib_helpers/helpers.py @@ -0,0 +1,3 @@ +def get_request_with_connection(connection, url): + connection.request("GET", url) + connection.getresponse() diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index b8d46d0558..b3d7f85a9f 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,3 +1,5 @@ +import os +import datetime from http.client import HTTPConnection, HTTPSConnection from socket import SocketIO from urllib.error import HTTPError @@ -374,6 +376,235 @@ def test_option_trace_propagation_targets( assert "baggage" not in request_headers +def test_request_source_disabled(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + enable_http_request_source=False, + http_request_source_threshold_ms=0, + ) + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO not in data + assert SPANDATA.CODE_NAMESPACE not in data + assert SPANDATA.CODE_FILEPATH not in data + assert SPANDATA.CODE_FUNCTION not in data + + +@pytest.mark.parametrize("enable_http_request_source", [None, True]) +def test_request_source_enabled( + sentry_init, capture_events, enable_http_request_source +): + sentry_options = { + "traces_sample_rate": 1.0, + "http_request_source_threshold_ms": 0, + } + if enable_http_request_source is not None: + sentry_options["enable_http_request_source"] = enable_http_request_source + + sentry_init(**sentry_options) + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data + + +def test_request_source(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=0, + ) + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data + + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib" + assert data.get(SPANDATA.CODE_FILEPATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source" + + +def test_request_source_with_module_in_search_path(sentry_init, capture_events): + """ + Test that request source is relative to the path of the module it ran in + """ + sentry_init( + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=0, + ) + + events = capture_events() + + with start_transaction(name="foo"): + from httplib_helpers.helpers import get_request_with_connection + + conn = HTTPConnection("localhost", port=PORT) + get_request_with_connection(conn, "/foo") + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data + + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert data.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" + assert data.get(SPANDATA.CODE_FILEPATH) == "httplib_helpers/helpers.py" + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" + + +def test_no_request_source_if_duration_too_short(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=100, + ) + + already_patched_putrequest = HTTPConnection.putrequest + + def overwrite_timestamps_putrequest(self, *args, **kwargs): + already_patched_putrequest(self, *args, **kwargs) + span = self._sentrysdk_span + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + + HTTPConnection.putrequest = overwrite_timestamps_putrequest + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO not in data + assert SPANDATA.CODE_NAMESPACE not in data + assert SPANDATA.CODE_FILEPATH not in data + assert SPANDATA.CODE_FUNCTION not in data + + +def test_request_source_if_duration_over_threshold(sentry_init, capture_events): + sentry_init( + enable_tracing=True, + enable_db_query_source=True, + db_query_source_threshold_ms=100, + ) + + already_patched_putrequest = HTTPConnection.putrequest + + def overwrite_timestamps_putrequest(self, *args, **kwargs): + already_patched_putrequest(self, *args, **kwargs) + span = self._sentrysdk_span + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + + HTTPConnection.putrequest = overwrite_timestamps_putrequest + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data + + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib" + assert data.get(SPANDATA.CODE_FILEPATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert ( + data.get(SPANDATA.CODE_FUNCTION) + == "test_query_source_if_duration_over_threshold" + ) + + def test_span_origin(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, debug=True) events = capture_events() From 3c2451df4196bba58626f6180d8dfe691b42c6b3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 12:57:00 +0200 Subject: [PATCH 2/6] lint and fix test --- sentry_sdk/tracing_utils.py | 1 + tests/integrations/stdlib/test_httplib.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index acbfe86ff3..1660fc2844 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -219,6 +219,7 @@ def _should_be_included( def add_source(span, project_root, in_app_include, in_app_exclude): + # type: (sentry_sdk.tracing.Span, Optional[str], Optional[list[str]], Optional[list[str]]) -> None """ Adds OTel compatible source code information to the span """ diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index b3d7f85a9f..ea85d25cc7 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -601,7 +601,7 @@ def overwrite_timestamps_putrequest(self, *args, **kwargs): assert ( data.get(SPANDATA.CODE_FUNCTION) - == "test_query_source_if_duration_over_threshold" + == "test_request_source_if_duration_over_threshold" ) From 91ec18828cf60e176a2a96b315693ded497cb73e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 13:15:17 +0200 Subject: [PATCH 3/6] remove print --- sentry_sdk/tracing_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 1660fc2844..6506cca266 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -336,7 +336,6 @@ def add_http_request_source(span): duration = span.timestamp - span.start_timestamp threshold = client.options.get("http_request_source_threshold_ms", 0) - print("division: ", duration / timedelta(milliseconds=1), threshold) slow_query = duration / timedelta(milliseconds=1) > threshold if not slow_query: From 3b054b6bfab7f86ad17fcc9c29ca20a4bc583d7e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 13:51:21 +0200 Subject: [PATCH 4/6] better testing approach --- tests/integrations/stdlib/test_httplib.py | 32 +++++++++++------------ 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index ea85d25cc7..fdd15c4b09 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -525,18 +525,17 @@ def test_no_request_source_if_duration_too_short(sentry_init, capture_events): already_patched_putrequest = HTTPConnection.putrequest - def overwrite_timestamps_putrequest(self, *args, **kwargs): - already_patched_putrequest(self, *args, **kwargs) - span = self._sentrysdk_span - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - - HTTPConnection.putrequest = overwrite_timestamps_putrequest + class HttpConnectionWithPatchedSpan(HTTPConnection): + def putrequest(self, *args, **kwargs) -> None: + already_patched_putrequest(self, *args, **kwargs) + span = self._sentrysdk_span # type: ignore + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) events = capture_events() with start_transaction(name="foo"): - conn = HTTPConnection("localhost", port=PORT) + conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() @@ -555,25 +554,24 @@ def overwrite_timestamps_putrequest(self, *args, **kwargs): def test_request_source_if_duration_over_threshold(sentry_init, capture_events): sentry_init( - enable_tracing=True, + traces_sample_rate=1.0, enable_db_query_source=True, db_query_source_threshold_ms=100, ) already_patched_putrequest = HTTPConnection.putrequest - def overwrite_timestamps_putrequest(self, *args, **kwargs): - already_patched_putrequest(self, *args, **kwargs) - span = self._sentrysdk_span - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - - HTTPConnection.putrequest = overwrite_timestamps_putrequest + class HttpConnectionWithPatchedSpan(HTTPConnection): + def putrequest(self, *args, **kwargs) -> None: + already_patched_putrequest(self, *args, **kwargs) + span = self._sentrysdk_span # type: ignore + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) events = capture_events() with start_transaction(name="foo"): - conn = HTTPConnection("localhost", port=PORT) + conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() From 27951251e96a650f1449b4e7536ea1cbef28425b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 14:03:09 +0200 Subject: [PATCH 5/6] change comment --- tests/integrations/stdlib/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/stdlib/__init__.py b/tests/integrations/stdlib/__init__.py index 247198726c..472e0151b2 100644 --- a/tests/integrations/stdlib/__init__.py +++ b/tests/integrations/stdlib/__init__.py @@ -1,6 +1,6 @@ import os import sys -# Load `asyncpg_helpers` into the module search path to test query source path names relative to module. See -# `test_query_source_with_module_in_search_path` +# Load `httplib_helpers` into the module search path to test request source path names relative to module. See +# `test_request_source_with_module_in_search_path` sys.path.insert(0, os.path.join(os.path.dirname(__file__))) From 77937e9f8563fc39b44743ec5b8b657279d19401 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Oct 2025 14:39:51 +0200 Subject: [PATCH 6/6] fix init arguments in test --- tests/integrations/stdlib/test_httplib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index fdd15c4b09..43c782558b 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -555,8 +555,8 @@ def putrequest(self, *args, **kwargs) -> None: def test_request_source_if_duration_over_threshold(sentry_init, capture_events): sentry_init( traces_sample_rate=1.0, - enable_db_query_source=True, - db_query_source_threshold_ms=100, + enable_http_request_source=True, + http_request_source_threshold_ms=100, ) already_patched_putrequest = HTTPConnection.putrequest