Skip to content

Commit 36ed64e

Browse files
authored
ref: Refactor ASGI middleware and improve contextvars error message (getsentry#701)
Found multiple issues with the asgi middleware: lack of warning if contextvars are broken -- as part of that I refactored/unified the error message we give in such situations, also added more information as gevent just recently released a version that deals with contextvars better exposed methods that were meant for overriding.. but all that is done in there can be done in event processors, so we make them private Fix getsentry#630 Fix getsentry#700 Fix getsentry#694
1 parent 8326668 commit 36ed64e

File tree

8 files changed

+190
-64
lines changed

8 files changed

+190
-64
lines changed

sentry_sdk/integrations/aiohttp.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
event_from_exception,
1616
transaction_from_function,
1717
HAS_REAL_CONTEXTVARS,
18+
CONTEXTVARS_ERROR_MESSAGE,
1819
AnnotatedValue,
1920
)
2021

@@ -60,9 +61,9 @@ def setup_once():
6061
if not HAS_REAL_CONTEXTVARS:
6162
# We better have contextvars or we're going to leak state between
6263
# requests.
63-
raise RuntimeError(
64+
raise DidNotEnable(
6465
"The aiohttp integration for Sentry requires Python 3.7+ "
65-
" or aiocontextvars package"
66+
" or aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
6667
)
6768

6869
ignore_logger("aiohttp.server")

sentry_sdk/integrations/asgi.py

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from sentry_sdk._types import MYPY
1313
from sentry_sdk.hub import Hub, _should_send_default_pii
1414
from sentry_sdk.integrations._wsgi_common import _filter_headers
15-
from sentry_sdk.utils import ContextVar, event_from_exception, transaction_from_function
15+
from sentry_sdk.utils import (
16+
ContextVar,
17+
event_from_exception,
18+
transaction_from_function,
19+
HAS_REAL_CONTEXTVARS,
20+
CONTEXTVARS_ERROR_MESSAGE,
21+
)
1622
from sentry_sdk.tracing import Span
1723

1824
if MYPY:
@@ -21,11 +27,15 @@
2127
from typing import Optional
2228
from typing import Callable
2329

30+
from typing_extensions import Literal
31+
2432
from sentry_sdk._types import Event, Hint
2533

2634

2735
_asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
2836

37+
_DEFAULT_TRANSACTION_NAME = "generic ASGI request"
38+
2939

3040
def _capture_exception(hub, exc):
3141
# type: (Hub, Any) -> None
@@ -59,8 +69,23 @@ def _looks_like_asgi3(app):
5969
class SentryAsgiMiddleware:
6070
__slots__ = ("app", "__call__")
6171

62-
def __init__(self, app):
63-
# type: (Any) -> None
72+
def __init__(self, app, unsafe_context_data=False):
73+
# type: (Any, bool) -> None
74+
"""
75+
Instrument an ASGI application with Sentry. Provides HTTP/websocket
76+
data to sent events and basic handling for exceptions bubbling up
77+
through the middleware.
78+
79+
:param unsafe_context_data: Disable errors when a proper contextvars installation could not be found. We do not recommend changing this from the default.
80+
"""
81+
82+
if not unsafe_context_data and not HAS_REAL_CONTEXTVARS:
83+
# We better have contextvars or we're going to leak state between
84+
# requests.
85+
raise RuntimeError(
86+
"The ASGI middleware for Sentry requires Python 3.7+ "
87+
"or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
88+
)
6489
self.app = app
6590

6691
if _looks_like_asgi3(app):
@@ -95,15 +120,17 @@ async def _run_app(self, scope, callback):
95120
processor = partial(self.event_processor, asgi_scope=scope)
96121
sentry_scope.add_event_processor(processor)
97122

98-
if scope["type"] in ("http", "websocket"):
123+
ty = scope["type"]
124+
125+
if ty in ("http", "websocket"):
99126
span = Span.continue_from_headers(dict(scope["headers"]))
100-
span.op = "{}.server".format(scope["type"])
127+
span.op = "{}.server".format(ty)
101128
else:
102129
span = Span()
103130
span.op = "asgi.server"
104131

105-
span.set_tag("asgi.type", scope["type"])
106-
span.transaction = "generic ASGI request"
132+
span.set_tag("asgi.type", ty)
133+
span.transaction = _DEFAULT_TRANSACTION_NAME
107134

108135
with hub.start_span(span) as span:
109136
# XXX: Would be cool to have correct span status, but we
@@ -121,38 +148,55 @@ def event_processor(self, event, hint, asgi_scope):
121148
# type: (Event, Hint, Any) -> Optional[Event]
122149
request_info = event.get("request", {})
123150

124-
if asgi_scope["type"] in ("http", "websocket"):
125-
request_info["url"] = self.get_url(asgi_scope)
126-
request_info["method"] = asgi_scope["method"]
127-
request_info["headers"] = _filter_headers(self.get_headers(asgi_scope))
128-
request_info["query_string"] = self.get_query(asgi_scope)
129-
130-
if asgi_scope.get("client") and _should_send_default_pii():
131-
request_info["env"] = {"REMOTE_ADDR": asgi_scope["client"][0]}
132-
133-
if asgi_scope.get("endpoint"):
151+
ty = asgi_scope["type"]
152+
if ty in ("http", "websocket"):
153+
request_info["method"] = asgi_scope.get("method")
154+
request_info["headers"] = headers = _filter_headers(
155+
self._get_headers(asgi_scope)
156+
)
157+
request_info["query_string"] = self._get_query(asgi_scope)
158+
159+
request_info["url"] = self._get_url(
160+
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
161+
)
162+
163+
client = asgi_scope.get("client")
164+
if client and _should_send_default_pii():
165+
request_info["env"] = {"REMOTE_ADDR": client[0]}
166+
167+
if (
168+
event.get("transaction", _DEFAULT_TRANSACTION_NAME)
169+
== _DEFAULT_TRANSACTION_NAME
170+
):
171+
endpoint = asgi_scope.get("endpoint")
134172
# Webframeworks like Starlette mutate the ASGI env once routing is
135173
# done, which is sometime after the request has started. If we have
136-
# an endpoint, overwrite our path-based transaction name.
137-
event["transaction"] = self.get_transaction(asgi_scope)
174+
# an endpoint, overwrite our generic transaction name.
175+
if endpoint:
176+
event["transaction"] = transaction_from_function(endpoint)
138177

139178
event["request"] = request_info
140179

141180
return event
142181

143-
def get_url(self, scope):
144-
# type: (Any) -> str
182+
# Helper functions for extracting request data.
183+
#
184+
# Note: Those functions are not public API. If you want to mutate request
185+
# data to your liking it's recommended to use the `before_send` callback
186+
# for that.
187+
188+
def _get_url(self, scope, default_scheme, host):
189+
# type: (Dict[str, Any], Literal["ws", "http"], Optional[str]) -> str
145190
"""
146191
Extract URL from the ASGI scope, without also including the querystring.
147192
"""
148-
scheme = scope.get("scheme", "http")
193+
scheme = scope.get("scheme", default_scheme)
194+
149195
server = scope.get("server", None)
150-
path = scope.get("root_path", "") + scope["path"]
196+
path = scope.get("root_path", "") + scope.get("path", "")
151197

152-
for key, value in scope["headers"]:
153-
if key == b"host":
154-
host_header = value.decode("latin-1")
155-
return "%s://%s%s" % (scheme, host_header, path)
198+
if host:
199+
return "%s://%s%s" % (scheme, host, path)
156200

157201
if server is not None:
158202
host, port = server
@@ -162,15 +206,18 @@ def get_url(self, scope):
162206
return "%s://%s%s" % (scheme, host, path)
163207
return path
164208

165-
def get_query(self, scope):
209+
def _get_query(self, scope):
166210
# type: (Any) -> Any
167211
"""
168212
Extract querystring from the ASGI scope, in the format that the Sentry protocol expects.
169213
"""
170-
return urllib.parse.unquote(scope["query_string"].decode("latin-1"))
214+
qs = scope.get("query_string")
215+
if not qs:
216+
return None
217+
return urllib.parse.unquote(qs.decode("latin-1"))
171218

172-
def get_headers(self, scope):
173-
# type: (Any) -> Dict[str, Any]
219+
def _get_headers(self, scope):
220+
# type: (Any) -> Dict[str, str]
174221
"""
175222
Extract headers from the ASGI scope, in the format that the Sentry protocol expects.
176223
"""
@@ -183,10 +230,3 @@ def get_headers(self, scope):
183230
else:
184231
headers[key] = value
185232
return headers
186-
187-
def get_transaction(self, scope):
188-
# type: (Any) -> Optional[str]
189-
"""
190-
Return a transaction string to identify the routed endpoint.
191-
"""
192-
return transaction_from_function(scope["endpoint"])

sentry_sdk/integrations/django/__init__.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sentry_sdk.tracing import record_sql_queries
1313
from sentry_sdk.utils import (
1414
HAS_REAL_CONTEXTVARS,
15+
CONTEXTVARS_ERROR_MESSAGE,
1516
logger,
1617
capture_internal_exceptions,
1718
event_from_exception,
@@ -301,11 +302,12 @@ def _patch_channels():
301302
# requests.
302303
#
303304
# We cannot hard-raise here because channels may not be used at all in
304-
# the current process.
305+
# the current process. That is the case when running traditional WSGI
306+
# workers in gunicorn+gevent and the websocket stuff in a separate
307+
# process.
305308
logger.warning(
306-
"We detected that you are using Django channels 2.0. To get proper "
307-
"instrumentation for ASGI requests, the Sentry SDK requires "
308-
"Python 3.7+ or the aiocontextvars package from PyPI."
309+
"We detected that you are using Django channels 2.0."
310+
+ CONTEXTVARS_ERROR_MESSAGE
309311
)
310312

311313
from sentry_sdk.integrations.django.asgi import patch_channels_asgi_handler_impl
@@ -324,12 +326,10 @@ def _patch_django_asgi_handler():
324326
# We better have contextvars or we're going to leak state between
325327
# requests.
326328
#
327-
# We cannot hard-raise here because Django may not be used at all in
328-
# the current process.
329+
# We cannot hard-raise here because Django's ASGI stuff may not be used
330+
# at all.
329331
logger.warning(
330-
"We detected that you are using Django 3. To get proper "
331-
"instrumentation for ASGI requests, the Sentry SDK requires "
332-
"Python 3.7+ or the aiocontextvars package from PyPI."
332+
"We detected that you are using Django 3." + CONTEXTVARS_ERROR_MESSAGE
333333
)
334334

335335
from sentry_sdk.integrations.django.asgi import patch_django_asgi_handler_impl

sentry_sdk/integrations/django/asgi.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ async def sentry_patched_asgi_handler(self, scope, receive, send):
2525
if Hub.current.get_integration(DjangoIntegration) is None:
2626
return await old_app(self, scope, receive, send)
2727

28-
middleware = SentryAsgiMiddleware(old_app.__get__(self, cls))._run_asgi3
28+
middleware = SentryAsgiMiddleware(
29+
old_app.__get__(self, cls), unsafe_context_data=True
30+
)._run_asgi3
2931
return await middleware(scope, receive, send)
3032

3133
cls.__call__ = sentry_patched_asgi_handler
@@ -40,7 +42,9 @@ async def sentry_patched_asgi_handler(self, receive, send):
4042
if Hub.current.get_integration(DjangoIntegration) is None:
4143
return await old_app(self, receive, send)
4244

43-
middleware = SentryAsgiMiddleware(lambda _scope: old_app.__get__(self, cls))
45+
middleware = SentryAsgiMiddleware(
46+
lambda _scope: old_app.__get__(self, cls), unsafe_context_data=True
47+
)
4448

4549
return await middleware(self.scope)(receive, send)
4650

sentry_sdk/integrations/sanic.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
capture_internal_exceptions,
99
event_from_exception,
1010
HAS_REAL_CONTEXTVARS,
11+
CONTEXTVARS_ERROR_MESSAGE,
1112
)
1213
from sentry_sdk.integrations import Integration, DidNotEnable
1314
from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers
@@ -55,7 +56,7 @@ def setup_once():
5556
# requests.
5657
raise DidNotEnable(
5758
"The sanic integration for Sentry requires Python 3.7+ "
58-
" or aiocontextvars package"
59+
" or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
5960
)
6061

6162
if SANIC_VERSION.startswith("0.8."):

sentry_sdk/integrations/tornado.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from sentry_sdk.hub import Hub, _should_send_default_pii
55
from sentry_sdk.utils import (
66
HAS_REAL_CONTEXTVARS,
7+
CONTEXTVARS_ERROR_MESSAGE,
78
event_from_exception,
89
capture_internal_exceptions,
910
transaction_from_function,
@@ -48,7 +49,8 @@ def setup_once():
4849
# Tornado is async. We better have contextvars or we're going to leak
4950
# state between requests.
5051
raise DidNotEnable(
51-
"The tornado integration for Sentry requires Python 3.6+ or the aiocontextvars package"
52+
"The tornado integration for Sentry requires Python 3.7+ or the aiocontextvars package"
53+
+ CONTEXTVARS_ERROR_MESSAGE
5254
)
5355

5456
ignore_logger("tornado.access")

sentry_sdk/utils.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -724,10 +724,15 @@ def strip_string(value, max_length=None):
724724

725725
def _is_contextvars_broken():
726726
# type: () -> bool
727+
"""
728+
Returns whether gevent/eventlet have patched the stdlib in a way where thread locals are now more "correct" than contextvars.
729+
"""
727730
try:
728731
from gevent.monkey import is_object_patched # type: ignore
729732

730733
if is_object_patched("threading", "local"):
734+
# Gevent 20.5 is able to patch both thread locals and contextvars,
735+
# in that case all is good.
731736
if is_object_patched("contextvars", "ContextVar"):
732737
return False
733738

@@ -749,31 +754,35 @@ def _is_contextvars_broken():
749754
def _get_contextvars():
750755
# type: () -> Tuple[bool, type]
751756
"""
752-
Try to import contextvars and use it if it's deemed safe. We should not use
753-
contextvars if gevent or eventlet have patched thread locals, as
754-
contextvars are unaffected by that patch.
757+
Figure out the "right" contextvars installation to use. Returns a
758+
`contextvars.ContextVar`-like class with a limited API.
755759
756-
https://github.com/gevent/gevent/issues/1407
760+
See https://docs.sentry.io/platforms/python/contextvars/ for more information.
757761
"""
758762
if not _is_contextvars_broken():
759763
# aiocontextvars is a PyPI package that ensures that the contextvars
760764
# backport (also a PyPI package) works with asyncio under Python 3.6
761765
#
762766
# Import it if available.
763-
if not PY2 and sys.version_info < (3, 7):
767+
if sys.version_info < (3, 7):
768+
# `aiocontextvars` is absolutely required for functional
769+
# contextvars on Python 3.6.
764770
try:
765771
from aiocontextvars import ContextVar # noqa
766772

767773
return True, ContextVar
768774
except ImportError:
769775
pass
776+
else:
777+
# On Python 3.7 contextvars are functional.
778+
try:
779+
from contextvars import ContextVar
770780

771-
try:
772-
from contextvars import ContextVar
781+
return True, ContextVar
782+
except ImportError:
783+
pass
773784

774-
return True, ContextVar
775-
except ImportError:
776-
pass
785+
# Fall back to basic thread-local usage.
777786

778787
from threading import local
779788

@@ -798,6 +807,15 @@ def set(self, value):
798807

799808
HAS_REAL_CONTEXTVARS, ContextVar = _get_contextvars()
800809

810+
CONTEXTVARS_ERROR_MESSAGE = """
811+
812+
With asyncio/ASGI applications, the Sentry SDK requires a functional
813+
installation of `contextvars` to avoid leaking scope/context data across
814+
requests.
815+
816+
Please refer to https://docs.sentry.io/platforms/python/contextvars/ for more information.
817+
"""
818+
801819

802820
def transaction_from_function(func):
803821
# type: (Callable[..., Any]) -> Optional[str]

0 commit comments

Comments
 (0)