diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index f8d96a4507..e4f432b1cb 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,5 +1,5 @@ -import sys from copy import deepcopy +from functools import wraps from typing import TYPE_CHECKING import sentry_sdk @@ -10,15 +10,14 @@ from sentry_sdk.utils import has_data_collection_enabled, transaction_from_function if TYPE_CHECKING: - from typing import Any, Awaitable, Callable, Dict, Optional - - from sentry_sdk._types import Event + from typing import Any, Callable, Optional try: from sentry_sdk.integrations.starlette import ( StarletteIntegration, StarletteRequestExtractor, _get_cached_request_body_attribute, + _is_async_callable, _wrap_sync_handler, ) except DidNotEnable: @@ -26,6 +25,7 @@ try: import fastapi # type: ignore + from starlette.requests import HTTPConnection, Request except ImportError: raise DidNotEnable("FastAPI is not installed") @@ -33,102 +33,79 @@ _DEFAULT_TRANSACTION_NAME = "generic FastAPI request" -# Vendored: https://github.com/Kludex/starlette/blob/0a29b5ccdcbd1285c75c4fdb5d62ae1d244a21b0/starlette/_utils.py#L11-L17 -if sys.version_info >= (3, 13): # pragma: no cover - from inspect import iscoroutinefunction -else: - from asyncio import iscoroutinefunction - - class FastApiIntegration(StarletteIntegration): identifier = "fastapi" @staticmethod def setup_once() -> None: - patch_get_request_handler() - - -def _set_transaction_name_and_source( - scope: "sentry_sdk.Scope", - transaction_style: str, - endpoint: "Optional[Callable[..., Any]]", - route_path: "Optional[str]", -) -> None: - name = "" - - if transaction_style == "endpoint" and endpoint: - name = transaction_from_function(endpoint) or "" - - elif transaction_style == "url" and route_path is not None: - name = route_path - - if not name: - name = _DEFAULT_TRANSACTION_NAME - source = TransactionSource.ROUTE - else: - source = SOURCE_FOR_STYLE[transaction_style] + # FastAPI uses the Starlette ASGI lifecycle, so make sure the + # request-scoped isolation scope is installed even when the FastAPI + # integration is enabled without the Starlette integration. + StarletteIntegration.setup_once() + patch_fastapi_init() - scope.set_transaction_name(name, source=source) +async def _sentry_fastapi_dependency(request: "HTTPConnection"): + if not isinstance(request, Request): + yield + return -async def _wrap_async_handler( - handler: "Callable[..., Awaitable[Any]]", *args: "Any", **kwargs: "Any" -) -> "Any": - """ - Wraps an asynchronous handler function to attach request info to errors and the server segment span. - The request body cached on the Starlette Request object is attached to streamed spans, but consuming the request body in the event - processor can still cause application hangs. - """ client = sentry_sdk.get_client() integration = client.get_integration(FastApiIntegration) if integration is None: - return await handler(*args, **kwargs) - - request = args[0] + yield + return + current_scope = sentry_sdk.get_current_scope() + effective_route_context = request.scope.get("fastapi", {}).get( + "effective_route_context" + ) route = request.scope.get("route") route_path = None - if route: - # FastAPI >= 0.137 stores the prefix-resolved path on an - # effective_route_context in scope["fastapi"], while - # scope["route"].path holds the unprefixed original. - # Prefer the effective context path when available. - effective_route_context = request.scope.get("fastapi", {}).get( - "effective_route_context" - ) - context_path = getattr(effective_route_context, "path", None) - - if context_path: - route_path = context_path - else: - path = getattr(route, "path", None) - if path is not None: - route_path = path - - server_span = sentry_sdk.get_current_scope()._server_segment_span + if effective_route_context is not None: + route_path = getattr(effective_route_context, "path", None) + + if route_path is None and route is not None: + route_path = getattr(route, "path", None) + + server_span = current_scope._server_segment_span if server_span is not None and route_path is not None: server_span.set_attribute(SPANDATA.HTTP_ROUTE, route_path) _set_transaction_name_and_source( - sentry_sdk.get_current_scope(), + current_scope, integration.transaction_style, endpoint=request.scope.get("endpoint"), route_path=route_path, ) + + # FastAPI may execute the dependant stored on the effective route context + # instead of the original APIRoute. + dependant = getattr(effective_route_context, "dependant", None) + if dependant is None: + dependant = getattr(route, "dependant", None) + if ( + dependant is not None + and dependant.call is not None + and not _is_async_callable(dependant.call) + ): + dependant.call = _wrap_sync_handler(dependant.call) + sentry_scope = sentry_sdk.get_isolation_scope() extractor = StarletteRequestExtractor(request) info = await extractor.extract_request_info() def _make_request_event_processor( - req: "Any", integration: "Any" - ) -> "Callable[[Event, Dict[str, Any]], Event]": - def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": - # Extract information from request - request_info = event.get("request", {}) + info: "dict[str, Any]", + ) -> "Callable[[Any, dict[str, Any]], Any]": + def event_processor( + event: "dict[str, Any]", hint: "dict[str, Any]" + ) -> "dict[str, Any]": + event_request = event.get("request", {}) if info: if "cookies" in info: - request_info["cookies"] = info["cookies"] + event_request["cookies"] = info["cookies"] if "data" in info: attach_request_data = True if has_data_collection_enabled(client.options): @@ -138,23 +115,19 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": ) if attach_request_data: - request_info["data"] = info["data"] - event["request"] = deepcopy(request_info) - + event_request["data"] = info["data"] + event["request"] = deepcopy(event_request) return event return event_processor sentry_scope._name = FastApiIntegration.identifier - sentry_scope.add_event_processor( - _make_request_event_processor(request, integration) - ) + sentry_scope.add_event_processor(_make_request_event_processor(info)) try: - return await handler(*args, **kwargs) + yield finally: current_span = get_current_span() - if type(current_span) is StreamedSpan: attach_request_data = True if has_data_collection_enabled(client.options): @@ -174,23 +147,47 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": ) -def patch_get_request_handler() -> None: - old_get_request_handler = fastapi.routing.get_request_handler +def patch_fastapi_init() -> None: + old_fastapi_init = fastapi.FastAPI.__init__ + + if getattr(old_fastapi_init, "_sentry_is_patched", False): + return + + @wraps(old_fastapi_init) + def _sentry_fastapi_init(self: "Any", *args: "Any", **kwargs: "Any") -> None: + dependencies = kwargs.get("dependencies") + if dependencies is None: + dependencies = [] - def _sentry_get_request_handler(*args: "Any", **kwargs: "Any") -> "Any": - dependant = kwargs.get("dependant") - if ( - dependant - and dependant.call is not None - and not iscoroutinefunction(dependant.call) - ): - dependant.call = _wrap_sync_handler(dependant.call) + kwargs["dependencies"] = [ + fastapi.Depends(_sentry_fastapi_dependency), + *dependencies, + ] - old_app = old_get_request_handler(*args, **kwargs) + old_fastapi_init(self, *args, **kwargs) - async def _sentry_app(*args: "Any", **kwargs: "Any") -> "Any": - return await _wrap_async_handler(old_app, *args, **kwargs) + _sentry_fastapi_init._sentry_is_patched = True # type: ignore[attr-defined] + fastapi.FastAPI.__init__ = _sentry_fastapi_init - return _sentry_app - fastapi.routing.get_request_handler = _sentry_get_request_handler +def _set_transaction_name_and_source( + scope: "sentry_sdk.Scope", + transaction_style: str, + endpoint: "Optional[Callable[..., Any]]", + route_path: "Optional[str]", +) -> None: + name = "" + + if transaction_style == "endpoint" and endpoint: + name = transaction_from_function(endpoint) or "" + + elif transaction_style == "url" and route_path is not None: + name = route_path + + if not name: + name = _DEFAULT_TRANSACTION_NAME + source = TransactionSource.ROUTE + else: + source = SOURCE_FOR_STYLE[transaction_style] + + scope.set_transaction_name(name, source=source) \ No newline at end of file diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index f6988d096d..4cc54694e3 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -468,6 +468,8 @@ def patch_asgi_app(root_path_in_path: "_RootPathInPath") -> None: Instrument Starlette ASGI app using the SentryAsgiMiddleware. """ old_app = Starlette.__call__ + if "_sentry_patched_asgi_app" in str(old_app): + return async def _sentry_patched_asgi_app( self: "Starlette", scope: "StarletteScope", receive: "Receive", send: "Send" @@ -663,6 +665,8 @@ def _sentry_sync_handler(*args: "Any", **kwargs: "Any") -> "Any": def patch_request_response() -> None: old_request_response = starlette.routing.request_response + if "_sentry_request_response" in str(old_request_response): + return def _sentry_request_response(func: "Callable[[Any], Any]") -> "ASGIApp": old_func = func diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index a79b915eef..278ab25ea3 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -12,12 +12,14 @@ from fastapi import ( APIRouter, Body, + Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, + WebSocket, ) from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.testclient import TestClient @@ -607,6 +609,147 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en assert str(data["active"]) == trace_context["data"]["thread.id"] +def test_active_thread_id_with_prefixed_router( + sentry_init, capture_envelopes +): + sentry_init( + auto_enabling_integrations=False, + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + ) + + app = FastAPI() + router = APIRouter() + + @router.get("/sync") + def _sync(): + return {"active": str(threading.current_thread().ident)} + + app.include_router(router, prefix="/api") + + envelopes = capture_envelopes() + response = TestClient(app).get("/api/sync") + + assert response.status_code == 200 + assert len(envelopes) == 1 + + transaction = next( + item.payload.json + for item in envelopes[0].items + if item.type == "transaction" + ) + assert ( + response.json()["active"] == transaction["contexts"]["trace"]["data"]["thread.id"] + ) + + +def test_global_dependency_preserves_existing_dependencies(sentry_init): + calls = [] + + def custom_dependency(): + calls.append(True) + + sentry_init(integrations=[FastApiIntegration()]) + + app = FastAPI(dependencies=[Depends(custom_dependency)]) + + @app.get("/") + async def _root(): + return {"message": "ok"} + + client = TestClient(app) + response = client.get("/") + + assert response.json() == {"message": "ok"} + assert calls == [True] + + +def test_global_dependency_runs_before_existing_dependencies(sentry_init): + seen_transaction_names = [] + + def custom_dependency(): + transaction = sentry_sdk.get_current_scope().transaction + seen_transaction_names.append(transaction.name if transaction else None) + + sentry_init( + auto_enabling_integrations=False, + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + ) + + app = FastAPI(dependencies=[Depends(custom_dependency)]) + + @app.get("/items/{item_id}") + async def _get_item(item_id: int): + return {"item_id": item_id} + + response = TestClient(app).get("/items/123") + + assert response.status_code == 200 + assert seen_transaction_names == ["/items/{item_id}"] + + +def test_global_dependency_captures_request_data(sentry_init, capture_events): + sentry_init( + auto_enabling_integrations=False, + integrations=[StarletteIntegration(), FastApiIntegration()], + send_default_pii=True, + ) + + app = FastAPI() + + @app.post("/message") + async def _message(): + capture_message("request body captured") + return {"message": "ok"} + + events = capture_events() + + response = TestClient(app).post("/message", json=BODY_JSON) + + assert response.status_code == 200 + (event,) = events + assert event["request"]["data"] == BODY_JSON + + +def test_global_dependency_request_processors_are_isolated(sentry_init, capture_events): + sentry_init(integrations=[FastApiIntegration()], send_default_pii=True) + + app = FastAPI() + + @app.get("/") + async def _root(): + capture_message("request") + return {"message": "ok"} + + events = capture_events() + client = TestClient(app) + + assert client.get("/", cookies={"request": "one"}).status_code == 200 + assert client.get("/", cookies={"request": "two"}).status_code == 200 + + assert [event["request"]["cookies"] for event in events] == [ + {"request": "one"}, + {"request": "two"}, + ] + + +def test_global_dependency_does_not_break_websockets(sentry_init): + sentry_init(integrations=[FastApiIntegration()]) + + app = FastAPI() + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + await websocket.send_text("ok") + + client = TestClient(app) + + with client.websocket_connect("/ws") as websocket: + assert websocket.receive_text() == "ok" + + @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): sentry_init(