Skip to content

Commit f0c7625

Browse files
committed
fix(django): trace mro chain for Django views (#1625)
1 parent dcf18b4 commit f0c7625

File tree

6 files changed

+86
-12
lines changed

6 files changed

+86
-12
lines changed

ddtrace/contrib/django/patch.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99
import sys
1010

11-
from inspect import isclass, isfunction
11+
from inspect import isclass, isfunction, getmro
1212

1313
from ddtrace import config, Pin
1414
from ddtrace.vendor import debtcollector, wrapt
@@ -434,17 +434,31 @@ def traced_template_render(django, pin, wrapped, instance, args, kwargs):
434434

435435

436436
def instrument_view(django, view):
437+
"""
438+
Helper to wrap Django views.
439+
440+
We want to wrap all lifecycle/http method functions for every class in the MRO for this view
441+
"""
442+
if isfunction(view):
443+
return _instrument_view(django, view)
444+
445+
for cls in reversed(getmro(view)):
446+
_instrument_view(django, cls)
447+
448+
return view
449+
450+
451+
def _instrument_view(django, view):
437452
"""Helper to wrap Django views."""
438453
# All views should be callable, double check before doing anything
439-
if not callable(view) or isinstance(view, wrapt.ObjectProxy):
454+
if not callable(view):
440455
return view
441456

442457
# Patch view HTTP methods and lifecycle methods
443458
http_method_names = getattr(view, "http_method_names", ("get", "delete", "post", "options", "head"))
444459
lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed")
445460
for name in list(http_method_names) + list(lifecycle_methods):
446461
try:
447-
# View methods can be staticmethods
448462
func = getattr(view, name, None)
449463
if not func or isinstance(func, wrapt.ObjectProxy):
450464
continue
@@ -472,8 +486,10 @@ def instrument_view(django, view):
472486
except Exception:
473487
log.debug("Failed to instrument Django response %r function %s", response_cls, name, exc_info=True)
474488

475-
# Return a wrapped version of this view
476-
return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
489+
# If the view itself is not wrapped, wrap it
490+
if not isinstance(view, wrapt.ObjectProxy):
491+
view = wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
492+
return view
477493

478494

479495
@with_traced_module

tests/contrib/django/django1_app/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.conf.urls import url
2+
from django.views.generic import TemplateView
23
from django.views.decorators.cache import cache_page
34

45
from .. import views
@@ -17,6 +18,9 @@
1718
url(r"^partial-view/$", views.partial_view, name="partial-view"),
1819
url(r"^lambda-view/$", views.lambda_view, name="lambda-view"),
1920
url(r"^error-500/$", views.error_500, name="error-500"),
21+
# This must precede composed tests.
22+
url(r"some-static-view/", TemplateView.as_view(template_name="my-template.html")),
2023
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
2124
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
25+
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
2226
]

tests/contrib/django/django_app/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.auth import login
33
from django.contrib.auth.models import User
44
from django.http import HttpResponse
5+
from django.views.generic import TemplateView
56
from django.views.decorators.cache import cache_page
67
from django.urls import include, path, re_path
78

@@ -46,6 +47,9 @@ def authenticated_view(request):
4647
re_path(r"re-path.*/", repath_view),
4748
path("path/", path_view),
4849
path("include/", include("tests.contrib.django.django_app.extra_urls")),
50+
# This must precede composed-view.
51+
url(r"^some-static-view/$", TemplateView.as_view(template_name="my-template.html")),
4952
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
5053
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
54+
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
5155
]

tests/contrib/django/test_django.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import django
2+
from django.views.generic import TemplateView
23
from django.test import modify_settings, override_settings
34
import os
45
import pytest
56

67
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY, SAMPLING_PRIORITY_KEY
8+
from ddtrace.contrib.django.patch import instrument_view
79
from ddtrace.ext import http, errors
810
from ddtrace.ext.priority import USER_KEEP
911
from ddtrace.propagation.http import HTTP_HEADER_TRACE_ID, HTTP_HEADER_PARENT_ID, HTTP_HEADER_SAMPLING_PRIORITY
@@ -492,7 +494,7 @@ def test_simple_view_options(client, test_spans):
492494
assert len(spans) == 1
493495
span = spans[0]
494496
span.assert_matches(
495-
resource="tests.contrib.django.views.BasicView.options", error=0,
497+
resource="django.views.generic.base.View.options", error=0,
496498
)
497499

