Repository: scoutapp/scout_apm_python

Issue:
Instrument the Django REST Framework (DRF)
Provide more detailed instrumentation of the Django REST Framework (DRF).

http://www.django-rest-framework.org/


Discussion:
@cschneid wrote on Slack that one customer reported no controller instrumentation for DRF - I'll investigate!
I set up a DRF test app ( https://github.com/adamchainz/scout-test-apps/tree/master/django_drf ) and found the current Django integration will track DRF `APIView`s and `ViewSet`s like other Django views. Version 2.2.0 that was just released includes some fixes from #203 that help here.  During this research I found two more things we could track:

* Setting the controller method to include which method inside a `ViewSet` was called
* Hooking into DRF's internal exception handling ( https://github.com/encode/django-rest-framework/blob/3.10.1/rest_framework/views.py#L436 ) so these responses are also tracked as errors

I'm sure there are other places in DRF we could add extra instrumentation, any comments appreciated.

Resolving patch:
diff --git a/src/scout_apm/django/middleware.py b/src/scout_apm/django/middleware.py
--- a/src/scout_apm/django/middleware.py
+++ b/src/scout_apm/django/middleware.py
@@ -37,6 +37,12 @@ def get_operation_name(request):
             + view_func.__name__
         )
 
+    django_rest_framework_name = _get_django_rest_framework_name(
+        request, view_func, view_name
+    )
+    if django_rest_framework_name is not None:
+        return django_rest_framework_name
+
     # Seems to be a Tastypie Resource. Need to resort to some stack inspection
     # to find a better name since its decorators don't wrap very well
     if view_name == "tastypie.resources.wrapper":
@@ -47,6 +53,28 @@ def get_operation_name(request):
     return "Controller/" + view_name
 
 
+def _get_django_rest_framework_name(request, view_func, view_name):
+    try:
+        from rest_framework.viewsets import ViewSetMixin
+    except ImportError:
+        return None
+
+    kls = getattr(view_func, "cls", None)
+    if isinstance(kls, type) and not issubclass(kls, ViewSetMixin):
+        return None
+
+    # Get 'actions' set in ViewSetMixin.as_view
+    actions = getattr(view_func, "actions", None)
+    if not actions or not isinstance(actions, dict):
+        return None
+
+    method_lower = request.method.lower()
+    if method_lower not in actions:
+        return None
+
+    return "Controller/{}.{}".format(view_name, actions[method_lower])
+
+
 def _get_tastypie_operation_name(request, view_func):
     try:
         from tastypie.resources import Resource


Test patch:
diff --git a/tests/integration/django_app.py b/tests/integration/django_app.py
--- a/tests/integration/django_app.py
+++ b/tests/integration/django_app.py
@@ -154,6 +154,30 @@ def template_response(request):
     return TemplateResponse(request, template, context)
 
 
+@SimpleLazyObject
+def drf_router():
+    """
+    DRF Router as a lazy object because it needs to import User model which
+    can't be done until after django.setup()
+    """
+    from django.contrib.auth.models import User
+    from rest_framework import routers
+    from rest_framework import serializers
+    from rest_framework import viewsets
+
+    class UserSerializer(serializers.Serializer):
+        id = serializers.IntegerField(label="ID", read_only=True)
+        username = serializers.CharField(max_length=200)
+
+    class UserViewSet(viewsets.ModelViewSet):
+        queryset = User.objects.all()
+        serializer_class = UserSerializer
+
+    router = routers.SimpleRouter()
+    router.register(r"users", UserViewSet)
+    return router
+
+
 @SimpleLazyObject
 def tastypie_api():
     """
@@ -199,6 +223,7 @@ def urlpatterns():
             path("template/", template),
             path("template-response/", template_response),
             path("admin/", admin.site.urls),
+            path("drf-router/", include(drf_router.urls)),
         ]
         if tastypie_api:
             patterns.append(path("tastypie-api/", include(tastypie_api.urls)))
@@ -219,6 +244,7 @@ def urlpatterns():
             url(r"^template/$", template),
             url(r"^template-response/$", template_response),
             url(r"^admin/", admin.site.urls),
+            url(r"^drf-router/", include(drf_router.urls)),
         ]
         if tastypie_api:
             patterns.append(url(r"^tastypie-api/", include(tastypie_api.urls)))
diff --git a/tests/integration/test_django.py b/tests/integration/test_django.py
--- a/tests/integration/test_django.py
+++ b/tests/integration/test_django.py
@@ -387,7 +387,40 @@ def test_admin_view_operation_name(url, expected_op_name, tracked_requests):
     assert span.operation == expected_op_name
 
 
+@pytest.mark.parametrize(
+    "url, expected_op_name",
+    [
+        [
+            "/drf-router/users/",
+            "Controller/tests.integration.django_app.UserViewSet.list",
+        ],
+        [
+            "/drf-router/users/1/",
+            "Controller/tests.integration.django_app.UserViewSet.retrieve",
+        ],
+    ],
+)
+def test_django_rest_framework_api_operation_name(
+    url, expected_op_name, tracked_requests
+):
+    with app_with_scout() as app:
+        make_admin_user()
+        response = TestApp(app).get(url)
+
+    assert response.status_int == 200
+    assert len(tracked_requests) == 1
+    spans = tracked_requests[0].complete_spans
+    assert [s.operation for s in spans] == [
+        "SQL/Query",
+        expected_op_name,
+        "Middleware",
+    ]
+
+
 def skip_if_no_tastypie():
+    # This would make more sense as a test decorator, but can't be one because
+    # it requires the Django application to be constructed first, under
+    # app_with_scout()
     if not django_app.tastypie_api:
         pytest.skip("No Tastypie")