8000 feat: Automatically instrument ASGI HTTP requests in Django Channels … · etherscan-io/sentry-python@dfa4878 · GitHub
[go: up one dir, main page]

Skip to content

Commit dfa4878

Browse files
authored
feat: Automatically instrument ASGI HTTP requests in Django Channels (getsentry#496)
* feat: Automatically instrument ASGI HTTP requests in Django Channels * fix: Add mitigation for potential source of memory leaks * fix: Prevent double-applying of asgi/wsgi middleware
1 parent 6a4bc2b commit dfa4878

File tree

6 files changed

+130
-44
lines changed

6 files changed

+130
-44
lines changed

sentry_sdk/integrations/asgi.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,27 @@
1010
from sentry_sdk._types import MYPY
1111
from sentry_sdk.hub import Hub, _should_send_default_pii
1212
from sentry_sdk.integrations._wsgi_common import _filter_headers
13-
from sentry_sdk.utils import transaction_from_function
13+
from sentry_sdk.utils import ContextVar, event_from_exception, transaction_from_function
1414

1515
if MYPY:
1616
from typing import Dict
17+
from typing import Any
18+
19+
20+
_asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
21+
22+
23+
def _capture_exception(hub, exc):
24+
# type: (Hub, Any) -> None
25+
26+
# Check client here as it might have been unset while streaming response
27+
if hub.client is not None:
28+
event, hint = event_from_exception(
29+
exc,
30+
client_options=hub.client.options,
31+
mechanism={"type": "asgi", "handled": False},
32+
)
33+
hub.capture_event(event, hint=hint)
1734

1835

1936
class SentryAsgiMiddleware:
@@ -35,20 +52,31 @@ async def run_asgi2(receive, send):
3552
return self._run_app(scope, lambda: self.app(scope, receive, send))
3653

3754
async def _run_app(self, scope, callback):
38-
hub = Hub.current
39-
with Hub(hub) as hub:
40-
with hub.configure_scope() as sentry_scope:
41-
sentry_scope._name = "asgi"
42-
sentry_scope.transaction = scope.get("path") or "unknown asgi request"
43-
44-
processor = functools.partial(self.event_processor, asgi_scope=scope)
45-
sentry_scope.add_event_processor(processor)
46-
47-
try:
48-
await callback()
49-
except Exception as exc:
50-
hub.capture_exception(exc)
51-
raise exc from None
55+
if _asgi_middleware_applied.get(False):
56+
return await callback()
57+
58+
_asgi_middleware_applied.set(True)
59+
try:
60+
hub = Hub(Hub.current)
61+
with hub:
62+
with hub.configure_scope() as sentry_scope:
63+
sentry_scope._name = "asgi"
64+
sentry_scope.transaction = (
65+
scope.get("path") or "unknown asgi request"
66+
)
67+
68+
processor = functools.partial(
69+
self.event_processor, asgi_scope=scope
70+
)
71+
sentry_scope.add_event_processor(processor)
72+
73+
try:
74+
await callback()
75+
except Exception as exc:
76+
_capture_exception(hub, exc)
77+
raise exc from None
78+
finally:
79+
_asgi_middleware_applied.set(False)
5280

5381
def event_processor(self, event, hint, asgi_scope):
5482
request_info = event.setdefault("request", {})

sentry_sdk/integrations/django/__init__.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.core import signals # type: ignore
1010

1111
from sentry_sdk._types import MYPY
12+
from sentry_sdk.utils import HAS_REAL_CONTEXTVARS
1213

1314
if MYPY:
1415
from typing import Any
@@ -101,9 +102,9 @@ def sentry_patched_wsgi_handler(self, environ, start_response):
101102
if Hub.current.get_integration(DjangoIntegration) is None:
102103
return old_app(self, environ, start_response)
103104

104-
return SentryWsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw))(
105-
environ, start_response
106-
)
105+
bound_old_app = old_app.__get__(self, WSGIHandler)
106+
107+
return SentryWsgiMiddleware(bound_old_app)(environ, start_response)
107108

108109
WSGIHandler.__call__ = sentry_patched_wsgi_handler
109110

@@ -211,6 +212,7 @@ def _django_queryset_repr(value, hint):
211212
id(value),
212213
)
213214

215+
_patch_channels()
214216
patch_django_middlewares()
215217

216218

@@ -271,6 +273,38 @@ def sentry_patched_drf_initial(self, request, *args, **kwargs):
271273
APIView.initial = sentry_patched_drf_initial
272274

273275

