Skip to content

Commit bd50a8e

Browse files
authored
feat(fastapi): Capture request body on segment span under streaming (#6136)
Refs PY-2322 Fixes #6020
1 parent 9c1d475 commit bd50a8e

3 files changed

Lines changed: 165 additions & 19 deletions

File tree

sentry_sdk/integrations/fastapi.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry_sdk.scope import should_send_default_pii
88
from sentry_sdk.traces import NoOpStreamedSpan, StreamedSpan
99
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource
10+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1011
from sentry_sdk.utils import transaction_from_function
1112

1213
from typing import TYPE_CHECKING
@@ -19,6 +20,7 @@
1920
from sentry_sdk.integrations.starlette import (
2021
StarletteIntegration,
2122
StarletteRequestExtractor,
23+
_set_request_body_data_on_streaming_segment,
2224
)
2325
except DidNotEnable:
2426
raise DidNotEnable("Starlette is not installed")
@@ -109,7 +111,8 @@ def _sentry_call(*args: "Any", **kwargs: "Any") -> "Any":
109111
old_app = old_get_request_handler(*args, **kwargs)
110112

111113
async def _sentry_app(*args: "Any", **kwargs: "Any") -> "Any":
112-
integration = sentry_sdk.get_client().get_integration(FastApiIntegration)
114+
client = sentry_sdk.get_client()
115+
integration = client.get_integration(FastApiIntegration)
113116
if integration is None:
114117
return await old_app(*args, **kwargs)
115118

@@ -144,6 +147,9 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event":
144147
_make_request_event_processor(request, integration)
145148
)
146149

150+
if has_span_streaming_enabled(client.options):
151+
_set_request_body_data_on_streaming_segment(info)
152+
147153
return await old_app(*args, **kwargs)
148154

149155
return _sentry_app

sentry_sdk/integrations/starlette.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any":
241241
return middleware_class
242242

243243

244-
def _serialize_body_data(data: "Any") -> str:
244+
def _serialize_request_body_data(data: "Any") -> str:
245245
# data may be a JSON-serializable value, an AnnotatedValue, or a dict with AnnotatedValue values
246246
def _default(value: "Any") -> "Any":
247247
if isinstance(value, AnnotatedValue):
@@ -251,6 +251,23 @@ def _default(value: "Any") -> "Any":
251251
return json.dumps(data, default=_default)
252252

253253

254+
def _set_request_body_data_on_streaming_segment(
255+
info: "Optional[Dict[str, Any]]",
256+
) -> None:
257+
current_span = sentry_sdk.get_current_span()
258+
if (
259+
info
260+
and "data" in info
261+
and isinstance(current_span, StreamedSpan)
262+
and not isinstance(current_span, NoOpStreamedSpan)
263+
):
264+
with capture_internal_exceptions():
265+
current_span._segment.set_attribute(
266+
"http.request.body.data",
267+
_serialize_request_body_data(info["data"]),
268+
)
269+
270+
254271
@ensure_integration_enabled(StarletteIntegration)
255272
def _capture_exception(exception: BaseException, handled: "Any" = False) -> None:
256273
event, hint = event_from_exception(
@@ -517,23 +534,8 @@ def event_processor(
517534
_make_request_event_processor(request, integration)
518535
)
519536

520-
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
521-
if is_span_streaming_enabled:
522-
current_span = sentry_sdk.get_current_span()
523-
524-
if (
525-
info
526-
and "data" in info
527-
and isinstance(current_span, StreamedSpan)
528-
and not isinstance(current_span, NoOpStreamedSpan)
529-
):
530-
data = info["data"]
531-
532-
with capture_internal_exceptions():
533-
current_span._segment.set_attribute(
534-
"http.request.body.data",
535-
_serialize_body_data(data),
536-
)
537+
if has_span_streaming_enabled(client.options):
538+
_set_request_body_data_on_streaming_segment(info)
537539

538540
return await old_func(*args, **kwargs)
539541

tests/integrations/fastapi/test_fastapi.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest import mock
77

88
import fastapi
9+
import starlette
910
from fastapi import FastAPI, HTTPException, Request
1011
from fastapi.testclient import TestClient
1112
from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -20,6 +21,7 @@
2021

2122

2223
FASTAPI_VERSION = parse_version(fastapi.__version__)
24+
STARLETTE_VERSION = parse_version(starlette.__version__)
2325

2426
from tests.integrations.conftest import parametrize_test_configurable_status_codes
2527
from tests.integrations.starlette import test_starlette
@@ -245,6 +247,142 @@ def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint):
245247
assert str(data["active"]) == segments[0]["attributes"]["thread.id"]
246248

247249

