Skip to content

Commit 1c03db6

Browse files
Kyle-Verhoogbrettlangdon
authored andcommitted
fix(django): trace mro chain for Django views (#1625)
1 parent 47746be commit 1c03db6

File tree

8 files changed

+178
-125
lines changed

8 files changed

+178
-125
lines changed

ddtrace/compat.py

Lines changed: 18 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
from ddtrace.vendor import six
77

88
__all__ = [
9-
'httplib',
10-
'iteritems',
11-
'PY2',
12-
'Queue',
13-
'stringify',
14-
'StringIO',
15-
'urlencode',
16-
'parse',
17-
'reraise',
9+
"httplib",
10+
"iteritems",
11+
"PY2",
12+
"Queue",
13+
"stringify",
14+
"StringIO",
15+
"urlencode",
16+
"parse",
17+
"reraise",
1818
]
1919

2020
PYTHON_VERSION_INFO = sys.version_info
@@ -42,7 +42,7 @@
4242
string_type = six.string_types[0]
4343
msgpack_type = six.binary_type
4444
# DEV: `six` doesn't have `float` in `integer_types`
45-
numeric_types = six.integer_types + (float, )
45+
numeric_types = six.integer_types + (float,)
4646

4747
# Pattern class generated by `re.compile`
4848
if PYTHON_VERSION_INFO >= (3, 7):
@@ -76,7 +76,9 @@ def time_ns():
7676
# Execute from a string to get around syntax errors from `yield from`
7777
# DEV: The idea to do this was stolen from `six`
7878
# https://github.com/benjaminp/six/blob/15e31431af97e5e64b80af0a3f598d382bcdd49a/six.py#L719-L737
79-
six.exec_(textwrap.dedent("""
79+
six.exec_(
80+
textwrap.dedent(
81+
"""
8082
import functools
8183
import asyncio
8284
@@ -100,7 +102,9 @@ def func_wrapper(*args, **kwargs):
100102
return result
101103
102104
return func_wrapper
103-
"""))
105+
"""
106+
)
107+
)
104108

105109
else:
106110
# asyncio is missing so we can't have coroutines; these
@@ -112,100 +116,6 @@ def iscoroutinefunction(fn):
112116
def make_async_decorator(tracer, fn, *params, **kw_params):
113117
return fn
114118

115-
# static version of getattr backported from Python 3.7
116-
try:
117-
from inspect import getattr_static
118-
except ImportError:
119-
import types
120-
121-
_sentinel = object()
122-
123-
def _static_getmro(klass):
124-
return type.__dict__['__mro__'].__get__(klass)
125-
126-
def _check_instance(obj, attr):
127-
instance_dict = {}
128-
try:
129-
instance_dict = object.__getattribute__(obj, "__dict__")
130-
except AttributeError:
131-
pass
132-
return dict.get(instance_dict, attr, _sentinel)
133-
134-
def _check_class(klass, attr):
135-
for entry in _static_getmro(klass):
136-
if _shadowed_dict(type(entry)) is _sentinel:
137-
try:
138-
return entry.__dict__[attr]
139-
except KeyError:
140-
pass
141-
return _sentinel
142-
143-
def _is_type(obj):
144-
try:
145-
_static_getmro(obj)
146-
except TypeError:
147-
return False
148-
return True
149-
150-
def _shadowed_dict(klass):
151-
dict_attr = type.__dict__["__dict__"]
152-
for entry in _static_getmro(klass):
153-
try:
154-
class_dict = dict_attr.__get__(entry)["__dict__"]
155-
except KeyError:
156-
pass
157-
else:
158-
if not (type(class_dict) is types.GetSetDescriptorType and # noqa: E721,E261,W504
159-
class_dict.__name__ == "__dict__" and # noqa: E261,W504
160-
class_dict.__objclass__ is entry):
161-
return class_dict
162-
return _sentinel
163-
164-
def getattr_static(obj, attr, default=_sentinel):
165-
"""Retrieve attributes without triggering dynamic lookup via the
166-
descriptor protocol, __getattr__ or __getattribute__.
167-
168-
Note: this function may not be able to retrieve all attributes
169-
that getattr can fetch (like dynamically created attributes)
170-
and may find attributes that getattr can't (like descriptors
171-
that raise AttributeError). It can also return descriptor objects
172-
instead of instance members in some cases. See the
173-
documentation for details.
174-
"""
175-
instance_result = _sentinel
176-
if not _is_type(obj):
177-
klass = type(obj)
178-
dict_attr = _shadowed_dict(klass)
179-
if (dict_attr is _sentinel or # noqa: E261,E721,W504
180-
type(dict_attr) is types.MemberDescriptorType):
181-
instance_result = _check_instance(obj, attr)
182-
else:
183-
klass = obj
184-
185-
klass_result = _check_class(klass, attr)
186-
187-
if instance_result is not _sentinel and klass_result is not _sentinel:
188-
if (_check_class(type(klass_result), '__get__') is not _sentinel and # noqa: W504,E261,E721
189-
_check_class(type(klass_result), '__set__') is not _sentinel):
190-
return klass_result
191-
192-
if instance_result is not _sentinel:
193-
return instance_result
194-
if klass_result is not _sentinel:
195-
return klass_result
196-
197-
if obj is klass:
198-
# for types we check the metaclass too
199-
for entry in _static_getmro(type(klass)):
200-
if _shadowed_dict(type(entry)) is _sentinel:
201-
try:
202-
return entry.__dict__[attr]
203-
except KeyError:
204-
pass
205-
if default is not _sentinel:
206-
return default
207-
raise AttributeError(attr)
208-
209119

210120
# DEV: There is `six.u()` which does something similar, but doesn't have the guard around `hasattr(s, 'decode')`
211121
def to_unicode(s):
@@ -219,8 +129,8 @@ def to_unicode(s):
219129

220130
# If the object has a `decode` method, then decode into `utf-8`
221131
# e.g. Python 2 `str`, Python 2/3 `bytearray`, etc
222-
if hasattr(s, 'decode'):
223-
return s.decode('utf-8')
132+
if hasattr(s, "decode"):
133+
return s.decode("utf-8")
224134

225135
# Always try to coerce the object into the `six.text_type` object we expect
226136
# e.g. `to_unicode(1)`, `to_unicode(dict(key='value'))`

ddtrace/contrib/django/patch.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
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
16-
from ddtrace.compat import getattr_static
1716
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
1817
from ddtrace.contrib import func_name, dbapi
1918
from ddtrace.ext import http, sql as sqlx, SpanTypes
@@ -433,28 +432,38 @@ def traced_template_render(django, pin, wrapped, instance, args, kwargs):
433432

434433

435434
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):
436450
"""Helper to wrap Django views."""
437451
# All views should be callable, double check before doing anything
438-
if not callable(view) or isinstance(view, wrapt.ObjectProxy):
452+
if not callable(view):
439453
return view
440454

