Skip to content

fix(django): trace mro chain for Django views #1625

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions ddtrace/contrib/django/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""
import sys

from inspect import isclass, isfunction
from inspect import isclass, isfunction, getmro

from ddtrace import config, Pin
from ddtrace.vendor import debtcollector, six, wrapt
Expand Down Expand Up @@ -476,17 +476,31 @@ def traced_template_render(django, pin, wrapped, instance, args, kwargs):


def instrument_view(django, view):
"""
Helper to wrap Django views.

We want to wrap all lifecycle/http method functions for every class in the MRO for this view
"""
if isfunction(view):
return _instrument_view(django, view)

for cls in reversed(getmro(view)):
_instrument_view(django, cls)

return view


def _instrument_view(django, view):
"""Helper to wrap Django views."""
# All views should be callable, double check before doing anything
if not callable(view) or isinstance(view, wrapt.ObjectProxy):
if not callable(view):
return view

# Patch view HTTP methods and lifecycle methods
http_method_names = getattr(view, "http_method_names", ("get", "delete", "post", "options", "head"))
lifecycle_methods = ("setup", "dispatch", "http_method_not_allowed")
for name in list(http_method_names) + list(lifecycle_methods):
try:
# View methods can be staticmethods
func = getattr(view, name, None)
if not func or isinstance(func, wrapt.ObjectProxy):
continue
Expand Down Expand Up @@ -514,8 +528,10 @@ def instrument_view(django, view):
except Exception:
log.debug("Failed to instrument Django response %r function %s", response_cls, name, exc_info=True)

# Return a wrapped version of this view
return wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
# If the view itself is not wrapped, wrap it
if not isinstance(view, wrapt.ObjectProxy):
view = wrapt.FunctionWrapper(view, traced_func(django, "django.view", resource=func_name(view)))
return view


@with_traced_module
Expand Down
4 changes: 4 additions & 0 deletions tests/contrib/django/django1_app/urls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.conf.urls import url
from django.views.generic import TemplateView
from django.views.decorators.cache import cache_page

from .. import views
Expand All @@ -20,6 +21,9 @@
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"),
# This must precede composed tests.
url(r"some-static-view/", TemplateView.as_view(template_name="my-template.html")),
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
]
4 changes: 4 additions & 0 deletions tests/contrib/django/django_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.contrib.auth import login
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.views.generic import TemplateView
from django.views.decorators.cache import cache_page
from django.urls import include, path, re_path

Expand Down Expand Up @@ -49,6 +50,9 @@ def authenticated_view(request):
re_path(r"re-path.*/", repath_view),
path("path/", path_view),
path("include/", include("tests.contrib.django.django_app.extra_urls")),
# This must precede composed-view.
url(r"^some-static-view/$", TemplateView.as_view(template_name="my-template.html")),
url(r"^composed-template-view/$", views.ComposedTemplateView.as_view(), name="composed-template-view"),
url(r"^composed-get-view/$", views.ComposedGetView.as_view(), name="composed-get-view"),
url(r"^composed-view/$", views.ComposedView.as_view(), name="composed-view"),
]
42 changes: 39 additions & 3 deletions tests/contrib/django/test_django.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import django
from django.views.generic import TemplateView
from django.test import modify_settings, override_settings
import os
import pytest

from ddtrace import config
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY, SAMPLING_PRIORITY_KEY
from ddtrace.contrib.django.patch import instrument_view
from ddtrace.ext import http, errors
from ddtrace.ext.priority import USER_KEEP
from ddtrace.propagation.http import HTTP_HEADER_TRACE_ID, HTTP_HEADER_PARENT_ID, HTTP_HEADER_SAMPLING_PRIORITY
Expand Down Expand Up @@ -580,7 +582,7 @@ def test_simple_view_options(client, test_spans):
assert len(spans) == 1
span = spans[0]
span.assert_matches(
resource="tests.contrib.django.views.BasicView.options", error=0,
resource="django.views.generic.base.View.options", error=0,
)


Expand Down Expand Up @@ -1376,7 +1378,9 @@ def test_custom_dispatch_template_view(client, test_spans):

spans = test_spans.get_spans()
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
"tests.contrib.django.views.ComposedTemplateView.dispatch",
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
"tests.contrib.django.views.AnotherCustomDispatchMixin.dispatch",
"django.views.generic.base.View.dispatch",
]


Expand All @@ -1391,8 +1395,40 @@ def test_custom_dispatch_get_view(client, test_spans):

spans = test_spans.get_spans()
assert [s.resource for s in spans if s.resource.endswith("dispatch")] == [
"tests.contrib.django.views.ComposedGetView.dispatch",
"tests.contrib.django.views.CustomDispatchMixin.dispatch",
"django.views.generic.base.View.dispatch",
]
assert [s.resource for s in spans if s.resource.endswith("get")] == [
"tests.contrib.django.views.ComposedGetView.get",
"tests.contrib.django.views.CustomGetView.get",
]


def test_view_mixin(client, test_spans):
from tests.contrib.django import views

assert views.DISPATCH_CALLED is False
resp = client.get("/composed-view/")
assert views.DISPATCH_CALLED is True

assert resp.status_code == 200
assert resp.content.strip() == b"custom dispatch"


def test_template_view_patching():
"""
Test to ensure that patching a view does not give it properties it did not have before
"""
# DEV: `vars(cls)` will give you only the properties defined on that class,
# it will not traverse through __mro__ to find the property from a parent class

# We are not starting with a "dispatch" property
assert "dispatch" not in vars(TemplateView)

# Manually call internal method for patching
instrument_view(django, TemplateView)
assert "dispatch" not in vars(TemplateView)

# Patch via `as_view()`
TemplateView.as_view()
assert "dispatch" not in vars(TemplateView)
17 changes: 16 additions & 1 deletion tests/contrib/django/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class ComposedTemplateView(TemplateView, CustomDispatchMixin, AnotherCustomDispa

def get_context_data(self, **kwargs):
context = super(ComposedTemplateView, self).get_context_data(**kwargs)
context['dispatch_call_counter'] = self.dispatch_call_counter
context["dispatch_call_counter"] = self.dispatch_call_counter
return context


Expand All @@ -142,3 +142,18 @@ def get(self, request):
if self.dispatch_call_counter == 1:
return super(ComposedGetView, self).get(request)
raise Exception("Custom dispatch not called.")


DISPATCH_CALLED = False


class CustomDispatchView(View):

def dispatch(self, request):
global DISPATCH_CALLED
DISPATCH_CALLED = True
return super(CustomDispatchView, self).dispatch(request)


class ComposedView(TemplateView, CustomDispatchView):
template_name = "custom_dispatch.html"
8 changes: 4 additions & 4 deletions tests/contrib/djangorestframework/test_djangorestframework.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ def test_trace_exceptions(client, test_spans): # noqa flake8 complains about sh
assert_span_http_status_code(sp, 500)
assert sp.get_tag("http.method") == "GET"

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