Skip to content

Commit 91fb199

Browse files
Gabin MarignierEmanuele Palazzetti
authored andcommitted
trace Django Rest Framework (#389)
* Patch rest_framework * Add a test app for rest_framework * Add unit tests for rest_framework integration * Use the existing django tox env to test rest_framework * Modify the docs
1 parent ea7f625 commit 91fb199

File tree

13 files changed

+313
-13
lines changed

13 files changed

+313
-13
lines changed

.circleci/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,13 @@ jobs:
298298
- tox-cache-django-{{ checksum "tox.ini" }}
299299
- run: tox -e '{py27,py34,py35,py36}-django{18,19,110,111}-djangopylibmc06-djangoredis45-pylibmc-redis{210}-memcached' --result-json /tmp/django.1.results
300300
- run: tox -e '{py27,py34,py35,py36}-django-autopatch{18,19,110,111}-djangopylibmc06-djangoredis45-pylibmc-redis{210}-memcached' --result-json /tmp/django.2.results
301+
- run: tox -e '{py27,py34,py35,py36}-django-drf{110,111}-djangorestframework{34,35,36,37}' --result-json /tmp/django.3.results
301302
- persist_to_workspace:
302303
root: /tmp
303304
paths:
304305
- django.1.results
305306
- django.2.results
307+
- django.3.results
306308
- save_cache:
307309
key: tox-cache-django-{{ checksum "tox.ini" }}
308310
paths:

ddtrace/contrib/django/apps.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22

33
# 3rd party
4-
from django.apps import AppConfig
4+
from django.apps import AppConfig, apps
55

66
# project
77
from .db import patch_db
@@ -68,3 +68,11 @@ def ready(self):
6868
patch_cache(tracer)
6969
except Exception:
7070
log.exception('error patching Django cache')
71+
72+
# Instrument rest_framework app to trace custom exception handling.
73+
if apps.is_installed('rest_framework'):
74+
try:
75+
from .restframework import patch_restframework
76+
patch_restframework(tracer)
77+
except Exception:
78+
log.exception('error patching rest_framework app')
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from wrapt import wrap_function_wrapper as wrap
2+
3+
from rest_framework.views import APIView
4+
5+
from ddtrace.util import unwrap
6+
7+
8+
def patch_restframework(tracer):
9+
""" Patches rest_framework app.
10+
11+
To trace exceptions occuring during view processing we currently use a TraceExceptionMiddleware.
12+
However the rest_framework handles exceptions before they come to our middleware.
13+
So we need to manually patch the rest_framework exception handler
14+
to set the exception stack trace in the current span.
15+
16+
"""
17+
18+
def _traced_handle_exception(wrapped, instance, args, kwargs):
19+
""" Sets the error message, error type and exception stack trace to the current span
20+
before calling the original exception handler.
21+
"""
22+
span = tracer.current_span()
23+
if span is not None:
24+
span.set_traceback()
25+
26+
return wrapped(*args, **kwargs)
27+
28+
# do not patch if already patched
29+
if getattr(APIView, '_datadog_patch', False):
30+
return
31+
else:
32+
setattr(APIView, '_datadog_patch', True)
33+
34+
# trace the handle_exception method
35+
wrap('rest_framework.views', 'APIView.handle_exception', _traced_handle_exception)
36+
37+
38+
def unpatch_restframework():
39+
""" Unpatches rest_framework app."""
40+
if getattr(APIView, '_datadog_patch', False):
41+
setattr(APIView, '_datadog_patch', False)
42+
unwrap(APIView, 'handle_exception')

docs/index.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,9 @@ We officially support Python 2.7, 3.4 and above.
538538
| celery | >= 3.1 |
539539
+-----------------+--------------------+
540540
| cassandra | >= 3.5 |
541-
+-----------------+--------------------+
541+
+---------------------+----------------+
542+
| djangorestframework | >= 3.4 |
543+
+---------------------+----------------+
542544
| django | >= 1.8 |
543545
+-----------------+--------------------+
544546
| elasticsearch | >= 1.6 |

tests/contrib/django/test_tracing_disabled.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 3rd party
22
from django.apps import apps
3-
from django.test import TestCase, override_settings
3+
from django.test import TestCase
44

55
# project
66
from ddtrace.tracer import Tracer
@@ -12,23 +12,25 @@
1212

1313
class DjangoTracingDisabledTest(TestCase):
1414
def setUp(self):
15-
tracer = Tracer()
16-
tracer.writer = DummyWriter()
17-
self.tracer = tracer
18-
# Backup the old conf
19-
self.backupTracer = settings.TRACER
15+
# backup previous conf
2016
self.backupEnabled = settings.ENABLED
21-
# Disable tracing
17+
self.backupTracer = settings.TRACER
18+
19+
# Use a new tracer to be sure that a new service
20+
# would be sent to the the writer
21+
self.tracer = Tracer()
22+
self.tracer.writer = DummyWriter()
23+
24+
# Restart app with tracing disabled
2225
settings.ENABLED = False
23-
settings.TRACER = tracer
24-
# Restart the app
25-
app = apps.get_app_config('datadog_django')
26-
app.ready()
26+
self.app = apps.get_app_config('datadog_django')
27+
self.app.ready()
2728

2829
def tearDown(self):
2930
# Reset the original settings
3031
settings.ENABLED = self.backupEnabled
3132
settings.TRACER = self.backupTracer
33+
self.app.ready()
3234

3335
def test_no_service_info_is_written(self):
3436
services = self.tracer.writer.pop_services()

tests/contrib/djangorestframework/__init__.py

Whitespace-only changes.

tests/contrib/djangorestframework/app/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from rest_framework.views import exception_handler
2+
from rest_framework.response import Response
3+
from rest_framework.exceptions import APIException
4+
from rest_framework import status
5+
6+
7+
def custom_exception_handler(exc, context):
8+
response = exception_handler(exc, context)
9+
10+
# We overwrite the response status code to 500
11+
if response is not None:
12+
return Response({'detail': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
13+
14+
return response
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Settings configuration for the Django web framework. Update this
3+
configuration if you need to change the default behavior of
4+
Django during tests
5+
"""
6+
import os
7+
import django
8+
9+
10+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11+
12+
DATABASES = {
13+
'default': {
14+
'ENGINE': 'django.db.backends.sqlite3',
15+
'NAME': ':memory:'
16+
}
17+
}
18+
19+
SITE_ID = 1
20+
SECRET_KEY = 'not_very_secret_in_tests'
21+
USE_I18N = True
22+
USE_L10N = True
23+
STATIC_URL = '/static/'
24+
ROOT_URLCONF = 'app.views'
25+
26+
TEMPLATES = [
27+
{
28+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
29+
'DIRS': [
30+
os.path.join(BASE_DIR, 'app', 'templates'),
31+
],
32+
'APP_DIRS': True,
33+
'OPTIONS': {
34+
'context_processors': [
35+
'django.template.context_processors.debug',
36+
'django.template.context_processors.request',
37+
'django.contrib.auth.context_processors.auth',
38+
'django.contrib.messages.context_processors.messages',
39+
],
40+
},
41+
},
42+
]
43+
44+
if django.VERSION >= (1, 10):
45+
MIDDLEWARE = [
46+
'django.contrib.sessions.middleware.SessionMiddleware',
47+
'django.middleware.common.CommonMiddleware',
48+
'django.middleware.csrf.CsrfViewMiddleware',
49+
'django.contrib.auth.middleware.AuthenticationMiddleware',
50+
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
51+
'django.contrib.messages.middleware.MessageMiddleware',
52+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
53+
'django.middleware.security.SecurityMiddleware',
54+
55+
'tests.contrib.django.app.middlewares.CatchExceptionMiddleware',
56+
]
57+
58+
# Always add the legacy conf to make sure we handle it properly
59+
# Pre 1.10 style
60+
MIDDLEWARE_CLASSES = [
61+
'django.contrib.sessions.middleware.SessionMiddleware',
62+
'django.middleware.common.CommonMiddleware',
63+
'django.middleware.csrf.CsrfViewMiddleware',
64+
'django.contrib.auth.middleware.AuthenticationMiddleware',
65+
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
66+
'django.contrib.messages.middleware.MessageMiddleware',
67+
'django.middleware.clickjacking.XFrameOptionsMiddleware',
68+
'django.middleware.security.SecurityMiddleware',
69+
70+
'tests.contrib.django.app.middlewares.CatchExceptionMiddleware',
71+
]
72+
73+
INSTALLED_APPS = [
74+
'django.contrib.admin',
75+
'django.contrib.auth',
76+
'django.contrib.contenttypes',
77+
'django.contrib.sessions',
78+
79+
# tracer app
80+
'ddtrace.contrib.django',
81+
82+
# djangorestframework
83+
'rest_framework'
84+
]
85+
86+
DATADOG_TRACE = {
87+
# tracer with a DummyWriter
88+
'TRACER': 'tests.contrib.django.utils.tracer',
89+
'ENABLED': True,
90+
'TAGS': {
91+
'env': 'test',
92+
},
93+
}
94+
95+
REST_FRAMEWORK = {
96+
'DEFAULT_PERMISSION_CLASSES': [
97+
'rest_framework.permissions.IsAdminUser',
98+
],
99+
100+
'EXCEPTION_HANDLER': 'app.exceptions.custom_exception_handler'
101+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.conf.urls import url, include
2+
from django.contrib.auth.models import User, Group
3+
from django.http import HttpResponse
4+
5+
from rest_framework import viewsets, routers, serializers
6+
from rest_framework.exceptions import APIException
7+
8+
9+
class UserSerializer(serializers.HyperlinkedModelSerializer):
10+
class Meta:
11+
model = User
12+
fields = ('url', 'username', 'email', 'groups')
13+
14+
15+
class UserViewSet(viewsets.ModelViewSet):
16+
"""
17+
API endpoint that allows users to be viewed or edited.
18+
"""
19+
queryset = User.objects.all().order_by('-date_joined')
20+
serializer_class = UserSerializer
21+
22+
23+
router = routers.DefaultRouter()
24+
router.register(r'users', UserViewSet)
25+
26+
# Wire up our API using automatic URL routing.
27+
# Additionally, we include login URLs for the browsable API.
28+
urlpatterns = [
29+
url(r'^', include(router.urls)),
30+
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
31+
]

0 commit comments

Comments
 (0)