Skip to content

Commit ce3b49f

Browse files
authored
feat: ASGI middleware (#429)
* feat: ASGI middleware Diff to sentry-asgi: ``` diff --git a/../sentry-asgi/sentry_asgi/middleware.py b/sentry_sdk/integrations/asgi.py index 37d1117..4c40750 100644 --- a/../sentry-asgi/sentry_asgi/middleware.py +++ b/sentry_sdk/integrations/asgi.py @@ -1,35 +1,44 @@ import functools import urllib -import sentry_sdk -from sentry_sdk.utils import event_from_exception, exc_info_from_error +from sentry_sdk._types import MYPY +from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.integrations._wsgi_common import _filter_headers +from sentry_sdk.utils import transaction_from_function +if MYPY: + from typing import Dict + + +class SentryAsgiMiddleware: + __slots__ = ("app",) -class SentryMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): - hub = sentry_sdk.Hub.current - with sentry_sdk.Hub(hub) as hub: + hub = Hub.current + with Hub(hub) as hub: with hub.configure_scope() as sentry_scope: + sentry_scope._name = "asgi" processor = functools.partial(self.event_processor, asgi_scope=scope) sentry_scope.add_event_processor(processor) - try: - await self.app(scope, receive, send) - except Exception as exc: - hub.capture_exception(exc) - raise exc from None + + try: + await self.app(scope, receive, send) + except Exception as exc: + hub.capture_exception(exc) + raise exc from None def event_processor(self, event, hint, asgi_scope): if asgi_scope["type"] in ("http", "websocket"): event["request"] = { "url": self.get_url(asgi_scope), "method": asgi_scope["method"], - "headers": self.get_headers(asgi_scope), + "headers": _filter_headers(self.get_headers(asgi_scope)), "query_string": self.get_query(asgi_scope), } - if asgi_scope.get("client"): + if asgi_scope.get("client") and _should_send_default_pii(): event["request"]["env"] = {"REMOTE_ADDR": asgi_scope["client"][0]} if asgi_scope.get("endpoint"): event["transaction"] = self.get_transaction(asgi_scope) @@ -66,7 +75,7 @@ class SentryMiddleware: """ Extract headers from the ASGI scope, in the format that the Sentry protocol expects. """ - headers = {} + headers = {} # type: Dict[str, str] for raw_key, raw_value in scope["headers"]: key = raw_key.decode("latin-1") value = raw_value.decode("latin-1") @@ -80,12 +89,4 @@ class SentryMiddleware: """ Return a transaction string to identify the routed endpoint. """ - endpoint = scope["endpoint"] - qualname = ( - getattr(endpoint, "__qualname__", None) - or getattr(endpoint, "__name__", None) - or None - ) - if not qualname: - return None - return "%s.%s" % (endpoint.__module__, qualname) + return transaction_from_function(scope["endpoint"]) ``` * fix: Add credits in docstring * fix: Linting * fix: Fix test * ref: Allow ASGI2 requests, add test for Django channels * fix: Fix testrun under python 2 * fix: Set transaction from endpoint at right point in time
1 parent a32f41c commit ce3b49f

File tree

8 files changed

+290
-2
lines changed

8 files changed

+290
-2
lines changed

sentry_sdk/integrations/asgi.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""
2+
An ASGI middleware.
3+
4+
Based on Tom Christie's `sentry-asgi <https://github.com/encode/sentry-asgi>`_.
5+
"""
6+
7+
import functools
8+
import urllib
9+
10+
from sentry_sdk._types import MYPY
11+
from sentry_sdk.hub import Hub, _should_send_default_pii
12+
from sentry_sdk.integrations._wsgi_common import _filter_headers
13+
from sentry_sdk.utils import transaction_from_function
14+
15+
if MYPY:
16+
from typing import Dict
17+
18+
19+
class SentryAsgiMiddleware:
20+
__slots__ = ("app",)
21+
22+
def __init__(self, app):
23+
self.app = app
24+
25+
def __call__(self, scope, receive=None, send=None):
26+
if receive is None or send is None:
27+
28+
async def run_asgi2(receive, send):
29+
return await self._run_app(
30+
scope, lambda: self.app(scope)(receive, send)
31+
)
32+
33+
return run_asgi2
34+
else:
35+
return self._run_app(scope, lambda: self.app(scope, receive, send))
36+
37+
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
52+
53+
def event_processor(self, event, hint, asgi_scope):
54+
request_info = event.setdefault("request", {})
55+
56+
if asgi_scope["type"] in ("http", "websocket"):
57+
request_info["url"] = self.get_url(asgi_scope)
58+
request_info["method"] = asgi_scope["method"]
59+
request_info["headers"] = _filter_headers(self.get_headers(asgi_scope))
60+
request_info["query_string"] = self.get_query(asgi_scope)
61+
62+
if asgi_scope.get("client") and _should_send_default_pii():
63+
request_info["env"] = {"REMOTE_ADDR": asgi_scope["client"][0]}
64+
65+
if asgi_scope.get("endpoint"):
66+
# Webframeworks like Starlette mutate the ASGI env once routing is
67+
# done, which is sometime after the request has started. If we have
68+
# an endpoint, overwrite our path-based transaction name.
69+
event["transaction"] = self.get_transaction(asgi_scope)
70+
return event
71+
72+
def get_url(self, scope):
73+
"""
74+
Extract URL from the ASGI scope, without also including the querystring.
75+
"""
76+
scheme = scope.get("scheme", "http")
77+
server = scope.get("server", None)
78+
path = scope.get("root_path", "") + scope["path"]
79+
80+
for key, value in scope["headers"]:
81+
if key == b"host":
82+
host_header = value.decode("latin-1")
83+
return "%s://%s%s" % (scheme, host_header, path)
84+
85+
if server is not None:
86+
host, port = server
87+
default_port = {"http": 80, "https": 443, "ws": 80, "wss": 443}[scheme]
88+
if port != default_port:
89+
return "%s://%s:%s%s" % (scheme, host, port, path)
90+
return "%s://%s%s" % (scheme, host, path)
91+
return path
92+
93+
def get_query(self, scope):
94+
"""
95+
Extract querystring from the ASGI scope, in the format that the Sentry protocol expects.
96+
"""
97+
return urllib.parse.unquote(scope["query_string"].decode("latin-1"))
98+
99+
def get_headers(self, scope):
100+
"""
101+
Extract headers from the ASGI scope, in the format that the Sentry protocol expects.
102+
"""
103+
headers = {} # type: Dict[str, str]
104+
for raw_key, raw_value in scope["headers"]:
105+
key = raw_key.decode("latin-1")
106+
value = raw_value.decode("latin-1")
107+
if key in headers:
108+
headers[key] = headers[key] + ", " + value
109+
else:
110+
headers[key] = value
111+
return headers
112+
113+
def get_transaction(self, scope):
114+
"""
115+
Return a transaction string to identify the routed endpoint.
116+
"""
117+
return transaction_from_function(scope["endpoint"])

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ def _capture_internal_warnings():
9797
if "SessionAuthenticationMiddleware" in str(warning.message):
9898
continue
9999

100+
if "Something has already installed a non-asyncio" in str(warning.message):
101+
continue
102+
100103
raise AssertionError(warning)
101104

102105

tests/integrations/asgi/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("starlette")

tests/integrations/asgi/test_asgi.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import sys
2+
3+
import pytest
4+
from sentry_sdk import capture_message
5+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
6+
from starlette.applications import Starlette
7+
from starlette.responses import PlainTextResponse
8+
from starlette.testclient import TestClient
9+
10+
11+
@pytest.fixture
12+
def app():
13+
app = Starlette()
14+
15+
@app.route("/sync-message")
16+
def hi(request):
17+
capture_message("hi", level="error")
18+
return PlainTextResponse("ok")
19+
20+
@app.route("/async-message")
21+
async def hi2(request):
22+
capture_message("hi", level="error")
23+
return PlainTextResponse("ok")
24+
25+
app.add_middleware(SentryAsgiMiddleware)
26+
27+
return app
28+
29+
30+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
31+
def test_sync_request_data(sentry_init, app, capture_events):
32+
sentry_init(send_default_pii=True)
33+
events = capture_events()
34+
35+
client = TestClient(app)
36+
response = client.get("/sync-message?foo=bar")
37+
38+
assert response.status_code == 200
39+
40+
event, = events
41+
assert event["transaction"] == "tests.integrations.asgi.test_asgi.app.<locals>.hi"
42+
assert event["request"]["env"] == {"REMOTE_ADDR": "testclient"}
43+
assert set(event["request"]["headers"]) == {
44+
"accept",
45+
"accept-encoding",
46+
"connection",
47+
"host",
48+
"user-agent",
49+
}
50+
assert event["request"]["query_string"] == "foo=bar"
51+
assert event["request"]["url"].endswith("/sync-message")
52+
assert event["request"]["method"] == "GET"
53+
54+
# Assert that state is not leaked
55+
events.clear()
56+
capture_message("foo")
57+
event, = events
58+
59+
assert "request" not in event
60+
assert "transaction" not in event
61+
62+
63+
def test_async_request_data(sentry_init, app, capture_events):
64+
sentry_init(send_default_pii=True)
65+
events = capture_events()
66+
67+
client = TestClient(app)
68+
response = client.get("/async-message?foo=bar")
69+
70+
assert response.status_code == 200
71+
72+
event, = events
73+
assert event["transaction"] == "tests.integrations.asgi.test_asgi.app.<locals>.hi2"
74+
assert event["request"]["env"] == {"REMOTE_ADDR": "testclient"}
75+
assert set(event["request"]["headers"]) == {
76+
"accept",
77+
"accept-encoding",
78+
"connection",
79+
"host",
80+
"user-agent",
81+
}
82+
assert event["request"]["query_string"] == "foo=bar"
83+
assert event["request"]["url"].endswith("/async-message")
84+
assert event["request"]["method"] == "GET"
85+
86+
# Assert that state is not leaked
87+
events.clear()
88+
capture_message("foo")
89+
event, = events
90+
91+
assert "request" not in event
92+
assert "transaction" not in event
93+
94+
95+
def test_errors(sentry_init, app, capture_events):
96+
sentry_init(send_default_pii=True)
97+
events = capture_events()
98+
99+
@app.route("/error")
100+
def myerror(request):
101+
raise ValueError("oh no")
102+
103+
client = TestClient(app, raise_server_exceptions=False)
104+
response = client.get("/error")
105+
106+
assert response.status_code == 500
107+
108+
event, = events
109+
assert (
110+
event["transaction"]
111+
== "tests.integrations.asgi.test_asgi.test_errors.<locals>.myerror"
112+
)
113+
exception, = event["exception"]["values"]
114+
115+
assert exception["type"] == "ValueError"
116+
assert exception["value"] == "oh no"
117+
assert any(
118+
frame["filename"].endswith("tests/integrations/asgi/test_asgi.py")
119+
for frame in exception["stacktrace"]["frames"]
120+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("channels")
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import pytest
2+
3+
4+
from channels.testing import HttpCommunicator
5+
6+
from sentry_sdk.integrations.django import DjangoIntegration
7+
8+
from tests.integrations.django.myapp.asgi import application
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_basic(sentry_init, capture_events):
13+
sentry_init(integrations=[DjangoIntegration()], send_default_pii=True)
14+
events = capture_events()
15+
16+
comm = HttpCommunicator(application, "GET", "/view-exc?test=query")
17+
response = await comm.get_response()
18+
assert response["status"] == 500
19+
20+
event, = events
21+
22+
exception, = event["exception"]["values"]
23+
assert exception["type"] == "ZeroDivisionError"
24+
25+
# Test that the ASGI middleware got set up correctly. Right now this needs
26+
# to be installed manually (see myapp/asgi.py)
27+
assert event["transaction"] == "/view-exc"
28+
assert event["request"] == {
29+
"cookies": {},
30+
"headers": {},
31+
"method": "GET",
32+
"query_string": "test=query",
33+
"url": "/view-exc",
34+
}

tests/integrations/django/myapp/asgi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
django.setup()
1515

16-
from sentry_asgi import SentryMiddleware
16+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
1717

1818
application = get_default_application()
19-
application = SentryMiddleware(application)
19+
application = SentryAsgiMiddleware(application)

tox.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,15 @@ envlist =
4848

4949
{py2.7,py3.7}-redis
5050

51+
py3.7-asgi
52+
5153
[testenv]
5254
deps =
5355
-r test-requirements.txt
5456

5557
django-{1.11,2.0,2.1,2.2}: djangorestframework>=3.0.0,<4.0.0
58+
py3.7-django-{1.11,2.0,2.1,2.2}: channels>2
59+
py3.7-django-{1.11,2.0,2.1,2.2}: pytest-asyncio
5660

5761
django-{1.6,1.7,1.8}: pytest-django<3.0
5862
django-{1.9,1.10,1.11,2.0,2.1,2.2,dev}: pytest-django>=3.0
@@ -127,6 +131,9 @@ deps =
127131

128132
redis: fakeredis
129133

134+
asgi: starlette
135+
asgi: requests
136+
130137
linters: black
131138
linters: flake8
132139
linters: flake8-import-order
@@ -150,6 +157,7 @@ setenv =
150157
aiohttp: TESTPATH=tests/integrations/aiohttp
151158
tornado: TESTPATH=tests/integrations/tornado
152159
redis: TESTPATH=tests/integrations/redis
160+
asgi: TESTPATH=tests/integrations/asgi
153161

154162
COVERAGE_FILE=.coverage-{envname}
155163
passenv =

0 commit comments

Comments
 (0)