From 793e2f78ea17ee64dca1ad134bca1700455fcd3e Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Apr 2026 12:38:30 -0400 Subject: [PATCH 1/3] feat(fastapi): Capture request body on segment span under streaming FastAPI runs its own request-body extraction and was missing the same logic as that which exists in the Starlette integration. Extract the logic that writes `http.request.body.data` on the streaming segment into a shared helper in the Starlette integration, and call it from FastAPI's request handler once the body has been read. --- sentry_sdk/integrations/fastapi.py | 8 +- sentry_sdk/integrations/starlette.py | 33 ++--- tests/integrations/fastapi/test_fastapi.py | 133 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 3572b1c07f..e7f06820b4 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -7,6 +7,7 @@ from sentry_sdk.scope import should_send_default_pii from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import transaction_from_function from typing import TYPE_CHECKING @@ -19,6 +20,7 @@ from sentry_sdk.integrations.starlette import ( StarletteIntegration, StarletteRequestExtractor, + _set_body_data_on_streaming_segment, ) except DidNotEnable: raise DidNotEnable("Starlette is not installed") @@ -102,7 +104,8 @@ def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any": old_app = old_get_request_handler(*args, **kwargs) async def _sentry_app(*args: "Any", **kwargs: "Any") -> "Any": - integration = sentry_sdk.get_client().get_integration(FastApiIntegration) + client = sentry_sdk.get_client() + integration = client.get_integration(FastApiIntegration) if integration is None: return await old_app(*args, **kwargs) @@ -137,6 +140,9 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": _make_request_event_processor(request, integration) ) + if has_span_streaming_enabled(client.options): + _set_body_data_on_streaming_segment(info) + return await old_app(*args, **kwargs) return _sentry_app diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 036b797685..09ef886d00 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -244,6 +244,22 @@ def _default(value: "Any") -> "Any": return json.dumps(data, default=_default) +def _set_body_data_on_streaming_segment( + info: "Optional[Dict[str, Any]]", +) -> None: + current_span = sentry_sdk.get_current_span() + if ( + info + and "data" in info + and isinstance(current_span, StreamedSpan) + and not isinstance(current_span, NoOpStreamedSpan) + ): + current_span._segment.set_attribute( + "http.request.body.data", + _serialize_body_data(info["data"]), + ) + + @ensure_integration_enabled(StarletteIntegration) def _capture_exception(exception: BaseException, handled: "Any" = False) -> None: event, hint = event_from_exception( @@ -510,21 +526,8 @@ def event_processor( _make_request_event_processor(request, integration) ) - is_span_streaming_enabled = has_span_streaming_enabled(client.options) - if is_span_streaming_enabled: - current_span = sentry_sdk.get_current_span() - - if ( - info - and "data" in info - and isinstance(current_span, StreamedSpan) - and not isinstance(current_span, NoOpStreamedSpan) - ): - data = info["data"] - current_span._segment.set_attribute( - "http.request.body.data", - _serialize_body_data(data), - ) + if has_span_streaming_enabled(client.options): + _set_body_data_on_streaming_segment(info) return await old_func(*args, **kwargs) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index d321db993c..c1a5d6614e 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -245,6 +245,139 @@ def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint): assert str(data["active"]) == segments[0]["attributes"]["thread.id"] +def _post_body_fastapi_app(handler_awaitable): + app = FastAPI() + + @app.post("/body") + async def _route(request: Request): + await handler_awaitable(request) + return {"ok": True} + + return app + + +@pytest.mark.parametrize("middleware_spans", [False, True]) +def test_request_body_data_does_not_scrub_pii_span_streaming( + sentry_init, capture_items, middleware_spans +): + sentry_init( + auto_enabling_integrations=False, + integrations=[ + StarletteIntegration(middleware_spans=middleware_spans), + FastApiIntegration(middleware_spans=middleware_spans), + ], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + async def _read_json(request): + await request.json() + + items = capture_items("span") + + client = TestClient(_post_body_fastapi_app(_read_json)) + response = client.post( + "/body", + json={ + "password": "ohno", + "authorization": "Bearer token", + "message": "hello", + }, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + attr = segments[0]["attributes"]["http.request.body.data"] + + # 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 + assert "ohno" in attr + assert "Bearer token" in attr + assert "hello" in attr + + +@pytest.mark.parametrize("middleware_spans", [False, True]) +def test_request_body_data_annotated_value_top_level_span_streaming( + sentry_init, capture_items, middleware_spans +): + sentry_init( + auto_enabling_integrations=False, + integrations=[ + StarletteIntegration(middleware_spans=middleware_spans), + FastApiIntegration(middleware_spans=middleware_spans), + ], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + async def _read_body(request): + await request.body() + + items = capture_items("span") + + client = TestClient(_post_body_fastapi_app(_read_body)) + response = client.post( + "/body", + content=b"not json and not form", + headers={"content-type": "application/octet-stream"}, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + attr = segments[0]["attributes"]["http.request.body.data"] + + assert isinstance(attr, str) + assert "!raw" in attr + + +@pytest.mark.parametrize("middleware_spans", [False, True]) +def test_request_body_data_annotated_value_nested_span_streaming( + sentry_init, capture_items, middleware_spans +): + pytest.importorskip("multipart") + + sentry_init( + auto_enabling_integrations=False, + integrations=[ + StarletteIntegration(middleware_spans=middleware_spans), + FastApiIntegration(middleware_spans=middleware_spans), + ], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + + async def _read_form(request): + await request.form() + + items = capture_items("span") + + client = TestClient(_post_body_fastapi_app(_read_form)) + response = client.post( + "/body", + data={"name": "erica"}, + files={"avatar": ("photo.jpg", b"fake-bytes", "image/jpeg")}, + ) + assert response.status_code == 200 + + sentry_sdk.flush() + + segments = [item.payload for item in items if item.payload.get("is_segment")] + assert len(segments) == 1 + attr = segments[0]["attributes"]["http.request.body.data"] + + assert isinstance(attr, str) + parsed = json.loads(attr) + assert parsed["name"] == "erica" + assert parsed["avatar"]["metadata"]["rem"] == [["!raw", "x"]] + assert "fake-bytes" not in attr + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.asyncio async def test_original_request_not_scrubbed( From a8295c515d72d8186c37700ea11d84ee56b61e82 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Apr 2026 13:07:22 -0400 Subject: [PATCH 2/3] test(fastapi): Skip body content test on Starlette < 0.21 FastAPI 0.79.1 pulls in Starlette 0.19.1, whose `TestClient` is built on `requests` and does not accept the `content` kwarg. Match the skipif already in place for the analogous Starlette test so CI on older FastAPI versions passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integrations/fastapi/test_fastapi.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index c1a5d6614e..990fd40ff4 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -6,6 +6,7 @@ from unittest import mock import fastapi +import starlette from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from fastapi.middleware.trustedhost import TrustedHostMiddleware @@ -20,6 +21,7 @@ FASTAPI_VERSION = parse_version(fastapi.__version__) +STARLETTE_VERSION = parse_version(starlette.__version__) from tests.integrations.conftest import parametrize_test_configurable_status_codes from tests.integrations.starlette import test_starlette @@ -299,6 +301,10 @@ async def _read_json(request): assert "hello" in attr +@pytest.mark.skipif( + STARLETTE_VERSION < (0, 21), + reason="Requires Starlette >= 0.21, because earlier versions use a requests-based TestClient which does not support the 'content' kwarg", +) @pytest.mark.parametrize("middleware_spans", [False, True]) def test_request_body_data_annotated_value_top_level_span_streaming( sentry_init, capture_items, middleware_spans From 4b5df10f92f5d50d048280d05823ee316b2fdd72 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Apr 2026 13:33:22 -0400 Subject: [PATCH 3/3] Renames --- sentry_sdk/integrations/fastapi.py | 4 ++-- sentry_sdk/integrations/starlette.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index e7f06820b4..747c00af1e 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -20,7 +20,7 @@ from sentry_sdk.integrations.starlette import ( StarletteIntegration, StarletteRequestExtractor, - _set_body_data_on_streaming_segment, + _set_request_body_data_on_streaming_segment, ) except DidNotEnable: raise DidNotEnable("Starlette is not installed") @@ -141,7 +141,7 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": ) if has_span_streaming_enabled(client.options): - _set_body_data_on_streaming_segment(info) + _set_request_body_data_on_streaming_segment(info) return await old_app(*args, **kwargs) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 09ef886d00..a69cab668b 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -234,7 +234,7 @@ async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any": return middleware_class -def _serialize_body_data(data: "Any") -> str: +def _serialize_request_body_data(data: "Any") -> str: # data may be a JSON-serializable value, an AnnotatedValue, or a dict with AnnotatedValue values def _default(value: "Any") -> "Any": if isinstance(value, AnnotatedValue): @@ -244,7 +244,7 @@ def _default(value: "Any") -> "Any": return json.dumps(data, default=_default) -def _set_body_data_on_streaming_segment( +def _set_request_body_data_on_streaming_segment( info: "Optional[Dict[str, Any]]", ) -> None: current_span = sentry_sdk.get_current_span() @@ -256,7 +256,7 @@ def _set_body_data_on_streaming_segment( ): current_span._segment.set_attribute( "http.request.body.data", - _serialize_body_data(info["data"]), + _serialize_request_body_data(info["data"]), ) @@ -527,7 +527,7 @@ def event_processor( ) if has_span_streaming_enabled(client.options): - _set_body_data_on_streaming_segment(info) + _set_request_body_data_on_streaming_segment(info) return await old_func(*args, **kwargs)