250+
def _post_body_fastapi_app(handler_awaitable):
251+
app = FastAPI()
252+
253+
@app.post("/body")
254+
async def _route(request: Request):
255+
await handler_awaitable(request)
256+
return {"ok": True}
257+
258+
return app
259+
260+
261+
@pytest.mark.parametrize("middleware_spans", [False, True])
262+
def test_request_body_data_does_not_scrub_pii_span_streaming(
263+
sentry_init, capture_items, middleware_spans
264+
):
265+
sentry_init(
266+
auto_enabling_integrations=False,
267+
integrations=[
268+
StarletteIntegration(middleware_spans=middleware_spans),
269+
FastApiIntegration(middleware_spans=middleware_spans),
270+
],
271+
traces_sample_rate=1.0,
272+
_experiments={"trace_lifecycle": "stream"},
273+
)
274+
275+
async def _read_json(request):
276+
await request.json()
277+
278+
items = capture_items("span")
279+
280+
client = TestClient(_post_body_fastapi_app(_read_json))
281+
response = client.post(
282+
"/body",
283+
json={
284+
"password": "ohno",
285+
"authorization": "Bearer token",
286+
"message": "hello",
287+
},
288+
)
289+
assert response.status_code == 200
290+
291+
sentry_sdk.flush()
292+
293+
segments = [item.payload for item in items if item.payload.get("is_segment")]
294+
assert len(segments) == 1
295+
attr = segments[0]["attributes"]["http.request.body.data"]
296+
297+
# Going forward, the sanitization of data will need to happen within the `before_send_span` hooks
298+
# See https://sentry.slack.com/archives/C09RR0KD2N7/p1776951331206129?thread_ts=1776951227.440659&cid=C09RR0KD2N7
299+
assert "ohno" in attr
300+
assert "Bearer token" in attr
301+
assert "hello" in attr
302+
303+
304+
@pytest.mark.skipif(
305+
STARLETTE_VERSION < (0, 21),
306+
reason="Requires Starlette >= 0.21, because earlier versions use a requests-based TestClient which does not support the 'content' kwarg",
307+
)
308+
@pytest.mark.parametrize("middleware_spans", [False, True])
309+
def test_request_body_data_annotated_value_top_level_span_streaming(
310+
sentry_init, capture_items, middleware_spans
311+
):
312+
sentry_init(
313+
auto_enabling_integrations=False,
314+
integrations=[
315+
StarletteIntegration(middleware_spans=middleware_spans),
316+
FastApiIntegration(middleware_spans=middleware_spans),
317+
],
318+
traces_sample_rate=1.0,
319+
_experiments={"trace_lifecycle": "stream"},
320+
)
321+
322+
async def _read_body(request):
323+
await request.body()
324+
325+
items = capture_items("span")
326+
327+
client = TestClient(_post_body_fastapi_app(_read_body))
328+
response = client.post(
329+
"/body",
330+
content=b"not json and not form",
331+
headers={"content-type": "application/octet-stream"},
332+
)
333+
assert response.status_code == 200
334+
335+
sentry_sdk.flush()
336+
337+
segments = [item.payload for item in items if item.payload.get("is_segment")]
338+
assert len(segments) == 1
339+
attr = segments[0]["attributes"]["http.request.body.data"]
340+
341+
assert isinstance(attr, str)
342+
assert attr == '""'
343+
344+
345+
@pytest.mark.parametrize("middleware_spans", [False, True])
346+
def test_request_body_data_annotated_value_nested_span_streaming(
347+
sentry_init, capture_items, middleware_spans
348+
):
349+
pytest.importorskip("multipart")
350+
351+
sentry_init(
352+
auto_enabling_integrations=False,
353+
integrations=[
354+
StarletteIntegration(middleware_spans=middleware_spans),
355+
FastApiIntegration(middleware_spans=middleware_spans),
356+
],
357+
traces_sample_rate=1.0,
358+
_experiments={"trace_lifecycle": "stream"},
359+
)
360+
361+
async def _read_form(request):
362+
await request.form()
363+
364+
items = capture_items("span")
365+
366+
client = TestClient(_post_body_fastapi_app(_read_form))
367+
response = client.post(
368+
"/body",
369+
data={"name": "erica"},
370+
files={"avatar": ("photo.jpg", b"fake-bytes", "image/jpeg")},
371+
)
372+
assert response.status_code == 200
373+
374+
sentry_sdk.flush()
375+
376+
segments = [item.payload for item in items if item.payload.get("is_segment")]
377+
assert len(segments) == 1
378+
attr = segments[0]["attributes"]["http.request.body.data"]
379+
380+
assert isinstance(attr, str)
381+
parsed = json.loads(attr)
382+
assert parsed["name"] == "erica"
383+
assert "fake-bytes" not in attr
384+
385+
248386
@pytest.mark.parametrize("span_streaming", [True, False])
249387
@pytest.mark.asyncio
250388
async def test_original_request_not_scrubbed(

0 commit comments

Comments
 (0)