276+
def _patch_channels():
277+
try:
278+
from channels.http import AsgiHandler # type: ignore
279+
except ImportError:
280+
return
281+
282+
if not HAS_REAL_CONTEXTVARS:
283+
# We better have contextvars or we're going to leak state between
284+
# requests.
285+
raise RuntimeError(
286+
"We detected that you are using Django channels 2.0. To get proper "
287+
"instrumentation for ASGI requests, the Sentry SDK requires "
288+
"Python 3.7+ or the aiocontextvars package from PyPI."
289+
)
290+
291+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
292+
293+
old_app = AsgiHandler.__call__
294+
295+
def sentry_patched_asgi_handler(self, receive, send):
296+
if Hub.current.get_integration(DjangoIntegration) is None:
297+
return old_app(receive, send)
298+
299+
middleware = SentryAsgiMiddleware(
300+
lambda _scope: old_app.__get__(self, AsgiHandler)
301+
)
302+
303+
return middleware(self.scope)(receive, send)
304+
305+
AsgiHandler.__call__ = sentry_patched_asgi_handler
306+
307+
274308
def _make_event_processor(weak_request, integration):
275309
# type: (Callable[[], WSGIRequest], DjangoIntegration) -> Callable
276310
def event_processor(event, hint):

sentry_sdk/integrations/wsgi.py

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import sys
33

44
from sentry_sdk.hub import Hub, _should_send_default_pii
5-
from sentry_sdk.utils import capture_internal_exceptions, event_from_exception
5+
from sentry_sdk.utils import (
6+
ContextVar,
7+
capture_internal_exceptions,
8+
event_from_exception,
9+
)
610
from sentry_sdk._compat import PY2, reraise, iteritems
711
from sentry_sdk.tracing import Span
812
from sentry_sdk.integrations._wsgi_common import _filter_headers
@@ -26,6 +30,9 @@
2630
E = TypeVar("E")
2731

2832

33+
_wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied")
34+
35+
2936
if PY2:
3037

3138
def wsgi_decoding_dance(s, charset="utf-8", errors="replace"):
@@ -83,27 +90,36 @@ def __init__(self, app):
8390

8491
def __call__(self, environ, start_response):
8592
# type: (Dict[str, str], Callable) -> _ScopedResponse
86-
hub = Hub(Hub.current)
87-
88-
with hub:
89-
with capture_internal_exceptions():
90-
with hub.configure_scope() as scope:
91-
scope.clear_breadcrumbs()
92-
scope._name = "wsgi"
93-
scope.add_event_processor(_make_wsgi_event_processor(environ))
94-
95-
span = Span.continue_from_environ(environ)
96-
span.op = "http.server"
97-
span.transaction = "generic WSGI request"
98-
99-
with hub.start_span(span) as span:
100-
try:
101-
rv = self.app(
102-
environ,
103-
functools.partial(_sentry_start_response, start_response, span),
104-
)
105-
except BaseException:
106-
reraise(*_capture_exception(hub))
93+
if _wsgi_middleware_applied.get(False):
94+
return self.app(environ, start_response)
95+
96+
_wsgi_middleware_applied.set(True)
97+
try:
98+
hub = Hub(Hub.current)
99+
100+
with hub:
101+
with capture_internal_exceptions():
102+
with hub.configure_scope() as scope:
103+
scope.clear_breadcrumbs()
104+
scope._name = "wsgi"
105+
scope.add_event_processor(_make_wsgi_event_processor(environ))
106+
107+
span = Span.continue_from_environ(environ)
108+
span.op = "http.server"
109+
span.transaction = "generic WSGI request"
110+
111+
with hub.start_span(span) as span:
112+
try:
113+
rv = self.app(
114+
environ,
115+
functools.partial(
116+
_sentry_start_response, start_response, span
117+
),
118+
)
119+
except BaseException:
120+
reraise(*_capture_exception(hub))
121+
finally:
122+
_wsgi_middleware_applied.set(False)
107123

108124
return _ScopedResponse(hub, rv)
109125

sentry_sdk/scope.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ def add_event_processor(
195195
196196
:param func: This function behaves like `before_send.`
197197
"""
198+
if len(self._event_processors) > 20:
199+
logger.warning(
200+
"Too many event processors on scope! Clearing list to free up some memory: %r",
201+
self._event_processors,
202+
)
203+
del self._event_processors[:]
204+
198205
self._event_processors.append(func)
199206

200207
def add_error_processor(

tests/integrations/django/channels/test_channels.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from channels.testing import HttpCommunicator
55

6+
from sentry_sdk import capture_message
67
from sentry_sdk.integrations.django import DjangoIntegration
78

89
from tests.integrations.django.myapp.asgi import application
@@ -32,3 +33,7 @@ async def test_basic(sentry_init, capture_events):
3233
"query_string": "test=query",
3334
"url": "/view-exc",
3435
}
36+
37+
capture_message("hi")
38+
event = events[-1]
39+
assert "request" not in event

tests/integrations/django/myapp/asgi.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,4 @@
1212
)
1313

1414
django.setup()
15-
16-
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
17-
1815
application = get_default_application()
19-
application = SentryAsgiMiddleware(application)

0 commit comments

Comments
 (0)
0