Skip to content

Commit d79da96

Browse files
fix: support django channels >= 3.0 (#2956) (#3149)
Currently ddtrace is not working with daphne #2945. This is due to how django channels configures consumers. Instead of passing a view to django.urls.path and django.urls.re_path django channel's URLRouter expects an asgi application. This causes an application to fail on startup after we incorrectly wrap an asgi application as a view. To fix this issue this change checks if the asgi application callback in URLPatterns is wrapped as a views. If so, we unwrap the asgi application. This approach should fix the issue described above. Co-authored-by: Brett Langdon <[email protected]> Co-authored-by: Kyle Verhoog <[email protected]> (cherry picked from commit e10a574) Co-authored-by: Munir Abdinur <[email protected]>
1 parent 3163f6c commit d79da96

File tree

8 files changed

+153
-13
lines changed

8 files changed

+153
-13
lines changed

ddtrace/contrib/django/patch.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ def _instrument_view(django, view):
404404

405405
# If the view itself is not wrapped, wrap it
406406
if not isinstance(view, wrapt.ObjectProxy):
407-
view = wrapt.FunctionWrapper(
407+
view = utils.DjangoViewProxy(
408408
view, traced_func(django, "django.view", resource=func_name(view), ignored_excs=[django.http.Http404])
409409
)
410410
return view
@@ -448,6 +448,24 @@ def django_asgi_modifier(span, scope):
448448
return TraceMiddleware(func(*args, **kwargs), integration_config=config.django, span_modifier=django_asgi_modifier)
449449

450450

451+
def unwrap_views(func, instance, args, kwargs):
452+
"""
453+
Django channels uses path() and re_path() to route asgi applications. This broke our initial assumption that
454+
django path/re_path/url functions only accept views. Here we unwrap ddtrace view instrumentation from asgi
455+
applications.
456+
457+
Ex. ``channels.routing.URLRouter([path('', get_asgi_application())])``
458+
On startup ddtrace.contrib.django.path.instrument_view() will wrap get_asgi_application in a DjangoViewProxy.
459+
Since get_asgi_application is not a django view callback this function will unwrap it.
460+
"""
461+
routes = get_argument_value(args, kwargs, 0, "routes")
462+
for route in routes:
463+
if isinstance(route.callback, utils.DjangoViewProxy):
464+
route.callback = route.callback.__wrapped__
465+
466+
return func(*args, **kwargs)
467+
468+
451469
def _patch(django):
452470
Pin().onto(django)
453471
trace_utils.wrap(django, "apps.registry.Apps.populate", traced_populate(django))
@@ -497,6 +515,17 @@ def _patch(django):
497515
import django.views.generic.base
498516
trace_utils.wrap(django, "views.generic.base.View.as_view", traced_as_view(django))
499517

518+
try:
519+
import channels
520+
import channels.routing
521+
522+
channels_version = tuple(int(x) for x in channels.__version__.split("."))
523+
if channels_version >= (3, 0):
524+
# ASGI3 is only supported in channels v3.0+
525+
trace_utils.wrap(channels.routing, "URLRouter.__init__", unwrap_views)
526+
except ImportError:
527+
pass # channels is not installed
528+
500529

501530
def patch():
502531
# DEV: this import will eventually be replaced with the module given from an import hook

ddtrace/contrib/django/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from .. import trace_utils
1212
from ...internal.logger import get_logger
13+
from ...vendor.wrapt import FunctionWrapper
1314
from .compat import get_resolver
1415
from .compat import user_is_authenticated
1516

@@ -294,3 +295,21 @@ def _after_request_tags(pin, span, request, response):
294295
finally:
295296
if span.resource == REQUEST_DEFAULT_RESOURCE:
296297
span.resource = request.method
298+
299+
300+
class DjangoViewProxy(FunctionWrapper):
301+
"""
302+
This custom function wrapper is used to wrap the callback passed to django views handlers (path/re_path/url).
303+
This allows us to distinguish between wrapped django views and wrapped asgi applications in django channels.
304+
"""
305+
306+
@property
307+
def __module__(self):
308+
"""
309+
DjangoViewProxy.__module__ defaults to ddtrace.contrib.django when a wrapped function does not have
310+
a __module__ attribute. This method ensures that DjangoViewProxy.__module__ always returns the module
311+
attribute of the wrapped function or an empty string if this attribute is not available.
312+
The function Django.urls.path() does not have a __module__ attribute and would require this override
313+
to resolve the correct module name.
314+
"""
315+
return self.__wrapped__.__module__
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
Fix application crash on startup when using ``channels >= 3.0``.

riotfile.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,11 +516,19 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
516516
"django": [
517517
">=2.1,<2.2",
518518
">=2.2,<2.3",
519+
],
520+
},
521+
),
522+
Venv(
523+
pys=select_pys(min_version="3.6"),
524+
pkgs={
525+
"django": [
519526
"~=3.0",
520527
"~=3.0.0",
521528
"~=3.1.0",
522529
"~=3.2.0",
523530
],
531+
"channels": ["~=3.0", latest],
524532
},
525533
),
526534
Venv(
@@ -530,6 +538,7 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
530538
"~=4.0.0",
531539
latest,
532540
],
541+
"channels": ["~=3.0", latest],
533542
},
534543
),
535544
],

tests/contrib/django/asgi.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
1+
from channels.auth import AuthMiddlewareStack
2+
from channels.routing import ProtocolTypeRouter
3+
from channels.routing import URLRouter
14
from django.core.asgi import get_asgi_application
5+
from django.urls import re_path
6+
7+
from ddtrace.contrib.asgi import TraceMiddleware
28

