Skip to content

Commit 064542d

Browse files
authored
feat(django): Add failed_request_status_codes (#7140)
1 parent e1b6e16 commit 064542d

5 files changed

Lines changed: 240 additions & 6 deletions

File tree

sentry_sdk/integrations/django/__init__.py

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import inspect
23
import sys
34
import threading
@@ -6,7 +7,12 @@
67

78
import sentry_sdk
89
from sentry_sdk.consts import OP, SPANDATA, SPANNAME
9-
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
10+
from sentry_sdk.integrations import (
11+
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
12+
DidNotEnable,
13+
Integration,
14+
_check_minimum_version,
15+
)
1016
from sentry_sdk.integrations._wsgi_common import (
1117
DEFAULT_HTTP_METHODS_TO_CAPTURE,
1218
RequestExtractor,
@@ -80,14 +86,21 @@
8086
from typing import TYPE_CHECKING
8187

8288
if TYPE_CHECKING:
89+
from collections.abc import Set
8390
from typing import Any, Callable, Dict, List, Optional, Union
8491

8592
from django.core.handlers.wsgi import WSGIRequest
8693
from django.http.request import QueryDict
8794
from django.http.response import HttpResponse
8895
from django.utils.datastructures import MultiValueDict
8996

90-
from sentry_sdk._types import Event, EventProcessor, Hint, NotImplementedType
97+
from sentry_sdk._types import (
98+
Event,
99+
EventProcessor,
100+
ExcInfo,
101+
Hint,
102+
NotImplementedType,
103+
)
91104
from sentry_sdk.integrations.wsgi import _ScopedResponse
92105
from sentry_sdk.traces import StreamedSpan
93106
from sentry_sdk.tracing import Span
@@ -116,6 +129,11 @@ class DjangoIntegration(Integration):
116129
:param signals_spans: Whether to create spans for signals. Defaults to `True`.
117130
:param signals_denylist: A list of signals to ignore when creating spans.
118131
:param cache_spans: Whether to create spans for cache operations. Defaults to `False`.
132+
:param failed_request_status_codes: Which HTTP error responses to report to Sentry.
133+
Django answers some exceptions itself instead of failing: `raise Http404` gets
134+
the user a 404 page, `PermissionDenied` a 403. Those are reported only if their
135+
status code is in this set, which defaults to the 5xx range. Exceptions Django
136+
gives up on end in a 500 and are always reported.
119137
"""
120138

121139
identifier = "django"
@@ -137,6 +155,8 @@ def __init__(
137155
db_transaction_spans: bool = False,
138156
signals_denylist: "Optional[list[signals.Signal]]" = None,
139157
http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
158+
*,
159+
failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES,
140160
) -> None:
141161
if transaction_style not in TRANSACTION_STYLE_VALUES:
142162
raise ValueError(
@@ -154,6 +174,8 @@ def __init__(
154174

155175
self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture))
156176

177+
self.failed_request_status_codes = failed_request_status_codes
178+
157179
@staticmethod
158180
def setup_once() -> None:
159181
_check_minimum_version(DjangoIntegration, DJANGO_VERSION)
@@ -199,6 +221,8 @@ def sentry_patched_wsgi_handler(
199221

200222
_patch_django_asgi_handler()
201223

224+
_patch_response_for_exception()
225+
202226
signals.got_request_exception.connect(_got_request_exception)
203227

204228
@add_global_event_processor
@@ -614,18 +638,89 @@ def _got_request_exception(request: "WSGIRequest" = None, **kwargs: "Any") -> No
614638
if integration is None:
615639
return
616640

641+
# Record that this exception is reported, so `_patch_response_for_exception`
642+
# doesn't report it a second time.
643+
with capture_internal_exceptions():
644+
request._sentry_exception_reported = True
645+
646+
_capture_exception(sys.exc_info(), request, integration, handled=False)
647+
648+
649+
def _capture_exception(
650+
exc_info: "Union[BaseException, ExcInfo]",
651+
request: "Optional[WSGIRequest]",
652+
integration: "DjangoIntegration",
653+
handled: bool,
654+
) -> None:
617655
if request is not None and integration.transaction_style == "url":
618656
scope = sentry_sdk.get_current_scope()
619657
_attempt_resolve_again(request, scope, integration.transaction_style)
620658

621659
event, hint = event_from_exception(
622-
sys.exc_info(),
623-
client_options=client.options,
624-
mechanism={"type": "django", "handled": False},
660+
exc_info,
661+
client_options=sentry_sdk.get_client().options,
662+
mechanism={"type": "django", "handled": handled},
625663
)
626664
sentry_sdk.capture_event(event, hint=hint)
627665

628666

667+
def _patch_response_for_exception() -> None:
668+
"""
669+
Report the errors Django answers itself.
670+
671+
Django deals with every exception in one function, which boils down to:
672+
673+
if isinstance(exc, Http404): return <404 page>
674+
if isinstance(exc, PermissionDenied): return <403 page>
675+
if isinstance(exc, SuspiciousOperation): return <400 page>
676+
got_request_exception.send(...) # Django gives up
677+
return <500 page>
678+
679+
We only ever listened to that signal, so we heard about the exceptions Django
680+
gives up on and about nothing else. Wrapping the function lets us see the rest
681+
too, along with the status code Django picked for them.
682+
"""
683+
try:
684+
from django.core.handlers import exception as exception_handler
685+
except ImportError:
686+
# Django < 1.10 does this in `BaseHandler`, nothing to patch here
687+
return
688+
689+
old_response_for_exception = getattr(
690+
exception_handler, "response_for_exception", None
691+
)
692+
if old_response_for_exception is None:
693+
return
694+
695+
@functools.wraps(old_response_for_exception)
696+
def sentry_patched_response_for_exception(
697+
request: "WSGIRequest", exc: Exception
698+
) -> "HttpResponse":
699+
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
700+
if integration is None:
701+
return old_response_for_exception(request, exc)
702+
703+
# Clear the flag before delegating. The same request can reach this
704+
# function twice: first when the view raises, then again if a middleware
705+
# raises while handing the response back out. Without the reset, the
706+
# first exception would keep the second one from being reported.
707+
with capture_internal_exceptions():
708+
request._sentry_exception_reported = False
709+
710+
response = old_response_for_exception(request, exc)
711+
712+
# The flag is set when Django gives up on the exception and fires
713+
# `got_request_exception`, which means we reported it already.
714+
if not getattr(request, "_sentry_exception_reported", False):
715+
status_code = getattr(response, "status_code", None)
716+
if status_code in integration.failed_request_status_codes:
717+
_capture_exception(exc, request, integration, handled=True)
718+
719+
return response
720+
721+
exception_handler.response_for_exception = sentry_patched_response_for_exception
722+
723+
629724
class DjangoRequestExtractor(RequestExtractor):
630725
def __init__(self, request: "Union[WSGIRequest, ASGIRequest]") -> None:
631726
try:

tests/integrations/django/asgi/test_asgi.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,3 +1126,37 @@ async def test_user_identity_error_event_data_collection(
11261126
assert "id" not in event.get("user", {})
11271127
assert "email" not in event.get("user", {})
11281128
assert "username" not in event.get("user", {})
1129+
1130+
1131+
@pytest.mark.parametrize("application", APPS)
1132+
@pytest.mark.asyncio
1133+
@pytest.mark.skipif(
1134+
django.VERSION < (3, 0), reason="Django ASGI support shipped in 3.0"
1135+
)
1136+
@pytest.mark.parametrize(
1137+
("integration_kwargs", "expected_type"),
1138+
(
1139+
({}, None),
1140+
({"failed_request_status_codes": {403, *range(500, 600)}}, "PermissionDenied"),
1141+
),
1142+
)
1143+
async def test_failed_request_status_codes(
1144+
sentry_init, capture_events, application, integration_kwargs, expected_type
1145+
):
1146+
sentry_init(integrations=[DjangoIntegration(**integration_kwargs)])
1147+
events = capture_events()
1148+
1149+
comm = HttpCommunicator(application, "GET", "/permission-denied-exc")
1150+
response = await comm.get_response()
1151+
await comm.wait()
1152+
1153+
assert response["status"] == 403
1154+
1155+
if expected_type is None:
1156+
assert not events
1157+
else:
1158+
(event,) = events
1159+
(exception,) = event["exception"]["values"]
1160+
assert exception["type"] == expected_type
1161+
assert exception["mechanism"]["handled"] is True
1162+
assert event["transaction"] == "/permission-denied-exc"

tests/integrations/django/myapp/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def path(path, *args, **kwargs):
102102
views.permission_denied_exc,
103103
name="permission_denied_exc",
104104
),
105+
path(
106+
"http404-exc",
107+
views.http404_exc,
108+
name="http404_exc",
109+
),
105110
path(
106111
"csrf-hello-not-exempt",
107112
views.csrf_hello_not_exempt,

tests/integrations/django/myapp/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from django.core.exceptions import PermissionDenied
88
from django.db import transaction
99
from django.dispatch import Signal
10-
from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerError
10+
from django.http import (
11+
Http404,
12+
HttpResponse,
13+
HttpResponseNotFound,
14+
HttpResponseServerError,
15+
)
1116
from django.shortcuts import render
1217
from django.template import Context, Template
1318
from django.template.response import TemplateResponse
@@ -339,6 +344,11 @@ def permission_denied_exc(*args, **kwargs):
339344
raise PermissionDenied("bye")
340345

341346

347+
@csrf_exempt
348+
def http404_exc(*args, **kwargs):
349+
raise Http404("bye")
350+
351+
342352
def csrf_hello_not_exempt(*args, **kwargs):
343353
return HttpResponse("ok")
344354

tests/integrations/django/test_basic.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,96 @@ def test_does_not_capture_403(
15181518
assert not items
15191519

15201520

1521+
@pytest.mark.parametrize(
1522+
("integration_kwargs", "endpoint", "status", "expected_type"),
1523+
(
1524+
# Django only turns exceptions into 4xx responses, so with the default
1525+
# (the 5xx range) none of them are reported
1526+
({}, "permission_denied_exc", "403 forbidden", None),
1527+
({}, "http404_exc", "404 not found", None),
1528+
(
1529+
{"failed_request_status_codes": set()},
1530+
"permission_denied_exc",
1531+
"403 forbidden",
1532+
None,
1533+
),
1534+
(
1535+
{"failed_request_status_codes": {403, *range(500, 600)}},
1536+
"permission_denied_exc",
1537+
"403 forbidden",
1538+
"PermissionDenied",
1539+
),
1540+
(
1541+
{"failed_request_status_codes": {404, *range(500, 600)}},
1542+
"http404_exc",
1543+
"404 not found",
1544+
"Http404",
1545+
),
1546+
# Only the status codes that were opted into are reported
1547+
(
1548+
{"failed_request_status_codes": {403}},
1549+
"http404_exc",
1550+
"404 not found",
1551+
None,
1552+
),
1553+
),
1554+
)
1555+
def test_failed_request_status_codes(
1556+
sentry_init,
1557+
client,
1558+
capture_events,
1559+
integration_kwargs,
1560+
endpoint,
1561+
status,
1562+
expected_type,
1563+
):
1564+
sentry_init(integrations=[DjangoIntegration(**integration_kwargs)])
1565+
events = capture_events()
1566+
1567+
_, response_status, _ = unpack_werkzeug_response(client.get(reverse(endpoint)))
1568+
assert response_status.lower() == status
1569+
1570+
# The test app's handler404 captures a message, ignore it here
1571+
error_events = [event for event in events if "exception" in event]
1572+
1573+
if expected_type is None:
1574+
assert not error_events
1575+
else:
1576+
(event,) = error_events
1577+
(exception,) = event["exception"]["values"]
1578+
assert exception["type"] == expected_type
1579+
assert exception["mechanism"]["type"] == "django"
1580+
assert exception["mechanism"]["handled"] is True
1581+
1582+
1583+
@pytest.mark.parametrize(
1584+
"integration_kwargs",
1585+
(
1586+
{},
1587+
{"failed_request_status_codes": set()},
1588+
{"failed_request_status_codes": {404}},
1589+
),
1590+
)
1591+
def test_failed_request_status_codes_unhandled_exception(
1592+
sentry_init, client, capture_events, integration_kwargs
1593+
):
1594+
"""
1595+
Exceptions Django gives up on are always reported, exactly once, no matter how
1596+
failed_request_status_codes is set.
1597+
"""
1598+
sentry_init(integrations=[DjangoIntegration(**integration_kwargs)])
1599+
events = capture_events()
1600+
1601+
_, status, _ = unpack_werkzeug_response(client.get(reverse("view_exc")))
1602+
assert status.lower() == "500 internal server error"
1603+
1604+
(event,) = events
1605+
(exception,) = event["exception"]["values"]
1606+
assert exception["type"] == "ZeroDivisionError"
1607+
assert exception["mechanism"]["type"] == "django"
1608+
assert exception["mechanism"]["handled"] is False
1609+
1610+
15211611
@pytest.mark.parametrize("span_streaming", [True, False])
15221612
def test_render_spans(
15231613
sentry_init,

0 commit comments

Comments
 (0)