diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py new file mode 100644 index 0000000000..da641088ba --- /dev/null +++ b/sentry_sdk/integrations/falcon.py @@ -0,0 +1,171 @@ +from __future__ import absolute_import + +import falcon # type: ignore +import falcon.api_helpers # type: ignore +from sentry_sdk.hub import Hub +from sentry_sdk.integrations import Integration +from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception + +if False: + from typing import Any + from typing import Callable + from typing import Dict + + +class FalconRequestExtractor(RequestExtractor): + def env(self): + return self.request.env + + def cookies(self): + return self.request.cookies + + def form(self): + return None # No such concept in Falcon + + def files(self): + return None # No such concept in Falcon + + def raw_data(self): + # As request data can only be read once we won't make this available + # to Sentry. Just send back a dummy string in case there was a + # content length. + # TODO(jmagnusson): Figure out if there's a way to support this + content_length = self.content_length() + if content_length > 0: + return "[REQUEST_CONTAINING_RAW_DATA]" + else: + return None + + def json(self): + try: + return self.request.media + except falcon.errors.HTTPBadRequest: + # NOTE(jmagnusson): We return `falcon.Request._media` here because + # falcon 1.4 doesn't do proper type checking in + # `falcon.Request.media`. This has been fixed in 2.0. + # Relevant code: https://github.com/falconry/falcon/blob/1.4.1/falcon/request.py#L953 + return self.request._media + + +class SentryFalconMiddleware(object): + """Captures exceptions in Falcon requests and send to Sentry""" + + def process_request(self, req, resp, *args, **kwargs): + hub = Hub.current + integration = hub.get_integration(FalconIntegration) + if integration is None: + return + + with hub.configure_scope() as scope: + scope._name = "falcon" + scope.add_event_processor(_make_request_event_processor(req, integration)) + + +class FalconIntegration(Integration): + identifier = "falcon" + + transaction_style = None + + def __init__(self, transaction_style="uri_template"): + # type: (str) -> None + TRANSACTION_STYLE_VALUES = ("uri_template", "path") + 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 + _patch_wsgi_app() + _patch_handle_exception() + _patch_prepare_middleware() + + +def _patch_wsgi_app(): + original_wsgi_app = falcon.API.__call__ + + def sentry_patched_wsgi_app(self, env, start_response): + hub = Hub.current + integration = hub.get_integration(FalconIntegration) + if integration is None: + return original_wsgi_app(self, env, start_response) + + sentry_wrapped = SentryWsgiMiddleware( + lambda envi, start_resp: original_wsgi_app(self, envi, start_resp) + ) + + return sentry_wrapped(env, start_response) + + falcon.API.__call__ = sentry_patched_wsgi_app + + +def _patch_handle_exception(): + original_handle_exception = falcon.API._handle_exception + + def sentry_patched_handle_exception(self, *args): + # NOTE(jmagnusson): falcon 2.0 changed falcon.API._handle_exception + # method signature from `(ex, req, resp, params)` to + # `(req, resp, ex, params)` + if isinstance(args[0], Exception): + ex = args[0] + else: + ex = args[2] + + was_handled = original_handle_exception(self, *args) + + hub = Hub.current + integration = hub.get_integration(FalconIntegration) + + if integration is not None and not _is_falcon_http_error(ex): + event, hint = event_from_exception( + ex, + client_options=hub.client.options, + mechanism={"type": "falcon", "handled": False}, + ) + hub.capture_event(event, hint=hint) + + return was_handled + + falcon.API._handle_exception = sentry_patched_handle_exception + + +def _patch_prepare_middleware(): + original_prepare_middleware = falcon.api_helpers.prepare_middleware + + def sentry_patched_prepare_middleware( + middleware=None, independent_middleware=False + ): + hub = Hub.current + integration = hub.get_integration(FalconIntegration) + if integration is not None: + middleware = [SentryFalconMiddleware()] + (middleware or []) + return original_prepare_middleware(middleware, independent_middleware) + + falcon.api_helpers.prepare_middleware = sentry_patched_prepare_middleware + + +def _is_falcon_http_error(ex): + return isinstance(ex, (falcon.HTTPError, falcon.http_status.HTTPStatus)) + + +def _make_request_event_processor(req, integration): + # type: (falcon.Request, FalconIntegration) -> Callable + + def inner(event, hint): + # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + if integration.transaction_style == "uri_template": + event["transaction"] = req.uri_template + elif integration.transaction_style == "path": + event["transaction"] = req.path + + with capture_internal_exceptions(): + FalconRequestExtractor(req).extract_into_event(event) + + return event + + return inner diff --git a/setup.py b/setup.py index e2ae7e7654..f036b38450 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ extras_require={ "flask": ["flask>=0.8", "blinker>=1.1"], "bottle": ["bottle>=0.12.13"], + "falcon": ["falcon>=1.4"], }, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/integrations/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py new file mode 100644 index 0000000000..995cb26a67 --- /dev/null +++ b/tests/integrations/falcon/test_falcon.py @@ -0,0 +1,306 @@ +from __future__ import absolute_import + +import logging + +import pytest + +pytest.importorskip("falcon") + +import falcon +import falcon.testing +import sentry_sdk +from sentry_sdk.integrations.falcon import FalconIntegration +from sentry_sdk.integrations.logging import LoggingIntegration + + +@pytest.fixture +def make_app(sentry_init): + def inner(): + class MessageResource: + def on_get(self, req, resp): + sentry_sdk.capture_message("hi") + resp.media = "hi" + + app = falcon.API() + app.add_route("/message", MessageResource()) + + return app + + return inner + + +@pytest.fixture +def make_client(make_app): + def inner(): + app = make_app() + return falcon.testing.TestClient(app) + + return inner + + +def test_has_context(sentry_init, capture_events, make_client): + sentry_init(integrations=[FalconIntegration()]) + events = capture_events() + + client = make_client() + response = client.simulate_get("/message") + assert response.status == falcon.HTTP_200 + + event, = events + assert event["transaction"] == "/message" # Falcon URI template + assert "data" not in event["request"] + assert event["request"]["url"] == "http://falconframework.org/message" + + +@pytest.mark.parametrize( + "transaction_style,expected_transaction", + [("uri_template", "/message"), ("path", "/message")], +) +def test_transaction_style( + sentry_init, make_client, capture_events, transaction_style, expected_transaction +): + integration = FalconIntegration(transaction_style=transaction_style) + sentry_init(integrations=[integration]) + events = capture_events() + + client = make_client() + response = client.simulate_get("/message") + assert response.status == falcon.HTTP_200 + + event, = events + assert event["transaction"] == expected_transaction + + +def test_errors(sentry_init, capture_exceptions, capture_events): + sentry_init(integrations=[FalconIntegration()], debug=True) + + class ZeroDivisionErrorResource: + def on_get(self, req, resp): + 1 / 0 + + app = falcon.API() + app.add_route("/", ZeroDivisionErrorResource()) + + exceptions = capture_exceptions() + events = capture_events() + + client = falcon.testing.TestClient(app) + + try: + client.simulate_get("/") + except ZeroDivisionError: + pass + + exc, = exceptions + assert isinstance(exc, ZeroDivisionError) + + event, = events + assert event["exception"]["values"][0]["mechanism"]["type"] == "falcon" + + +def test_falcon_large_json_request(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + + data = {"foo": {"bar": "a" * 2000}} + + class Resource: + def on_post(self, req, resp): + assert req.media == data + sentry_sdk.capture_message("hi") + resp.media = "ok" + + app = falcon.API() + app.add_route("/", Resource()) + + events = capture_events() + + client = falcon.testing.TestClient(app) + response = client.simulate_post("/", json=data) + assert response.status == falcon.HTTP_200 + + event, = events + assert event["_meta"]["request"]["data"]["foo"]["bar"] == { + "": {"len": 2000, "rem": [["!limit", "x", 509, 512]]} + } + assert len(event["request"]["data"]["foo"]["bar"]) == 512 + + +@pytest.mark.parametrize("data", [{}, []], ids=["empty-dict", "empty-list"]) +def test_falcon_empty_json_request(sentry_init, capture_events, data): + sentry_init(integrations=[FalconIntegration()]) + + class Resource: + def on_post(self, req, resp): + assert req.media == data + sentry_sdk.capture_message("hi") + resp.media = "ok" + + app = falcon.API() + app.add_route("/", Resource()) + + events = capture_events() + + client = falcon.testing.TestClient(app) + response = client.simulate_post("/", json=data) + assert response.status == falcon.HTTP_200 + + event, = events + assert event["request"]["data"] == data + + +def test_falcon_raw_data_request(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + + class Resource: + def on_post(self, req, resp): + sentry_sdk.capture_message("hi") + resp.media = "ok" + + app = falcon.API() + app.add_route("/", Resource()) + + events = capture_events() + + client = falcon.testing.TestClient(app) + response = client.simulate_post("/", body="hi") + assert response.status == falcon.HTTP_200 + + event, = events + assert event["request"]["headers"]["Content-Length"] == "2" + assert event["request"]["data"] == "" + + +def test_logging(sentry_init, capture_events): + sentry_init( + integrations=[FalconIntegration(), LoggingIntegration(event_level="ERROR")] + ) + + logger = logging.getLogger() + + app = falcon.API() + + class Resource: + def on_get(self, req, resp): + logger.error("hi") + resp.media = "ok" + + app.add_route("/", Resource()) + + events = capture_events() + + client = falcon.testing.TestClient(app) + client.simulate_get("/") + + event, = events + assert event["level"] == "error" + + +def test_500(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + + app = falcon.API() + + class Resource: + def on_get(self, req, resp): + 1 / 0 + + app.add_route("/", Resource()) + + def http500_handler(ex, req, resp, params): + sentry_sdk.capture_exception(ex) + resp.media = {"message": "Sentry error: %s" % sentry_sdk.last_event_id()} + + app.add_error_handler(Exception, http500_handler) + + events = capture_events() + + client = falcon.testing.TestClient(app) + response = client.simulate_get("/") + + event, = events + assert response.json == {"message": "Sentry error: %s" % event["event_id"]} + + +def test_error_in_errorhandler(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + + app = falcon.API() + + class Resource: + def on_get(self, req, resp): + raise ValueError() + + app.add_route("/", Resource()) + + def http500_handler(ex, req, resp, params): + 1 / 0 + + app.add_error_handler(Exception, http500_handler) + + events = capture_events() + + client = falcon.testing.TestClient(app) + + with pytest.raises(ZeroDivisionError): + client.simulate_get("/") + + event, = events + + last_ex_values = event["exception"]["values"][-1] + assert last_ex_values["type"] == "ZeroDivisionError" + assert last_ex_values["stacktrace"]["frames"][-1]["vars"]["ex"] == "ValueError()" + + +def test_bad_request_not_captured(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + events = capture_events() + + app = falcon.API() + + class Resource: + def on_get(self, req, resp): + raise falcon.HTTPBadRequest() + + app.add_route("/", Resource()) + + client = falcon.testing.TestClient(app) + + client.simulate_get("/") + + assert not events + + +def test_does_not_leak_scope(sentry_init, capture_events): + sentry_init(integrations=[FalconIntegration()]) + events = capture_events() + + with sentry_sdk.configure_scope() as scope: + scope.set_tag("request_data", False) + + app = falcon.API() + + class Resource: + def on_get(self, req, resp): + with sentry_sdk.configure_scope() as scope: + scope.set_tag("request_data", True) + + def generator(): + for row in range(1000): + with sentry_sdk.configure_scope() as scope: + assert scope._tags["request_data"] + + yield (str(row) + "\n").encode() + + resp.stream = generator() + + app.add_route("/", Resource()) + + client = falcon.testing.TestClient(app) + response = client.simulate_get("/") + + expected_response = "".join(str(row) + "\n" for row in range(1000)) + assert response.text == expected_response + assert not events + + with sentry_sdk.configure_scope() as scope: + assert not scope._tags["request_data"] diff --git a/tox.ini b/tox.ini index 916ff6ecb1..0dc3795a17 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,9 @@ envlist = {pypy,py2.7,py3.5,py3.6,py3.7,py3.8}-bottle-0.12 + {pypy,py2.7,py3.5,py3.6,py3.7,py3.8}-falcon-1.4 + {pypy,py2.7,py3.5,py3.6,py3.7,py3.8}-falcon-2.0 + {py3.5,py3.6,py3.7}-sanic-{0.8,18,19} {pypy,py2.7,py3.5,py3.6,py3.7,py3.8}-celery-{4.1,4.2,4.3} @@ -71,6 +74,9 @@ deps = bottle-0.12: bottle>=0.12,<0.13 bottle-dev: git+https://github.com/bottlepy/bottle#egg=bottle + falcon-1.4: falcon>=1.4,<1.5 + falcon-2.0: falcon>=2.0.0rc3,<3.0 + sanic-0.8: sanic>=0.8,<0.9 sanic-18: sanic>=18.0,<19.0 sanic-19: sanic>=19.0,<20.0 @@ -129,6 +135,7 @@ setenv = django: TESTPATH=tests/integrations/django flask: TESTPATH=tests/integrations/flask bottle: TESTPATH=tests/integrations/bottle + falcon: TESTPATH=tests/integrations/falcon celery: TESTPATH=tests/integrations/celery requests: TESTPATH=tests/integrations/requests aws_lambda: TESTPATH=tests/integrations/aws_lambda @@ -149,6 +156,7 @@ usedevelop = True extras = flask: flask bottle: bottle + falcon: falcon basepython = py2.7: python2.7