1+ import functools
12import inspect
23import sys
34import threading
67
78import sentry_sdk
89from 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+ )
1016from sentry_sdk .integrations ._wsgi_common import (
1117 DEFAULT_HTTP_METHODS_TO_CAPTURE ,
1218 RequestExtractor ,
8086from typing import TYPE_CHECKING
8187
8288if 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+
629724class DjangoRequestExtractor (RequestExtractor ):
630725 def __init__ (self , request : "Union[WSGIRequest, ASGIRequest]" ) -> None :
631726 try :
0 commit comments