Repository: DataDog/dd-trace-py

Issue:
(django) fix crash in django trace response with pre-loaded template
Previous code assumed `response.template_name` is a string. This would throw an exception in views that load templates themselves and pass to `TemplateResponse`, since they hold on to a reference to a `Template` object. Per django's documentation (https://docs.djangoproject.com/en/3.0/ref/template-response/#django.template.response.SimpleTemplateResponse.__init__), template could be:

> A backend-dependent template object (such as those returned by get_template()), the name of a template, or a list of template names.

This fix accounts for the other cases.

---

I've added some test coverage as well. Please let me know if there are any additional coverage that might be helpful, or changes you think I should make. 


Discussion:

Resolving patch:
diff --git a/ddtrace/contrib/django/patch.py b/ddtrace/contrib/django/patch.py
--- a/ddtrace/contrib/django/patch.py
+++ b/ddtrace/contrib/django/patch.py
@@ -11,7 +11,7 @@
 from inspect import isclass, isfunction
 
 from ddtrace import config, Pin
-from ddtrace.vendor import debtcollector, wrapt
+from ddtrace.vendor import debtcollector, six, wrapt
 from ddtrace.compat import getattr_static
 from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
 from ddtrace.contrib import func_name, dbapi
@@ -424,7 +424,25 @@ def traced_get_response(django, pin, func, instance, args, kwargs):
                     span.error = 1
                 span.set_tag("django.response.class", func_name(response))
                 if hasattr(response, "template_name"):
-                    utils.set_tag_array(span, "django.response.template", response.template_name)
+                    # template_name is a bit of a misnomer, as it could be any of:
+                    # a list of strings, a tuple of strings, a single string, or an instance of Template
+                    # for more detail, see:
+                    # https://docs.djangoproject.com/en/3.0/ref/template-response/#django.template.response.SimpleTemplateResponse.template_name
+                    template = response.template_name
+
+                    if isinstance(template, six.string_types):
+                        template_names = [template]
+                    elif isinstance(template, (list, tuple,)):
+                        template_names = template
+                    elif hasattr(template, "template"):
+                        # ^ checking by attribute here because
+                        # django backend implementations don't have a common base
+                        # `.template` is also the most consistent across django versions
+                        template_names = [template.template.name]
+                    else:
+                        template_names = None
+
+                    utils.set_tag_array(span, "django.response.template", template_names)
 
                 headers = dict(response.items())
                 store_response_headers(headers, span, config.django)


Test patch:
diff --git a/tests/contrib/django/django1_app/urls.py b/tests/contrib/django/django1_app/urls.py
--- a/tests/contrib/django/django1_app/urls.py
+++ b/tests/contrib/django/django1_app/urls.py
@@ -17,4 +17,7 @@
     url(r"^partial-view/$", views.partial_view, name="partial-view"),
     url(r"^lambda-view/$", views.lambda_view, name="lambda-view"),
     url(r"^error-500/$", views.error_500, name="error-500"),
+    url(r"^template-view/$", views.template_view, name="template-view"),
+    url(r"^template-simple-view/$", views.template_simple_view, name="template-simple-view"),
+    url(r"^template-list-view/$", views.template_list_view, name="template-list-view"),
 ]
diff --git a/tests/contrib/django/django_app/urls.py b/tests/contrib/django/django_app/urls.py
--- a/tests/contrib/django/django_app/urls.py
+++ b/tests/contrib/django/django_app/urls.py
@@ -43,6 +43,9 @@ def authenticated_view(request):
     url(r"^partial-view/$", views.partial_view, name="partial-view"),
     url(r"^lambda-view/$", views.lambda_view, name="lambda-view"),
     url(r"^error-500/$", views.error_500, name="error-500"),
+    url(r"^template-view/$", views.template_view, name="template-view"),
+    url(r"^template-simple-view/$", views.template_simple_view, name="template-simple-view"),
+    url(r"^template-list-view/$", views.template_list_view, name="template-list-view"),
     re_path(r"re-path.*/", repath_view),
     path("path/", path_view),
     path("include/", include("tests.contrib.django.django_app.extra_urls")),
