Skip to content

Commit

Permalink
feat(starlette): Allow to configure status codes to report to Sentry (g…
Browse files Browse the repository at this point in the history
  • Loading branch information
sentrivana authored and arjenzorgdoc committed Sep 30, 2024
1 parent 7a90ff2 commit 68cebc5
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 16 deletions.
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

0 comments on commit 68cebc5

Please sign in to comment.