Repository: scoutapp/scout_apm_python

Issue:
Improve tastypie support
Currently some [Django Tastypie](https://django-tastypie.readthedocs.io/en/latest/) views are named simply `tastypie.resources` in their operation name. We can probably find better names, and maybe support some Tastypie specific tags!


Discussion:

Resolving patch:
diff --git a/src/scout_apm/compat.py b/src/scout_apm/compat.py
--- a/src/scout_apm/compat.py
+++ b/src/scout_apm/compat.py
@@ -8,6 +8,7 @@
 
 string_type = str if sys.version_info[0] >= 3 else basestring  # noqa: F821
 text_type = str if sys.version_info[0] >= 3 else unicode  # noqa: F821
+string_types = tuple({string_type, text_type})
 
 # Python 2 (and very early 3.x) didn't have ContextDecorator, so define it for ourselves
 if sys.version_info < (3, 2):
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
@@ -1,9 +1,12 @@
 # coding=utf-8
 from __future__ import absolute_import, division, print_function, unicode_literals
 
+import sys
+
 import django
 from django.conf import settings
 
+from scout_apm.compat import string_types
 from scout_apm.core.config import scout_config
 from scout_apm.core.tracked_request import TrackedRequest
 from scout_apm.core.web_requests import (
@@ -34,9 +37,52 @@ def get_operation_name(request):
             + view_func.__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":
+        tastypie_name = _get_tastypie_operation_name(request, view_func)
+        if tastypie_name is not None:
+            return tastypie_name
+
     return "Controller/" + view_name
 
 
+def _get_tastypie_operation_name(request, view_func):
+    try:
+        from tastypie.resources import Resource
+    except ImportError:
+        return None
+
+    if sys.version_info[0] == 2:  # pragma: no cover
+        try:
+            wrapper = view_func.__closure__[0].cell_contents
+        except (AttributeError, IndexError):
+            return None
+    elif sys.version_info[0] == 3:
+        try:
+            wrapper = view_func.__wrapped__
+        except AttributeError:
+            return None
+
+    if not hasattr(wrapper, "__closure__") or len(wrapper.__closure__) != 2:
+        return None
+
+    instance = wrapper.__closure__[0].cell_contents
+    if not isinstance(instance, Resource):  # pragma: no cover
+        return None
+
+    method_name = wrapper.__closure__[1].cell_contents
+    if not isinstance(method_name, string_types):  # pragma: no cover
+        return None
+
+    if method_name.startswith("dispatch_"):  # pragma: no cover
+        method_name = request.method.lower() + method_name.split("dispatch", 1)[1]
+
+    return "Controller/{}.{}.{}".format(
+        instance.__module__, instance.__class__.__name__, method_name
+    )
+
+
 def track_request_view_data(request, tracked_request):
     path = request.path
     tracked_request.tag(


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
@@ -123,16 +123,40 @@ def template_response(request):
     return TemplateResponse(request, template, context)
 
 
+@SimpleLazyObject
+def tastypie_api():
+    """
+    Tastypie API 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
+
+    try:
+        from tastypie.api import Api as TastypieApi
+        from tastypie.resources import ModelResource as TastypieModelResource
+    except ImportError:
+        return None
+
+    class UserResource(TastypieModelResource):
+        class Meta:
+            queryset = User.objects.all()
+            allowed_methods = ["get"]
+
+    api = TastypieApi(api_name="v1")
+    api.register(UserResource())
+    return api
+
+
 @SimpleLazyObject
 def urlpatterns():
     """
     URL's as a lazy object because they touch admin.site.urls and that isn't
     ready until django.setup() has been called
     """
-    try:
-        from django.urls import path
+    if django.VERSION >= (2, 0):
+        from django.urls import include, path
 
-        return [
+        patterns = [
             path("", home),
             path("hello/", hello),
             path("crash/", crash),
@@ -142,10 +166,14 @@ def urlpatterns():
             path("template-response/", template_response),
             path("admin/", admin.site.urls),
         ]
-    except ImportError:  # Django < 2.0
-        from django.conf.urls import url
+        if tastypie_api:
+            patterns.append(path("tastypie-api/", include(tastypie_api.urls)))
+        return patterns
+
+    else:
+        from django.conf.urls import include, url
 
-        return [
+        patterns = [
             url(r"^$", home),
             url(r"^hello/$", hello),
             url(r"^crash/$", crash),
@@ -155,3 +183,6 @@ def urlpatterns():
             url(r"^template-response/$", template_response),
             url(r"^admin/", admin.site.urls),
         ]
+        if tastypie_api:
+            patterns.append(url(r"^tastypie-api/", include(tastypie_api.urls)))
+        return patterns
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
@@ -26,6 +26,13 @@
     parametrize_queue_time_header_name,
     parametrize_user_ip_headers,
 )
+from tests.tools import delete_attributes, pretend_package_unavailable, skip_if_python_2
+
+try:
+    from django.urls import resolve
+except ImportError:
+    from django.core.urlresolvers import resolve
+
 
 skip_unless_new_style_middleware = pytest.mark.skipif(
     django.VERSION < (1, 10), reason="new-style middleware was added in Django 1.10"
@@ -70,6 +77,17 @@ def app_with_scout(**settings):
         yield app
 
 
+def make_admin_user():
+    from django.contrib.auth.models import User
+
+    password = "password"
+    user = User.objects.create_superuser(
+        id=1, username="admin", email="admin@example.com", password=password
+    )
+    user.testing_password = password
+    return user
+
+
 def test_install_sql_instrumentation_again():
     """
     Check second call doesn't crash (should be a no-op)
@@ -294,17 +312,13 @@ def test_template_response(tracked_requests):
 )
 def test_admin_view_operation_name(url, expected_op_name, tracked_requests):
     with app_with_scout() as app:
-        from django.contrib.auth.models import User
-
-        User.objects.create_superuser(
-            id=1, username="admin", email="admin@example.com", password="password"
-        )
+        admin_user = make_admin_user()
         test_app = TestApp(app)
         login_response = test_app.get("/admin/login/")
         assert login_response.status_int == 200
         form = login_response.form
-        form["username"] = "admin"
-        form["password"] = "password"
+        form["username"] = admin_user.username
+        form["password"] = admin_user.testing_password
         form.submit()
         response = test_app.get(url)
 
@@ -317,6 +331,76 @@ def test_admin_view_operation_name(url, expected_op_name, tracked_requests):
     assert span.operation == expected_op_name
 
 
+def skip_if_no_tastypie():
+    if not django_app.tastypie_api:
+        pytest.skip("No Tastypie")
+
+
+@pytest.mark.parametrize(
+    "url, expected_op_name",
+    [
+        [
+            "/tastypie-api/v1/user/1/",
+            "Controller/tests.integration.django_app.UserResource.get_detail",
+        ],
+        [
+            "/tastypie-api/v1/user/",
+            "Controller/tests.integration.django_app.UserResource.get_list",
+        ],
+    ],
+)
+def test_tastypie_api_operation_name(url, expected_op_name, tracked_requests):
+    with app_with_scout() as app:
+        skip_if_no_tastypie()
+        make_admin_user()
+        response = TestApp(app).get(url, {"format": "json"})
+
+    assert response.status_int == 200
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    span = tracked_request.complete_spans[-2]
+    assert span.operation == expected_op_name
+
+
+def test_tastypie_api_operation_name_fail_no_tastypie(tracked_requests):
+    with app_with_scout() as app:
+        skip_if_no_tastypie()
+        with pretend_package_unavailable("tastypie"):
+            response = TestApp(app).get("/tastypie-api/v1/user/", {"format": "json"})
+
+    assert response.status_int == 200
+    span = tracked_requests[0].complete_spans[-2]
+    assert span.operation == "Controller/tastypie.resources.wrapper"
+
+
+@skip_if_python_2
+def test_tastypie_api_operation_name_fail_no_wrapper(tracked_requests):
+    with app_with_scout() as app:
+        skip_if_no_tastypie()
+        url = "/tastypie-api/v1/user/"
+        view_func = resolve(url).func
+        with delete_attributes(view_func, "__wrapped__"):
+            response = TestApp(app).get(url, {"format": "json"})
+
+    assert response.status_int == 200
+    span = tracked_requests[0].complete_spans[-2]
+    assert span.operation == "Controller/tastypie.resources.wrapper"
+
+
+@skip_if_python_2
+def test_tastypie_api_operation_name_fail_no_closure(tracked_requests):
+    with app_with_scout() as app:
+        skip_if_no_tastypie()
+        url = "/tastypie-api/v1/user/"
+        view_func = resolve(url).func
+        with mock.patch.object(view_func, "__wrapped__"):
+            response = TestApp(app).get(url, {"format": "json"})
+
+    assert response.status_int == 200
+    span = tracked_requests[0].complete_spans[-2]
+    assert span.operation == "Controller/tastypie.resources.wrapper"
+
+
 def test_no_monitor(tracked_requests):
     with app_with_scout(SCOUT_MONITOR=False) as app:
         response = TestApp(app).get("/hello/")
diff --git a/tests/tools.py b/tests/tools.py
--- a/tests/tools.py
+++ b/tests/tools.py
@@ -9,6 +9,10 @@
 from scout_apm.core import objtrace
 from tests.compat import mock
 
+skip_if_python_2 = pytest.mark.skipif(
+    sys.version_info[0] == 2, reason="Requires Python 3"
+)
+
 skip_if_objtrace_not_extension = pytest.mark.skipif(
     not objtrace.is_extension, reason="Requires objtrace C extension"
 )
@@ -17,6 +21,16 @@
 )
 
 
+@contextmanager
+def delete_attributes(obj, *attributes):
+    origs = [getattr(obj, attr) for attr in attributes]
+    for attr in attributes:
+        delattr(obj, attr)
+    yield
+    for attr, orig in zip(attributes, origs):
+        setattr(obj, attr, orig)
+
+
 @contextmanager
 def pretend_package_unavailable(name):
     # Scrub it and sub-modules from sys.modules