From 3c93394f65ef7fb23a0703f21b8968a0f13b4245 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:48:12 +0530 Subject: [PATCH 01/27] refactor(fastapi): register active thread dependency globally --- sentry_sdk/integrations/fastapi.py | 39 +++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index f8d96a4507..2b70752aa5 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -29,6 +29,8 @@ except ImportError: raise DidNotEnable("FastAPI is not installed") +from starlette.requests import HTTPConnection, Request + _DEFAULT_TRANSACTION_NAME = "generic FastAPI request" @@ -45,7 +47,42 @@ class FastApiIntegration(StarletteIntegration): @staticmethod def setup_once() -> None: - patch_get_request_handler() + patch_fastapi_init() + + +def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: + if not isinstance(request, Request): + return + + endpoint = request.scope.get("endpoint") + if endpoint is not None and iscoroutinefunction(endpoint): + return + + _update_active_thread() + + +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 = [] + + kwargs["dependencies"] = [ + *dependencies, + fastapi.Depends(_sentry_fastapi_dependency), + ] + + old_fastapi_init(self, *args, **kwargs) + + _sentry_fastapi_init._sentry_is_patched = True # type: ignore[attr-defined] + fastapi.FastAPI.__init__ = _sentry_fastapi_init + def _set_transaction_name_and_source( From 1b09b1b3cdbcebc15576eb8f4f0e03c74df9acbe Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:48:20 +0530 Subject: [PATCH 02/27] refactor(fastapi): remove private request handler patch --- sentry_sdk/integrations/fastapi.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 2b70752aa5..939324dcca 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,5 +1,6 @@ import sys from copy import deepcopy +from functools import wraps from typing import TYPE_CHECKING import sentry_sdk @@ -19,7 +20,7 @@ StarletteIntegration, StarletteRequestExtractor, _get_cached_request_body_attribute, - _wrap_sync_handler, + _update_active_thread, ) except DidNotEnable: raise DidNotEnable("Starlette is not installed") @@ -209,25 +210,3 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": SPANDATA.HTTP_REQUEST_BODY_DATA, request_body, ) - - -def patch_get_request_handler() -> None: - old_get_request_handler = fastapi.routing.get_request_handler - - 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) - - old_app = old_get_request_handler(*args, **kwargs) - - async def _sentry_app(*args: "Any", **kwargs: "Any") -> "Any": - return await _wrap_async_handler(old_app, *args, **kwargs) - - return _sentry_app - - fastapi.routing.get_request_handler = _sentry_get_request_handler From 2f20f37f5486c52b5385fb8a4f56fe630a5aa7c4 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:48:31 +0530 Subject: [PATCH 03/27] test(fastapi): cover global dependency websocket compatibility --- tests/integrations/fastapi/test_fastapi.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index a79b915eef..183c6a7976 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 @@ -608,6 +610,22 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +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" + + def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): sentry_init( auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode From ed2f825786dd793fd6fcaff3dc02b78e864575df Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:48:50 +0530 Subject: [PATCH 04/27] style(fastapi): tidy global dependency setup --- sentry_sdk/integrations/fastapi.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 939324dcca..c4020d3d8c 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -27,11 +27,10 @@ try: import fastapi # type: ignore + from starlette.requests import HTTPConnection, Request except ImportError: raise DidNotEnable("FastAPI is not installed") -from starlette.requests import HTTPConnection, Request - _DEFAULT_TRANSACTION_NAME = "generic FastAPI request" @@ -85,7 +84,6 @@ def _sentry_fastapi_init(self: "Any", *args: "Any", **kwargs: "Any") -> None: fastapi.FastAPI.__init__ = _sentry_fastapi_init - def _set_transaction_name_and_source( scope: "sentry_sdk.Scope", transaction_style: str, From 1544170a68d7bd72ec37fc0f5bc036c8f6b0ea61 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:48:54 +0530 Subject: [PATCH 05/27] test(fastapi): preserve user global dependencies --- tests/integrations/fastapi/test_fastapi.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 183c6a7976..c36dc338af 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -610,6 +610,27 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +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_does_not_break_websockets(sentry_init): sentry_init(integrations=[FastApiIntegration()]) From 14696bbaf17e561c529bf34d31b96abed41ae698 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 08:49:06 +0530 Subject: [PATCH 06/27] test(fastapi): fix global dependency test placement --- tests/integrations/fastapi/test_fastapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index c36dc338af..ef38e8e2ea 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -609,7 +609,6 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en assert str(data["active"]) == trace_context["data"]["thread.id"] -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) def test_global_dependency_preserves_existing_dependencies(sentry_init): calls = [] @@ -647,6 +646,7 @@ async def websocket_endpoint(websocket: 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( auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode From 74f7ec21daa128c837c8645b6f6350e2c96dd280 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 09:54:23 +0530 Subject: [PATCH 07/27] refactor(fastapi): move request setup into global dependency --- sentry_sdk/integrations/fastapi.py | 162 +++++++---------------------- 1 file changed, 37 insertions(+), 125 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index c4020d3d8c..bfc809e116 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,27 +1,18 @@ import sys -from copy import deepcopy from functools import wraps from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.traces import StreamedSpan, get_current_span from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource -from sentry_sdk.utils import has_data_collection_enabled, transaction_from_function +from sentry_sdk.utils import 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, - _update_active_thread, - ) + from sentry_sdk.integrations.starlette import StarletteIntegration except DidNotEnable: raise DidNotEnable("Starlette is not installed") @@ -35,13 +26,6 @@ _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" @@ -50,15 +34,45 @@ def setup_once() -> None: patch_fastapi_init() -def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: +async def _sentry_fastapi_dependency( + request: "HTTPConnection" = None, +) -> None: if not isinstance(request, Request): return - endpoint = request.scope.get("endpoint") - if endpoint is not None and iscoroutinefunction(endpoint): + integration = sentry_sdk.get_client().get_integration(FastApiIntegration) + if integration is None: return - _update_active_thread() + current_scope = sentry_sdk.get_current_scope() + route = request.scope.get("route") + + route_path = None + if route: + 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 = 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( + current_scope, + integration.transaction_style, + endpoint=request.scope.get("endpoint"), + route_path=route_path, + ) + + sentry_sdk.get_isolation_scope()._name = FastApiIntegration.identifier def patch_fastapi_init() -> None: @@ -106,105 +120,3 @@ def _set_transaction_name_and_source( scope.set_transaction_name(name, source=source) - -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] - - 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 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(), - integration.transaction_style, - endpoint=request.scope.get("endpoint"), - route_path=route_path, - ) - 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", {}) - if info: - if "cookies" in info: - request_info["cookies"] = info["cookies"] - if "data" in info: - attach_request_data = True - if has_data_collection_enabled(client.options): - attach_request_data = ( - "incoming_request" - in client.options["data_collection"]["http_bodies"] - ) - - if attach_request_data: - request_info["data"] = info["data"] - event["request"] = deepcopy(request_info) - - return event - - return event_processor - - sentry_scope._name = FastApiIntegration.identifier - sentry_scope.add_event_processor( - _make_request_event_processor(request, integration) - ) - - try: - return await handler(*args, **kwargs) - finally: - current_span = get_current_span() - - if type(current_span) is StreamedSpan: - attach_request_data = True - if has_data_collection_enabled(client.options): - attach_request_data = ( - "incoming_request" - in client.options["data_collection"]["http_bodies"] - ) - - if attach_request_data: - request_body = _get_cached_request_body_attribute( - client=client, request=request - ) - if request_body: - current_span._segment.set_attribute( - SPANDATA.HTTP_REQUEST_BODY_DATA, - request_body, - ) From 1a75d1d0c094bba601ef72acfc426c0dcd427249 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 09:56:35 +0530 Subject: [PATCH 08/27] style(fastapi): tidy global dependency signature --- sentry_sdk/integrations/fastapi.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index bfc809e116..fcc39cca34 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,4 +1,3 @@ -import sys from functools import wraps from typing import TYPE_CHECKING @@ -34,9 +33,7 @@ def setup_once() -> None: patch_fastapi_init() -async def _sentry_fastapi_dependency( - request: "HTTPConnection" = None, -) -> None: +async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: if not isinstance(request, Request): return From ead2b68c172ec93a6019072773ea83b3ef187e52 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 09:58:29 +0530 Subject: [PATCH 09/27] fix(fastapi): preserve sync endpoint thread tracking --- sentry_sdk/integrations/fastapi.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index fcc39cca34..d45b02264b 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -11,7 +11,11 @@ from typing import Any, Callable, Optional try: - from sentry_sdk.integrations.starlette import StarletteIntegration + from sentry_sdk.integrations.starlette import ( + StarletteIntegration, + _is_async_callable, + _wrap_sync_handler, + ) except DidNotEnable: raise DidNotEnable("Starlette is not installed") @@ -69,6 +73,15 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: route_path=route_path, ) + route = request.scope.get("route") + 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_sdk.get_isolation_scope()._name = FastApiIntegration.identifier From 952133406dc10c929827bf5739ba9f058fda9341 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 09:59:03 +0530 Subject: [PATCH 10/27] fix(fastapi): wrap effective route dependant --- sentry_sdk/integrations/fastapi.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index d45b02264b..68a6de7bf7 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -46,21 +46,16 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: return current_scope = sentry_sdk.get_current_scope() - route = request.scope.get("route") + effective_route_context = request.scope.get("fastapi", {}).get( + "effective_route_context" + ) + route = effective_route_context or request.scope.get("route") route_path = None if route: - 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 + path = getattr(route, "path", None) + if path is not None: + route_path = path server_span = current_scope._server_segment_span if server_span is not None and route_path is not None: From ecb4063e1a0eb94f2175961f575b2971580d6c59 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:00:08 +0530 Subject: [PATCH 11/27] fix(fastapi): use effective dependant for sync routes --- sentry_sdk/integrations/fastapi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 68a6de7bf7..c0f5b06d62 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -68,7 +68,6 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: route_path=route_path, ) - route = request.scope.get("route") dependant = getattr(route, "dependant", None) if ( dependant is not None From 0359de427ae6566e2a85f940fd20e931edc7dbd6 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:00:22 +0530 Subject: [PATCH 12/27] test(fastapi): cover sync thread tracking in prefixed routes --- tests/integrations/fastapi/test_fastapi.py | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index ef38e8e2ea..e88c8f7a43 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -609,6 +609,38 @@ 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 = [] From 8902d6b07f7e47316a608f9f111e17b16ef90272 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:01:46 +0530 Subject: [PATCH 13/27] style(fastapi): format thread metadata assertion --- tests/integrations/fastapi/test_fastapi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index e88c8f7a43..f8471b4200 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -638,7 +638,9 @@ def _sync(): for item in envelopes[0].items if item.type == "transaction" ) - assert response.json()["active"] == transaction["contexts"]["trace"]["data"]["thread.id"] + assert ( + response.json()["active"] == transaction["contexts"]["trace"]["data"]["thread.id"] + ) def test_global_dependency_preserves_existing_dependencies(sentry_init): From 45318bac63d4bc60f697b4d40ad8de1f982b0c66 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:43:58 +0530 Subject: [PATCH 14/27] refactor(fastapi): restore request lifecycle in global dependency --- sentry_sdk/integrations/fastapi.py | 72 +++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index c0f5b06d62..3447f15fb1 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -1,11 +1,13 @@ +from copy import deepcopy from functools import wraps from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.traces import StreamedSpan, get_current_span from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource -from sentry_sdk.utils import transaction_from_function +from sentry_sdk.utils import has_data_collection_enabled, transaction_from_function if TYPE_CHECKING: from typing import Any, Callable, Optional @@ -13,6 +15,8 @@ try: from sentry_sdk.integrations.starlette import ( StarletteIntegration, + StarletteRequestExtractor, + _get_cached_request_body_attribute, _is_async_callable, _wrap_sync_handler, ) @@ -37,12 +41,15 @@ def setup_once() -> None: patch_fastapi_init() -async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: +async def _sentry_fastapi_dependency(request: "HTTPConnection"): if not isinstance(request, Request): + yield return - integration = sentry_sdk.get_client().get_integration(FastApiIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(FastApiIntegration) if integration is None: + yield return current_scope = sentry_sdk.get_current_scope() @@ -68,6 +75,8 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: route_path=route_path, ) + # FastAPI 0.137+ may execute the dependant stored on the effective route + # context instead of the original APIRoute. dependant = getattr(route, "dependant", None) if ( dependant is not None @@ -76,7 +85,59 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection") -> None: ): dependant.call = _wrap_sync_handler(dependant.call) - sentry_sdk.get_isolation_scope()._name = FastApiIntegration.identifier + sentry_scope = sentry_sdk.get_isolation_scope() + extractor = StarletteRequestExtractor(request) + info = await extractor.extract_request_info() + + def _make_request_event_processor( + 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: + event_request["cookies"] = info["cookies"] + if "data" in info: + attach_request_data = True + if has_data_collection_enabled(client.options): + attach_request_data = ( + "incoming_request" + in client.options["data_collection"]["http_bodies"] + ) + + if attach_request_data: + 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(info)) + + try: + yield + finally: + current_span = get_current_span() + if type(current_span) is StreamedSpan: + attach_request_data = True + if has_data_collection_enabled(client.options): + attach_request_data = ( + "incoming_request" + in client.options["data_collection"]["http_bodies"] + ) + + if attach_request_data: + request_body = _get_cached_request_body_attribute( + client=client, request=request + ) + if request_body: + current_span._segment.set_attribute( + SPANDATA.HTTP_REQUEST_BODY_DATA, + request_body, + ) def patch_fastapi_init() -> None: @@ -92,8 +153,8 @@ def _sentry_fastapi_init(self: "Any", *args: "Any", **kwargs: "Any") -> None: dependencies = [] kwargs["dependencies"] = [ - *dependencies, fastapi.Depends(_sentry_fastapi_dependency), + *dependencies, ] old_fastapi_init(self, *args, **kwargs) @@ -123,4 +184,3 @@ def _set_transaction_name_and_source( source = SOURCE_FOR_STYLE[transaction_style] scope.set_transaction_name(name, source=source) - From c06dae1829ef774f65f1a88b4fd93f552d54c969 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:44:46 +0530 Subject: [PATCH 15/27] refactor(fastapi): simplify request event processor helper --- sentry_sdk/integrations/fastapi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 3447f15fb1..31b3cc7e38 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -90,7 +90,7 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection"): info = await extractor.extract_request_info() def _make_request_event_processor( - request_info: "dict[str, Any]", + info: "dict[str, Any]", ) -> "Callable[[Any, dict[str, Any]], Any]": def event_processor( event: "dict[str, Any]", hint: "dict[str, Any]" @@ -183,4 +183,4 @@ def _set_transaction_name_and_source( else: source = SOURCE_FOR_STYLE[transaction_style] - scope.set_transaction_name(name, source=source) + scope.set_transaction_name(name, source=source) \ No newline at end of file From 5010b8116b53aed71335d662a8024ab4a914d5ee Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:45:04 +0530 Subject: [PATCH 16/27] test(fastapi): cover dependency order and request data --- tests/integrations/fastapi/test_fastapi.py | 53 +++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index f8471b4200..a34fd7bca2 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -664,6 +664,57 @@ async def _root(): 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()], + ) + + 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}"] + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 121), + reason="FastAPI < 0.121 uses Starlette's request_response implementation", +) +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_does_not_break_websockets(sentry_init): sentry_init(integrations=[FastApiIntegration()]) @@ -1364,4 +1415,4 @@ async def _error(): } found = True - assert found, "No event with exception found" + assert found, "No event with exception found" \ No newline at end of file From d1861c9a21d7106809bd224e9a727ceab35b18af Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:45:18 +0530 Subject: [PATCH 17/27] test(fastapi): enable tracing for dependency order check --- tests/integrations/fastapi/test_fastapi.py | 1310 +------------------- 1 file changed, 2 insertions(+), 1308 deletions(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index a34fd7bca2..22be653b08 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -1,652 +1,3 @@ -import base64 -import json -import logging -import os -import threading -import warnings -from unittest import mock - -import fastapi -import pytest -import starlette -from fastapi import ( - APIRouter, - Body, - Depends, - FastAPI, - File, - Form, - HTTPException, - Request, - UploadFile, - WebSocket, -) -from fastapi.middleware.trustedhost import TrustedHostMiddleware -from fastapi.testclient import TestClient - -import sentry_sdk -from sentry_sdk import capture_message -from sentry_sdk.consts import SPANDATA -from sentry_sdk.feature_flags import add_feature_flag -from sentry_sdk.integrations.asgi import SentryAsgiMiddleware -from sentry_sdk.integrations.fastapi import FastApiIntegration -from sentry_sdk.integrations.starlette import StarletteIntegration -from sentry_sdk.utils import parse_version - -FASTAPI_VERSION = parse_version(fastapi.__version__) -STARLETTE_VERSION = parse_version(starlette.__version__) - -PICTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "photo.jpg") - -BODY_JSON = {"some": "json", "for": "testing", "nested": {"numbers": 123}} - -BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="photo.jpg"\r\nContent-Type: image/jpg\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace( - "{{image_data}}", str(base64.b64encode(open(PICTURE, "rb").read())) -) - -PARSED_FORM = starlette.datastructures.FormData( - [ - ("username", "Jane"), - ("password", "hello123"), - ( - "photo", - starlette.datastructures.UploadFile( - filename="photo.jpg", - file=open(PICTURE, "rb"), - ), - ), - ] -) - -from tests.integrations.conftest import parametrize_test_configurable_status_codes -from tests.integrations.starlette import test_starlette - - -def fastapi_app_factory(): - app = FastAPI() - mounted_app = FastAPI() - - @app.get("/error") - async def _error(): - capture_message("Hi") - 1 / 0 - return {"message": "Hi"} - - @app.get("/message") - async def _message(): - capture_message("Hi") - return {"message": "Hi"} - - @mounted_app.get("/nomessage") - @app.delete("/nomessage") - @app.get("/nomessage") - @app.head("/nomessage") - @app.options("/nomessage") - @app.patch("/nomessage") - @app.post("/nomessage") - @app.put("/nomessage") - @app.trace("/nomessage") - async def _nomessage(): - return {"message": "nothing here..."} - - @app.get("/message/{message_id}") - async def _message_with_id(message_id): - capture_message("Hi") - return {"message": "Hi"} - - @app.get("/sync/thread_ids") - def _thread_ids_sync(): - return { - "main": str(threading.main_thread().ident), - "active": str(threading.current_thread().ident), - } - - @app.get("/async/thread_ids") - async def _thread_ids_async(): - return { - "main": str(threading.main_thread().ident), - "active": str(threading.current_thread().ident), - } - - @app.post("/body/json") - async def body_json(payload: dict = Body(...)): - capture_message("hi") - return {"status": "ok"} - - @app.post("/body/form") - async def body_form( - username: str = Form(...), - password: str = Form(...), - photo: UploadFile = File(...), - ): - capture_message("hi") - return {"status": "ok"} - - app.mount("/root", mounted_app) - - return app - - -@pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_request_info_json_body( - sentry_init, capture_events, capture_items, span_streaming -): - sentry_init( - traces_sample_rate=1.0, - send_default_pii=True, - integrations=[StarletteIntegration()], - trace_lifecycle="stream" if span_streaming else "static", - ) - - app = fastapi_app_factory() - client = TestClient(app) - - if span_streaming: - items = capture_items("event", "span") - - client.post( - "/body/json", - json=BODY_JSON, - headers={ - "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", - }, - ) - - (event,) = (item.payload for item in items if item.type == "event") - assert event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - assert event["request"]["data"] == BODY_JSON - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - server_span = next( - span for span in spans if span["attributes"]["sentry.op"] == "http.server" - ) - - assert json.loads( - server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] - ) == {"some": "json", "for": "testing", "nested": {"numbers": 123}} - else: - events = capture_events() - - client.post( - "/body/json", - json=BODY_JSON, - headers={ - "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", - }, - ) - - (event, transaction_event) = events - - assert event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - assert event["request"]["data"] == BODY_JSON - - assert transaction_event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - assert transaction_event["request"]["data"] == BODY_JSON - - -@pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_formdata_request_body( - sentry_init, capture_events, capture_items, span_streaming -): - sentry_init( - traces_sample_rate=1.0, - send_default_pii=True, - max_request_body_size="always", - integrations=[StarletteIntegration()], - trace_lifecycle="stream" if span_streaming else "static", - ) - - app = fastapi_app_factory() - client = TestClient(app) - - if span_streaming: - items = capture_items("event", "span") - - client.post( - "/body/form", - data=BODY_FORM.encode("utf-8"), - headers={ - "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", - }, - ) - - (event,) = (item.payload for item in items if item.type == "event") - assert event["request"]["data"].keys() == PARSED_FORM.keys() - assert event["request"]["data"]["username"] == PARSED_FORM["username"] - assert event["request"]["data"]["password"] == "[Filtered]" - assert event["request"]["data"]["photo"] == "" - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - server_span = next( - span for span in spans if span["attributes"]["sentry.op"] == "http.server" - ) - - # Going forward, the sanitization of data will need to happen within the `before_send_span` hooks - # See https://sentry.slack.com/archives/C09RR0KD2N7/p1776951331206129?thread_ts=1776951227.440659&cid=C09RR0KD2N7 - parsed_form_attribute = json.loads( - server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] - ) - assert parsed_form_attribute.keys() == PARSED_FORM.keys() - assert parsed_form_attribute["username"] == PARSED_FORM["username"] - assert parsed_form_attribute["password"] == "hello123" - assert parsed_form_attribute["photo"] == "[Unparsable]" - else: - events = capture_events() - - client.post( - "/body/form", - data=BODY_FORM.encode("utf-8"), - headers={ - "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", - }, - ) - - (event, transaction_event) = events - assert event["request"]["data"].keys() == PARSED_FORM.keys() - assert event["request"]["data"]["username"] == PARSED_FORM["username"] - assert event["request"]["data"]["password"] == "[Filtered]" - assert event["request"]["data"]["photo"] == "" - assert event["_meta"]["request"]["data"]["photo"] == { - "": {"rem": [["!raw", "x"]]} - } - - assert transaction_event["request"]["data"].keys() == PARSED_FORM.keys() - assert ( - transaction_event["request"]["data"]["username"] == PARSED_FORM["username"] - ) - assert transaction_event["request"]["data"]["password"] == "[Filtered]" - assert transaction_event["request"]["data"]["photo"] == "" - assert transaction_event["_meta"]["request"]["data"]["photo"] == { - "": {"rem": [["!raw", "x"]]} - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_request_body_too_big( - sentry_init, capture_events, capture_items, span_streaming -): - sentry_init( - traces_sample_rate=1.0, - send_default_pii=True, - integrations=[StarletteIntegration()], - trace_lifecycle="stream" if span_streaming else "static", - ) - - app = fastapi_app_factory() - client = TestClient(app) - - if span_streaming: - items = capture_items("event", "span") - - client.post( - "/body/form", - data=BODY_FORM.encode("utf-8"), - headers={ - "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", - "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", - }, - ) - - (event,) = (item.payload for item in items if item.type == "event") - assert event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - # Because request is too big only the AnnotatedValue is extracted. - assert event["_meta"]["request"]["data"] == {"": {"rem": [["!config", "x"]]}} - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - server_span = next( - span for span in spans if span["attributes"]["sentry.op"] == "http.server" - ) - - # Because request is too big only the AnnotatedValue is extracted. - assert ( - server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] - == "[Exceeds maximum size]" - ) - else: - events = capture_events() - - client.post( - "/body/form", - data=BODY_FORM.encode("utf-8"), - headers={ - "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", - "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", - }, - ) - - (event, transaction_event) = events - assert event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - # Because request is too big only the AnnotatedValue is extracted. - assert event["_meta"]["request"]["data"] == {"": {"rem": [["!config", "x"]]}} - - assert transaction_event["request"]["cookies"] == { - "tasty_cookie": "strawberry", - "yummy_cookie": "choco", - } - # Because request is too big only the AnnotatedValue is extracted. - assert transaction_event["_meta"]["request"]["data"] == { - "": {"rem": [["!config", "x"]]} - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -async def test_formdata_request_body_data_collection_http_bodies_empty( - sentry_init, capture_events, capture_items, span_streaming -): - sentry_init( - traces_sample_rate=1.0, - max_request_body_size="always", - integrations=[StarletteIntegration()], - trace_lifecycle="stream" if span_streaming else "static", - data_collection={"http_bodies": []}, - ) - - app = fastapi_app_factory() - client = TestClient(app) - - headers = {"content-type": "multipart/form-data; boundary=fd721ef49ea403a6"} - - if span_streaming: - items = capture_items("event", "span") - - client.post("/body/form", data=BODY_FORM.encode("utf-8"), headers=headers) - - (event,) = (item.payload for item in items if item.type == "event") - assert "data" not in event["request"] - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - server_span = next( - span for span in spans if span["attributes"]["sentry.op"] == "http.server" - ) - assert SPANDATA.HTTP_REQUEST_BODY_DATA not in server_span["attributes"] - else: - events = capture_events() - - client.post("/body/form", data=BODY_FORM.encode("utf-8"), headers=headers) - - (event, _) = events - assert "data" not in event["request"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("span_streaming", [True, False]) -@pytest.mark.parametrize( - "data_collection, expect_body", - [ - pytest.param(None, True, id="no_data_collection_experiment"), - pytest.param({}, True, id="data_collection_http_bodies_default"), - pytest.param( - {"http_bodies": ["incoming_request"]}, - True, - id="data_collection_http_bodies_incoming_request", - ), - pytest.param( - {"http_bodies": []}, False, id="data_collection_http_bodies_empty" - ), - ], -) -async def test_request_body_data_collection( - sentry_init, - capture_events, - capture_items, - span_streaming, - data_collection, - expect_body, -): - sentry_init( - traces_sample_rate=1.0, - integrations=[StarletteIntegration()], - trace_lifecycle="stream" if span_streaming else "static", - data_collection=data_collection, - ) - - app = fastapi_app_factory() - client = TestClient(app) - - if span_streaming: - items = capture_items("event", "span") - - client.post("/body/json", json=BODY_JSON) - - (event,) = (item.payload for item in items if item.type == "event") - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - server_span = next( - span for span in spans if span["attributes"]["sentry.op"] == "http.server" - ) - - if expect_body: - assert event["request"]["data"] == BODY_JSON - assert ( - json.loads(server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA]) - == BODY_JSON - ) - else: - assert "data" not in event["request"] - assert SPANDATA.HTTP_REQUEST_BODY_DATA not in server_span["attributes"] - else: - events = capture_events() - - client.post("/body/json", json=BODY_JSON) - - (event, _) = events - - if expect_body: - assert event["request"]["data"] == BODY_JSON - else: - assert "data" not in event["request"] - - -@pytest.mark.asyncio -async def test_response(sentry_init, capture_events): - # FastAPI is heavily based on Starlette so we also need - # to enable StarletteIntegration. - # In the future this will be auto enabled. - sentry_init( - integrations=[StarletteIntegration(), FastApiIntegration()], - traces_sample_rate=1.0, - send_default_pii=True, - ) - - app = fastapi_app_factory() - - events = capture_events() - - client = TestClient(app) - response = client.get("/message") - - assert response.json() == {"message": "Hi"} - - assert len(events) == 2 - - (message_event, transaction_event) = events - assert message_event["message"] == "Hi" - assert transaction_event["transaction"] == "/message" - - -@pytest.mark.parametrize( - "url,transaction_style,expected_transaction,expected_source", - [ - ( - "/message", - "url", - "/message", - "route", - ), - ( - "/message", - "endpoint", - "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message", - "component", - ), - ( - "/message/123456", - "url", - "/message/{message_id}", - "route", - ), - ( - "/message/123456", - "endpoint", - "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message_with_id", - "component", - ), - ], -) -def test_transaction_style( - sentry_init, - capture_events, - url, - transaction_style, - expected_transaction, - expected_source, -): - sentry_init( - integrations=[ - StarletteIntegration(transaction_style=transaction_style), - FastApiIntegration(transaction_style=transaction_style), - ], - ) - app = fastapi_app_factory() - - events = capture_events() - - client = TestClient(app) - client.get(url) - - (event,) = events - assert event["transaction"] == expected_transaction - assert event["transaction_info"] == {"source": expected_source} - - # Assert that state is not leaked - events.clear() - capture_message("foo") - (event,) = events - - assert "request" not in event - assert "transaction" not in event - - -def test_legacy_setup( - sentry_init, - capture_events, -): - # Check that behaviour does not change - # if the user just adds the new Integrations - # and forgets to remove SentryAsgiMiddleware - sentry_init() - app = fastapi_app_factory() - asgi_app = SentryAsgiMiddleware(app) - - events = capture_events() - - client = TestClient(asgi_app) - client.get("/message/123456") - - (event,) = events - assert event["transaction"] == "/message/{message_id}" - - -@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) -def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): - sentry_init( - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - ) - app = fastapi_app_factory() - asgi_app = SentryAsgiMiddleware(app) - - envelopes = capture_envelopes() - - client = TestClient(asgi_app) - response = client.get(endpoint) - assert response.status_code == 200 - - data = json.loads(response.content) - - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 - - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 - - for item in profiles: - transactions = item.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] - - transactions = [item for item in envelopes[0].items if item.type == "transaction"] - assert len(transactions) == 1 - - for item in transactions: - transaction = item.payload.json - trace_context = transaction["contexts"]["trace"] - 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()]) @@ -674,6 +25,7 @@ def custom_dependency(): sentry_init( auto_enabling_integrations=False, integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, ) app = FastAPI(dependencies=[Depends(custom_dependency)]) @@ -757,662 +109,4 @@ def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): @pytest.mark.parametrize("span_streaming", [True, False]) -@pytest.mark.asyncio -async def test_original_request_not_scrubbed( - sentry_init, capture_events, span_streaming -): - sentry_init( - auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode - integrations=[StarletteIntegration(), FastApiIntegration()], - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", - ) - - app = FastAPI() - - @app.post("/error") - async def _error(request: Request): - logging.critical("Oh no!") - assert request.headers["Authorization"] == "Bearer ohno" - assert request.headers["Proxy-Authorization"] == "Basic ohno" - assert await request.json() == {"password": "secret"} - - return {"error": "Oh no!"} - - events = capture_events() - - client = TestClient(app) - client.post( - "/error", - json={"password": "secret"}, - headers={ - "Authorization": "Bearer ohno", - "Proxy-Authorization": "Basic ohno", - }, - ) - - event = events[0] - assert event["request"]["data"] == {"password": "[Filtered]"} - assert event["request"]["headers"]["authorization"] == "[Filtered]" - assert event["request"]["headers"]["proxy-authorization"] == "[Filtered]" - - -def test_response_status_code_ok_in_transaction_context(sentry_init, capture_envelopes): - """ - Tests that the response status code is added to the transaction "response" context. - """ - sentry_init( - integrations=[StarletteIntegration(), FastApiIntegration()], - traces_sample_rate=1.0, - release="demo-release", - ) - - envelopes = capture_envelopes() - - app = fastapi_app_factory() - - client = TestClient(app) - client.get("/message") - - (_, transaction_envelope) = envelopes - transaction = transaction_envelope.get_transaction_event() - - assert transaction["type"] == "transaction" - assert len(transaction["contexts"]) > 0 - assert "response" in transaction["contexts"].keys(), ( - "Response context not found in transaction" - ) - assert transaction["contexts"]["response"]["status_code"] == 200 - - -def test_response_status_code_error_in_transaction_context( - sentry_init, - capture_envelopes, -): - """ - Tests that the response status code is added to the transaction "response" context. - """ - sentry_init( - integrations=[StarletteIntegration(), FastApiIntegration()], - traces_sample_rate=1.0, - release="demo-release", - ) - - envelopes = capture_envelopes() - - app = fastapi_app_factory() - - client = TestClient(app) - with pytest.raises(ZeroDivisionError): - client.get("/error") - - ( - _, - _, - transaction_envelope, - ) = envelopes - transaction = transaction_envelope.get_transaction_event() - - assert transaction["type"] == "transaction" - assert len(transaction["contexts"]) > 0 - assert "response" in transaction["contexts"].keys(), ( - "Response context not found in transaction" - ) - assert transaction["contexts"]["response"]["status_code"] == 500 - - -def test_response_status_code_not_found_in_transaction_context( - sentry_init, - capture_envelopes, -): - """ - Tests that the response status code is added to the transaction "response" context. - """ - sentry_init( - integrations=[StarletteIntegration(), FastApiIntegration()], - traces_sample_rate=1.0, - release="demo-release", - ) - - envelopes = capture_envelopes() - - app = fastapi_app_factory() - - client = TestClient(app) - client.get("/non-existing-route-123") - - (transaction_envelope,) = envelopes - transaction = transaction_envelope.get_transaction_event() - - assert transaction["type"] == "transaction" - assert len(transaction["contexts"]) > 0 - assert "response" in transaction["contexts"].keys(), ( - "Response context not found in transaction" - ) - assert transaction["contexts"]["response"]["status_code"] == 404 - - -@pytest.mark.parametrize("span_streaming", [True, False]) -@pytest.mark.parametrize( - "request_url,transaction_style,expected_transaction_name,expected_transaction_source", - [ - ( - "/message/123456", - "endpoint", - "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message_with_id", - "component", - ), - ( - "/message/123456", - "url", - "/message/{message_id}", - "route", - ), - ], -) -def test_transaction_name( - sentry_init, - request_url, - transaction_style, - expected_transaction_name, - expected_transaction_source, - capture_envelopes, - capture_items, - span_streaming, -): - """ - Tests that the transaction name is something meaningful. - """ - sentry_init( - auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. - integrations=[ - StarletteIntegration(transaction_style=transaction_style), - FastApiIntegration(transaction_style=transaction_style), - ], - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", - ) - - if span_streaming: - items = capture_items("span") - else: - envelopes = capture_envelopes() - - app = fastapi_app_factory() - - client = TestClient(app) - client.get(request_url) - - if span_streaming: - sentry_sdk.flush() - segments = [item.payload for item in items if item.payload.get("is_segment")] - assert len(segments) == 1 - segment = segments[0] - assert segment["name"] == expected_transaction_name - assert ( - segment["attributes"]["sentry.segment.name.source"] - == expected_transaction_source - ) - else: - (_, transaction_envelope) = envelopes - transaction_event = transaction_envelope.get_transaction_event() - - assert transaction_event["transaction"] == expected_transaction_name - assert ( - transaction_event["transaction_info"]["source"] - == expected_transaction_source - ) - - -@pytest.mark.parametrize("span_streaming", [True, False]) -def test_http_route_with_prefix( - sentry_init, - capture_envelopes, - capture_items, - span_streaming, -): - sentry_init( - auto_enabling_integrations=False, - integrations=[ - StarletteIntegration(transaction_style="url"), - FastApiIntegration(transaction_style="url"), - ], - traces_sample_rate=1.0, - trace_lifecycle="stream" if span_streaming else "static", - ) - - if span_streaming: - items = capture_items("span") - else: - envelopes = capture_envelopes() - - app = FastAPI() - router = APIRouter() - - @router.get("/users/{user_id}") - async def get_user(user_id: int): - return {"user_id": user_id} - - app.include_router(router, prefix="/api") - - client = TestClient(app) - client.get("/api/users/123") - - if span_streaming: - sentry_sdk.flush() - segments = [item.payload for item in items if item.payload.get("is_segment")] - assert len(segments) == 1 - segment = segments[0] - assert segment["name"] == "/api/users/{user_id}" - assert segment["attributes"]["sentry.segment.name.source"] == "route" - assert segment["attributes"]["http.route"] == "/api/users/{user_id}" - else: - (transaction_envelope,) = envelopes - transaction_event = transaction_envelope.get_transaction_event() - assert transaction_event["transaction"] == "/api/users/{user_id}" - assert transaction_event["transaction_info"]["source"] == "route" - - -def test_route_endpoint_equal_dependant_call(sentry_init): - """ - Tests that the route endpoint name is equal to the wrapped dependant call name. - """ - sentry_init( - auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. - integrations=[ - StarletteIntegration(), - FastApiIntegration(), - ], - traces_sample_rate=1.0, - ) - - app = fastapi_app_factory() - - for route in app.router.routes: - if not hasattr(route, "dependant"): - continue - assert route.endpoint.__qualname__ == route.dependant.call.__qualname__ - - -@pytest.mark.parametrize( - "request_url,transaction_style,expected_transaction_name,expected_transaction_source", - [ - ( - "/message/123456", - "endpoint", - "http://testserver/message/123456", - "url", - ), - ( - "/message/123456", - "url", - "http://testserver/message/123456", - "url", - ), - ], -) -def test_transaction_name_in_traces_sampler( - sentry_init, - request_url, - transaction_style, - expected_transaction_name, - expected_transaction_source, -): - """ - Tests that a custom traces_sampler retrieves a meaningful transaction name. - In this case the URL or endpoint, because we do not have the route yet. - """ - - def dummy_traces_sampler(sampling_context): - assert ( - sampling_context["transaction_context"]["name"] == expected_transaction_name - ) - assert ( - sampling_context["transaction_context"]["source"] - == expected_transaction_source - ) - - sentry_init( - auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. - integrations=[StarletteIntegration(transaction_style=transaction_style)], - traces_sampler=dummy_traces_sampler, - traces_sample_rate=1.0, - ) - - app = fastapi_app_factory() - - client = TestClient(app) - client.get(request_url) - - -@pytest.mark.parametrize("middleware_spans", [False, True]) -@pytest.mark.parametrize( - "request_url,transaction_style,expected_transaction_name,expected_transaction_source", - [ - ( - "/message/123456", - "endpoint", - "starlette.middleware.trustedhost.TrustedHostMiddleware", - "component", - ), - ( - "/message/123456", - "url", - "http://testserver/message/123456", - "url", - ), - ], -) -def test_transaction_name_in_middleware( - sentry_init, - middleware_spans, - request_url, - transaction_style, - expected_transaction_name, - expected_transaction_source, - capture_envelopes, -): - """ - Tests that the transaction name is something meaningful. - """ - sentry_init( - auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. - integrations=[ - StarletteIntegration( - transaction_style=transaction_style, middleware_spans=middleware_spans - ), - FastApiIntegration( - transaction_style=transaction_style, middleware_spans=middleware_spans - ), - ], - traces_sample_rate=1.0, - ) - - envelopes = capture_envelopes() - - app = fastapi_app_factory() - - app.add_middleware( - TrustedHostMiddleware, - allowed_hosts=[ - "example.com", - ], - ) - - client = TestClient(app) - client.get(request_url) - - (transaction_envelope,) = envelopes - transaction_event = transaction_envelope.get_transaction_event() - - assert transaction_event["contexts"]["response"]["status_code"] == 400 - assert transaction_event["transaction"] == expected_transaction_name - assert ( - transaction_event["transaction_info"]["source"] == expected_transaction_source - ) - - -@test_starlette.parametrize_test_configurable_status_codes_deprecated -def test_configurable_status_codes_deprecated( - sentry_init, - capture_events, - failed_request_status_codes, - status_code, - expected_error, -): - with pytest.warns(DeprecationWarning): - starlette_integration = StarletteIntegration( - failed_request_status_codes=failed_request_status_codes - ) - - with pytest.warns(DeprecationWarning): - fast_api_integration = FastApiIntegration( - failed_request_status_codes=failed_request_status_codes - ) - - sentry_init( - integrations=[ - starlette_integration, - fast_api_integration, - ] - ) - - events = capture_events() - - app = FastAPI() - - @app.get("/error") - async def _error(): - raise HTTPException(status_code) - - client = TestClient(app) - client.get("/error") - - if expected_error: - assert len(events) == 1 - else: - assert not events - - -@pytest.mark.skipif( - FASTAPI_VERSION < (0, 80), - reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", -) -def test_transaction_http_method_default(sentry_init, capture_events): - """ - By default OPTIONS and HEAD requests do not create a transaction. - """ - # FastAPI is heavily based on Starlette so we also need - # to enable StarletteIntegration. - # In the future this will be auto enabled. - sentry_init( - traces_sample_rate=1.0, - integrations=[ - StarletteIntegration(), - FastApiIntegration(), - ], - ) - - app = fastapi_app_factory() - - events = capture_events() - - client = TestClient(app) - client.get("/nomessage") - client.options("/nomessage") - client.head("/nomessage") - - assert len(events) == 1 - - (event,) = events - - assert event["request"]["method"] == "GET" - - -@pytest.mark.skipif( - FASTAPI_VERSION < (0, 80), - reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", -) -def test_transaction_http_method_custom(sentry_init, capture_events): - # FastAPI is heavily based on Starlette so we also need - # to enable StarletteIntegration. - # In the future this will be auto enabled. - sentry_init( - traces_sample_rate=1.0, - integrations=[ - StarletteIntegration( - http_methods_to_capture=( - "OPTIONS", - "head", - ), # capitalization does not matter - ), - FastApiIntegration( - http_methods_to_capture=( - "OPTIONS", - "head", - ), # capitalization does not matter - ), - ], - ) - - app = fastapi_app_factory() - - events = capture_events() - - client = TestClient(app) - client.get("/nomessage") - client.options("/nomessage") - client.head("/nomessage") - - assert len(events) == 2 - - (event1, event2) = events - - assert event1["request"]["method"] == "OPTIONS" - 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(), - ], - 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"][SPANDATA.URL_FULL] == ( - "http://testserver/root/nomessage" - ) - assert server_span["attributes"][SPANDATA.URL_PATH] == "/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, - capture_events, - failed_request_status_codes, - status_code, - expected_error, -): - integration_kwargs = {} - if failed_request_status_codes is not None: - integration_kwargs["failed_request_status_codes"] = failed_request_status_codes - - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - starlette_integration = StarletteIntegration(**integration_kwargs) - fastapi_integration = FastApiIntegration(**integration_kwargs) - - sentry_init(integrations=[starlette_integration, fastapi_integration]) - - events = capture_events() - - app = FastAPI() - - @app.get("/error") - async def _error(): - raise HTTPException(status_code) - - client = TestClient(app) - client.get("/error") - - assert len(events) == int(expected_error) - - -@pytest.mark.parametrize("transaction_style", ["endpoint", "url"]) -def test_app_host(sentry_init, capture_events, transaction_style): - sentry_init( - traces_sample_rate=1.0, - integrations=[ - StarletteIntegration(transaction_style=transaction_style), - FastApiIntegration(transaction_style=transaction_style), - ], - ) - - app = FastAPI() - subapp = FastAPI() - - @subapp.get("/subapp") - async def subapp_route(): - return {"message": "Hello world!"} - - app.host("subapp", subapp) - - events = capture_events() - - client = TestClient(app) - client.get("/subapp", headers={"Host": "subapp"}) - - assert len(events) == 1 - - (event,) = events - assert "transaction" in event - - if transaction_style == "url": - assert event["transaction"] == "/subapp" - else: - assert event["transaction"].endswith("subapp_route") - - -@pytest.mark.asyncio -async def test_feature_flags(sentry_init, capture_events): - sentry_init( - traces_sample_rate=1.0, - integrations=[StarletteIntegration(), FastApiIntegration()], - ) - - events = capture_events() - - app = FastAPI() - - @app.get("/error") - async def _error(): - add_feature_flag("hello", False) - - with sentry_sdk.start_span(name="test-span"): - with sentry_sdk.start_span(name="test-span-2"): - raise ValueError("something is wrong!") - - try: - client = TestClient(app) - client.get("/error") - except ValueError: - pass - - found = False - for event in events: - if "exception" in event.keys(): - assert event["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - ] - } - found = True - - assert found, "No event with exception found" \ No newline at end of file +@pytest.mark.asyncio \ No newline at end of file From cbd4e1b6ce80e7d372b463e1808fa7d7a4377bf6 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:45:55 +0530 Subject: [PATCH 18/27] test(fastapi): cover dependency order and request data --- tests/integrations/fastapi/test_fastapi.py | 1309 +++++++++++++++++++- 1 file changed, 1308 insertions(+), 1 deletion(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 22be653b08..44943c786a 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -1,3 +1,652 @@ +import base64 +import json +import logging +import os +import threading +import warnings +from unittest import mock + +import fastapi +import pytest +import starlette +from fastapi import ( + APIRouter, + Body, + Depends, + FastAPI, + File, + Form, + HTTPException, + Request, + UploadFile, + WebSocket, +) +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.testclient import TestClient + +import sentry_sdk +from sentry_sdk import capture_message +from sentry_sdk.consts import SPANDATA +from sentry_sdk.feature_flags import add_feature_flag +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration +from sentry_sdk.utils import parse_version + +FASTAPI_VERSION = parse_version(fastapi.__version__) +STARLETTE_VERSION = parse_version(starlette.__version__) + +PICTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "photo.jpg") + +BODY_JSON = {"some": "json", "for": "testing", "nested": {"numbers": 123}} + +BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="photo.jpg"\r\nContent-Type: image/jpg\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace( + "{{image_data}}", str(base64.b64encode(open(PICTURE, "rb").read())) +) + +PARSED_FORM = starlette.datastructures.FormData( + [ + ("username", "Jane"), + ("password", "hello123"), + ( + "photo", + starlette.datastructures.UploadFile( + filename="photo.jpg", + file=open(PICTURE, "rb"), + ), + ), + ] +) + +from tests.integrations.conftest import parametrize_test_configurable_status_codes +from tests.integrations.starlette import test_starlette + + +def fastapi_app_factory(): + app = FastAPI() + mounted_app = FastAPI() + + @app.get("/error") + async def _error(): + capture_message("Hi") + 1 / 0 + return {"message": "Hi"} + + @app.get("/message") + async def _message(): + capture_message("Hi") + return {"message": "Hi"} + + @mounted_app.get("/nomessage") + @app.delete("/nomessage") + @app.get("/nomessage") + @app.head("/nomessage") + @app.options("/nomessage") + @app.patch("/nomessage") + @app.post("/nomessage") + @app.put("/nomessage") + @app.trace("/nomessage") + async def _nomessage(): + return {"message": "nothing here..."} + + @app.get("/message/{message_id}") + async def _message_with_id(message_id): + capture_message("Hi") + return {"message": "Hi"} + + @app.get("/sync/thread_ids") + def _thread_ids_sync(): + return { + "main": str(threading.main_thread().ident), + "active": str(threading.current_thread().ident), + } + + @app.get("/async/thread_ids") + async def _thread_ids_async(): + return { + "main": str(threading.main_thread().ident), + "active": str(threading.current_thread().ident), + } + + @app.post("/body/json") + async def body_json(payload: dict = Body(...)): + capture_message("hi") + return {"status": "ok"} + + @app.post("/body/form") + async def body_form( + username: str = Form(...), + password: str = Form(...), + photo: UploadFile = File(...), + ): + capture_message("hi") + return {"status": "ok"} + + app.mount("/root", mounted_app) + + return app + + +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_request_info_json_body( + sentry_init, capture_events, capture_items, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[StarletteIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + ) + + app = fastapi_app_factory() + client = TestClient(app) + + if span_streaming: + items = capture_items("event", "span") + + client.post( + "/body/json", + json=BODY_JSON, + headers={ + "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", + }, + ) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + assert event["request"]["data"] == BODY_JSON + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + server_span = next( + span for span in spans if span["attributes"]["sentry.op"] == "http.server" + ) + + assert json.loads( + server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] + ) == {"some": "json", "for": "testing", "nested": {"numbers": 123}} + else: + events = capture_events() + + client.post( + "/body/json", + json=BODY_JSON, + headers={ + "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", + }, + ) + + (event, transaction_event) = events + + assert event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + assert event["request"]["data"] == BODY_JSON + + assert transaction_event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + assert transaction_event["request"]["data"] == BODY_JSON + + +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_formdata_request_body( + sentry_init, capture_events, capture_items, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + max_request_body_size="always", + integrations=[StarletteIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + ) + + app = fastapi_app_factory() + client = TestClient(app) + + if span_streaming: + items = capture_items("event", "span") + + client.post( + "/body/form", + data=BODY_FORM.encode("utf-8"), + headers={ + "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", + }, + ) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["data"].keys() == PARSED_FORM.keys() + assert event["request"]["data"]["username"] == PARSED_FORM["username"] + assert event["request"]["data"]["password"] == "[Filtered]" + assert event["request"]["data"]["photo"] == "" + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + server_span = next( + span for span in spans if span["attributes"]["sentry.op"] == "http.server" + ) + + # Going forward, the sanitization of data will need to happen within the `before_send_span` hooks + # See https://sentry.slack.com/archives/C09RR0KD2N7/p1776951331206129?thread_ts=1776951227.440659&cid=C09RR0KD2N7 + parsed_form_attribute = json.loads( + server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] + ) + assert parsed_form_attribute.keys() == PARSED_FORM.keys() + assert parsed_form_attribute["username"] == PARSED_FORM["username"] + assert parsed_form_attribute["password"] == "hello123" + assert parsed_form_attribute["photo"] == "[Unparsable]" + else: + events = capture_events() + + client.post( + "/body/form", + data=BODY_FORM.encode("utf-8"), + headers={ + "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", + }, + ) + + (event, transaction_event) = events + assert event["request"]["data"].keys() == PARSED_FORM.keys() + assert event["request"]["data"]["username"] == PARSED_FORM["username"] + assert event["request"]["data"]["password"] == "[Filtered]" + assert event["request"]["data"]["photo"] == "" + assert event["_meta"]["request"]["data"]["photo"] == { + "": {"rem": [["!raw", "x"]]} + } + + assert transaction_event["request"]["data"].keys() == PARSED_FORM.keys() + assert ( + transaction_event["request"]["data"]["username"] == PARSED_FORM["username"] + ) + assert transaction_event["request"]["data"]["password"] == "[Filtered]" + assert transaction_event["request"]["data"]["photo"] == "" + assert transaction_event["_meta"]["request"]["data"]["photo"] == { + "": {"rem": [["!raw", "x"]]} + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_request_body_too_big( + sentry_init, capture_events, capture_items, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + send_default_pii=True, + integrations=[StarletteIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + ) + + app = fastapi_app_factory() + client = TestClient(app) + + if span_streaming: + items = capture_items("event", "span") + + client.post( + "/body/form", + data=BODY_FORM.encode("utf-8"), + headers={ + "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", + "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", + }, + ) + + (event,) = (item.payload for item in items if item.type == "event") + assert event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + # Because request is too big only the AnnotatedValue is extracted. + assert event["_meta"]["request"]["data"] == {"": {"rem": [["!config", "x"]]}} + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + server_span = next( + span for span in spans if span["attributes"]["sentry.op"] == "http.server" + ) + + # Because request is too big only the AnnotatedValue is extracted. + assert ( + server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA] + == "[Exceeds maximum size]" + ) + else: + events = capture_events() + + client.post( + "/body/form", + data=BODY_FORM.encode("utf-8"), + headers={ + "content-type": "multipart/form-data; boundary=fd721ef49ea403a6", + "cookie": "yummy_cookie=choco; tasty_cookie=strawberry", + }, + ) + + (event, transaction_event) = events + assert event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + # Because request is too big only the AnnotatedValue is extracted. + assert event["_meta"]["request"]["data"] == {"": {"rem": [["!config", "x"]]}} + + assert transaction_event["request"]["cookies"] == { + "tasty_cookie": "strawberry", + "yummy_cookie": "choco", + } + # Because request is too big only the AnnotatedValue is extracted. + assert transaction_event["_meta"]["request"]["data"] == { + "": {"rem": [["!config", "x"]]} + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +async def test_formdata_request_body_data_collection_http_bodies_empty( + sentry_init, capture_events, capture_items, span_streaming +): + sentry_init( + traces_sample_rate=1.0, + max_request_body_size="always", + integrations=[StarletteIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + data_collection={"http_bodies": []}, + ) + + app = fastapi_app_factory() + client = TestClient(app) + + headers = {"content-type": "multipart/form-data; boundary=fd721ef49ea403a6"} + + if span_streaming: + items = capture_items("event", "span") + + client.post("/body/form", data=BODY_FORM.encode("utf-8"), headers=headers) + + (event,) = (item.payload for item in items if item.type == "event") + assert "data" not in event["request"] + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + server_span = next( + span for span in spans if span["attributes"]["sentry.op"] == "http.server" + ) + assert SPANDATA.HTTP_REQUEST_BODY_DATA not in server_span["attributes"] + else: + events = capture_events() + + client.post("/body/form", data=BODY_FORM.encode("utf-8"), headers=headers) + + (event, _) = events + assert "data" not in event["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize( + "data_collection, expect_body", + [ + pytest.param(None, True, id="no_data_collection_experiment"), + pytest.param({}, True, id="data_collection_http_bodies_default"), + pytest.param( + {"http_bodies": ["incoming_request"]}, + True, + id="data_collection_http_bodies_incoming_request", + ), + pytest.param( + {"http_bodies": []}, False, id="data_collection_http_bodies_empty" + ), + ], +) +async def test_request_body_data_collection( + sentry_init, + capture_events, + capture_items, + span_streaming, + data_collection, + expect_body, +): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration()], + trace_lifecycle="stream" if span_streaming else "static", + data_collection=data_collection, + ) + + app = fastapi_app_factory() + client = TestClient(app) + + if span_streaming: + items = capture_items("event", "span") + + client.post("/body/json", json=BODY_JSON) + + (event,) = (item.payload for item in items if item.type == "event") + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + server_span = next( + span for span in spans if span["attributes"]["sentry.op"] == "http.server" + ) + + if expect_body: + assert event["request"]["data"] == BODY_JSON + assert ( + json.loads(server_span["attributes"][SPANDATA.HTTP_REQUEST_BODY_DATA]) + == BODY_JSON + ) + else: + assert "data" not in event["request"] + assert SPANDATA.HTTP_REQUEST_BODY_DATA not in server_span["attributes"] + else: + events = capture_events() + + client.post("/body/json", json=BODY_JSON) + + (event, _) = events + + if expect_body: + assert event["request"]["data"] == BODY_JSON + else: + assert "data" not in event["request"] + + +@pytest.mark.asyncio +async def test_response(sentry_init, capture_events): + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + response = client.get("/message") + + assert response.json() == {"message": "Hi"} + + assert len(events) == 2 + + (message_event, transaction_event) = events + assert message_event["message"] == "Hi" + assert transaction_event["transaction"] == "/message" + + +@pytest.mark.parametrize( + "url,transaction_style,expected_transaction,expected_source", + [ + ( + "/message", + "url", + "/message", + "route", + ), + ( + "/message", + "endpoint", + "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message", + "component", + ), + ( + "/message/123456", + "url", + "/message/{message_id}", + "route", + ), + ( + "/message/123456", + "endpoint", + "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message_with_id", + "component", + ), + ], +) +def test_transaction_style( + sentry_init, + capture_events, + url, + transaction_style, + expected_transaction, + expected_source, +): + sentry_init( + integrations=[ + StarletteIntegration(transaction_style=transaction_style), + FastApiIntegration(transaction_style=transaction_style), + ], + ) + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get(url) + + (event,) = events + assert event["transaction"] == expected_transaction + assert event["transaction_info"] == {"source": expected_source} + + # Assert that state is not leaked + events.clear() + capture_message("foo") + (event,) = events + + assert "request" not in event + assert "transaction" not in event + + +def test_legacy_setup( + sentry_init, + capture_events, +): + # Check that behaviour does not change + # if the user just adds the new Integrations + # and forgets to remove SentryAsgiMiddleware + sentry_init() + app = fastapi_app_factory() + asgi_app = SentryAsgiMiddleware(app) + + events = capture_events() + + client = TestClient(asgi_app) + client.get("/message/123456") + + (event,) = events + assert event["transaction"] == "/message/{message_id}" + + +@pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) +@mock.patch("sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0) +def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): + sentry_init( + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + ) + app = fastapi_app_factory() + asgi_app = SentryAsgiMiddleware(app) + + envelopes = capture_envelopes() + + client = TestClient(asgi_app) + response = client.get(endpoint) + assert response.status_code == 200 + + data = json.loads(response.content) + + envelopes = [envelope for envelope in envelopes] + assert len(envelopes) == 1 + + profiles = [item for item in envelopes[0].items if item.type == "profile"] + assert len(profiles) == 1 + + for item in profiles: + transactions = item.payload.json["transactions"] + assert len(transactions) == 1 + assert str(data["active"]) == transactions[0]["active_thread_id"] + + transactions = [item for item in envelopes[0].items if item.type == "transaction"] + assert len(transactions) == 1 + + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + 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()]) @@ -109,4 +758,662 @@ def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): @pytest.mark.parametrize("span_streaming", [True, False]) -@pytest.mark.asyncio \ No newline at end of file +@pytest.mark.asyncio +async def test_original_request_not_scrubbed( + sentry_init, capture_events, span_streaming +): + sentry_init( + auto_enabling_integrations=False, # Ensure httpx is not auto-enabled; its legacy start_span interferes with streaming mode + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + + app = FastAPI() + + @app.post("/error") + async def _error(request: Request): + logging.critical("Oh no!") + assert request.headers["Authorization"] == "Bearer ohno" + assert request.headers["Proxy-Authorization"] == "Basic ohno" + assert await request.json() == {"password": "secret"} + + return {"error": "Oh no!"} + + events = capture_events() + + client = TestClient(app) + client.post( + "/error", + json={"password": "secret"}, + headers={ + "Authorization": "Bearer ohno", + "Proxy-Authorization": "Basic ohno", + }, + ) + + event = events[0] + assert event["request"]["data"] == {"password": "[Filtered]"} + assert event["request"]["headers"]["authorization"] == "[Filtered]" + assert event["request"]["headers"]["proxy-authorization"] == "[Filtered]" + + +def test_response_status_code_ok_in_transaction_context(sentry_init, capture_envelopes): + """ + Tests that the response status code is added to the transaction "response" context. + """ + sentry_init( + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + release="demo-release", + ) + + envelopes = capture_envelopes() + + app = fastapi_app_factory() + + client = TestClient(app) + client.get("/message") + + (_, transaction_envelope) = envelopes + transaction = transaction_envelope.get_transaction_event() + + assert transaction["type"] == "transaction" + assert len(transaction["contexts"]) > 0 + assert "response" in transaction["contexts"].keys(), ( + "Response context not found in transaction" + ) + assert transaction["contexts"]["response"]["status_code"] == 200 + + +def test_response_status_code_error_in_transaction_context( + sentry_init, + capture_envelopes, +): + """ + Tests that the response status code is added to the transaction "response" context. + """ + sentry_init( + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + release="demo-release", + ) + + envelopes = capture_envelopes() + + app = fastapi_app_factory() + + client = TestClient(app) + with pytest.raises(ZeroDivisionError): + client.get("/error") + + ( + _, + _, + transaction_envelope, + ) = envelopes + transaction = transaction_envelope.get_transaction_event() + + assert transaction["type"] == "transaction" + assert len(transaction["contexts"]) > 0 + assert "response" in transaction["contexts"].keys(), ( + "Response context not found in transaction" + ) + assert transaction["contexts"]["response"]["status_code"] == 500 + + +def test_response_status_code_not_found_in_transaction_context( + sentry_init, + capture_envelopes, +): + """ + Tests that the response status code is added to the transaction "response" context. + """ + sentry_init( + integrations=[StarletteIntegration(), FastApiIntegration()], + traces_sample_rate=1.0, + release="demo-release", + ) + + envelopes = capture_envelopes() + + app = fastapi_app_factory() + + client = TestClient(app) + client.get("/non-existing-route-123") + + (transaction_envelope,) = envelopes + transaction = transaction_envelope.get_transaction_event() + + assert transaction["type"] == "transaction" + assert len(transaction["contexts"]) > 0 + assert "response" in transaction["contexts"].keys(), ( + "Response context not found in transaction" + ) + assert transaction["contexts"]["response"]["status_code"] == 404 + + +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize( + "request_url,transaction_style,expected_transaction_name,expected_transaction_source", + [ + ( + "/message/123456", + "endpoint", + "tests.integrations.fastapi.test_fastapi.fastapi_app_factory.._message_with_id", + "component", + ), + ( + "/message/123456", + "url", + "/message/{message_id}", + "route", + ), + ], +) +def test_transaction_name( + sentry_init, + request_url, + transaction_style, + expected_transaction_name, + expected_transaction_source, + capture_envelopes, + capture_items, + span_streaming, +): + """ + Tests that the transaction name is something meaningful. + """ + sentry_init( + auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. + integrations=[ + StarletteIntegration(transaction_style=transaction_style), + FastApiIntegration(transaction_style=transaction_style), + ], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + + if span_streaming: + items = capture_items("span") + else: + envelopes = capture_envelopes() + + app = fastapi_app_factory() + + client = TestClient(app) + client.get(request_url) + + if span_streaming: + sentry_sdk.flush() + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + segment = segments[0] + assert segment["name"] == expected_transaction_name + assert ( + segment["attributes"]["sentry.segment.name.source"] + == expected_transaction_source + ) + else: + (_, transaction_envelope) = envelopes + transaction_event = transaction_envelope.get_transaction_event() + + assert transaction_event["transaction"] == expected_transaction_name + assert ( + transaction_event["transaction_info"]["source"] + == expected_transaction_source + ) + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_http_route_with_prefix( + sentry_init, + capture_envelopes, + capture_items, + span_streaming, +): + sentry_init( + auto_enabling_integrations=False, + integrations=[ + StarletteIntegration(transaction_style="url"), + FastApiIntegration(transaction_style="url"), + ], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + + if span_streaming: + items = capture_items("span") + else: + envelopes = capture_envelopes() + + app = FastAPI() + router = APIRouter() + + @router.get("/users/{user_id}") + async def get_user(user_id: int): + return {"user_id": user_id} + + app.include_router(router, prefix="/api") + + client = TestClient(app) + client.get("/api/users/123") + + if span_streaming: + sentry_sdk.flush() + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + segment = segments[0] + assert segment["name"] == "/api/users/{user_id}" + assert segment["attributes"]["sentry.segment.name.source"] == "route" + assert segment["attributes"]["http.route"] == "/api/users/{user_id}" + else: + (transaction_envelope,) = envelopes + transaction_event = transaction_envelope.get_transaction_event() + assert transaction_event["transaction"] == "/api/users/{user_id}" + assert transaction_event["transaction_info"]["source"] == "route" + + +def test_route_endpoint_equal_dependant_call(sentry_init): + """ + Tests that the route endpoint name is equal to the wrapped dependant call name. + """ + sentry_init( + auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. + integrations=[ + StarletteIntegration(), + FastApiIntegration(), + ], + traces_sample_rate=1.0, + ) + + app = fastapi_app_factory() + + for route in app.router.routes: + if not hasattr(route, "dependant"): + continue + assert route.endpoint.__qualname__ == route.dependant.call.__qualname__ + + +@pytest.mark.parametrize( + "request_url,transaction_style,expected_transaction_name,expected_transaction_source", + [ + ( + "/message/123456", + "endpoint", + "http://testserver/message/123456", + "url", + ), + ( + "/message/123456", + "url", + "http://testserver/message/123456", + "url", + ), + ], +) +def test_transaction_name_in_traces_sampler( + sentry_init, + request_url, + transaction_style, + expected_transaction_name, + expected_transaction_source, +): + """ + Tests that a custom traces_sampler retrieves a meaningful transaction name. + In this case the URL or endpoint, because we do not have the route yet. + """ + + def dummy_traces_sampler(sampling_context): + assert ( + sampling_context["transaction_context"]["name"] == expected_transaction_name + ) + assert ( + sampling_context["transaction_context"]["source"] + == expected_transaction_source + ) + + sentry_init( + auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. + integrations=[StarletteIntegration(transaction_style=transaction_style)], + traces_sampler=dummy_traces_sampler, + traces_sample_rate=1.0, + ) + + app = fastapi_app_factory() + + client = TestClient(app) + client.get(request_url) + + +@pytest.mark.parametrize("middleware_spans", [False, True]) +@pytest.mark.parametrize( + "request_url,transaction_style,expected_transaction_name,expected_transaction_source", + [ + ( + "/message/123456", + "endpoint", + "starlette.middleware.trustedhost.TrustedHostMiddleware", + "component", + ), + ( + "/message/123456", + "url", + "http://testserver/message/123456", + "url", + ), + ], +) +def test_transaction_name_in_middleware( + sentry_init, + middleware_spans, + request_url, + transaction_style, + expected_transaction_name, + expected_transaction_source, + capture_envelopes, +): + """ + Tests that the transaction name is something meaningful. + """ + sentry_init( + auto_enabling_integrations=False, # Make sure that httpx integration is not added, because it adds tracing information to the starlette test clients request. + integrations=[ + StarletteIntegration( + transaction_style=transaction_style, middleware_spans=middleware_spans + ), + FastApiIntegration( + transaction_style=transaction_style, middleware_spans=middleware_spans + ), + ], + traces_sample_rate=1.0, + ) + + envelopes = capture_envelopes() + + app = fastapi_app_factory() + + app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=[ + "example.com", + ], + ) + + client = TestClient(app) + client.get(request_url) + + (transaction_envelope,) = envelopes + transaction_event = transaction_envelope.get_transaction_event() + + assert transaction_event["contexts"]["response"]["status_code"] == 400 + assert transaction_event["transaction"] == expected_transaction_name + assert ( + transaction_event["transaction_info"]["source"] == expected_transaction_source + ) + + +@test_starlette.parametrize_test_configurable_status_codes_deprecated +def test_configurable_status_codes_deprecated( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + with pytest.warns(DeprecationWarning): + starlette_integration = StarletteIntegration( + failed_request_status_codes=failed_request_status_codes + ) + + with pytest.warns(DeprecationWarning): + fast_api_integration = FastApiIntegration( + failed_request_status_codes=failed_request_status_codes + ) + + sentry_init( + integrations=[ + starlette_integration, + fast_api_integration, + ] + ) + + events = capture_events() + + app = FastAPI() + + @app.get("/error") + async def _error(): + raise HTTPException(status_code) + + client = TestClient(app) + client.get("/error") + + if expected_error: + assert len(events) == 1 + else: + assert not events + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_default(sentry_init, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(), + FastApiIntegration(), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 1 + + (event,) = events + + assert event["request"]["method"] == "GET" + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_custom(sentry_init, capture_events): + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + FastApiIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + + assert event1["request"]["method"] == "OPTIONS" + 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(), + ], + 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"][SPANDATA.URL_FULL] == ( + "http://testserver/root/nomessage" + ) + assert server_span["attributes"][SPANDATA.URL_PATH] == "/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, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + integration_kwargs = {} + if failed_request_status_codes is not None: + integration_kwargs["failed_request_status_codes"] = failed_request_status_codes + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + starlette_integration = StarletteIntegration(**integration_kwargs) + fastapi_integration = FastApiIntegration(**integration_kwargs) + + sentry_init(integrations=[starlette_integration, fastapi_integration]) + + events = capture_events() + + app = FastAPI() + + @app.get("/error") + async def _error(): + raise HTTPException(status_code) + + client = TestClient(app) + client.get("/error") + + assert len(events) == int(expected_error) + + +@pytest.mark.parametrize("transaction_style", ["endpoint", "url"]) +def test_app_host(sentry_init, capture_events, transaction_style): + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(transaction_style=transaction_style), + FastApiIntegration(transaction_style=transaction_style), + ], + ) + + app = FastAPI() + subapp = FastAPI() + + @subapp.get("/subapp") + async def subapp_route(): + return {"message": "Hello world!"} + + app.host("subapp", subapp) + + events = capture_events() + + client = TestClient(app) + client.get("/subapp", headers={"Host": "subapp"}) + + assert len(events) == 1 + + (event,) = events + assert "transaction" in event + + if transaction_style == "url": + assert event["transaction"] == "/subapp" + else: + assert event["transaction"].endswith("subapp_route") + + +@pytest.mark.asyncio +async def test_feature_flags(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[StarletteIntegration(), FastApiIntegration()], + ) + + events = capture_events() + + app = FastAPI() + + @app.get("/error") + async def _error(): + add_feature_flag("hello", False) + + with sentry_sdk.start_span(name="test-span"): + with sentry_sdk.start_span(name="test-span-2"): + raise ValueError("something is wrong!") + + try: + client = TestClient(app) + client.get("/error") + except ValueError: + pass + + found = False + for event in events: + if "exception" in event.keys(): + assert event["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + found = True + + assert found, "No event with exception found" From 24224970fec45513ec30e99efe287216518eb463 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:46:38 +0530 Subject: [PATCH 19/27] fix(fastapi): fall back to the routed dependant --- sentry_sdk/integrations/fastapi.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 31b3cc7e38..a7b4fdf23b 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -56,13 +56,14 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection"): effective_route_context = request.scope.get("fastapi", {}).get( "effective_route_context" ) - route = effective_route_context or request.scope.get("route") + route = request.scope.get("route") route_path = None - if route: - path = getattr(route, "path", None) - if path is not None: - route_path = path + 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: @@ -75,9 +76,11 @@ async def _sentry_fastapi_dependency(request: "HTTPConnection"): route_path=route_path, ) - # FastAPI 0.137+ may execute the dependant stored on the effective route - # context instead of the original APIRoute. - dependant = getattr(route, "dependant", None) + # 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 From d5f1df77b889dd3ceddb0beb21c50af2794b9ca1 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 10:46:47 +0530 Subject: [PATCH 20/27] test(fastapi): cover request data on all supported versions --- tests/integrations/fastapi/test_fastapi.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 44943c786a..7535ecfef8 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -689,10 +689,6 @@ async def _get_item(item_id: int): assert seen_transaction_names == ["/items/{item_id}"] -@pytest.mark.skipif( - FASTAPI_VERSION < (0, 121), - reason="FastAPI < 0.121 uses Starlette's request_response implementation", -) def test_global_dependency_captures_request_data(sentry_init, capture_events): sentry_init( auto_enabling_integrations=False, From 40fe65d3c6eaeecaf80d143fecab563bed0ef52d Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:04:24 +0530 Subject: [PATCH 21/27] fix(fastapi): install request isolation with integration --- sentry_sdk/integrations/fastapi.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index a7b4fdf23b..e4f432b1cb 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -38,6 +38,10 @@ class FastApiIntegration(StarletteIntegration): @staticmethod def setup_once() -> None: + # 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() From d0b531015882dd4eff8557e1f9b2ad8420bd3924 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:04:43 +0530 Subject: [PATCH 22/27] test(fastapi): isolate request event processors --- tests/integrations/fastapi/test_fastapi.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 7535ecfef8..278ab25ea3 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -712,6 +712,28 @@ async def _message(): 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()]) From 18fb09b5a79b4bcbea9c2c4a07163f88a54fd12d Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:15:19 +0530 Subject: [PATCH 23/27] fix(starlette): reuse ASGI instrumentation for FastAPI --- sentry_sdk/integrations/starlette.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index f6988d096d..f8ec886feb 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -463,16 +463,27 @@ def _sentry_middleware_init( Middleware.__init__ = _sentry_middleware_init # type: ignore[method-assign] +def _get_starlette_integration() -> "Optional[StarletteIntegration]": + client = sentry_sdk.get_client() + integration = client.get_integration(StarletteIntegration) + if integration is None: + integration = client.get_integration("fastapi") + return integration # type: ignore[return-value] + + def patch_asgi_app(root_path_in_path: "_RootPathInPath") -> None: """ Instrument Starlette ASGI app using the SentryAsgiMiddleware. """ old_app = Starlette.__call__ + if getattr(old_app, "_sentry_is_patched", False): + return + async def _sentry_patched_asgi_app( self: "Starlette", scope: "StarletteScope", receive: "Receive", send: "Send" ) -> None: - integration = sentry_sdk.get_client().get_integration(StarletteIntegration) + integration = _get_starlette_integration() if integration is None: return await old_app(self, scope, receive, send) @@ -481,17 +492,14 @@ async def _sentry_patched_asgi_app( mechanism_type=StarletteIntegration.identifier, transaction_style=integration.transaction_style, span_origin=StarletteIntegration.origin, - http_methods_to_capture=( - integration.http_methods_to_capture - if integration - else DEFAULT_HTTP_METHODS_TO_CAPTURE - ), + http_methods_to_capture=integration.http_methods_to_capture, asgi_version=3, root_path_in_path=root_path_in_path, ) return await middleware(scope, receive, send) + _sentry_patched_asgi_app._sentry_is_patched = True # type: ignore[attr-defined] Starlette.__call__ = _sentry_patched_asgi_app # type: ignore[method-assign] From 33d32a469effb4d5f15106fafca1cbc4adcebbb1 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:15:27 +0530 Subject: [PATCH 24/27] test(fastapi): verify request processor cleanup --- tests/integrations/fastapi/test_fastapi.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 278ab25ea3..cf42c0375c 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -713,7 +713,11 @@ async def _message(): def test_global_dependency_request_processors_are_isolated(sentry_init, capture_events): - sentry_init(integrations=[FastApiIntegration()], send_default_pii=True) + sentry_init( + auto_enabling_integrations=False, + integrations=[FastApiIntegration()], + send_default_pii=True, + ) app = FastAPI() @@ -726,11 +730,11 @@ async def _root(): client = TestClient(app) assert client.get("/", cookies={"request": "one"}).status_code == 200 - assert client.get("/", cookies={"request": "two"}).status_code == 200 + assert client.get("/").status_code == 200 - assert [event["request"]["cookies"] for event in events] == [ + assert [event["request"].get("cookies") for event in events] == [ {"request": "one"}, - {"request": "two"}, + None, ] From 7165336a0e7e5af5e9607edd1146683408cb38a1 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:23:06 +0530 Subject: [PATCH 25/27] sync starlette integration with verified PR state --- sentry_sdk/integrations/starlette.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index f8ec886feb..f6988d096d 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -463,27 +463,16 @@ def _sentry_middleware_init( Middleware.__init__ = _sentry_middleware_init # type: ignore[method-assign] -def _get_starlette_integration() -> "Optional[StarletteIntegration]": - client = sentry_sdk.get_client() - integration = client.get_integration(StarletteIntegration) - if integration is None: - integration = client.get_integration("fastapi") - return integration # type: ignore[return-value] - - def patch_asgi_app(root_path_in_path: "_RootPathInPath") -> None: """ Instrument Starlette ASGI app using the SentryAsgiMiddleware. """ old_app = Starlette.__call__ - if getattr(old_app, "_sentry_is_patched", False): - return - async def _sentry_patched_asgi_app( self: "Starlette", scope: "StarletteScope", receive: "Receive", send: "Send" ) -> None: - integration = _get_starlette_integration() + integration = sentry_sdk.get_client().get_integration(StarletteIntegration) if integration is None: return await old_app(self, scope, receive, send) @@ -492,14 +481,17 @@ async def _sentry_patched_asgi_app( mechanism_type=StarletteIntegration.identifier, transaction_style=integration.transaction_style, span_origin=StarletteIntegration.origin, - http_methods_to_capture=integration.http_methods_to_capture, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), asgi_version=3, root_path_in_path=root_path_in_path, ) return await middleware(scope, receive, send) - _sentry_patched_asgi_app._sentry_is_patched = True # type: ignore[attr-defined] Starlette.__call__ = _sentry_patched_asgi_app # type: ignore[method-assign] From 3c150061145c5edc699e07362d555f23e935a8f0 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Fri, 2 Oct 2026 11:23:13 +0530 Subject: [PATCH 26/27] sync fastapi tests with verified PR state --- tests/integrations/fastapi/test_fastapi.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index cf42c0375c..278ab25ea3 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -713,11 +713,7 @@ async def _message(): def test_global_dependency_request_processors_are_isolated(sentry_init, capture_events): - sentry_init( - auto_enabling_integrations=False, - integrations=[FastApiIntegration()], - send_default_pii=True, - ) + sentry_init(integrations=[FastApiIntegration()], send_default_pii=True) app = FastAPI() @@ -730,11 +726,11 @@ async def _root(): client = TestClient(app) assert client.get("/", cookies={"request": "one"}).status_code == 200 - assert client.get("/").status_code == 200 + assert client.get("/", cookies={"request": "two"}).status_code == 200 - assert [event["request"].get("cookies") for event in events] == [ + assert [event["request"]["cookies"] for event in events] == [ {"request": "one"}, - None, + {"request": "two"}, ] From 15198cf52dd8428116ce476fa52fba77a32d540d Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Wed, 7 Oct 2026 18:06:52 +0530 Subject: [PATCH 27/27] fix(starlette): prevent duplicate patching --- sentry_sdk/integrations/starlette.py | 4 ++++ 1 file changed, 4 insertions(+) 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