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
@@ -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+
629718class DjangoRequestExtractor (RequestExtractor ):
630719 def __init__ (self , request : "Union[WSGIRequest, ASGIRequest]" ) -> None :
631720 try :
0 commit comments