498500

@@ -1272,7 +1274,9 @@ def test_custom_dispatch_template_view(client, test_spans):
12721274

12731275
spans = test_spans.get_spans()
12741276
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1275-
"tests.contrib.django.views.ComposedTemplateView.dispatch",
1277+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1278+
"tests.contrib.django.views.AnotherCustomDispatchMixin.dispatch",
1279+
"django.views.generic.base.View.dispatch",
12761280
]
12771281

12781282

@@ -1287,8 +1291,40 @@ def test_custom_dispatch_get_view(client, test_spans):
12871291

12881292
spans = test_spans.get_spans()
12891293
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1290-
"tests.contrib.django.views.ComposedGetView.dispatch",
1294+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1295+
"django.views.generic.base.View.dispatch",
12911296
]
12921297
assert [s.resource for s in spans if s.resource.endswith("get")] == [
12931298
"tests.contrib.django.views.ComposedGetView.get",
1299+
"tests.contrib.django.views.CustomGetView.get",
12941300
]
1301+
1302+
1303+
def test_view_mixin(client, test_spans):
1304+
from tests.contrib.django import views
1305+
1306+
assert views.DISPATCH_CALLED is False
1307+
resp = client.get("/composed-view/")
1308+
assert views.DISPATCH_CALLED is True
1309+
1310+
assert resp.status_code == 200
1311+
assert resp.content.strip() == b"custom dispatch"
1312+
1313+
1314+
def test_template_view_patching():
1315+
"""
1316+
Test to ensure that patching a view does not give it properties it did not have before
1317+
"""
1318+
# DEV: `vars(cls)` will give you only the properties defined on that class,
1319+
# it will not traverse through __mro__ to find the property from a parent class
1320+
1321+
# We are not starting with a "dispatch" property
1322+
assert "dispatch" not in vars(TemplateView)
1323+
1324+
# Manually call internal method for patching
1325+
instrument_view(django, TemplateView)
1326+
assert "dispatch" not in vars(TemplateView)
1327+
1328+
# Patch via `as_view()`
1329+
TemplateView.as_view()
1330+
assert "dispatch" not in vars(TemplateView)

tests/contrib/django/views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,17 @@ def get(self, request):
118118
if self.dispatch_call_counter == 1:
119119
return super(ComposedGetView, self).get(request)
120120
raise Exception("Custom dispatch not called.")
121+
122+
123+
DISPATCH_CALLED = False
124+
125+
126+
class CustomDispatchView(View):
127+
def dispatch(self, request):
128+
global DISPATCH_CALLED
129+
DISPATCH_CALLED = True
130+
return super(CustomDispatchView, self).dispatch(request)
131+
132+
133+
class ComposedView(TemplateView, CustomDispatchView):
134+
template_name = "custom_dispatch.html"

tests/contrib/djangorestframework/test_djangorestframework.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ def test_trace_exceptions(client, test_spans): # noqa flake8 complains about sh
2323
assert_span_http_status_code(sp, 500)
2424
assert sp.get_tag("http.method") == "GET"
2525

26-
# the DRF integration should set the traceback on the django.view span
26+
# the DRF integration should set the traceback on the django.view.dispatch span
2727
# (as it's the current span when the exception info is set)
28-
view_spans = list(test_spans.filter_spans(name="django.view"))
29-
assert len(view_spans) == 1
30-
err_span = view_spans[0]
28+
view_dispatch_spans = list(test_spans.filter_spans(name="django.view.dispatch"))
29+
assert len(view_dispatch_spans) == 1
30+
err_span = view_dispatch_spans[0]
3131
assert err_span.error == 1
3232
assert err_span.get_tag("error.msg") == "Authentication credentials were not provided."
3333
assert "NotAuthenticated" in err_span.get_tag("error.stack")

0 commit comments

Comments
 (0)