Skip to content

Commit 873caf2

Browse files
fix(django): patch staticmethods in views (#1246)
Co-authored-by: Brett Langdon <[email protected]>
1 parent f645e76 commit 873caf2

File tree

6 files changed

+123
-3
lines changed

6 files changed

+123
-3
lines changed

ddtrace/compat.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,100 @@ def iscoroutinefunction(fn):
112112
def make_async_decorator(tracer, fn, *params, **kw_params):
113113
return fn
114114

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+
115209

116210
# DEV: There is `six.u()` which does something similar, but doesn't have the guard around `hasattr(s, 'decode')`
117211
def to_unicode(s):

ddtrace/contrib/django/patch.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from ddtrace import config, Pin
1515
from ddtrace.vendor import debtcollector, wrapt
16+
from ddtrace.compat import getattr_static
1617
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
1718
from ddtrace.contrib import func_name, dbapi
1819
from ddtrace.ext import http, sql as sqlx, SpanTypes
@@ -433,7 +434,6 @@ def traced_template_render(django, pin, wrapped, instance, args, kwargs):
433434

434435
def instrument_view(django, view):
435436
"""Helper to wrap Django views."""
436-
437437
# All views should be callable, double check before doing anything
438438
if not callable(view) or isinstance(view, wrapt.ObjectProxy):
439439
return view
@@ -443,13 +443,18 @@ def instrument_view(django, view):
443443
lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed")
444444
for name in list(http_method_names) + list(lifecycle_methods):
445445
try:
446-
func = getattr(view, name, None)
446+
# View methods can be staticmethods
447+
func = getattr_static(view, name, None)
447448
if not func or isinstance(func, wrapt.ObjectProxy):
448449
continue
449450

450451
resource = "{0}.{1}".format(func_name(view), name)
451452
op_name = "django.view.{0}".format(name)
452-
wrap(view, name, traced_func(django, name=op_name, resource=resource))
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)
453458
except Exception:
454459
log.debug("Failed to instrument Django view %r function %s", view, name, exc_info=True)
455460

tests/contrib/django/django1_app/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
url(r"^cached-template/$", views.TemplateCachedUserList.as_view(), name="cached-template-list"),
1212
url(r"^cached-users/$", cache_page(60)(views.UserList.as_view()), name="cached-users-list"),
1313
url(r"^fail-view/$", views.ForbiddenView.as_view(), name="forbidden-view"),
14+
url(r"^static-method-view/$", views.StaticMethodView.as_view(), name="static-method-view"),
1415
url(r"^fn-view/$", views.function_view, name="fn-view"),
1516
url(r"^feed-view/$", views.FeedView(), name="feed-view"),
1617
url(r"^partial-view/$", views.partial_view, name="partial-view"),

tests/contrib/django/django_app/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def path_view(request):
2121
url(r"^cached-template/$", views.TemplateCachedUserList.as_view(), name="cached-template-list"),
2222
url(r"^cached-users/$", cache_page(60)(views.UserList.as_view()), name="cached-users-list"),
2323
url(r"^fail-view/$", views.ForbiddenView.as_view(), name="forbidden-view"),
24+
url(r"^static-method-view/$", views.StaticMethodView.as_view(), name="static-method-view"),
2425
url(r"^fn-view/$", views.function_view, name="fn-view"),
2526
url(r"^feed-view/$", views.FeedView(), name="feed-view"),
2627
url(r"^partial-view/$", views.partial_view, name="partial-view"),

tests/contrib/django/test_django.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,19 @@ def test_middleware_trace_errors(client, test_spans):
416416
assert span.resource == "GET tests.contrib.django.views.ForbiddenView"
417417

418418

419+
def test_middleware_trace_staticmethod(client, test_spans):
420+
# ensures that the internals are properly traced
421+
assert client.get("/static-method-view/").status_code == 200
422+
423+
span = test_spans.get_root_span()
424+
assert span.get_tag("http.status_code") == "200"
425+
assert span.get_tag(http.URL) == "http://testserver/static-method-view/"
426+
if django.VERSION >= (2, 2, 0):
427+
assert span.resource == "GET ^static-method-view/$"
428+
else:
429+
assert span.resource == "GET tests.contrib.django.views.StaticMethodView"
430+
431+
419432
def test_middleware_trace_partial_based_view(client, test_spans):
420433
# ensures that the internals are properly traced when using a function views
421434
assert client.get("/partial-view/").status_code == 200

tests/contrib/django/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def get(self, request, *args, **kwargs):
4141
return HttpResponse(status=403)
4242

4343

44+
class StaticMethodView(View):
45+
@staticmethod
46+
def get(request):
47+
return HttpResponse("")
48+
49+
4450
def function_view(request):
4551
return HttpResponse(status=200)
4652

0 commit comments

Comments
 (0)