diff --git a/.sampo/changesets/django-rest-framework-error-tracking.md b/.sampo/changesets/django-rest-framework-error-tracking.md new file mode 100644 index 000000000..eb6f251b7 --- /dev/null +++ b/.sampo/changesets/django-rest-framework-error-tracking.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a Django REST Framework error-only exception handler that captures handled 5xx API exceptions while preserving DRF responses, leaves expected 4xx errors excluded by default, and consistently inherits Django middleware, client, request-filter, and exception-autocapture configuration. diff --git a/posthog/integrations/drf.py b/posthog/integrations/drf.py new file mode 100644 index 000000000..6c66d342a --- /dev/null +++ b/posthog/integrations/drf.py @@ -0,0 +1,211 @@ +"""Django REST Framework exception handling integration. + +Django REST Framework (DRF) converts many exceptions into ``Response`` objects +before Django's middleware can observe them. Configure this module's +``exception_handler`` alongside :class:`PosthogContextMiddleware` to capture +handled server errors while leaving DRF's response behavior unchanged:: + + REST_FRAMEWORK = { + "EXCEPTION_HANDLER": "posthog.integrations.drf.exception_handler", + } + +This is an error-only handler: it captures handled response errors but does +not create the request context. Keep :class:`PosthogContextMiddleware` enabled +to attach Django request properties and capture exceptions that DRF re-raises. +By default, only responses with a 5xx status are captured; expected 4xx API +errors are ignored. + +``capture_exceptions`` follows the Django middleware setting. An explicit +factory argument wins over ``POSTHOG_MW_CAPTURE_EXCEPTIONS``. A boolean setting +wins next. Setting it explicitly to ``None`` inherits the effective PostHog +client's ``enable_exception_autocapture`` option, while an omitted or malformed +setting preserves the legacy Django default of ``True``. + +Projects that already have a custom DRF exception handler can wrap it in an +application module:: + + from myapp.api import existing_exception_handler + from posthog.integrations.drf import create_exception_handler + + exception_handler = create_exception_handler(existing_exception_handler) + +Then point ``REST_FRAMEWORK["EXCEPTION_HANDLER"]`` at that application-level +``exception_handler``. DRF is imported lazily, so importing the PostHog SDK does +not require DRF to be installed. +""" + +import logging +from typing import Any, Callable, Mapping, Optional, cast + +from ..client import Client +from ..contexts import _default_capture_exceptions +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, + exception_is_already_captured as _exception_is_already_captured, +) + +_logger = logging.getLogger("posthog") + +_CAPTURE_METADATA: _ExceptionCaptureMetadata = { + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": {"type": "middleware", "handled": True}, +} + + +def _default_exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + from rest_framework.views import exception_handler as drf_exception_handler + + return drf_exception_handler(exc, context) + + +def _configured_client(client: Optional[Client]) -> Optional[Client]: + if client is not None: + return client + + try: + from django.conf import settings + + for setting_name in ("POSTHOG_DRF_CLIENT", "POSTHOG_MW_CLIENT"): + configured_client = getattr(settings, setting_name, None) + if isinstance(configured_client, Client): + return configured_client + except Exception: + # Django may not be configured when a handler is created at import time. + pass + + return None + + +def _capture_exceptions_enabled( + configured: Optional[bool], client: Optional[Client] +) -> bool: + """Resolve explicit, Django, and client exception-capture configuration.""" + if configured is not None: + return configured + + try: + from django.conf import settings + + if not hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS"): + return True + django_setting = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS + except Exception: + return True + + if isinstance(django_setting, bool): + return django_setting + if django_setting is not None: + return True + + try: + client_default = _default_capture_exceptions(client) + except Exception: + return True + return client_default if isinstance(client_default, bool) else True + + +def _passes_django_request_filter(context: Mapping[str, Any]) -> bool: + """Apply the Django middleware request filter to the underlying request.""" + try: + from django.conf import settings + + request_filter = getattr(settings, "POSTHOG_MW_REQUEST_FILTER", None) + except Exception: + return True + + request = context.get("request") + if not callable(request_filter) or request is None: + return True + + # DRF wraps Django's HttpRequest. Pass the same object that + # PosthogContextMiddleware evaluates so filters behave consistently. + django_request = getattr(request, "_request", request) + return bool(request_filter(django_request)) + + +def _capture_exception(client: Optional[Client], exc: Exception) -> None: + if _exception_is_already_captured(exc): + return + + if client is not None: + _capture_exception_with_metadata(client, exc, _CAPTURE_METADATA) + else: + from .. import capture_exception + + cast(Any, capture_exception)(exc, _capture_metadata=_CAPTURE_METADATA) + + +def 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]: + """Create a PostHog-instrumented DRF exception handler. + + Args: + handler: Handler to delegate to. Defaults to DRF's standard exception + handler. Its return value and raised exceptions are preserved. + client: Optional PostHog client. Client precedence is this argument, + the legacy ``POSTHOG_DRF_CLIENT`` alias, ``POSTHOG_MW_CLIENT``, then + the global PostHog client. + capture_exceptions: Whether to capture handled DRF exceptions. An + explicit value takes precedence over ``POSTHOG_MW_CAPTURE_EXCEPTIONS``. + A boolean setting is used directly; an explicit ``None`` setting + inherits the effective client's ``enable_exception_autocapture``. + An omitted or malformed setting preserves the legacy ``True`` default. + capture_4xx: Also capture handled 4xx responses. Disabled by default to + avoid reporting expected API errors. + exception_filter: Optional final filter called with ``(exception, + response, context)``. Returning ``False`` suppresses capture. + + When ``POSTHOG_MW_REQUEST_FILTER`` is configured, it is also applied to the + underlying Django request before capture so handled errors cannot bypass the + middleware's per-request exclusion. + + The delegated handler is called first. A ``None`` response is never + captured here because DRF will re-raise that exception, allowing Django's + middleware to capture it as unhandled. + """ + delegate = handler or _default_exception_handler + + def posthog_exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + response = delegate(exc, context) + if response is None: + return None + resolved_client = _configured_client(client) + if not _capture_exceptions_enabled(capture_exceptions, resolved_client): + return response + + try: + status_code = int(response.status_code) + should_capture = status_code >= 500 or ( + capture_4xx and 400 <= status_code < 500 + ) + if should_capture: + should_capture = _passes_django_request_filter(context) + if should_capture and exception_filter is not None: + should_capture = bool(exception_filter(exc, response, context)) + if should_capture: + _capture_exception(resolved_client, exc) + except Exception: + # Error tracking must never alter DRF's exception response. + _logger.exception("Failed to capture Django REST Framework exception") + + return response + + return posthog_exception_handler + + +def exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any: + """Capture handled DRF 5xx exceptions using DRF's default handler.""" + return _DEFAULT_EXCEPTION_HANDLER(exc, context) + + +_DEFAULT_EXCEPTION_HANDLER = create_exception_handler() diff --git a/posthog/test/integrations/test_drf_integration.py b/posthog/test/integrations/test_drf_integration.py new file mode 100644 index 000000000..154d81517 --- /dev/null +++ b/posthog/test/integrations/test_drf_integration.py @@ -0,0 +1,388 @@ +import builtins +import unittest +from unittest.mock import Mock, patch + +import django +import posthog +from django.conf import settings +from django.test import override_settings + +if not settings.configured: + settings.configure( + DEBUG=True, + SECRET_KEY="test-secret-key", + INSTALLED_APPS=[], + MIDDLEWARE=[], + ) + django.setup() + +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.response import Response + +from posthog.client import Client +from posthog.contexts import ( + get_tags as get_context_properties, + new_context, + tag as set_context_property, +) +from posthog.integrations.drf import create_exception_handler, exception_handler + + +class ServiceUnavailable(APIException): + status_code = 503 + default_detail = "Service unavailable" + + +class TestDjangoRestFrameworkIntegration(unittest.TestCase): + def test_default_handler_captures_5xx_with_canonical_metadata(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = ServiceUnavailable() + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": { + "type": "middleware", + "handled": True, + }, + }, + ) + + def test_default_handler_does_not_capture_expected_4xx(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = ValidationError({"name": ["This field is required."]}) + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 400) + client.capture_exception.assert_not_called() + + def test_capture_4xx_is_opt_in(self): + client = Mock() + handler = create_exception_handler(client=client, capture_4xx=True) + exception = ValidationError("Invalid input") + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 400) + client.capture_exception.assert_called_once() + + def test_unhandled_exception_is_left_for_django_middleware(self): + client = Mock() + handler = create_exception_handler(client=client) + exception = RuntimeError("unhandled") + + response = handler(exception, {}) + + self.assertIsNone(response) + client.capture_exception.assert_not_called() + + def test_custom_handler_response_and_context_are_preserved(self): + client = Mock() + response = Response({"detail": "custom"}, status=502) + delegate = Mock(return_value=response) + handler = create_exception_handler(delegate, client=client) + exception = RuntimeError("upstream failed") + context = {"view": object(), "request": object()} + + returned_response = handler(exception, context) + + self.assertIs(returned_response, response) + delegate.assert_called_once_with(exception, context) + client.capture_exception.assert_called_once() + + def test_custom_handler_exception_is_preserved(self): + client = Mock() + handler_error = LookupError("handler failed") + delegate = Mock(side_effect=handler_error) + handler = create_exception_handler(delegate, client=client) + + with self.assertRaisesRegex(LookupError, "handler failed"): + handler(RuntimeError("view failed"), {}) + + client.capture_exception.assert_not_called() + + def test_explicit_capture_opt_out_preserves_response_without_running_filters(self): + client = Mock() + response = Response({"detail": "unavailable"}, status=503) + delegate = Mock(return_value=response) + exception_filter = Mock(return_value=True) + request_filter = Mock(return_value=True) + handler = create_exception_handler( + delegate, + client=client, + capture_exceptions=False, + exception_filter=exception_filter, + ) + exception = RuntimeError("upstream failed") + context = {"request": object()} + + with override_settings(POSTHOG_MW_REQUEST_FILTER=request_filter): + returned_response = handler(exception, context) + + self.assertIs(returned_response, response) + delegate.assert_called_once_with(exception, context) + request_filter.assert_not_called() + exception_filter.assert_not_called() + client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False) + def test_module_handler_inherits_django_capture_opt_out(self): + exception = ServiceUnavailable() + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(exception, {}) + + self.assertEqual(response.status_code, 503) + capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False) + def test_explicit_capture_setting_overrides_django_opt_out(self): + client = Mock() + handler = create_exception_handler(client=client, capture_exceptions=True) + exception = ServiceUnavailable() + + response = handler(exception, {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_explicit_client_default(self): + for enabled in (False, True): + with self.subTest(enable_exception_autocapture=enabled): + client = Mock(spec=Client) + client.enable_exception_autocapture = enabled + handler = create_exception_handler(client=client) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_configured_client_default(self): + drf_client = Mock(spec=Client) + drf_client.enable_exception_autocapture = False + middleware_client = Mock(spec=Client) + middleware_client.enable_exception_autocapture = True + + with override_settings( + POSTHOG_DRF_CLIENT=drf_client, + POSTHOG_MW_CLIENT=middleware_client, + ): + response = exception_handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + drf_client.capture_exception.assert_not_called() + middleware_client.capture_exception.assert_not_called() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_none_django_setting_inherits_global_client_default(self): + original_default_client = posthog.default_client + + try: + for enabled in (False, True): + with self.subTest(enable_exception_autocapture=enabled): + global_client = Mock(spec=Client) + global_client.enable_exception_autocapture = enabled + posthog.default_client = global_client + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + finally: + posthog.default_client = original_default_client + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None) + def test_explicit_capture_setting_overrides_client_default(self): + client = Mock(spec=Client) + client.enable_exception_autocapture = False + handler = create_exception_handler(client=client, capture_exceptions=True) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + def test_omitted_django_capture_setting_preserves_legacy_default(self): + self.assertFalse(hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS")) + original_default_client = posthog.default_client + global_client = Mock(spec=Client) + global_client.enable_exception_autocapture = False + posthog.default_client = global_client + + try: + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(ServiceUnavailable(), {}) + finally: + posthog.default_client = original_default_client + + self.assertEqual(response.status_code, 503) + capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS="invalid") + def test_malformed_django_capture_setting_preserves_legacy_default(self): + client = Mock(spec=Client) + client.enable_exception_autocapture = False + handler = create_exception_handler(client=client) + + response = handler(ServiceUnavailable(), {}) + + self.assertEqual(response.status_code, 503) + client.capture_exception.assert_called_once() + + @override_settings(POSTHOG_MW_REQUEST_FILTER=lambda request: False) + def test_django_middleware_request_filter_suppresses_capture(self): + client = Mock() + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + django_request = object() + drf_request = Mock(_request=django_request) + + response = handler(RuntimeError("filtered"), {"request": drf_request}) + + self.assertEqual(response.status_code, 500) + client.capture_exception.assert_not_called() + + def test_django_middleware_request_filter_receives_underlying_request(self): + client = Mock() + request_filter = Mock(return_value=True) + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + django_request = object() + drf_request = Mock(_request=django_request) + + with override_settings(POSTHOG_MW_REQUEST_FILTER=request_filter): + handler(RuntimeError("tracked"), {"request": drf_request}) + + request_filter.assert_called_once_with(django_request) + client.capture_exception.assert_called_once() + + def test_exception_filter_can_suppress_capture(self): + client = Mock() + exception_filter = Mock(return_value=False) + handler = create_exception_handler( + lambda exc, context: Response(status=500), + client=client, + exception_filter=exception_filter, + ) + exception = RuntimeError("filtered") + context = {"request": object()} + + handler(exception, context) + + exception_filter.assert_called_once() + client.capture_exception.assert_not_called() + + def test_already_captured_exception_is_not_captured_twice(self): + client = Mock() + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + exception = RuntimeError("already captured") + setattr(exception, "__posthog_exception_captured", True) + + handler(exception, {}) + + client.capture_exception.assert_not_called() + + def test_capture_exclusion_preserves_existing_django_context(self): + observed_properties = [] + client = Mock() + + def delegate(exc, context): + observed_properties.append(get_context_properties()) + return Response(status=503) + + handler = create_exception_handler( + delegate, client=client, capture_exceptions=False + ) + + with new_context(): + set_context_property("$request_path", "/api/widgets") + response = handler(RuntimeError("excluded"), {}) + + self.assertEqual(response.status_code, 503) + self.assertEqual(observed_properties, [{"$request_path": "/api/widgets"}]) + client.capture_exception.assert_not_called() + + def test_capture_includes_existing_django_request_properties(self): + observed_properties = [] + client = Mock() + + def capture_exception(*args, **kwargs): + observed_properties.append(get_context_properties()) + + client.capture_exception.side_effect = capture_exception + handler = create_exception_handler( + lambda exc, context: Response(status=500), client=client + ) + + with new_context(): + set_context_property("$request_path", "/api/widgets") + handler(RuntimeError("failed"), {}) + + self.assertEqual(observed_properties, [{"$request_path": "/api/widgets"}]) + + def test_capture_failure_does_not_change_response(self): + client = Mock() + client.capture_exception.side_effect = RuntimeError("capture failed") + response = Response(status=500) + handler = create_exception_handler(lambda exc, context: response, client=client) + + with self.assertLogs("posthog", level="ERROR"): + returned_response = handler(RuntimeError("view failed"), {}) + + self.assertIs(returned_response, response) + + def test_module_handler_uses_global_client(self): + exception = ServiceUnavailable() + + with patch("posthog.capture_exception") as capture_exception: + response = exception_handler(exception, {}) + + self.assertEqual(response.status_code, 503) + capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django_rest_framework.exception_handler", + "mechanism": { + "type": "middleware", + "handled": True, + }, + }, + ) + + def test_importing_module_does_not_import_drf(self): + real_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name == "rest_framework" or name.startswith("rest_framework."): + raise AssertionError("DRF imported eagerly") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=guarded_import): + # Reloading executes all module-level imports and factory setup. + import importlib + import posthog.integrations.drf as drf_integration + + importlib.reload(drf_integration) diff --git a/pyproject.toml b/pyproject.toml index f4a0a31ab..251acd35d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "pytest-asyncio", "jsonschema>=4.0", "django>=5.2.15,<6.0", + "djangorestframework>=3.15,<4", "openai-agents>=0.18", "anthropic>=0.72", "langgraph>=1.0", diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 32f085b62..956efda25 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -338,6 +338,7 @@ alias posthog.integrations.asgi.Client -> posthog.client.Client alias posthog.integrations.asgi.contexts -> posthog.contexts alias posthog.integrations.django.Client -> posthog.client.Client alias posthog.integrations.django.contexts -> posthog.contexts +alias posthog.integrations.drf.Client -> posthog.client.Client alias posthog.mcp.CaptureEventData -> posthog.mcp.types.CaptureEventData alias posthog.mcp.CollectFeedbackOptions -> posthog.mcp.types.CollectFeedbackOptions alias posthog.mcp.FeedbackReport -> posthog.mcp.types.FeedbackReport @@ -1418,6 +1419,8 @@ function posthog.get_tags() -> Dict[str, Any] 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] function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) +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] +function posthog.integrations.drf.exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any function posthog.join() -> None function posthog.load_feature_flags() function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None @@ -1793,6 +1796,7 @@ module posthog.integrations module posthog.integrations.asgi module posthog.integrations.celery module posthog.integrations.django +module posthog.integrations.drf module posthog.integrations.flask module posthog.mcp module posthog.mcp.asgi diff --git a/uv.lock b/uv.lock index 406e0e5ed..5a1db59a1 100644 --- a/uv.lock +++ b/uv.lock @@ -830,6 +830,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/82/9fab66569b3e682205b52c2b203058a816c0755bc54e2adcd5d3f6018c43/django_stubs_ext-5.2.1-py3-none-any.whl", hash = "sha256:98fb0646f1a1ef07708eec5f6f7d27523f12c0c8714abae8db981571ff957588", size = 9153, upload-time = "2025-06-17T18:06:57.986Z" }, ] +[[package]] +name = "djangorestframework" +version = "3.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/2e/b3ce9d449b1ed9f9dd74fb7dfbc5f20860d5f40c2b4b10a2c3eabd8ff579/djangorestframework-3.18.1.tar.gz", hash = "sha256:605d79fa2ec2f02905492e5ea13d903c2d842d0b4c915a57f7bf02ab9f3c91dd", size = 915653, upload-time = "2026-09-07T18:04:08.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/83/5ed615e47339d8e65f62eb1a8133f8f046492eb3a25ad40d90acb9a208b3/djangorestframework-3.18.1-py3-none-any.whl", hash = "sha256:f1409d698967aaf82d5d98d76b549c8fba8bd92ebd0d83d24454508f72168dc2", size = 901373, upload-time = "2026-09-07T18:04:06.237Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -2860,6 +2872,7 @@ test = [ { name = "claude-agent-sdk" }, { name = "coverage" }, { name = "django" }, + { name = "djangorestframework" }, { name = "fastmcp" }, { name = "flask" }, { name = "freezegun" }, @@ -2906,6 +2919,7 @@ requires-dist = [ { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" },