diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 585abe25de..5a41654498 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import json from copy import deepcopy @@ -7,6 +9,12 @@ from sentry_sdk._types import TYPE_CHECKING +try: + from django.http.request import RawPostDataException +except ImportError: + RawPostDataException = None + + if TYPE_CHECKING: import sentry_sdk @@ -67,10 +75,22 @@ def extract_into_event(self, event): if not request_body_within_bounds(client, content_length): data = AnnotatedValue.removed_because_over_size_limit() else: + # First read the raw body data + # It is important to read this first because if it is Django + # it will cache the body and then we can read the cached version + # again in parsed_body() (or json() or wherever). + raw_data = None + try: + raw_data = self.raw_data() + except (RawPostDataException, ValueError): + # If DjangoRestFramework is used it already read the body for us + # so reading it here will fail. We can ignore this. + pass + parsed_body = self.parsed_body() if parsed_body is not None: data = parsed_body - elif self.raw_data(): + elif raw_data: data = AnnotatedValue.removed_because_raw_data() else: data = None diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index bd785a23c2..18f6a58811 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -94,12 +94,6 @@ def sentry_patched_create_request(self, *args, **kwargs): with hub.configure_scope() as scope: request, error_response = old_create_request(self, *args, **kwargs) - - # read the body once, to signal Django to cache the body stream - # so we can read the body in our event processor - # (otherwise Django closes the body stream and makes it impossible to read it again) - _ = request.body - scope.add_event_processor(_make_asgi_request_event_processor(request)) return request, error_response diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py index c1034d0d85..d918afad66 100644 --- a/sentry_sdk/integrations/django/views.py +++ b/sentry_sdk/integrations/django/views.py @@ -47,13 +47,13 @@ def sentry_patched_make_view_atomic(self, *args, **kwargs): hub = Hub.current integration = hub.get_integration(DjangoIntegration) - if integration is not None and integration.middleware_spans: - if ( + is_async_view = ( iscoroutinefunction is not None and wrap_async_view is not None and iscoroutinefunction(callback) - ): + ) + if is_async_view: sentry_wrapped_callback = wrap_async_view(hub, callback) else: sentry_wrapped_callback = _wrap_sync_view(hub, callback) diff --git a/tests/integrations/django/asgi/image.png b/tests/integrations/django/asgi/image.png new file mode 100644 index 0000000000..8db277a9fc Binary files /dev/null and b/tests/integrations/django/asgi/image.png differ diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index c7f5f1dfd9..21a72e4a32 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -1,4 +1,6 @@ +import base64 import json +import os import django import pytest @@ -370,16 +372,105 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e assert error_event["contexts"]["trace"]["trace_id"] == trace_id +PICTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "image.png") +BODY_FORM = """--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="username"\r\n\r\nJane\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="password"\r\n\r\nhello123\r\n--fd721ef49ea403a6\r\nContent-Disposition: form-data; name="photo"; filename="image.png"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: base64\r\n\r\n{{image_data}}\r\n--fd721ef49ea403a6--\r\n""".replace( + "{{image_data}}", base64.b64encode(open(PICTURE, "rb").read()).decode("utf-8") +).encode( + "utf-8" +) +BODY_FORM_CONTENT_LENGTH = str(len(BODY_FORM)).encode("utf-8") + + @pytest.mark.parametrize("application", APPS) @pytest.mark.parametrize( - "body,expected_return_data", + "send_default_pii,method,headers,url_name,body,expected_data", [ ( + True, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"", + None, + ), + ( + True, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"some raw text body", + "", + ), + ( + True, + "POST", + [(b"content-type", b"application/json")], + "post_echo_async", b'{"username":"xyz","password":"xyz"}', {"username": "xyz", "password": "xyz"}, ), - (b"hello", ""), - (b"", None), + ( + True, + "POST", + [(b"content-type", b"application/xml")], + "post_echo_async", + b'', + "", + ), + ( + True, + "POST", + [ + (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"), + (b"content-length", BODY_FORM_CONTENT_LENGTH), + ], + "post_echo_async", + BODY_FORM, + {"password": "hello123", "photo": "", "username": "Jane"}, + ), + ( + False, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"", + None, + ), + ( + False, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"some raw text body", + "", + ), + ( + False, + "POST", + [(b"content-type", b"application/json")], + "post_echo_async", + b'{"username":"xyz","password":"xyz"}', + {"username": "xyz", "password": "[Filtered]"}, + ), + ( + False, + "POST", + [(b"content-type", b"application/xml")], + "post_echo_async", + b'', + "", + ), + ( + False, + "POST", + [ + (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"), + (b"content-length", BODY_FORM_CONTENT_LENGTH), + ], + "post_echo_async", + BODY_FORM, + {"password": "[Filtered]", "photo": "", "username": "Jane"}, + ), ], ) @pytest.mark.asyncio @@ -388,28 +479,42 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) async def test_asgi_request_body( - sentry_init, capture_envelopes, application, body, expected_return_data + sentry_init, + capture_envelopes, + application, + send_default_pii, + method, + headers, + url_name, + body, + expected_data, ): - sentry_init(integrations=[DjangoIntegration()], send_default_pii=True) + sentry_init( + send_default_pii=send_default_pii, + integrations=[ + DjangoIntegration(), + ], + ) envelopes = capture_envelopes() comm = HttpCommunicator( application, - method="POST", - path=reverse("post_echo_async"), + method=method, + headers=headers, + path=reverse(url_name), body=body, - headers=[(b"content-type", b"application/json")], ) response = await comm.get_response() - assert response["status"] == 200 + + await comm.wait() assert response["body"] == body (envelope,) = envelopes event = envelope.get_event() - if expected_return_data is not None: - assert event["request"]["data"] == expected_return_data + if expected_data is not None: + assert event["request"]["data"] == expected_data else: assert "data" not in event["request"] diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index 6362adc121..08262b4e8a 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -237,10 +237,10 @@ def thread_ids_sync(*args, **kwargs): ) exec( - """@csrf_exempt -def post_echo_async(request): + """async def post_echo_async(request): sentry_sdk.capture_message("hi") - return HttpResponse(request.body)""" + return HttpResponse(request.body) +post_echo_async.csrf_exempt = True""" ) else: async_message = None