Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(starlette): Allow to configure status codes to report to Sentry #3008

Merged
merged 11 commits into from
Jun 6, 2024
4 changes: 3 additions & 1 deletion sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


if TYPE_CHECKING:
from collections.abc import MutableMapping
from collections.abc import Container, MutableMapping

from datetime import datetime

Expand Down Expand Up @@ -220,3 +220,5 @@
},
total=False,
)

HttpStatusCodeRange = Union[int, Container[int]]
23 changes: 21 additions & 2 deletions sentry_sdk/integrations/_wsgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 = (
Expand Down Expand Up @@ -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
18 changes: 14 additions & 4 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 53 additions & 1 deletion tests/integrations/fastapi/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
71 changes: 63 additions & 8 deletions tests/integrations/starlette/test_starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()],
Expand All @@ -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)

Expand All @@ -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+
Expand All @@ -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"],
Expand Down Expand Up @@ -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
):
"""
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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
Loading