Skip to content

Commit 44db0ad

Browse files
committed
feat(django): Add failed_request_status_codes
1 parent 2b0db9f commit 44db0ad

5 files changed

Lines changed: 233 additions & 5 deletions

File tree

sentry_sdk/integrations/django/__init__.py

Lines changed: 93 additions & 4 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,6 +86,7 @@
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
@@ -116,6 +123,11 @@ class DjangoIntegration(Integration):
116123
:param signals_spans: Whether to create spans for signals. Defaults to `True`.
117124
:param signals_denylist: A list of signals to ignore when creating spans.
118125
:param cache_spans: Whether to create spans for cache operations. Defaults to `False`.
126+
:param failed_request_status_codes: Which HTTP error responses to report to Sentry.
127+
Django answers some exceptions itself instead of failing: `raise Http404` gets
128+
the user a 404 page, `PermissionDenied` a 403. Those are reported only if their
129+
status code is in this set, which defaults to the 5xx range. Exceptions Django
130+
gives up on end in a 500 and are always reported.
119131
"""
120132

121133
identifier = "django"
@@ -137,6 +149,8 @@ def __init__(
137149
db_transaction_spans: bool = False,
138150
signals_denylist: "Optional[list[signals.Signal]]" = None,
139151
http_methods_to_capture: "tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
152+
*,
153+
failed_request_status_codes: "Set[int]" = _DEFAULT_FAILED_REQUEST_STATUS_CODES,
140154
) -> None:
141155
if transaction_style not in TRANSACTION_STYLE_VALUES:
142156
raise ValueError(
@@ -154,6 +168,8 @@ def __init__(
154168

155169
self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture))
156170

171+
self.failed_request_status_codes = failed_request_status_codes
172+
157173
@staticmethod
158174
def setup_once() -> None:
159175
_check_minimum_version(DjangoIntegration, DJANGO_VERSION)
@@ -199,6 +215,8 @@ def sentry_patched_wsgi_handler(
199215

200216
_patch_django_asgi_handler()
201217

218+
_patch_response_for_exception()
219+
202220
signals.got_request_exception.connect(_got_request_exception)
203221

204222
@add_global_event_processor
@@ -614,18 +632,89 @@ def _got_request_exception(request: "WSGIRequest" = None, **kwargs: "Any") -> No
614632
if integration is None:
615633
return
616634

635+
# Record that this exception is reported, so `_patch_response_for_exception`
636+
# doesn't report it a second time.
637+
with capture_internal_exceptions():
638+
request._sentry_exception_reported = True
639+
640+
_capture_exception(sys.exc_info(), request, integration, handled=False)
641+
642+
643+
def _capture_exception(
644+
exc_info: "Any",
645+
request: "Optional[WSGIRequest]",
646+
integration: "DjangoIntegration",
647+
handled: bool,
648+
) -> None:
617649
if request is not None and integration.transaction_style == "url":
618650
scope = sentry_sdk.get_current_scope()
619651
_attempt_resolve_again(request, scope, integration.transaction_style)
620652

621653
event, hint = event_from_exception(
622-
sys.exc_info(),
623-
client_options=client.options,
624-
mechanism={"type": "django", "handled": False},
654+
exc_info,
655+
client_options=sentry_sdk.get_client().options,
656+
mechanism={"type": "django", "handled": handled},
625657
)
626658
sentry_sdk.capture_event(event, hint=hint)
627659

628660

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

345350

351+
@csrf_exempt
352+
def http404_exc(*args, **kwargs):
353+
raise Http404("bye")
354+
355+
346356
def csrf_hello_not_exempt(*args, **kwargs):
347357
return HttpResponse("ok")
348358

tests/integrations/django/test_basic.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,6 +1761,96 @@ def test_does_not_capture_403(
17611761
assert not events
17621762

17631763

1764+
@pytest.mark.parametrize(
1765+
("integration_kwargs", "endpoint", "status", "expected_type"),
1766+
(
1767+
# Django only turns exceptions into 4xx responses, so with the default
1768+
# (the 5xx range) none of them are reported
1769+
({}, "permission_denied_exc", "403 forbidden", None),
1770+
({}, "http404_exc", "404 not found", None),
1771+
(
1772+
{"failed_request_status_codes": set()},
1773+
"permission_denied_exc",
1774+
"403 forbidden",
1775+
None,
1776+
),
1777+
(
1778+
{"failed_request_status_codes": {403, *range(500, 600)}},
1779+
"permission_denied_exc",
1780+
"403 forbidden",
1781+
"PermissionDenied",
1782+
),
1783+
(
1784+
{"failed_request_status_codes": {404, *range(500, 600)}},
1785+
"http404_exc",
1786+
"404 not found",
1787+
"Http404",
1788+
),
1789+
# Only the status codes that were opted into are reported
1790+
(
1791+
{"failed_request_status_codes": {403}},
1792+
"http404_exc",
1793+
"404 not found",
1794+
None,
1795+
),
1796+
),
1797+
)
1798+
def test_failed_request_status_codes(
1799+
sentry_init,
1800+
client,
1801+
capture_events,
1802+
integration_kwargs,
1803+
endpoint,
1804+
status,
1805+
expected_type,
1806+
):
1807+
sentry_init(integrations=[DjangoIntegration(**integration_kwargs)])
1808+
events = capture_events()
1809+
1810+
_, response_status, _ = unpack_werkzeug_response(client.get(reverse(endpoint)))
1811+
assert response_status.lower() == status
1812+
1813+
# The test app's handler404 captures a message, ignore it here
1814+
error_events = [event for event in events if "exception" in event]
1815+
1816+
if expected_type is None:
1817+
assert not error_events
1818+
else:
1819+
(event,) = error_events
1820+
(exception,) = event["exception"]["values"]
1821+
assert exception["type"] == expected_type
1822+
assert exception["mechanism"]["type"] == "django"
1823+
assert exception["mechanism"]["handled"] is True
1824+
1825+
1826+
@pytest.mark.parametrize(
1827+
"integration_kwargs",
1828+
(
1829+
{},
1830+
{"failed_request_status_codes": set()},
1831+
{"failed_request_status_codes": {404}},
1832+
),
1833+
)
1834+
def test_failed_request_status_codes_unhandled_exception(
1835+
sentry_init, client, capture_events, integration_kwargs
1836+
):
1837+
"""
1838+
Exceptions Django gives up on are always reported, exactly once, no matter how
1839+
failed_request_status_codes is set.
1840+
"""
1841+
sentry_init(integrations=[DjangoIntegration(**integration_kwargs)])
1842+
events = capture_events()
1843+
1844+
_, status, _ = unpack_werkzeug_response(client.get(reverse("view_exc")))
1845+
assert status.lower() == "500 internal server error"
1846+
1847+
(event,) = events
1848+
(exception,) = event["exception"]["values"]
1849+
assert exception["type"] == "ZeroDivisionError"
1850+
assert exception["mechanism"]["type"] == "django"
1851+
assert exception["mechanism"]["handled"] is False
1852+
1853+
17641854
@pytest.mark.parametrize("span_streaming", [True, False])
17651855
def test_render_spans(
17661856
sentry_init,

0 commit comments

Comments
 (0)