39

410
application = get_asgi_application()
11+
12+
13+
async def simple_asgi_app(scope, receive, send):
14+
await send({"type": "http.response.start", "status": 200, "headers": [(b"Content-Type", b"text/plain")]})
15+
await send({"type": "http.response.body", "body": b"Hello World. It's me simple asgi app"})
16+
17+
18+
channels_application = ProtocolTypeRouter(
19+
{
20+
"http": AuthMiddlewareStack(
21+
URLRouter(
22+
[
23+
re_path(r"traced-simple-asgi-app/", TraceMiddleware(simple_asgi_app)),
24+
re_path(r"simple-asgi-app/", simple_asgi_app),
25+
re_path(r"", application),
26+
]
27+
),
28+
)
29+
}
30+
)

tests/contrib/django/test_django_snapshots.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from contextlib import contextmanager
12
import os
23
import subprocess
34
import sys
@@ -12,8 +13,8 @@
1213
SERVER_PORT = 8000
1314

1415

15-
@pytest.fixture(scope="function")
16-
def daphne_client(snapshot):
16+
@contextmanager
17+
def daphne_client(django_asgi):
1718
"""Runs a django app hosted with a daphne webserver in a subprocess and
1819
returns a client which can be used to query it.
1920
@@ -33,7 +34,7 @@ def daphne_client(snapshot):
3334

3435
# ddtrace-run uses execl which replaces the process but the webserver process itself might spawn new processes.
3536
# Right now it doesn't but it's possible that it might in the future (ex. uwsgi).
36-
cmd = ["ddtrace-run", "daphne", "-p", str(SERVER_PORT), "tests.contrib.django.asgi:application"]
37+
cmd = ["ddtrace-run", "daphne", "-p", str(SERVER_PORT), "tests.contrib.django.asgi:%s" % django_asgi]
3738
proc = subprocess.Popen(
3839
cmd,
3940
stdout=subprocess.PIPE,
@@ -156,28 +157,52 @@ def test_psycopg_query_default(client, psycopg2_patched):
156157

157158

158159
@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="ASGI not supported in django<3")
159-
@pytest.mark.snapshot(
160+
@snapshot(
160161
variants={
161162
"30": (3, 0, 0) <= django.VERSION < (3, 1, 0),
162163
"31": (3, 1, 0) <= django.VERSION < (3, 2, 0),
163164
"3x": django.VERSION >= (3, 2, 0),
164165
},
166+
token_override="tests.contrib.django.test_django_snapshots.test_asgi_200",
165167
)
166-
def test_asgi_200(daphne_client):
167-
resp = daphne_client.get("/")
168-
assert resp.status_code == 200
169-
assert resp.content == b"Hello, test app."
168+
@pytest.mark.parametrize("django_asgi", ["application", "channels_application"])
169+
def test_asgi_200(django_asgi):
170+
with daphne_client(django_asgi) as client:
171+
resp = client.get("/")
172+
assert resp.status_code == 200
173+
assert resp.content == b"Hello, test app."
174+
175+
176+
@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="ASGI not supported in django<3")
177+
@snapshot()
178+
def test_asgi_200_simple_app():
179+
# The path simple-asgi-app/ routes to an ASGI Application that is not traced
180+
# This test should generate an empty snapshot
181+
with daphne_client("channels_application") as client:
182+
resp = client.get("/simple-asgi-app/")
183+
assert resp.status_code == 200
184+
assert resp.content == b"Hello World. It's me simple asgi app"
170185

171186

172187
@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="ASGI not supported in django<3")
173-
@pytest.mark.snapshot(
188+
@snapshot()
189+
def test_asgi_200_traced_simple_app():
190+
with daphne_client("channels_application") as client:
191+
resp = client.get("/traced-simple-asgi-app/")
192+
assert resp.status_code == 200
193+
assert resp.content == b"Hello World. It's me simple asgi app"
194+
195+
196+
@pytest.mark.skipif(django.VERSION < (3, 0, 0), reason="ASGI not supported in django<3")
197+
@snapshot(
174198
ignores=["meta.error.stack"],
175199
variants={
176200
"30": (3, 0, 0) <= django.VERSION < (3, 1, 0),
177201
"31": (3, 1, 0) <= django.VERSION < (3, 2, 0),
178202
"3x": django.VERSION >= (3, 2, 0),
179203
},
180204
)
181-
def test_asgi_500(daphne_client):
182-
resp = daphne_client.get("/error-500/")
183-
assert resp.status_code == 500
205+
def test_asgi_500():
206+
with daphne_client("application") as client:
207+
resp = client.get("/error-500/")
208+
assert resp.status_code == 500
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[[
2+
{
3+
"name": "asgi.request",
4+
"service": null,
5+
"resource": "GET /traced-simple-asgi-app/",
6+
"trace_id": 0,
7+
"span_id": 1,
8+
"parent_id": 0,
9+
"type": "web",
10+
"meta": {
11+
"asgi.version": "3.0",
12+
"http.method": "GET",
13+
"http.status_code": "200",
14+
"http.url": "http://127.0.0.1:8000/traced-simple-asgi-app/",
15+
"http.version": "1.1",
16+
"runtime-id": "74a5040e28844593a7cbe579f49ab5e4"
17+
},
18+
"metrics": {
19+
"_dd.agent_psr": 1.0,
20+
"_dd.top_level": 1,
21+
"_dd.tracer_kr": 1.0,
22+
"_sampling_priority_v1": 1,
23+
"system.pid": 3299
24+
},
25+
"duration": 491700,
26+
"start": 1641325348884634800
27+
}]]

0 commit comments

Comments
 (0)