close
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(starlette): Capture request body on segment span under streaming
When span streaming is enabled, attach the request body (serialized via
a JSON default that unwraps AnnotatedValue) to the active segment span
as `http.request.body.data` inside `patch_request_response`. This
mirrors the body capture that the legacy request-event processor does,
so streamed spans carry the same payload context without relying on
transaction events.

PII scrubbing is intentionally not applied here — under span
streaming, sanitization is expected to happen in `before_send_span`
hooks. Tests cover the unscrubbed passthrough, a top-level
AnnotatedValue (raw bytes), and a nested AnnotatedValue inside a
multipart form upload.

Refs PY-2362

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
  • Loading branch information
ericapisani and claude committed Apr 23, 2026
commit 32286b8c4789648664115064f6961465fe065501
28 changes: 28 additions & 0 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import functools
import json
import warnings
from collections.abc import Set
from copy import deepcopy
Expand Down Expand Up @@ -233,6 +234,16 @@ async def _sentry_send(*args: "Any", **kwargs: "Any") -> "Any":
return middleware_class


def _serialize_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):
return {"value": value.value, "metadata": value.metadata}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return {"value": value.value, "metadata": value.metadata}
return value.value

return str(value)

return json.dumps(data, default=_default)
Comment on lines +237 to +244

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the data here have AnnotatedValues in it? It seems to come straight from Starlette (i.e., I don't believe it's gone through any SDK sanitization at this point)?

I hope we can just get rid of the whole AnnotatedValue business in span first.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the data here have AnnotatedValues in it?

It can when a raw value is present, or if the request body exceeds the size limit set by the max_request_body_size property.

Both of these occur within the extract_request_info method I linked to in the comment above.

I don't believe it's gone through any SDK sanitization at this point?

From this thread, sanitization, at least for the request body, will be the user's responsibility using the before_send_span once span-first launches.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. In that case looks good -- but in case of AnnotatedValues, I'd just include the sanitized value, and not the metadata part (see https://github.com/getsentry/sentry-python/pull/6123/changes#r3151943009) as that's internal event protocol and it won't be of any value to the user.

Stamping this already to unblock.



@ensure_integration_enabled(StarletteIntegration)
def _capture_exception(exception: BaseException, handled: "Any" = False) -> None:
event, hint = event_from_exception(
Expand Down Expand Up @@ -500,6 +511,23 @@ def event_processor(
_make_request_event_processor(request, integration)
)
Comment thread
ericapisani marked this conversation as resolved.

client = sentry_sdk.get_client()
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
Comment thread
cursor[bot] marked this conversation as resolved.
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.set_attribute(
"http.request.body.data",
_serialize_body_data(data),
)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

return await old_func(*args, **kwargs)

func = _sentry_async_func
Expand Down
118 changes: 118 additions & 0 deletions tests/integrations/starlette/test_starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,124 @@ def test_active_thread_id_span_streaming(sentry_init, capture_items, endpoint):
assert str(data["active"]) == segments[0]["attributes"]["thread.id"]


def _post_body_app(handler_awaitable):
async def _handler(request):
await handler_awaitable(request)
return starlette.responses.JSONResponse({"ok": True})

return starlette.applications.Starlette(
routes=[starlette.routing.Route("/body", _handler, methods=["POST"])],
)


def test_request_body_data_scrubs_pii_span_streaming(sentry_init, capture_items):
sentry_init(
auto_enabling_integrations=False,
integrations=[StarletteIntegration()],
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_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


def test_request_body_data_annotated_value_top_level_span_streaming(
sentry_init, capture_items
):
sentry_init(
auto_enabling_integrations=False,
integrations=[StarletteIntegration()],
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_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


def test_request_body_data_annotated_value_nested_span_streaming(
sentry_init, capture_items
):
pytest.importorskip("multipart")

sentry_init(
auto_enabling_integrations=False,
integrations=[StarletteIntegration()],
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_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
Comment thread
cursor[bot] marked this conversation as resolved.


def test_original_request_not_scrubbed(sentry_init, capture_events):
sentry_init(integrations=[StarletteIntegration()])

Expand Down
Loading