diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index bb44896a04..9f0b64216a 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -32,6 +32,7 @@ def _get_url( asgi_scope: "Dict[str, Any]", default_scheme: "Literal['ws', 'http']", host: "Optional[Union[AnnotatedValue, str]]", + path_includes_root_path: "bool" = True, ) -> str: """ Extract URL from the ASGI scope, without also including the querystring. @@ -39,7 +40,11 @@ def _get_url( scheme = asgi_scope.get("scheme", default_scheme) server = asgi_scope.get("server", None) - path = asgi_scope.get("root_path", "") + asgi_scope.get("path", "") + path = ( + asgi_scope.get("path", "") + if path_includes_root_path + else asgi_scope.get("root_path", "") + asgi_scope.get("path", "") + ) if host: return "%s://%s%s" % (scheme, host, path) @@ -81,7 +86,9 @@ def _get_ip(asgi_scope: "Any") -> str: return asgi_scope.get("client")[0] -def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]": +def _get_request_data( + asgi_scope: "Any", path_includes_root_path: "bool" = True +) -> "Dict[str, Any]": """ Returns data related to the HTTP request from the ASGI scope. """ @@ -96,7 +103,10 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]": request_data["query_string"] = _get_query(asgi_scope) request_data["url"] = _get_url( - asgi_scope, "http" if ty == "http" else "ws", headers.get("host") + asgi_scope, + "http" if ty == "http" else "ws", + headers.get("host"), + path_includes_root_path=path_includes_root_path, ) client = asgi_scope.get("client") @@ -106,7 +116,9 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]": return request_data -def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": +def _get_request_attributes( + asgi_scope: "Any", path_includes_root_path: "bool" = True +) -> "dict[str, Any]": """ Return attributes related to the HTTP request from the ASGI scope. """ @@ -127,7 +139,10 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": attributes["http.query"] = query url_without_query_string = _get_url( - asgi_scope, "http" if ty == "http" else "ws", headers.get("host") + asgi_scope, + "http" if ty == "http" else "ws", + headers.get("host"), + path_includes_root_path=path_includes_root_path, ) query_string = _get_query(asgi_scope) attributes["url.full"] = ( diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index f0470e33fc..55e024f92a 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -105,6 +105,7 @@ class SentryAsgiMiddleware: "mechanism_type", "span_origin", "http_methods_to_capture", + "path_includes_root_path", ) def __init__( @@ -116,6 +117,7 @@ def __init__( span_origin: str = "manual", http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE, asgi_version: "Optional[int]" = None, + path_includes_root_path: bool = True, ) -> None: """ Instrument an ASGI application with Sentry. Provides HTTP/websocket @@ -152,6 +154,7 @@ def __init__( self.span_origin = span_origin self.app = app self.http_methods_to_capture = http_methods_to_capture + self.path_includes_root_path = path_includes_root_path if asgi_version is None: if _looks_like_asgi3(app): @@ -319,7 +322,8 @@ async def _run_app( with span_ctx as span: if isinstance(span, StreamedSpan): for attribute, value in _get_request_attributes( - scope + scope, + path_includes_root_path=self.path_includes_root_path, ).items(): span.set_attribute(attribute, value) @@ -401,7 +405,11 @@ def event_processor( self, event: "Event", hint: "Hint", asgi_scope: "Any" ) -> "Optional[Event]": request_data = event.get("request", {}) - request_data.update(_get_request_data(asgi_scope)) + request_data.update( + _get_request_data( + asgi_scope, path_includes_root_path=self.path_includes_root_path + ) + ) event["request"] = deepcopy(request_data) # Only set transaction name if not already set by Starlette or FastAPI (or other frameworks) @@ -447,7 +455,12 @@ def _get_transaction_name_and_source( if endpoint: name = transaction_from_function(endpoint) or "" else: - name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path_includes_root_path=self.path_includes_root_path, + ) source = TransactionSource.URL elif transaction_style == "url": @@ -459,7 +472,12 @@ def _get_transaction_name_and_source( if path is not None: name = path else: - name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path_includes_root_path=self.path_includes_root_path, + ) source = TransactionSource.URL if name is None: @@ -484,7 +502,12 @@ def _get_segment_name_and_source( if endpoint: name = qualname_from_function(endpoint) or "" else: - name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path_includes_root_path=self.path_includes_root_path, + ) source = SegmentSource.URL.value elif segment_style == "url": @@ -496,7 +519,12 @@ def _get_segment_name_and_source( if path is not None: name = path else: - name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None) + name = _get_url( + asgi_scope, + "http" if ty == "http" else "ws", + host=None, + path_includes_root_path=self.path_includes_root_path, + ) source = SegmentSource.URL.value if name is None: diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index f0c90a7921..70a0fec430 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -92,6 +92,11 @@ def __init__( mechanism_type="asgi", span_origin=span_origin, asgi_version=3, + # Unlike Starlette, LiteStar does not extend scope["root_path"] with the mount path. + # Since LiteStar handles servers that include and do not include scope["root_path"] in scope["path"] + # with the commit below, keep the existing behavior for compatibility. + # https://github.com/litestar-org/litestar/commit/72dda171768bd470adc065c47c1ecf1d80b5e749 + path_includes_root_path=False, ) def _capture_request_exception(self, exc: Exception) -> None: diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 6a5603d825..4578706fe3 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -17,6 +17,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + package_version, ) if TYPE_CHECKING: @@ -86,16 +87,25 @@ def setup_once() -> None: def patch_asgi_app() -> None: old_app = Quart.__call__ + version = package_version("quart") + async def sentry_patched_asgi_app( self: "Any", scope: "Any", receive: "Any", send: "Any" ) -> "Any": - if sentry_sdk.get_client().get_integration(QuartIntegration) is None: + if ( + sentry_sdk.get_client().get_integration(QuartIntegration) is None + or version is None + ): return await old_app(self, scope, receive, send) middleware = SentryAsgiMiddleware( lambda *a, **kw: old_app(self, *a, **kw), span_origin=QuartIntegration.origin, asgi_version=3, + # Starting with the commit below, Quart treats any scope["path"] + # that does not include scope["root_path"] as invalid. + # https://github.com/pallets/quart/commit/7be545c + path_includes_root_path=version >= (0, 19), ) return await middleware(scope, receive, send) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 1482efc25b..dc2b7d00e1 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -143,7 +143,10 @@ def setup_once() -> None: ) patch_middlewares() - patch_asgi_app() + # Starlette's Mount includes scope["root_path"] in scope["path"] starting with: + # https://github.com/Kludex/starlette/commit/e8f0dcd54e4ceec47e02c45f5275374e292339ad. + path_includes_root_path = version >= (0, 33) + patch_asgi_app(path_includes_root_path=path_includes_root_path) patch_request_response() if version >= (0, 24): @@ -427,7 +430,7 @@ def _sentry_middleware_init( Middleware.__init__ = _sentry_middleware_init -def patch_asgi_app() -> None: +def patch_asgi_app(path_includes_root_path: "bool") -> None: """ Instrument Starlette ASGI app using the SentryAsgiMiddleware. """ @@ -451,6 +454,7 @@ async def _sentry_patched_asgi_app( else DEFAULT_HTTP_METHODS_TO_CAPTURE ), asgi_version=3, + path_includes_root_path=path_includes_root_path, ) return await middleware(scope, receive, send) diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 1c9328a09d..e23efaa612 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -77,6 +77,7 @@ def __init__( mechanism_type="asgi", span_origin=span_origin, asgi_version=3, + path_includes_root_path=False, ) diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 4e9eb95556..37afb0b30f 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -1049,3 +1049,53 @@ async def test_transaction_http_method_custom( (event1, event2) = events assert event1["request"]["method"] == "OPTIONS" assert event2["request"]["method"] == "HEAD" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +@pytest.mark.skipif( + django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0" +) +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_request_url( + sentry_init, + capture_events, + capture_items, + application, + span_streaming, +): + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + comm = HttpCommunicator( + application, + "GET", + "/root/nomessage", + ) + comm.scope["root_path"] = "/root" + + if span_streaming: + items = capture_items("span") + await comm.get_response() + await comm.wait() + + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == ("/root/nomessage") + else: + events = capture_events() + + await comm.get_response() + await comm.wait() + + (event,) = events + assert event["request"]["url"] == "/root/nomessage" diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index dc6bde89c8..583d95fd7f 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -62,6 +62,7 @@ def fastapi_app_factory(): app = FastAPI() + mounted_app = FastAPI() @app.get("/error") async def _error(): @@ -74,6 +75,7 @@ async def _message(): capture_message("Hi") return {"message": "Hi"} + @mounted_app.get("/nomessage") @app.delete("/nomessage") @app.get("/nomessage") @app.head("/nomessage") @@ -118,6 +120,8 @@ async def body_form( capture_message("hi") return {"status": "ok"} + app.mount("/root", mounted_app) + return app @@ -1043,6 +1047,47 @@ def test_transaction_http_method_custom(sentry_init, capture_events): assert event2["request"]["method"] == "HEAD" +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_url(sentry_init, capture_events, capture_items, span_streaming): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[ + StarletteIntegration(), + ], + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + + starlette_app = fastapi_app_factory() + + client = TestClient(starlette_app) + + if span_streaming: + items = capture_items("span") + + client.get("/root/nomessage") + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == ( + "http://testserver/root/nomessage" + ) + else: + events = capture_events() + + client.get("/root/nomessage") + + (event,) = events + assert event["request"]["url"] == "http://testserver/root/nomessage" + + @parametrize_test_configurable_status_codes def test_configurable_status_codes( sentry_init, diff --git a/tests/integrations/litestar/test_litestar.py b/tests/integrations/litestar/test_litestar.py index 4abb037e36..97770932ea 100644 --- a/tests/integrations/litestar/test_litestar.py +++ b/tests/integrations/litestar/test_litestar.py @@ -16,9 +16,12 @@ import sentry_sdk from sentry_sdk import capture_message from sentry_sdk.integrations.litestar import LitestarIntegration +from sentry_sdk.utils import package_version from tests.conftest import ApproxDict from tests.integrations.conftest import parametrize_test_configurable_status_codes +LITESTAR_VERSION = package_version("litestar") + def litestar_app_factory(middleware=None, debug=True, exception_handlers=None): class MyController(Controller): @@ -47,6 +50,10 @@ async def message_with_id() -> "dict[str, Any]": capture_message("hi") return {"status": "ok"} + @get("/nomessage") + async def nomessage() -> "dict[str, Any]": + return {"status": "ok"} + logging_config = LoggingConfig() app = Litestar( @@ -55,6 +62,7 @@ async def message_with_id() -> "dict[str, Any]": custom_error, message, message_with_id, + nomessage, MyController, ], debug=debug, @@ -818,3 +826,48 @@ async def error() -> None: ... event_exception = events[0]["exception"]["values"][0] assert event_exception["type"] == "RuntimeError" assert event_exception["value"] == "Too Hot" + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_url(sentry_init, capture_events, capture_items, span_streaming): + sentry_init( + traces_sample_rate=1.0, + integrations=[LitestarIntegration()], + send_default_pii=True, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + + litestar_app = litestar_app_factory() + client = TestClient( + litestar_app, base_url="http://testserver.local", root_path="/root" + ) + + if span_streaming: + items = capture_items("span") + + # https://github.com/litestar-org/litestar/commit/72dda171768bd470adc065c47c1ecf1d80b5e749 + url = "/root/nomessage" if LITESTAR_VERSION >= (2, 5, 3) else "/nomessage" + client.get(url) + + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == ( + "http://testserver.local/root/nomessage" + ) + else: + events = capture_events() + + # https://github.com/litestar-org/litestar/commit/72dda171768bd470adc065c47c1ecf1d80b5e749 + url = "/root/nomessage" if LITESTAR_VERSION >= (2, 5, 3) else "/nomessage" + client.get(url) + + (event,) = events + assert event["request"]["url"] == "http://testserver.local/root/nomessage" diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 7c7579501b..6d3529d157 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -14,7 +14,9 @@ set_tag, ) from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE +from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE, package_version + +QUART_VERSION = package_version("quart") def quart_app_factory(): @@ -44,6 +46,10 @@ async def hi(): capture_message("hi") return "ok" + @app.route("/nomessage") + async def nomessage(): + return "ok" + @app.route("/message/") async def hi_with_id(message_id): capture_message("hi with id") @@ -682,6 +688,25 @@ async def test_span_origin(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "auto.http.quart" +@pytest.mark.asyncio +async def test_request_url(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[quart_sentry.QuartIntegration()], + ) + app = quart_app_factory() + client = app.test_client() + + events = capture_events() + + # https://github.com/pallets/quart/commit/7be545c + url = "/root/nomessage" if QUART_VERSION >= (0, 19) else "/nomessage" + await client.get(url, root_path="/root") + + (event,) = events + assert event["request"]["url"] == "http://localhost/root/nomessage" + + @pytest.mark.asyncio async def test_span_streaming_basic(sentry_init, capture_items): sentry_init( @@ -966,3 +991,31 @@ async def test_span_streaming_sensitive_header_passthrough_with_pii( segment["attributes"]["http.request.header.authorization"] == "Bearer secret-token" ) + + +@pytest.mark.asyncio +async def test_span_streaming_request_url(sentry_init, capture_items): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[quart_sentry.QuartIntegration()], + _experiments={ + "trace_lifecycle": "stream", + }, + ) + app = quart_app_factory() + client = app.test_client() + + items = capture_items("span") + + # https://github.com/pallets/quart/commit/7be545c + url = "/root/nomessage" if QUART_VERSION >= (0, 19) else "/nomessage" + await client.get(url, root_path="/root") + + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span for span in spans if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == "http://localhost/root/nomessage" diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 22ae7c55a4..5287911be6 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -146,6 +146,12 @@ async def _body_raw(request): "TRACE", ] + mounted_app = starlette.applications.Starlette( + routes=[ + starlette.routing.Route("/nomessage", _nomessage, methods=all_methods), + ], + ) + app = starlette.applications.Starlette( debug=debug, routes=[ @@ -160,6 +166,7 @@ async def _body_raw(request): starlette.routing.Route("/body/json", _body_json, methods=["POST"]), starlette.routing.Route("/body/form", _body_form, methods=["POST"]), starlette.routing.Route("/body/raw", _body_raw, methods=["POST"]), + starlette.routing.Mount("/root", app=mounted_app), ], middleware=middleware, ) @@ -1477,6 +1484,47 @@ def test_transaction_http_method_default(sentry_init, capture_events): assert event["request"]["method"] == "GET" +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_url(sentry_init, capture_events, capture_items, span_streaming): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[ + StarletteIntegration(), + ], + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + + starlette_app = starlette_app_factory() + + client = TestClient(starlette_app) + + if span_streaming: + items = capture_items("span") + + client.get("/root/nomessage") + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == ( + "http://testserver/root/nomessage" + ) + else: + events = capture_events() + + client.get("/root/nomessage") + + (event,) = events + assert event["request"]["url"] == "http://testserver/root/nomessage" + + @pytest.mark.skipif( STARLETTE_VERSION < (0, 21), reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests", diff --git a/tests/integrations/starlite/test_starlite.py b/tests/integrations/starlite/test_starlite.py index 3b6dc44131..3ea483f39f 100644 --- a/tests/integrations/starlite/test_starlite.py +++ b/tests/integrations/starlite/test_starlite.py @@ -41,6 +41,10 @@ async def message_with_id() -> "Dict[str, Any]": capture_message("hi") return {"status": "ok"} + @get("/nomessage") + async def nomessage() -> "Dict[str, Any]": + return {"status": "ok"} + logging_config = LoggingConfig() app = Starlite( @@ -49,6 +53,7 @@ async def message_with_id() -> "Dict[str, Any]": custom_error, message, message_with_id, + nomessage, MyController, ], debug=debug, @@ -574,3 +579,44 @@ async def __call__(self, scope, receive, send): } else: assert "user" not in event + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_url(sentry_init, capture_events, capture_items, span_streaming): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarliteIntegration()], + send_default_pii=True, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) + + starlite_app = starlite_app_factory() + client = TestClient( + starlite_app, base_url="http://testserver.local", root_path="/root" + ) + + if span_streaming: + items = capture_items("span") + + client.get("/nomessage") + + sentry_sdk.flush() + spans = [item.payload for item in items] + + (server_span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "http.server" + ) + assert server_span["attributes"]["url.full"] == ( + "http://testserver.local/root/nomessage" + ) + else: + events = capture_events() + + client.get("/nomessage") + + (event,) = events + assert event["request"]["url"] == "http://testserver.local/root/nomessage"