441455
# Patch view HTTP methods and lifecycle methods
442456
http_method_names = getattr(view, "http_method_names", ("get", "delete", "post", "options", "head"))
443457
lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed")
444458
for name in list(http_method_names) + list(lifecycle_methods):
445459
try:
446-
# View methods can be staticmethods
447-
func = getattr_static(view, name, None)
460+
func = getattr(view, name, None)
448461
if not func or isinstance(func, wrapt.ObjectProxy):
449462
continue
450463

451464
resource = "{0}.{1}".format(func_name(view), name)
452465
op_name = "django.view.{0}".format(name)
453-
454-
# Set attribute here rather than using wrapt.wrappers.wrap_function_wrapper
455-
# since it will not resolve attribute to staticmethods
456-
wrapper = wrapt.FunctionWrapper(func, traced_func(django, name=op_name, resource=resource))
457-
setattr(view, name, wrapper)
466+
wrap(view, name, traced_func(django, name=op_name, resource=resource))
458467
except Exception:
459468
log.debug("Failed to instrument Django view %r function %s", view, name, exc_info=True)
460469

@@ -475,8 +484,10 @@ def instrument_view(django, view):
475484
except Exception:
476485
log.debug("Failed to instrument Django response %r function %s", response_cls, name, exc_info=True)
477486

478-
# Return a wrapped version of this view
479-
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
480491

481492

482493
@with_traced_module

tests/contrib/django/django1_app/urls.py

Lines changed: 6 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,4 +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")),
23+
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
24+
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"),
2026
]

tests/contrib/django/django_app/urls.py

Lines changed: 6 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,4 +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")),
36+
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
37+
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"),
3339
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
custom dispatch {{ dispatch_call_counter }}

tests/contrib/django/test_django.py

Lines changed: 70 additions & 1 deletion
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

@@ -1226,3 +1228,70 @@ def test_urlpatterns_repath(client, test_spans):
12261228

12271229
# Ensure the view was traced
12281230
assert len(list(test_spans.filter_spans(name="django.view"))) == 1
1231+
1232+
1233+
def test_custom_dispatch_template_view(client, test_spans):
1234+
"""
1235+
Test that a template view with a custom dispatch method inherited from a
1236+
mixin is called.
1237+
"""
1238+
resp = client.get("/composed-template-view/")
1239+
assert resp.status_code == 200
1240+
assert resp.content.strip() == b"custom dispatch 2"
1241+
1242+
spans = test_spans.get_spans()
1243+
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1244+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1245+
"tests.contrib.django.views.AnotherCustomDispatchMixin.dispatch",
1246+
"django.views.generic.base.View.dispatch",
1247+
]
1248+
1249+
1250+
def test_custom_dispatch_get_view(client, test_spans):
1251+
"""
1252+
Test that a get method on a view with a custom dispatch method inherited
1253+
from a mixin is called.
1254+
"""
1255+
resp = client.get("/composed-get-view/")
1256+
assert resp.status_code == 200
1257+
assert resp.content.strip() == b"custom get"
1258+
1259+
spans = test_spans.get_spans()
1260+
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
1261+
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
1262+
"django.views.generic.base.View.dispatch",
1263+
]
1264+
assert [s.resource for s in spans if s.resource.endswith("get")] == [
1265+
"tests.contrib.django.views.ComposedGetView.get",
1266+
"tests.contrib.django.views.CustomGetView.get",
1267+
]
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)

0 commit comments

Comments
 (0)