Skip to content

Commit f655e66

Browse files
fix(starlette): Stop eagerly consuming request bodies for streamed spans (#6282)
Only attach cached request bodies to streamed spans to avoid eagerly consuming the request body. Use the `_json` and `_form` attributes instead of `json()` and `form()` accessors so the SDK does not consume the body. If neither `_json` nor `_form` exists, either the request body is not JSON/FormData or the endpoint did not yet access the request body. In that case, omit the request body attribute.
1 parent 09ddd92 commit f655e66

3 files changed

Lines changed: 295 additions & 249 deletions

File tree

‎sentry_sdk/consts.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,12 @@ class SPANDATA:
825825
Example: GET
826826
"""
827827

828+
HTTP_REQUEST_BODY_DATA = "http.request.body.data"
829+
"""
830+
HTTP request body data. Can be given as string or structural data of any format.
831+
Example: "[{\"role\": \"user\", \"message\": \"hello\"}]"
832+
"""
833+
828834
HTTP_REQUEST_HEADER = "http.request.header"
829835
"""
830836
Prefix for HTTP request header attributes. The header name (lowercased) is
@@ -838,12 +844,6 @@ class SPANDATA:
838844
Example: GET
839845
"""
840846

841-
HTTP_REQUEST_BODY_DATA = "http.request.body.data"
842-
"""
843-
The HTTP request body data as string.
844-
Example: "[{\"role\": \"user\", \"message\": \"hello\"}]"
845-
"""
846-
847847
HTTP_QUERY = "http.query"
848848
"""
849849
The Query string present in the URL.

‎sentry_sdk/integrations/starlette.py‎

Lines changed: 99 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from typing import TYPE_CHECKING
99

1010
import sentry_sdk
11-
from sentry_sdk.consts import OP
11+
from sentry_sdk._types import OVER_SIZE_LIMIT_SUBSTITUTE
12+
from sentry_sdk.consts import OP, SPANDATA
1213
from sentry_sdk.integrations import (
1314
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
1415
DidNotEnable,
@@ -46,7 +47,9 @@
4647
import starlette # type: ignore
4748
from starlette import __version__ as STARLETTE_VERSION
4849
from starlette.applications import Starlette # type: ignore
49-
from starlette.datastructures import UploadFile # type: ignore
50+
from starlette.datastructures import ( # type: ignore
51+
UploadFile,
52+
)
5053
from starlette.middleware import Middleware # type: ignore
5154
from starlette.middleware.authentication import ( # type: ignore
5255
AuthenticationMiddleware,
@@ -478,61 +481,113 @@ def _is_async_callable(obj: "Any") -> bool:
478481
)
479482

480483

481-
def patch_request_response() -> None:
482-
old_request_response = starlette.routing.request_response
484+
def _get_cached_request_body_attribute(
485+
client: "sentry_sdk.client.BaseClient", request: "Request"
486+
) -> "Optional[str]":
487+
"""
488+
Returns a stringified JSON representation of the request body if the request body is cached and within size bounds.
489+
"""
490+
if "content-length" not in request.headers:
491+
return None
483492

484-
def _sentry_request_response(func: "Callable[[Any], Any]") -> "ASGIApp":
485-
old_func = func
493+
try:
494+
content_length = int(request.headers["content-length"])
495+
except ValueError:
496+
return None
486497

487-
is_coroutine = _is_async_callable(old_func)
488-
if is_coroutine:
498+
if content_length and not request_body_within_bounds(client, content_length):
499+
return OVER_SIZE_LIMIT_SUBSTITUTE
489500

490-
async def _sentry_async_func(*args: "Any", **kwargs: "Any") -> "Any":
491-
client = sentry_sdk.get_client()
492-
integration = client.get_integration(StarletteIntegration)
493-
if integration is None:
494-
return await old_func(*args, **kwargs)
501+
if hasattr(request, "_json"):
502+
return json.dumps(request._json)
495503

496-
request = args[0]
504+
formdata_body = getattr(request, "_form", None)
505+
if formdata_body is None:
506+
return None
497507

498-
_set_transaction_name_and_source(
499-
sentry_sdk.get_current_scope(),
500-
integration.transaction_style,
501-
request,
502-
)
508+
form_data = {}
509+
for key, val in formdata_body.items():
510+
is_file = isinstance(val, UploadFile)
511+
form_data[key] = val if not is_file else "[Unparsable]"
503512

504-
sentry_scope = sentry_sdk.get_isolation_scope()
505-
extractor = StarletteRequestExtractor(request)
506-
info = await extractor.extract_request_info()
513+
return json.dumps(form_data)
507514

508-
def _make_request_event_processor(
509-
req: "Any", integration: "Any"
510-
) -> "Callable[[Event, dict[str, Any]], Event]":
511-
def event_processor(
512-
event: "Event", hint: "Dict[str, Any]"
513-
) -> "Event":
514-
# Add info from request to event
515-
request_info = event.get("request", {})
516-
if info:
517-
if "cookies" in info:
518-
request_info["cookies"] = info["cookies"]
519-
if "data" in info:
520-
request_info["data"] = info["data"]
521-
event["request"] = deepcopy(request_info)
522515

523-
return event
516+
async def _wrap_async_handler(
517+
handler: "Callable[..., Awaitable[Any]]", *args: "Any", **kwargs: "Any"
518+
) -> "Any":
519+
"""
520+
Wraps an asynchronous handler function to attach request info to errors and the server segment span.
521+
The request body cached on the Starlette Request object is attached to streamed spans, but consuming the request body in the event
522+
processor can still cause application hangs.
523+
"""
524+
client = sentry_sdk.get_client()
525+
integration = client.get_integration(StarletteIntegration)
526+
if integration is None:
527+
return await handler(*args, **kwargs)
524528

525-
return event_processor
529+
request = args[0]
526530

527-
sentry_scope._name = StarletteIntegration.identifier
528-
sentry_scope.add_event_processor(
529-
_make_request_event_processor(request, integration)
531+
_set_transaction_name_and_source(
532+
sentry_sdk.get_current_scope(),
533+
integration.transaction_style,
534+
request,
535+
)
536+
537+
sentry_scope = sentry_sdk.get_isolation_scope()
538+
extractor = StarletteRequestExtractor(request)
539+
540+
info = await extractor.extract_request_info()
541+
542+
def _make_request_event_processor(
543+
req: "Any", integration: "Any"
544+
) -> "Callable[[Event, dict[str, Any]], Event]":
545+
def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event":
546+
# Add info from request to event
547+
request_info = event.get("request", {})
548+
if info:
549+
if "cookies" in info:
550+
request_info["cookies"] = info["cookies"]
551+
if "data" in info:
552+
request_info["data"] = info["data"]
553+
event["request"] = deepcopy(request_info)
554+
555+
return event
556+
557+
return event_processor
558+
559+
sentry_scope._name = StarletteIntegration.identifier
560+
sentry_scope.add_event_processor(
561+
_make_request_event_processor(request, integration)
562+
)
563+
564+
try:
565+
return await handler(*args, **kwargs)
566+
finally:
567+
current_span = get_current_span()
568+
569+
if type(current_span) is StreamedSpan:
570+
request_body = _get_cached_request_body_attribute(
571+
client=client, request=request
572+
)
573+
if request_body:
574+
current_span._segment.set_attribute(
575+
SPANDATA.HTTP_REQUEST_BODY_DATA,
576+
request_body,
530577
)
531578

532-
if has_span_streaming_enabled(client.options):
533-
_set_request_body_data_on_streaming_segment(info)
534579

535-
return await old_func(*args, **kwargs)
580+
def patch_request_response() -> None:
581+
old_request_response = starlette.routing.request_response
582+
583+
def _sentry_request_response(func: "Callable[[Any], Any]") -> "ASGIApp":
584+
old_func = func
585+
586+
is_coroutine = _is_async_callable(old_func)
587+
if is_coroutine:
588+
589+
async def _sentry_async_func(*args: "Any", **kwargs: "Any") -> "Any":
590+
return await _wrap_async_handler(old_func, *args, **kwargs)
536591

537592
func = _sentry_async_func
538593

0 commit comments

Comments
 (0)