From 67f9dd8f2df57bdcf8fab6df8545ee0c7e15360f Mon Sep 17 00:00:00 2001 From: pgjones Date: Sun, 14 Nov 2021 11:55:56 +0000 Subject: [PATCH 1/2] Add a Quart integration This is based on the Flask integration but includes background and websocket exceptions, and works with asgi. --- sentry_sdk/integrations/__init__.py | 1 + sentry_sdk/integrations/quart.py | 171 +++++++++ setup.py | 1 + tests/integrations/quart/__init__.py | 3 + tests/integrations/quart/test_quart.py | 509 +++++++++++++++++++++++++ tox.ini | 8 + 6 files changed, 693 insertions(+) create mode 100644 sentry_sdk/integrations/quart.py create mode 100644 tests/integrations/quart/__init__.py create mode 100644 tests/integrations/quart/test_quart.py diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 777c363e14..64df275c25 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -54,6 +54,7 @@ def iter_default_integrations(with_auto_enabling_integrations): _AUTO_ENABLING_INTEGRATIONS = ( "sentry_sdk.integrations.django.DjangoIntegration", "sentry_sdk.integrations.flask.FlaskIntegration", + "sentry_sdk.integrations.quart.QuartIntegration", "sentry_sdk.integrations.bottle.BottleIntegration", "sentry_sdk.integrations.falcon.FalconIntegration", "sentry_sdk.integrations.sanic.SanicIntegration", diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py new file mode 100644 index 0000000000..411817c708 --- /dev/null +++ b/sentry_sdk/integrations/quart.py @@ -0,0 +1,171 @@ +from __future__ import absolute_import + +from sentry_sdk.hub import _should_send_default_pii, Hub +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations._wsgi_common import _filter_headers +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception + +from sentry_sdk._types import MYPY + +if MYPY: + from typing import Any + from typing import Dict + from typing import Union + + from sentry_sdk._types import EventProcessor + +try: + import quart_auth # type: ignore +except ImportError: + quart_auth = None + +try: + from quart import ( # type: ignore + Request, + Quart, + _request_ctx_stack, + _websocket_ctx_stack, + _app_ctx_stack, + ) + from quart.signals import ( # type: ignore + got_background_exception, + got_request_exception, + got_websocket_exception, + request_started, + websocket_started, + ) +except ImportError: + raise DidNotEnable("Quart is not installed") + +TRANSACTION_STYLE_VALUES = ("endpoint", "url") + + +class QuartIntegration(Integration): + identifier = "quart" + + transaction_style = None + + def __init__(self, transaction_style="endpoint"): + # type: (str) -> None + if transaction_style not in TRANSACTION_STYLE_VALUES: + raise ValueError( + "Invalid value for transaction_style: %s (must be in %s)" + % (transaction_style, TRANSACTION_STYLE_VALUES) + ) + self.transaction_style = transaction_style + + @staticmethod + def setup_once(): + # type: () -> None + + request_started.connect(_request_websocket_started) + websocket_started.connect(_request_websocket_started) + got_background_exception.connect(_capture_exception) + got_request_exception.connect(_capture_exception) + got_websocket_exception.connect(_capture_exception) + + old_app = Quart.__call__ + + async def sentry_patched_asgi_app(self, scope, receive, send): + # type: (Any, Any, Any, Any) -> Any + if Hub.current.get_integration(QuartIntegration) is None: + return await old_app(self, scope, receive, send) + + middleware = SentryAsgiMiddleware(lambda *a, **kw: old_app(self, *a, **kw)) + middleware.__call__ = middleware._run_asgi3 + return await middleware(scope, receive, send) + + Quart.__call__ = sentry_patched_asgi_app + + +def _request_websocket_started(sender, **kwargs): + # type: (Quart, **Any) -> None + hub = Hub.current + integration = hub.get_integration(QuartIntegration) + if integration is None: + return + + app = _app_ctx_stack.top.app + with hub.configure_scope() as scope: + if _request_ctx_stack.top is not None: + request_websocket = _request_ctx_stack.top.request + if _websocket_ctx_stack.top is not None: + request_websocket = _websocket_ctx_stack.top.websocket + + # Set the transaction name here, but rely on ASGI middleware + # to actually start the transaction + try: + if integration.transaction_style == "endpoint": + scope.transaction = request_websocket.url_rule.endpoint + elif integration.transaction_style == "url": + scope.transaction = request_websocket.url_rule.rule + except Exception: + pass + + evt_processor = _make_request_event_processor( + app, request_websocket, integration + ) + scope.add_event_processor(evt_processor) + + +def _make_request_event_processor(app, request, integration): + # type: (Quart, Request, QuartIntegration) -> EventProcessor + def inner(event, hint): + # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + # if the request is gone we are fine not logging the data from + # it. This might happen if the processor is pushed away to + # another thread. + if request is None: + return event + + with capture_internal_exceptions(): + # TODO: Figure out what to do with request body. Methods on request + # are async, but event processors are not. + + request_info = event.setdefault("request", {}) + request_info["url"] = request.url + request_info["query_string"] = request.query_string + request_info["method"] = request.method + request_info["headers"] = _filter_headers(dict(request.headers)) + + if _should_send_default_pii(): + request_info["env"] = {"REMOTE_ADDR": request.access_route[0]} + _add_user_to_event(event) + + return event + + return inner + + +def _capture_exception(sender, exception, **kwargs): + # type: (Quart, Union[ValueError, BaseException], **Any) -> None + hub = Hub.current + if hub.get_integration(QuartIntegration) is None: + return + + # If an integration is there, a client has to be there. + client = hub.client # type: Any + + event, hint = event_from_exception( + exception, + client_options=client.options, + mechanism={"type": "quart", "handled": False}, + ) + + hub.capture_event(event, hint=hint) + + +def _add_user_to_event(event): + # type: (Dict[str, Any]) -> None + if quart_auth is None: + return + + user = quart_auth.current_user + if user is None: + return + + with capture_internal_exceptions(): + user_info = event.setdefault("user", {}) + + user_info["id"] = quart_auth.current_user._auth_id diff --git a/setup.py b/setup.py index 97363af076..653ea6ea01 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ def get_file_text(file_name): install_requires=["urllib3>=1.10.0", "certifi"], extras_require={ "flask": ["flask>=0.11", "blinker>=1.1"], + "quart": ["quart>=0.16.1", "blinker>=1.1"], "bottle": ["bottle>=0.12.13"], "falcon": ["falcon>=1.4"], "django": ["django>=1.8"], diff --git a/tests/integrations/quart/__init__.py b/tests/integrations/quart/__init__.py new file mode 100644 index 0000000000..ea02dfb3a6 --- /dev/null +++ b/tests/integrations/quart/__init__.py @@ -0,0 +1,3 @@ +import pytest + +quart = pytest.importorskip("quart") diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py new file mode 100644 index 0000000000..c43253ceed --- /dev/null +++ b/tests/integrations/quart/test_quart.py @@ -0,0 +1,509 @@ +import pytest + +quart = pytest.importorskip("quart") + +from quart import Quart, Response, abort, stream_with_context +from quart.views import View + +from quart_auth import AuthManager, AuthUser, login_user + +from sentry_sdk import ( + set_tag, + configure_scope, + capture_message, + capture_exception, + last_event_id, +) +from sentry_sdk.integrations.logging import LoggingIntegration +import sentry_sdk.integrations.quart as quart_sentry + + +auth_manager = AuthManager() + + +@pytest.fixture +async def app(): + app = Quart(__name__) + app.debug = True + app.config["TESTING"] = True + app.secret_key = "haha" + + auth_manager.init_app(app) + + @app.route("/message") + async def hi(): + capture_message("hi") + return "ok" + + return app + + +@pytest.fixture(params=("auto", "manual")) +def integration_enabled_params(request): + if request.param == "auto": + return {"auto_enabling_integrations": True} + elif request.param == "manual": + return {"integrations": [quart_sentry.QuartIntegration()]} + else: + raise ValueError(request.param) + + +@pytest.mark.asyncio +async def test_has_context(sentry_init, app, capture_events): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + events = capture_events() + + client = app.test_client() + response = await client.get("/message") + assert response.status_code == 200 + + (event,) = events + assert event["transaction"] == "hi" + assert "data" not in event["request"] + assert event["request"]["url"] == "http://localhost/message" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "transaction_style,expected_transaction", [("endpoint", "hi"), ("url", "/message")] +) +async def test_transaction_style( + sentry_init, app, capture_events, transaction_style, expected_transaction +): + sentry_init( + integrations=[ + quart_sentry.QuartIntegration(transaction_style=transaction_style) + ] + ) + events = capture_events() + + client = app.test_client() + response = await client.get("/message") + assert response.status_code == 200 + + (event,) = events + assert event["transaction"] == expected_transaction + + +@pytest.mark.asyncio +@pytest.mark.parametrize("debug", (True, False)) +@pytest.mark.parametrize("testing", (True, False)) +async def test_errors( + sentry_init, + capture_exceptions, + capture_events, + app, + debug, + testing, + integration_enabled_params, +): + sentry_init(debug=True, **integration_enabled_params) + + app.debug = debug + app.testing = testing + + @app.route("/") + async def index(): + 1 / 0 + + exceptions = capture_exceptions() + events = capture_events() + + client = app.test_client() + try: + await client.get("/") + except ZeroDivisionError: + pass + + (exc,) = exceptions + assert isinstance(exc, ZeroDivisionError) + + (event,) = events + assert event["exception"]["values"][0]["mechanism"]["type"] == "quart" + + +@pytest.mark.asyncio +async def test_quart_auth_not_installed( + sentry_init, app, capture_events, monkeypatch, integration_enabled_params +): + sentry_init(**integration_enabled_params) + + monkeypatch.setattr(quart_sentry, "quart_auth", None) + + events = capture_events() + + client = app.test_client() + await client.get("/message") + + (event,) = events + assert event.get("user", {}).get("id") is None + + +@pytest.mark.asyncio +async def test_quart_auth_not_configured( + sentry_init, app, capture_events, monkeypatch, integration_enabled_params +): + sentry_init(**integration_enabled_params) + + assert quart_sentry.quart_auth + + events = capture_events() + client = app.test_client() + await client.get("/message") + + (event,) = events + assert event.get("user", {}).get("id") is None + + +@pytest.mark.asyncio +async def test_quart_auth_partially_configured( + sentry_init, app, capture_events, monkeypatch, integration_enabled_params +): + sentry_init(**integration_enabled_params) + + events = capture_events() + + client = app.test_client() + await client.get("/message") + + (event,) = events + assert event.get("user", {}).get("id") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("send_default_pii", [True, False]) +@pytest.mark.parametrize("user_id", [None, "42", "3"]) +async def test_quart_auth_configured( + send_default_pii, + sentry_init, + app, + user_id, + capture_events, + monkeypatch, + integration_enabled_params, +): + sentry_init(send_default_pii=send_default_pii, **integration_enabled_params) + + @app.route("/login") + async def login(): + if user_id is not None: + login_user(AuthUser(user_id)) + return "ok" + + events = capture_events() + + client = app.test_client() + assert (await client.get("/login")).status_code == 200 + assert not events + + assert (await client.get("/message")).status_code == 200 + + (event,) = events + if user_id is None or not send_default_pii: + assert event.get("user", {}).get("id") is None + else: + assert event["user"]["id"] == str(user_id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "integrations", + [ + [quart_sentry.QuartIntegration()], + [quart_sentry.QuartIntegration(), LoggingIntegration(event_level="ERROR")], + ], +) +async def test_errors_not_reported_twice( + sentry_init, integrations, capture_events, app +): + sentry_init(integrations=integrations) + + @app.route("/") + async def index(): + try: + 1 / 0 + except Exception as e: + app.logger.exception(e) + raise e + + events = capture_events() + + client = app.test_client() + # with pytest.raises(ZeroDivisionError): + await client.get("/") + + assert len(events) == 1 + + +@pytest.mark.asyncio +async def test_logging(sentry_init, capture_events, app): + # ensure that Quart's logger magic doesn't break ours + sentry_init( + integrations=[ + quart_sentry.QuartIntegration(), + LoggingIntegration(event_level="ERROR"), + ] + ) + + @app.route("/") + async def index(): + app.logger.error("hi") + return "ok" + + events = capture_events() + + client = app.test_client() + await client.get("/") + + (event,) = events + assert event["level"] == "error" + + +@pytest.mark.asyncio +async def test_no_errors_without_request(app, sentry_init): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + async with app.app_context(): + capture_exception(ValueError()) + + +def test_cli_commands_raise(app): + if not hasattr(app, "cli"): + pytest.skip("Too old quart version") + + from quart.cli import ScriptInfo + + @app.cli.command() + def foo(): + 1 / 0 + + with pytest.raises(ZeroDivisionError): + app.cli.main( + args=["foo"], prog_name="myapp", obj=ScriptInfo(create_app=lambda _: app) + ) + + +@pytest.mark.asyncio +async def test_500(sentry_init, capture_events, app): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + + app.debug = False + app.testing = False + + @app.route("/") + async def index(): + 1 / 0 + + @app.errorhandler(500) + async def error_handler(err): + return "Sentry error: %s" % last_event_id() + + events = capture_events() + + client = app.test_client() + response = await client.get("/") + + (event,) = events + assert (await response.get_data(as_text=True)) == "Sentry error: %s" % event[ + "event_id" + ] + + +@pytest.mark.asyncio +async def test_error_in_errorhandler(sentry_init, capture_events, app): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + + app.debug = False + app.testing = False + + @app.route("/") + async def index(): + raise ValueError() + + @app.errorhandler(500) + async def error_handler(err): + 1 / 0 + + events = capture_events() + + client = app.test_client() + + with pytest.raises(ZeroDivisionError): + await client.get("/") + + event1, event2 = events + + (exception,) = event1["exception"]["values"] + assert exception["type"] == "ValueError" + + exception = event2["exception"]["values"][-1] + assert exception["type"] == "ZeroDivisionError" + + +@pytest.mark.asyncio +async def test_bad_request_not_captured(sentry_init, capture_events, app): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + events = capture_events() + + @app.route("/") + async def index(): + abort(400) + + client = app.test_client() + + await client.get("/") + + assert not events + + +@pytest.mark.asyncio +async def test_does_not_leak_scope(sentry_init, capture_events, app): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + events = capture_events() + + with configure_scope() as scope: + scope.set_tag("request_data", False) + + @app.route("/") + async def index(): + with configure_scope() as scope: + scope.set_tag("request_data", True) + + async def generate(): + for row in range(1000): + with configure_scope() as scope: + assert scope._tags["request_data"] + + yield str(row) + "\n" + + return Response(stream_with_context(generate)(), mimetype="text/csv") + + client = app.test_client() + response = await client.get("/") + assert (await response.get_data(as_text=True)) == "".join( + str(row) + "\n" for row in range(1000) + ) + assert not events + + with configure_scope() as scope: + assert not scope._tags["request_data"] + + +@pytest.mark.asyncio +async def test_scoped_test_client(sentry_init, app): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + + @app.route("/") + async def index(): + return "ok" + + async with app.test_client() as client: + response = await client.get("/") + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exc_cls", [ZeroDivisionError, Exception]) +async def test_errorhandler_for_exception_swallows_exception( + sentry_init, app, capture_events, exc_cls +): + # In contrast to error handlers for a status code, error + # handlers for exceptions can swallow the exception (this is + # just how the Quart signal works) + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + events = capture_events() + + @app.route("/") + async def index(): + 1 / 0 + + @app.errorhandler(exc_cls) + async def zerodivision(e): + return "ok" + + async with app.test_client() as client: + response = await client.get("/") + assert response.status_code == 200 + + assert not events + + +@pytest.mark.asyncio +async def test_tracing_success(sentry_init, capture_events, app): + sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()]) + + @app.before_request + async def _(): + set_tag("before_request", "yes") + + @app.route("/message_tx") + async def hi_tx(): + set_tag("view", "yes") + capture_message("hi") + return "ok" + + events = capture_events() + + async with app.test_client() as client: + response = await client.get("/message_tx") + assert response.status_code == 200 + + message_event, transaction_event = events + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "hi_tx" + assert transaction_event["tags"]["view"] == "yes" + assert transaction_event["tags"]["before_request"] == "yes" + + assert message_event["message"] == "hi" + assert message_event["transaction"] == "hi_tx" + assert message_event["tags"]["view"] == "yes" + assert message_event["tags"]["before_request"] == "yes" + + +@pytest.mark.asyncio +async def test_tracing_error(sentry_init, capture_events, app): + sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()]) + + events = capture_events() + + @app.route("/error") + async def error(): + 1 / 0 + + async with app.test_client() as client: + response = await client.get("/error") + assert response.status_code == 500 + + error_event, transaction_event = events + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "error" + + assert error_event["transaction"] == "error" + (exception,) = error_event["exception"]["values"] + assert exception["type"] == "ZeroDivisionError" + + +@pytest.mark.asyncio +async def test_class_based_views(sentry_init, app, capture_events): + sentry_init(integrations=[quart_sentry.QuartIntegration()]) + events = capture_events() + + @app.route("/") + class HelloClass(View): + methods = ["GET"] + + async def dispatch_request(self): + capture_message("hi") + return "ok" + + app.add_url_rule("/hello-class/", view_func=HelloClass.as_view("hello_class")) + + async with app.test_client() as client: + response = await client.get("/hello-class/") + assert response.status_code == 200 + + (event,) = events + + assert event["message"] == "hi" + assert event["transaction"] == "hello_class" diff --git a/tox.ini b/tox.ini index 8f19258398..d282f65d17 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,8 @@ envlist = {pypy,py2.7,py3.5,py3.6,py3.7,py3.8,py3.9}-flask-1.1 {py3.6,py3.8,py3.9}-flask-2.0 + {py3.7,py3.8,py3.9}-quart + {pypy,py2.7,py3.5,py3.6,py3.7,py3.8,py3.9}-bottle-0.12 {pypy,py2.7,py3.5,py3.6,py3.7}-falcon-1.4 @@ -124,6 +126,10 @@ deps = flask-1.1: Flask>=1.1,<1.2 flask-2.0: Flask>=2.0,<2.1 + quart: quart>=0.16.1 + quart: quart-auth + quart: pytest-asyncio + bottle-0.12: bottle>=0.12,<0.13 falcon-1.4: falcon>=1.4,<1.5 @@ -244,6 +250,7 @@ setenv = beam: TESTPATH=tests/integrations/beam django: TESTPATH=tests/integrations/django flask: TESTPATH=tests/integrations/flask + quart: TESTPATH=tests/integrations/quart bottle: TESTPATH=tests/integrations/bottle falcon: TESTPATH=tests/integrations/falcon celery: TESTPATH=tests/integrations/celery @@ -278,6 +285,7 @@ extras = flask: flask bottle: bottle falcon: falcon + quart: quart basepython = py2.7: python2.7 From cdfaaabfaed635224b7ff4b28bf6bf4d036e8e36 Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 3 Jan 2022 13:30:07 +0000 Subject: [PATCH 2/2] Disable Quart auto integration This can be reverted when the integration is considered stable. --- sentry_sdk/integrations/__init__.py | 1 - tests/integrations/quart/test_quart.py | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 64df275c25..777c363e14 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -54,7 +54,6 @@ def iter_default_integrations(with_auto_enabling_integrations): _AUTO_ENABLING_INTEGRATIONS = ( "sentry_sdk.integrations.django.DjangoIntegration", "sentry_sdk.integrations.flask.FlaskIntegration", - "sentry_sdk.integrations.quart.QuartIntegration", "sentry_sdk.integrations.bottle.BottleIntegration", "sentry_sdk.integrations.falcon.FalconIntegration", "sentry_sdk.integrations.sanic.SanicIntegration", diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index c43253ceed..0b886ebf18 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -38,11 +38,9 @@ async def hi(): return app -@pytest.fixture(params=("auto", "manual")) +@pytest.fixture(params=("manual")) def integration_enabled_params(request): - if request.param == "auto": - return {"auto_enabling_integrations": True} - elif request.param == "manual": + if request.param == "manual": return {"integrations": [quart_sentry.QuartIntegration()]} else: raise ValueError(request.param)