Skip to content

Commit cd116a6

Browse files
committed
feat(drf): support exception capture opt-out
1 parent 6561e08 commit cd116a6

4 files changed

Lines changed: 73 additions & 4 deletions

File tree

‎.sampo/changesets/django-rest-framework-error-tracking.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
pypi/posthog: minor
33
---
44

5-
Add a Django REST Framework exception handler integration that automatically captures handled 5xx API exceptions while preserving DRF responses and leaving expected 4xx errors excluded by default.
5+
Add a Django REST Framework exception handler integration that automatically captures handled 5xx API exceptions while preserving DRF responses, leaving expected 4xx errors excluded by default, and honoring exception-capture opt-outs.

‎posthog/integrations/drf.py‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
}
1111
1212
By default, only responses with a 5xx status are captured. Expected 4xx API
13-
errors are ignored. Projects that already have a custom DRF exception handler
14-
can wrap it in an application module::
13+
errors are ignored. Set ``POSTHOG_MW_CAPTURE_EXCEPTIONS = False`` to keep the
14+
Django request context without capturing Django or DRF exceptions.
15+
16+
Projects that already have a custom DRF exception handler can wrap it in an
17+
application module::
1518
1619
from myapp.api import existing_exception_handler
1720
from posthog.integrations.drf import create_exception_handler
@@ -66,6 +69,21 @@ def _configured_client(client: Optional[Client]) -> Optional[Client]:
6669
return None
6770

6871

72+
def _capture_exceptions_enabled(configured: Optional[bool]) -> bool:
73+
"""Resolve explicit configuration before the shared Django middleware setting."""
74+
if configured is not None:
75+
return configured
76+
77+
try:
78+
from django.conf import settings
79+
80+
django_setting = getattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS", None)
81+
except Exception:
82+
return True
83+
84+
return django_setting if isinstance(django_setting, bool) else True
85+
86+
6987
def _passes_django_request_filter(context: Mapping[str, Any]) -> bool:
7088
"""Apply the Django middleware request filter to the underlying request."""
7189
try:
@@ -102,6 +120,7 @@ def create_exception_handler(
102120
handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None,
103121
*,
104122
client: Optional[Client] = None,
123+
capture_exceptions: Optional[bool] = None,
105124
capture_4xx: bool = False,
106125
exception_filter: Optional[
107126
Callable[[Exception, Any, Mapping[str, Any]], bool]
@@ -115,6 +134,9 @@ def create_exception_handler(
115134
client: Optional PostHog client. When omitted, ``POSTHOG_DRF_CLIENT`` or
116135
``POSTHOG_MW_CLIENT`` is used when configured, then the global
117136
PostHog client.
137+
capture_exceptions: Whether to capture handled DRF exceptions. When
138+
omitted, inherits a boolean ``POSTHOG_MW_CAPTURE_EXCEPTIONS`` setting
139+
and otherwise defaults to ``True``. An explicit value takes precedence.
118140
capture_4xx: Also capture handled 4xx responses. Disabled by default to
119141
avoid reporting expected API errors.
120142
exception_filter: Optional final filter called with ``(exception,
@@ -134,6 +156,8 @@ def posthog_exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any
134156
response = delegate(exc, context)
135157
if response is None:
136158
return None
159+
if not _capture_exceptions_enabled(capture_exceptions):
160+
return response
137161

138162
try:
139163
status_code = int(response.status_code)

‎posthog/test/integrations/test_drf_integration.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,51 @@ def test_custom_handler_exception_is_preserved(self):
107107

108108
client.capture_exception.assert_not_called()
109109

110+
def test_explicit_capture_opt_out_preserves_response_without_running_filters(self):
111+
client = Mock()
112+
response = Response({"detail": "unavailable"}, status=503)
113+
delegate = Mock(return_value=response)
114+
exception_filter = Mock(return_value=True)
115+
request_filter = Mock(return_value=True)
116+
handler = create_exception_handler(
117+
delegate,
118+
client=client,
119+
capture_exceptions=False,
120+
exception_filter=exception_filter,
121+
)
122+
exception = RuntimeError("upstream failed")
123+
context = {"request": object()}
124+
125+
with override_settings(POSTHOG_MW_REQUEST_FILTER=request_filter):
126+
returned_response = handler(exception, context)
127+
128+
self.assertIs(returned_response, response)
129+
delegate.assert_called_once_with(exception, context)
130+
request_filter.assert_not_called()
131+
exception_filter.assert_not_called()
132+
client.capture_exception.assert_not_called()
133+
134+
@override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False)
135+
def test_module_handler_inherits_django_capture_opt_out(self):
136+
exception = ServiceUnavailable()
137+
138+
with patch("posthog.capture_exception") as capture_exception:
139+
response = exception_handler(exception, {})
140+
141+
self.assertEqual(response.status_code, 503)
142+
capture_exception.assert_not_called()
143+
144+
@override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False)
145+
def test_explicit_capture_setting_overrides_django_opt_out(self):
146+
client = Mock()
147+
handler = create_exception_handler(client=client, capture_exceptions=True)
148+
exception = ServiceUnavailable()
149+
150+
response = handler(exception, {})
151+
152+
self.assertEqual(response.status_code, 503)
153+
client.capture_exception.assert_called_once()
154+
110155
@override_settings(POSTHOG_MW_REQUEST_FILTER=lambda request: False)
111156
def test_django_middleware_request_filter_suppresses_capture(self):
112157
client = Mock()

‎references/public_api_snapshot.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1404,7 +1404,7 @@ function posthog.get_tags() -> Dict[str, Any]
14041404
function posthog.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime.datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str]
14051405
function posthog.identify_context(distinct_id: str)
14061406
function posthog.integrations.django.markcoroutinefunction(func)
1407-
function posthog.integrations.drf.create_exception_handler(handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, *, client: Optional[Client] = None, capture_4xx: bool = False, exception_filter: Optional[Callable[[Exception, Any, Mapping[str, Any]], bool]] = None) -> Callable[[Exception, Mapping[str, Any]], Any]
1407+
function posthog.integrations.drf.create_exception_handler(handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, capture_4xx: bool = False, exception_filter: Optional[Callable[[Exception, Any, Mapping[str, Any]], bool]] = None) -> Callable[[Exception, Mapping[str, Any]], Any]
14081408
function posthog.integrations.drf.exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any
14091409
function posthog.join() -> None
14101410
function posthog.load_feature_flags()

0 commit comments

Comments
 (0)