Skip to content

Commit ca8d9cc

Browse files
committed
fix(django): trace mro chain for Django views (#1625)
1 parent 74bcb03 commit ca8d9cc

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
@@ -9,7 +9,7 @@
99
import os
1010
import sys
1111

12-
from inspect import isclass, isfunction
12+
from inspect import isclass, isfunction, getmro
1313

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

433433

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

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

473-
# Return a wrapped version of this view
474-
return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
487+
# If the view itself is not wrapped, wrap it
488+
if not isinstance(view, wrapt.ObjectProxy):
489+
view = wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
490+
return view
475491

476492

477493
@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
@@ -1,5 +1,6 @@
11
from django.conf.urls import url
22
from django.http import HttpResponse
3+
from django.views.generic import TemplateView
34
from django.views.decorators.cache import cache_page
45
from django.urls import include, path, re_path
56

@@ -30,6 +31,9 @@ def path_view(request):
3031
re_path(r"re-path.*/", repath_view),
3132
path("path/", path_view),
3233
path("include/", include("tests.contrib.django.django_app.extra_urls")),
34+
# This must precede composed-view.
35+
url(r"^some-static-view/$", TemplateView.as_view(template_name="my-template.html")),
3336
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
3437
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
38+
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
3539
]

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

@@ -1239,7 +1241,9 @@ def test_custom_dispatch_template_view(client, test_spans):
12391241

12401242
spans = test_spans.get_spans()
12411243
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1242-
"tests.contrib.django.views.ComposedTemplateView.dispatch",
1244+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1245+
"tests.contrib.django.views.AnotherCustomDispatchMixin.dispatch",
1246+
"django.views.generic.base.View.dispatch",
12431247
]
12441248

12451249

@@ -1254,8 +1258,40 @@ def test_custom_dispatch_get_view(client, test_spans):
12541258

12551259
spans = test_spans.get_spans()
12561260
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1257-
"tests.contrib.django.views.ComposedGetView.dispatch",
1261+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1262+
"django.views.generic.base.View.dispatch",
12581263
]
12591264
assert [s.resource for s in spans if s.resource.endswith("get")] == [
12601265
"tests.contrib.django.views.ComposedGetView.get",
1266+
"tests.contrib.django.views.CustomGetView.get",
12611267
]
1268+
1269+
1270+
def test_view_mixin(client, test_spans):
1271+
from tests.contrib.django import views
1272+
1273+
assert views.DISPATCH_CALLED is False
1274+
resp = client.get("/composed-view/")
1275+
assert views.DISPATCH_CALLED is True
1276+
1277+
assert resp.status_code == 200
1278+
assert resp.content.strip() == b"custom dispatch"
1279+
1280+
1281+
def test_template_view_patching():
1282+
"""
1283+
Test to ensure that patching a view does not give it properties it did not have before
1284+
"""
1285+
# DEV: `vars(cls)` will give you only the properties defined on that class,
1286+
# it will not traverse through __mro__ to find the property from a parent class
1287+
1288+
# We are not starting with a "dispatch" property
1289+
assert "dispatch" not in vars(TemplateView)
1290+
1291+
# Manually call internal method for patching
1292+
instrument_view(django, TemplateView)
1293+
assert "dispatch" not in vars(TemplateView)
1294+
1295+
# Patch via `as_view()`
1296+
TemplateView.as_view()
1297+
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)