Skip to content

Commit 35d86b6

Browse files
Make reading the request body work in Django ASGI apps. (#2495)
Handle request body in ASGI based Django apps. Starting with Django 4.1 the stream representing the request body is closed immediately preventing us from reading it. This fix reads the request body early on, so it is cached by Django and can be then read by our integration to add to the events sent to Sentry. --------- Co-authored by Daniel Szoke <[email protected]> Co-authored-by: Ivana Kellyerova <[email protected]>
1 parent 522abef commit 35d86b6

File tree

6 files changed

+151
-7
lines changed

6 files changed

+151
-7
lines changed

sentry_sdk/integrations/django/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
from django.urls import Resolver404
4848
except ImportError:
4949
from django.core.urlresolvers import Resolver404
50+
51+
# Only available in Django 3.0+
52+
try:
53+
from django.core.handlers.asgi import ASGIRequest
54+
except Exception:
55+
ASGIRequest = None
56+
5057
except ImportError:
5158
raise DidNotEnable("Django not installed")
5259

@@ -410,7 +417,7 @@ def _before_get_response(request):
410417
_set_transaction_name_and_source(scope, integration.transaction_style, request)
411418

412419
scope.add_event_processor(
413-
_make_event_processor(weakref.ref(request), integration)
420+
_make_wsgi_request_event_processor(weakref.ref(request), integration)
414421
)
415422

416423

@@ -462,9 +469,9 @@ def sentry_patched_get_response(self, request):
462469
patch_get_response_async(BaseHandler, _before_get_response)
463470

464471

465-
def _make_event_processor(weak_request, integration):
472+
def _make_wsgi_request_event_processor(weak_request, integration):
466473
# type: (Callable[[], WSGIRequest], DjangoIntegration) -> EventProcessor
467-
def event_processor(event, hint):
474+
def wsgi_request_event_processor(event, hint):
468475
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
469476
# if the request is gone we are fine not logging the data from
470477
# it. This might happen if the processor is pushed away to
@@ -473,6 +480,11 @@ def event_processor(event, hint):
473480
if request is None:
474481
return event
475482

483+
django_3 = ASGIRequest is not None
484+
if django_3 and type(request) == ASGIRequest:
485+
# We have a `asgi_request_event_processor` for this.
486+
return event
487+
476488
try:
477489
drf_request = request._sentry_drf_request_backref()
478490
if drf_request is not None:
@@ -489,7 +501,7 @@ def event_processor(event, hint):
489501

490502
return event
491503

492-
return event_processor
504+
return wsgi_request_event_processor
493505

494506

495507
def _got_request_exception(request=None, **kwargs):

sentry_sdk/integrations/django/asgi.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,56 @@
1111
from sentry_sdk import Hub, _functools
1212
from sentry_sdk._types import TYPE_CHECKING
1313
from sentry_sdk.consts import OP
14+
from sentry_sdk.hub import _should_send_default_pii
1415

1516
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
17+
from sentry_sdk.utils import capture_internal_exceptions
18+
19+
from django.core.handlers.wsgi import WSGIRequest
20+
1621

1722
if TYPE_CHECKING:
1823
from typing import Any
24+
from typing import Dict
1925
from typing import Union
2026
from typing import Callable
2127

28+
from django.core.handlers.asgi import ASGIRequest
2229
from django.http.response import HttpResponse
2330

31+
from sentry_sdk.integrations.django import DjangoIntegration
32+
from sentry_sdk._types import EventProcessor
33+
34+
35+
def _make_asgi_request_event_processor(request, integration):
36+
# type: (ASGIRequest, DjangoIntegration) -> EventProcessor
37+
def asgi_request_event_processor(event, hint):
38+
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
39+
# if the request is gone we are fine not logging the data from
40+
# it. This might happen if the processor is pushed away to
41+
# another thread.
42+
from sentry_sdk.integrations.django import (
43+
DjangoRequestExtractor,
44+
_set_user_info,
45+
)
46+
47+
if request is None:
48+
return event
49+
50+
if type(request) == WSGIRequest:
51+
return event
52+
53+
with capture_internal_exceptions():
54+
DjangoRequestExtractor(request).extract_into_event(event)
55+
56+
if _should_send_default_pii():
57+
with capture_internal_exceptions():
58+
_set_user_info(request, event)
59+
60+
return event
61+
62+
return asgi_request_event_processor
63+
2464

2565
def patch_django_asgi_handler_impl(cls):
2666
# type: (Any) -> None
@@ -31,16 +71,46 @@ def patch_django_asgi_handler_impl(cls):
3171

3272
async def sentry_patched_asgi_handler(self, scope, receive, send):
3373
# type: (Any, Any, Any, Any) -> Any
34-
if Hub.current.get_integration(DjangoIntegration) is None:
74+
hub = Hub.current
75+
integration = hub.get_integration(DjangoIntegration)
76+
if integration is None:
3577
return await old_app(self, scope, receive, send)
3678

3779
middleware = SentryAsgiMiddleware(
3880
old_app.__get__(self, cls), unsafe_context_data=True
3981
)._run_asgi3
82+
4083
return await middleware(scope, receive, send)
4184

4285
cls.__call__ = sentry_patched_asgi_handler
4386

87+
modern_django_asgi_support = hasattr(cls, "create_request")
88+
if modern_django_asgi_support:
89+
old_create_request = cls.create_request
90+
91+
def sentry_patched_create_request(self, *args, **kwargs):
92+
# type: (Any, *Any, **Any) -> Any
93+
hub = Hub.current
94+
integration = hub.get_integration(DjangoIntegration)
95+
if integration is None:
96+
return old_create_request(self, *args, **kwargs)
97+
98+
with hub.configure_scope() as scope:
99+
request, error_response = old_create_request(self, *args, **kwargs)
100+
101+
# read the body once, to signal Django to cache the body stream
102+
# so we can read the body in our event processor
103+
# (otherwise Django closes the body stream and makes it impossible to read it again)
104+
_ = request.body
105+
106+
scope.add_event_processor(
107+
_make_asgi_request_event_processor(request, integration)
108+
)
109+
110+
return request, error_response
111+
112+
cls.create_request = sentry_patched_create_request
113+
44114

45115
def patch_get_response_async(cls, _before_get_response):
46116
# type: (Any, Any) -> None

tests/integrations/django/asgi/test_asgi.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
from sentry_sdk.integrations.django import DjangoIntegration
88
from tests.integrations.django.myapp.asgi import channels_application
99

10+
try:
11+
from django.urls import reverse
12+
except ImportError:
13+
from django.core.urlresolvers import reverse
14+
1015
try:
1116
from unittest import mock # python 3.3 and above
1217
except ImportError:
@@ -353,3 +358,47 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e
353358

354359
assert msg_event["contexts"]["trace"]["trace_id"] == trace_id
355360
assert error_event["contexts"]["trace"]["trace_id"] == trace_id
361+
362+
363+
@pytest.mark.parametrize("application", APPS)
364+
@pytest.mark.parametrize(
365+
"body,expected_return_data",
366+
[
367+
(
368+
b'{"username":"xyz","password":"xyz"}',
369+
{"username": "xyz", "password": "xyz"},
370+
),
371+
(b"hello", ""),
372+
(b"", None),
373+
],
374+
)
375+
@pytest.mark.asyncio
376+
@pytest.mark.skipif(
377+
django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1"
378+
)
379+
async def test_asgi_request_body(
380+
sentry_init, capture_envelopes, application, body, expected_return_data
381+
):
382+
sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
383+
384+
envelopes = capture_envelopes()
385+
386+
comm = HttpCommunicator(
387+
application,
388+
method="POST",
389+
path=reverse("post_echo_async"),
390+
body=body,
391+
headers=[(b"content-type", b"application/json")],
392+
)
393+
response = await comm.get_response()
394+
395+
assert response["status"] == 200
396+
assert response["body"] == body
397+
398+
(envelope,) = envelopes
399+
event = envelope.get_event()
400+
401+
if expected_return_data is not None:
402+
assert event["request"]["data"] == expected_return_data
403+
else:
404+
assert "data" not in event["request"]

tests/integrations/django/myapp/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ def path(path, *args, **kwargs):
8282
path("async/thread_ids", views.thread_ids_async, name="thread_ids_async")
8383
)
8484

85+
if views.post_echo_async is not None:
86+
urlpatterns.append(
87+
path("post_echo_async", views.post_echo_async, name="post_echo_async")
88+
)
89+
8590
# rest framework
8691
try:
8792
urlpatterns.append(

tests/integrations/django/myapp/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,15 @@ def thread_ids_sync(*args, **kwargs):
235235
})
236236
return HttpResponse(response)"""
237237
)
238+
239+
exec(
240+
"""@csrf_exempt
241+
def post_echo_async(request):
242+
sentry_sdk.capture_message("hi")
243+
return HttpResponse(request.body)"""
244+
)
238245
else:
239246
async_message = None
240247
my_async_view = None
241248
thread_ids_async = None
249+
post_echo_async = None

tox.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ deps =
288288
django: Werkzeug<2.1.0
289289
django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0
290290

291-
{py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: pytest-asyncio
292-
{py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: channels[daphne]>2
291+
{py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2}: pytest-asyncio
292+
{py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2}: channels[daphne]>2
293293

294294
django-v{1.8,1.9,1.10,1.11,2.0,2.1}: pytest-django<4.0
295295
django-v{2.2,3.0,3.1,3.2}: pytest-django>=4.0

0 commit comments

Comments
 (0)