diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 9f7546e81b..2aa9588a3d 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: - from collections.abc import MutableMapping + from collections.abc import Container, MutableMapping from datetime import datetime @@ -220,3 +220,5 @@ }, total=False, ) + + HttpStatusCodeRange = Union[int, Container[int]] diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 6e6705a7d3..b94b721622 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -3,7 +3,7 @@ import sentry_sdk from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.utils import AnnotatedValue +from sentry_sdk.utils import AnnotatedValue, logger from sentry_sdk._types import TYPE_CHECKING try: @@ -18,7 +18,7 @@ from typing import Mapping from typing import Optional from typing import Union - from sentry_sdk._types import Event + from sentry_sdk._types import Event, HttpStatusCodeRange SENSITIVE_ENV_KEYS = ( @@ -200,3 +200,22 @@ def _filter_headers(headers): ) for k, v in headers.items() } + + +def _in_http_status_code_range(code, code_ranges): + # type: (int, list[HttpStatusCodeRange]) -> bool + for target in code_ranges: + if isinstance(target, int): + if code == target: + return True + continue + + try: + if code in target: + return True + except TypeError: + logger.warning( + "failed_request_status_codes has to be a list of integers or containers" + ) + + return False diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index cb0f977d99..ac55f8058f 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -7,6 +7,7 @@ from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import ( + _in_http_status_code_range, _is_json_content_type, request_body_within_bounds, ) @@ -30,7 +31,7 @@ if TYPE_CHECKING: from typing import Any, Awaitable, Callable, Dict, Optional, Tuple - from sentry_sdk._types import Event + from sentry_sdk._types import Event, HttpStatusCodeRange try: import starlette # type: ignore @@ -71,14 +72,17 @@ class StarletteIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="url"): - # type: (str) -> None + def __init__(self, transaction_style="url", failed_request_status_codes=None): + # type: (str, Optional[list[HttpStatusCodeRange]]) -> 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 + self.failed_request_status_codes = failed_request_status_codes or [ + range(500, 599) + ] @staticmethod def setup_once(): @@ -198,12 +202,18 @@ def _sentry_middleware_init(self, *args, **kwargs): async def _sentry_patched_exception_handler(self, *args, **kwargs): # type: (Any, Any, Any) -> None + integration = sentry_sdk.get_client().get_integration( + StarletteIntegration + ) + exp = args[0] is_http_server_error = ( hasattr(exp, "status_code") and isinstance(exp.status_code, int) - and exp.status_code >= 500 + and _in_http_status_code_range( + exp.status_code, integration.failed_request_status_codes + ) ) if is_http_server_error: _capture_exception(exp, handled=True) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 00f693fd8c..428ee77654 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -4,7 +4,7 @@ from unittest import mock import pytest -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from fastapi.middleware.trustedhost import TrustedHostMiddleware @@ -501,3 +501,55 @@ def test_transaction_name_in_middleware( assert ( transaction_event["transaction_info"]["source"] == expected_transaction_source ) + + +@pytest.mark.parametrize( + "failed_request_status_codes,status_code,expected_error", + [ + (None, 500, True), + (None, 400, False), + ([500, 501], 500, True), + ([500, 501], 401, False), + ([range(400, 499)], 401, True), + ([range(400, 499)], 500, False), + ([range(400, 499), range(500, 599)], 300, False), + ([range(400, 499), range(500, 599)], 403, True), + ([range(400, 499), range(500, 599)], 503, True), + ([range(400, 403), 500, 501], 401, True), + ([range(400, 403), 500, 501], 405, False), + ([range(400, 403), 500, 501], 501, True), + ([range(400, 403), 500, 501], 503, False), + ([None], 500, False), + ], +) +def test_configurable_status_codes( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + sentry_init( + integrations=[ + StarletteIntegration( + failed_request_status_codes=failed_request_status_codes + ), + FastApiIntegration(failed_request_status_codes=failed_request_status_codes), + ] + ) + + events = capture_events() + + app = FastAPI() + + @app.get("/error") + async def _error(): + raise HTTPException(status_code) + + client = TestClient(app) + client.get("/error") + + if expected_error: + assert len(events) == 1 + else: + assert not events diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index e1f3c1a482..9e58daf567 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -25,6 +25,7 @@ AuthenticationError, SimpleUser, ) +from starlette.exceptions import HTTPException from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware @@ -258,7 +259,7 @@ async def my_send(*args, **kwargs): @pytest.mark.asyncio -async def test_starlettrequestextractor_content_length(sentry_init): +async def test_starletterequestextractor_content_length(sentry_init): scope = SCOPE.copy() scope["headers"] = [ [b"content-length", str(len(json.dumps(BODY_JSON))).encode()], @@ -270,7 +271,7 @@ async def test_starlettrequestextractor_content_length(sentry_init): @pytest.mark.asyncio -async def test_starlettrequestextractor_cookies(sentry_init): +async def test_starletterequestextractor_cookies(sentry_init): starlette_request = starlette.requests.Request(SCOPE) extractor = StarletteRequestExtractor(starlette_request) @@ -281,7 +282,7 @@ async def test_starlettrequestextractor_cookies(sentry_init): @pytest.mark.asyncio -async def test_starlettrequestextractor_json(sentry_init): +async def test_starletterequestextractor_json(sentry_init): starlette_request = starlette.requests.Request(SCOPE) # Mocking async `_receive()` that works in Python 3.7+ @@ -295,7 +296,7 @@ async def test_starlettrequestextractor_json(sentry_init): @pytest.mark.asyncio -async def test_starlettrequestextractor_form(sentry_init): +async def test_starletterequestextractor_form(sentry_init): scope = SCOPE.copy() scope["headers"] = [ [b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"], @@ -323,7 +324,7 @@ async def test_starlettrequestextractor_form(sentry_init): @pytest.mark.asyncio -async def test_starlettrequestextractor_body_consumed_twice( +async def test_starletterequestextractor_body_consumed_twice( sentry_init, capture_events ): """ @@ -361,7 +362,7 @@ async def test_starlettrequestextractor_body_consumed_twice( @pytest.mark.asyncio -async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init): +async def test_starletterequestextractor_extract_request_info_too_big(sentry_init): sentry_init( send_default_pii=True, integrations=[StarletteIntegration()], @@ -392,7 +393,7 @@ async def test_starlettrequestextractor_extract_request_info_too_big(sentry_init @pytest.mark.asyncio -async def test_starlettrequestextractor_extract_request_info(sentry_init): +async def test_starletterequestextractor_extract_request_info(sentry_init): sentry_init( send_default_pii=True, integrations=[StarletteIntegration()], @@ -423,7 +424,7 @@ async def test_starlettrequestextractor_extract_request_info(sentry_init): @pytest.mark.asyncio -async def test_starlettrequestextractor_extract_request_info_no_pii(sentry_init): +async def test_starletterequestextractor_extract_request_info_no_pii(sentry_init): sentry_init( send_default_pii=False, integrations=[StarletteIntegration()], @@ -1078,3 +1079,57 @@ def test_transaction_name_in_middleware( assert ( transaction_event["transaction_info"]["source"] == expected_transaction_source ) + + +@pytest.mark.parametrize( + "failed_request_status_codes,status_code,expected_error", + [ + (None, 500, True), + (None, 400, False), + ([500, 501], 500, True), + ([500, 501], 401, False), + ([range(400, 499)], 401, True), + ([range(400, 499)], 500, False), + ([range(400, 499), range(500, 599)], 300, False), + ([range(400, 499), range(500, 599)], 403, True), + ([range(400, 499), range(500, 599)], 503, True), + ([range(400, 403), 500, 501], 401, True), + ([range(400, 403), 500, 501], 405, False), + ([range(400, 403), 500, 501], 501, True), + ([range(400, 403), 500, 501], 503, False), + ([None], 500, False), + ], +) +def test_configurable_status_codes( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + sentry_init( + integrations=[ + StarletteIntegration( + failed_request_status_codes=failed_request_status_codes + ) + ] + ) + + events = capture_events() + + async def _error(request): + raise HTTPException(status_code) + + app = starlette.applications.Starlette( + routes=[ + starlette.routing.Route("/error", _error, methods=["GET"]), + ], + ) + + client = TestClient(app) + client.get("/error") + + if expected_error: + assert len(events) == 1 + else: + assert not events