From fe0421113219578a2db449cc3851859614042846 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:57:57 -0300 Subject: [PATCH] Fix transaction naming for django-ninja endpoints with transaction_style="function_name" (#1) * Initial plan * Add django-ninja support for transaction_style=function_name Co-authored-by: avilaton <2209022+avilaton@users.noreply.github.com> * Address code review feedback: fix exception handling and improve test Co-authored-by: avilaton <2209022+avilaton@users.noreply.github.com> * Fix trailing whitespace in docstring Co-authored-by: avilaton <2209022+avilaton@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: avilaton <2209022+avilaton@users.noreply.github.com> --- sentry_sdk/integrations/django/__init__.py | 36 ++++++++++ tests/integrations/django/myapp/urls.py | 4 ++ tests/integrations/django/myapp/views.py | 26 ++++++++ tests/integrations/django/test_ninja.py | 78 ++++++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 tests/integrations/django/test_ninja.py diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 2595c33ea8..9d9d986331 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -382,6 +382,41 @@ def _patch_django_asgi_handler() -> None: patch_django_asgi_handler_impl(ASGIHandler) +def _unwrap_django_ninja_view(fn: "Callable[..., Any]", request: "WSGIRequest") -> "Callable[..., Any]": + """ + Unwrap django-ninja PathView to get the actual endpoint function. + + Django-ninja wraps endpoint functions in PathView.get_view() which returns + a sync_view_wrapper or async_view_wrapper. These wrappers have a closure + that contains the PathView instance, which has the list of operations. + We need to find the operation that matches the request method to get the + actual endpoint function. + """ + try: + # Check if this is a django-ninja view wrapper by looking for PathView in closure + if ( + hasattr(fn, "__closure__") + and fn.__closure__ + and hasattr(fn, "__qualname__") + and "PathView.get_view" in fn.__qualname__ + ): + # Extract PathView from the closure + for cell in fn.__closure__: + path_view = cell.cell_contents + # Check if this is actually a PathView instance + if ( + type(path_view).__name__ == "PathView" + and hasattr(path_view, "operations") + ): + # Find the operation matching the request method + for operation in path_view.operations: + if request.method in operation.methods: + return operation.view_func + except Exception: + pass + return fn + + def _set_transaction_name_and_source( scope: "sentry_sdk.Scope", transaction_style: str, request: "WSGIRequest" ) -> None: @@ -389,6 +424,7 @@ def _set_transaction_name_and_source( transaction_name = None if transaction_style == "function_name": fn = resolve(request.path).func + fn = _unwrap_django_ninja_view(fn, request) transaction_name = transaction_from_function(getattr(fn, "view_class", fn)) elif transaction_style == "url": diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index 26d5a1bf2c..24589e4b8b 100644 --- a/tests/integrations/django/myapp/urls.py +++ b/tests/integrations/django/myapp/urls.py @@ -157,5 +157,9 @@ def path(path, *args, **kwargs): except AttributeError: pass +# django-ninja +if views.ninja_api is not None: + urlpatterns.append(path("ninja/", views.ninja_api.urls)) + handler500 = views.handler500 handler404 = views.handler404 diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index 6d199a3740..7c79ba1781 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -370,3 +370,29 @@ def send_myapp_custom_signal(request): myapp_custom_signal.send(sender="hello") myapp_custom_signal_silenced.send(sender="hello") return HttpResponse("ok") + + +# django-ninja integration +try: + from ninja import NinjaAPI + + ninja_api = NinjaAPI() + + @ninja_api.get("/ninja-hello") + def ninja_hello(request): + """Ninja hello endpoint""" + return {"message": "Hello from Ninja"} + + @ninja_api.post("/ninja-hello") + def ninja_hello_post(request): + """Ninja hello POST endpoint""" + return {"message": "POST Hello from Ninja"} + + @ninja_api.get("/ninja-message") + def ninja_message(request): + """Ninja message endpoint that captures a message""" + capture_message("ninja message") + return {"message": "Ninja message sent"} + +except ImportError: + ninja_api = None diff --git a/tests/integrations/django/test_ninja.py b/tests/integrations/django/test_ninja.py new file mode 100644 index 0000000000..93e7f0dc75 --- /dev/null +++ b/tests/integrations/django/test_ninja.py @@ -0,0 +1,78 @@ +"""Tests for django-ninja integration with transaction_style="function_name".""" + +import pytest + +django = pytest.importorskip("django") +ninja = pytest.importorskip("ninja") + +from sentry_sdk.integrations.django import DjangoIntegration + + +@pytest.mark.parametrize( + "transaction_style,url,expected_transaction,expected_source", + [ + ( + "function_name", + "/ninja/ninja-message", + "tests.integrations.django.myapp.views.ninja_message", + "component", + ), + ( + "url", + "/ninja/ninja-message", + "/ninja/ninja-message", + "route", + ), + ], +) +def test_ninja_transaction_style( + sentry_init, + client, + capture_events, + transaction_style, + url, + expected_transaction, + expected_source, +): + """Test that django-ninja endpoints work correctly with different transaction styles.""" + sentry_init( + integrations=[DjangoIntegration(transaction_style=transaction_style)], + send_default_pii=True, + ) + events = capture_events() + + # Make the request + response = client.get(url) + assert response.status_code == 200 + + # Check the captured event + (event,) = events + assert event["transaction"] == expected_transaction + assert event["transaction_info"] == {"source": expected_source} + + +def test_ninja_multiple_methods_same_path( + sentry_init, + client, + capture_events, +): + """Test that ninja endpoints with multiple HTTP methods on same path work correctly.""" + sentry_init( + integrations=[DjangoIntegration(transaction_style="function_name")], + send_default_pii=True, + ) + events = capture_events() + + # Test GET + response = client.get("/ninja/ninja-hello") + assert response.status_code == 200 + + # Test POST - note that ninja-hello has both GET and POST handlers + response = client.post("/ninja/ninja-hello") + assert response.status_code == 200 + + # We should have 0 events because ninja-hello doesn't capture messages + # This test just validates that the endpoints don't error and can be properly + # resolved by the integration without crashing + assert len(events) == 0 +