diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 0f71a0d460..b45efa4d67 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -906,6 +906,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] @@ -1261,6 +1263,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..6506cca266 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -218,33 +218,11 @@ 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): + # type: (sentry_sdk.tracing.Span, Optional[str], Optional[list[str]], Optional[list[str]]) -> None """ 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 +287,68 @@ 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) + 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..472e0151b2 --- /dev/null +++ b/tests/integrations/stdlib/__init__.py @@ -0,0 +1,6 @@ +import os +import sys + +# 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__))) 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..43c782558b 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,233 @@ 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 + + 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 = HttpConnectionWithPatchedSpan("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( + traces_sample_rate=1.0, + enable_http_request_source=True, + http_request_source_threshold_ms=100, + ) + + already_patched_putrequest = HTTPConnection.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 = HttpConnectionWithPatchedSpan("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_if_duration_over_threshold" + ) + + def test_span_origin(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, debug=True) events = capture_events()