From ea6b0eeea98c614900babd35ccd061523d1df822 Mon Sep 17 00:00:00 2001 From: Marcelo Galigniana Date: Sat, 1 Aug 2026 22:51:19 -0300 Subject: [PATCH] feat(integrations): Add span for DRF authentication --- sentry_sdk/consts.py | 1 + sentry_sdk/integrations/django/__init__.py | 41 +++++++++++ tests/integrations/django/myapp/urls.py | 14 ++++ tests/integrations/django/myapp/views.py | 17 ++++- tests/integrations/django/test_basic.py | 84 ++++++++++++++++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index c6db94d780..985a7e5ce1 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1257,6 +1257,7 @@ class OP: SUBPROCESS_WAIT = "subprocess.wait" SUBPROCESS_COMMUNICATE = "subprocess.communicate" TEMPLATE_RENDER = "template.render" + VIEW_AUTHENTICATE = "view.authenticate" VIEW_RENDER = "view.render" VIEW_RESPONSE_RENDER = "view.response.render" WEBSOCKET_SERVER = "websocket.server" diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 0f8dfcd24a..6068df2587 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -298,6 +298,10 @@ def _patch_drf() -> None: DRF request object, such that we can later use either in `DjangoRequestExtractor`. + We also patch DRF's authentication to create a span, so that the work done + by the configured authentication classes (which often involves database + queries) doesn't show up as part of the view itself. + This function is not called directly on SDK setup, because importing almost any part of Django Rest Framework will try to access Django settings (where `sentry_sdk.init()` might be called from in the first place). Instead we @@ -339,6 +343,43 @@ def sentry_patched_drf_initial( APIView.initial = sentry_patched_drf_initial + with capture_internal_exceptions(): + try: + from rest_framework.request import Request # type: ignore + except ImportError: + pass + else: + old_drf_authenticate = Request._authenticate + + def sentry_patched_drf_authenticate(self: "Request") -> "Any": + client = sentry_sdk.get_client() + integration = client.get_integration(DjangoIntegration) + # Nothing to time if there are no authenticators configured + # for this view. + if integration is None or not getattr(self, "authenticators", None): + return old_drf_authenticate(self) + + if has_span_streaming_enabled(client.options): + if sentry_sdk.traces.get_current_span() is None: + return old_drf_authenticate(self) + with sentry_sdk.traces.start_span( + name="authenticate", + attributes={ + "sentry.op": OP.VIEW_AUTHENTICATE, + "sentry.origin": DjangoIntegration.origin, + }, + ): + return old_drf_authenticate(self) + else: + with sentry_sdk.start_span( + op=OP.VIEW_AUTHENTICATE, + name="authenticate", + origin=DjangoIntegration.origin, + ): + return old_drf_authenticate(self) + + Request._authenticate = sentry_patched_drf_authenticate + def _patch_channels() -> None: try: diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index 2c1cad4298..87d11b791b 100644 --- a/tests/integrations/django/myapp/urls.py +++ b/tests/integrations/django/myapp/urls.py @@ -150,6 +150,20 @@ def path(path, *args, **kwargs): ) ) urlpatterns.append(path("rest-hello", views.rest_hello, name="rest_hello")) + urlpatterns.append( + path( + "rest-authenticated-hello", + views.rest_authenticated_hello, + name="rest_authenticated_hello", + ) + ) + urlpatterns.append( + path( + "rest-unauthenticated-hello", + views.rest_unauthenticated_hello, + name="rest_unauthenticated_hello", + ) + ) urlpatterns.append( path("rest-json-response", views.rest_json_response, name="rest_json_response") ) diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index ebaa3b37eb..21f27455a5 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -22,9 +22,24 @@ ) try: - from rest_framework.decorators import api_view + from rest_framework.authentication import BaseAuthentication + from rest_framework.decorators import api_view, authentication_classes from rest_framework.response import Response + class DummyAuthentication(BaseAuthentication): + def authenticate(self, request): + return None + + @api_view(["GET"]) + @authentication_classes([DummyAuthentication]) + def rest_authenticated_hello(request): + return HttpResponse("ok") + + @api_view(["GET"]) + @authentication_classes([]) + def rest_unauthenticated_hello(request): + return HttpResponse("ok") + @api_view(["POST"]) def rest_framework_exc(request): 1 / 0 diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 8f6c32cdb0..93b5477010 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1642,6 +1642,90 @@ def test_rest_framework_basic( assert event["request"]["headers"]["Content-Type"] == ct +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_rest_framework_authentication_span( + sentry_init, + client, + capture_events, + capture_items, + render_span_tree, + span_streaming, +): + pytest.importorskip("rest_framework") + sentry_init( + integrations=[ + DjangoIntegration(middleware_spans=False, signals_spans=False), + ], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + if span_streaming: + items = capture_items("span") + + client.get(reverse("rest_authenticated_hello")) + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + + assert ( + render_span_tree(spans) + == """\ +- sentry.op="http.server": name="/rest-authenticated-hello" + - sentry.op="view.authenticate": name="authenticate"\ +""" + ) + else: + events = capture_events() + + client.get(reverse("rest_authenticated_hello")) + + (transaction,) = events + + assert ( + render_span_tree(transaction["spans"], transaction["contexts"]["trace"]) + == """\ +- op="http.server": description=null + - op="view.authenticate": description="authenticate"\ +""" + ) + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_rest_framework_authentication_span_without_authenticators( + sentry_init, + client, + capture_events, + capture_items, + span_streaming, +): + pytest.importorskip("rest_framework") + sentry_init( + integrations=[ + DjangoIntegration(middleware_spans=False, signals_spans=False), + ], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + if span_streaming: + items = capture_items("span") + + client.get(reverse("rest_unauthenticated_hello")) + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + + # only the root span + assert len(spans) == 1 + else: + events = capture_events() + + client.get(reverse("rest_unauthenticated_hello")) + + (transaction,) = events + + assert transaction["spans"] == [] + + @pytest.mark.parametrize( "endpoint", ["rest_permission_denied_exc", "permission_denied_exc"] )