diff --git a/tests/contrib/django/templates/basic.html b/tests/contrib/django/templates/basic.html
new file mode 100644
--- /dev/null
+++ b/tests/contrib/django/templates/basic.html
@@ -0,0 +1 @@
+some content
diff --git a/tests/contrib/django/test_django.py b/tests/contrib/django/test_django.py
--- a/tests/contrib/django/test_django.py
+++ b/tests/contrib/django/test_django.py
@@ -411,6 +411,61 @@ def test_lambda_based_view(client, test_spans):
         assert span.resource == "GET tests.contrib.django.views.<lambda>"
 
 
+def test_template_view(client, test_spans):
+    resp = client.get("/template-view/")
+    assert resp.status_code == 200
+    assert resp.content == b"some content\n"
+
+    view_spans = list(test_spans.filter_spans(name="django.view"))
+    assert len(view_spans) == 1
+
+    # Assert span properties
+    view_span = view_spans[0]
+    view_span.assert_matches(
+        name="django.view", service="django", resource="tests.contrib.django.views.template_view", error=0,
+    )
+
+    root_span = test_spans.get_root_span()
+    assert root_span.get_tag("django.response.template") == "basic.html"
+
+
+def test_template_simple_view(client, test_spans):
+    resp = client.get("/template-simple-view/")
+    assert resp.status_code == 200
+    assert resp.content == b"some content\n"
+
+    view_spans = list(test_spans.filter_spans(name="django.view"))
+    assert len(view_spans) == 1
+
+    # Assert span properties
+    view_span = view_spans[0]
+    view_span.assert_matches(
+        name="django.view", service="django", resource="tests.contrib.django.views.template_simple_view", error=0,
+    )
+
+    root_span = test_spans.get_root_span()
+    assert root_span.get_tag("django.response.template") == "basic.html"
+
+
+def test_template_list_view(client, test_spans):
+    resp = client.get("/template-list-view/")
+    assert resp.status_code == 200
+    assert resp.content == b"some content\n"
+
+    view_spans = list(test_spans.filter_spans(name="django.view"))
+    assert len(view_spans) == 1
+
+    # Assert span properties
+    view_span = view_spans[0]
+    view_span.assert_matches(
+        name="django.view", service="django", resource="tests.contrib.django.views.template_list_view", error=0,
+    )
+
+    root_span = test_spans.get_root_span()
+    assert root_span.get_tag("django.response.template.0") == "doesntexist.html"
+    assert root_span.get_tag("django.response.template.1") == "basic.html"
+
+
 def test_middleware_trace_function_based_view(client, test_spans):
     # ensures that the internals are properly traced when using a function views
     assert client.get("/fn-view/").status_code == 200
diff --git a/tests/contrib/django/views.py b/tests/contrib/django/views.py
--- a/tests/contrib/django/views.py
+++ b/tests/contrib/django/views.py
@@ -4,12 +4,12 @@
 
 from functools import partial
 
-from django.http import HttpResponse
-
-from django.views.generic import ListView, TemplateView, View
-
 from django.contrib.auth.models import User
 from django.contrib.syndication.views import Feed
+from django.http import HttpResponse
+from django.template import loader
+from django.template.response import TemplateResponse
+from django.views.generic import ListView, TemplateView, View
 
 
 class UserList(ListView):
@@ -84,3 +84,25 @@ def index(request):
     response = HttpResponse("Hello, test app.")
     response["my-response-header"] = "my_response_value"
     return response
+
+
+def template_view(request):
+    """
+    View that uses a template instance
+    """
+    template = loader.select_template(["basic.html"])
+    return TemplateResponse(request, template)
+
+
+def template_simple_view(request):
+    """
+    Basic django templated view
+    """
+    return TemplateResponse(request, "basic.html")
+
+
+def template_list_view(request):
+    """
+    For testing resolving a list of templates
+    """
+    return TemplateResponse(request, ["doesntexist.html", "basic.html"])