feat: ASGI middleware (#429) · gitcommit90/sentry-python@ce3b49f · GitHub
Skip to content

Commit ce3b49f

Browse files
authored
feat: ASGI middleware (getsentry#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

8 files changed

Lines changed: 290 additions & 2 deletions

File tree

sentry_sdk/integrations/asgi.py

Lines changed: 117 additions & 0 deletions

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

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")
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

0 commit comments

Comments
 (0)