diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 6dae8d9188..a2d6b51033 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -9,13 +9,19 @@ parse_version, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + Integration, + DidNotEnable, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Set + from sentry_sdk.integrations.wsgi import _ScopedResponse from typing import Any from typing import Dict @@ -28,6 +34,7 @@ try: from bottle import ( Bottle, + HTTPResponse, Route, request as bottle_request, __version__ as BOTTLE_VERSION, @@ -45,8 +52,13 @@ class BottleIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + *, + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( @@ -54,6 +66,7 @@ def __init__(self, transaction_style="endpoint"): % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.failed_request_status_codes = failed_request_status_codes @staticmethod def setup_once(): @@ -102,26 +115,29 @@ def _patched_handle(self, environ): old_make_callback = Route._make_callback - @ensure_integration_enabled(BottleIntegration, old_make_callback) + @functools.wraps(old_make_callback) def patched_make_callback(self, *args, **kwargs): # type: (Route, *object, **object) -> Any - client = sentry_sdk.get_client() prepared_callback = old_make_callback(self, *args, **kwargs) + integration = sentry_sdk.get_client().get_integration(BottleIntegration) + if integration is None: + return prepared_callback + def wrapped_callback(*args, **kwargs): # type: (*object, **object) -> Any - try: res = prepared_callback(*args, **kwargs) except Exception as exception: - event, hint = event_from_exception( - exception, - client_options=client.options, - mechanism={"type": "bottle", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) + _capture_exception(exception, handled=False) raise exception + if ( + isinstance(res, HTTPResponse) + and res.status_code in integration.failed_request_status_codes + ): + _capture_exception(res, handled=True) + return res return wrapped_callback @@ -191,3 +207,13 @@ def event_processor(event, hint): return event return event_processor + + +def _capture_exception(exception, handled): + # type: (BaseException, bool) -> None + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "bottle", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index 9dd23cf45a..9cc436a229 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -3,12 +3,14 @@ import logging from io import BytesIO -from bottle import Bottle, debug as set_debug, abort, redirect +from bottle import Bottle, debug as set_debug, abort, redirect, HTTPResponse from sentry_sdk import capture_message +from sentry_sdk.integrations.bottle import BottleIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.integrations.logging import LoggingIntegration from werkzeug.test import Client +from werkzeug.wrappers import Response import sentry_sdk.integrations.bottle as bottle_sentry @@ -445,3 +447,80 @@ def test_span_origin( (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.bottle" + + +@pytest.mark.parametrize("raise_error", [True, False]) +@pytest.mark.parametrize( + ("integration_kwargs", "status_code", "should_capture"), + ( + ({}, None, False), + ({}, 400, False), + ({}, 451, False), # Highest 4xx status code + ({}, 500, True), + ({}, 511, True), # Highest 5xx status code + ({"failed_request_status_codes": set()}, 500, False), + ({"failed_request_status_codes": set()}, 511, False), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 500, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False), + ), +) +def test_failed_request_status_codes( + sentry_init, + capture_events, + integration_kwargs, + status_code, + should_capture, + raise_error, +): + sentry_init(integrations=[BottleIntegration(**integration_kwargs)]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + if status_code is not None: + response = HTTPResponse(status=status_code) + if raise_error: + raise response + else: + return response + return "OK" + + client = Client(app, Response) + response = client.get("/") + + expected_status = 200 if status_code is None else status_code + assert response.status_code == expected_status + + if should_capture: + (event,) = events + assert event["exception"]["values"][0]["type"] == "HTTPResponse" + else: + assert not events + + +def test_failed_request_status_codes_non_http_exception(sentry_init, capture_events): + """ + If an exception, which is not an instance of HTTPResponse, is raised, it should be captured, even if + failed_request_status_codes is empty. + """ + sentry_init(integrations=[BottleIntegration(failed_request_status_codes=set())]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + 1 / 0 + + client = Client(app, Response) + + try: + client.get("/") + except ZeroDivisionError: + pass + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ZeroDivisionError"