Skip to content

Commit 50b0c71

Browse files
authored
fix(django): restore process_exception to capture view exceptions (#350)
Restores the process_exception method that was removed in v6.7.5 (PR #328), which broke exception capture from Django views and downstream middleware. Django converts view exceptions into responses before they propagate through the middleware stack's __call__ method, so the context manager's exception handler never sees them. Django provides these exceptions via the process_exception hook instead. Changes: - Add process_exception method to capture exceptions from views and downstream middleware with proper request context and tags - Add tests verifying process_exception behavior and settings (capture_exceptions, request_filter)
1 parent f719c3d commit 50b0c71

File tree

3 files changed

+152
-19
lines changed

3 files changed

+152
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
# Unreleased
2+
3+
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
4+
15
# 6.7.11 - 2025-10-28
26

37
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
48

59
# 6.7.10 - 2025-10-24
610

711
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
8-
- fix(django): Exception capture works correctly via context manager (addresses #329)
912

1013
# 6.7.9 - 2025-10-22
1114

posthog/integrations/django.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,32 @@ async def __acall__(self, request):
220220
contexts.tag(k, v)
221221

222222
return await self.get_response(request)
223+
224+
def process_exception(self, request, exception):
225+
# type: (HttpRequest, Exception) -> None
226+
"""
227+
Process exceptions from views and downstream middleware.
228+
229+
Django calls this WHILE still inside the context created by __call__,
230+
so request tags have already been extracted and set. This method just
231+
needs to capture the exception directly.
232+
233+
Django converts view exceptions into responses before they propagate through
234+
the middleware stack, so the context manager in __call__/__acall__ never sees them.
235+
236+
Note: Django's process_exception is always synchronous, even for async views.
237+
"""
238+
if self.request_filter and not self.request_filter(request):
239+
return
240+
241+
if not self.capture_exceptions:
242+
return
243+
244+
# Context and tags already set by __call__ or __acall__
245+
# Just capture the exception
246+
if self.client:
247+
self.client.capture_exception(exception)
248+
else:
249+
from posthog import capture_exception
250+
251+
capture_exception(exception)

posthog/test/integrations/test_middleware.py

Lines changed: 119 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,86 @@ def extra_tags_func(request):
201201

202202
self.assertEqual(tags["$request_method"], "PATCH")
203203

204+
def test_process_exception_called_during_view_exception(self):
205+
"""
206+
Unit test verifying process_exception captures exceptions per Django's contract.
207+
208+
Since this is a library test (no Django runtime), we simulate how Django
209+
would invoke our middleware in production:
210+
1. Middleware.__call__ creates context with request tags
211+
2. View raises exception inside get_response
212+
3. Django's BaseHandler catches it, calls process_exception, returns error response
213+
4. Exception never propagates to middleware's context manager
214+
215+
We manually call process_exception to simulate Django's behavior - this is
216+
the only way to test the hook without a full Django integration test.
217+
"""
218+
mock_client = Mock()
219+
view_exception = ValueError("View raised this error")
220+
error_response = Mock(status_code=500)
221+
222+
def mock_get_response(request):
223+
# Simulate Django's exception handling: catches view exception,
224+
# calls process_exception hook if it exists, returns error response
225+
if hasattr(middleware, "process_exception"):
226+
middleware.process_exception(request, view_exception)
227+
return error_response
228+
229+
middleware = self.create_middleware(get_response=mock_get_response)
230+
middleware.client = mock_client
231+
232+
request = MockRequest(
233+
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
234+
method="POST",
235+
path="/api/endpoint",
236+
)
237+
response = middleware(request)
238+
239+
self.assertEqual(response.status_code, 500)
240+
mock_client.capture_exception.assert_called_once_with(view_exception)
241+
242+
def test_process_exception_respects_capture_exceptions_false(self):
243+
"""Verify process_exception respects capture_exceptions=False setting"""
244+
mock_client = Mock()
245+
view_exception = ValueError("Should not be captured")
246+
247+
def mock_get_response(request):
248+
if hasattr(middleware, "process_exception"):
249+
middleware.process_exception(request, view_exception)
250+
return Mock(status_code=500)
251+
252+
middleware = self.create_middleware(
253+
capture_exceptions=False, get_response=mock_get_response
254+
)
255+
middleware.client = mock_client
256+
257+
request = MockRequest()
258+
middleware(request)
259+
260+
mock_client.capture_exception.assert_not_called()
261+
262+
def test_process_exception_respects_request_filter(self):
263+
"""Verify process_exception respects request_filter setting"""
264+
mock_client = Mock()
265+
view_exception = ValueError("Should be filtered")
266+
267+
def mock_get_response(request):
268+
if hasattr(middleware, "process_exception"):
269+
middleware.process_exception(request, view_exception)
270+
return Mock(status_code=500)
271+
272+
middleware = self.create_middleware(
273+
request_filter=lambda req: False,
274+
capture_exceptions=True,
275+
get_response=mock_get_response,
276+
)
277+
middleware.client = mock_client
278+
279+
request = MockRequest()
280+
middleware(request)
281+
282+
mock_client.capture_exception.assert_not_called()
283+
204284

205285
class TestPosthogContextMiddlewareSync(unittest.TestCase):
206286
"""Test synchronous middleware behavior"""
@@ -250,31 +330,52 @@ def test_sync_middleware_with_filter(self):
250330
self.assertEqual(response, mock_response)
251331
get_response.assert_called_once_with(request)
252332

253-
def test_sync_middleware_exception_capture(self):
254-
"""Test that sync middleware captures exceptions during request processing"""
255-
mock_client = Mock()
333+
def test_view_exceptions_only_captured_via_process_exception(self):
334+
"""
335+
Demonstrates that process_exception is required to capture view exceptions.
336+
337+
In production Django, view exceptions don't propagate to middleware's context
338+
manager because Django's BaseHandler catches them first and converts them to
339+
error responses. Django provides the exception via process_exception hook instead.
256340
257-
# Make get_response raise an exception
258-
def raise_exception(request):
259-
raise ValueError("Test exception")
341+
This unit test proves:
342+
1. Context manager in __call__ never sees view exceptions (Django intercepts)
343+
2. Only process_exception can capture them
344+
3. Without process_exception, exceptions are silently lost (v6.7.5 regression)
260345
261-
get_response = Mock(side_effect=raise_exception)
346+
We manually call process_exception to verify the hook works - in production,
347+
Django's BaseHandler would call it when a view raises.
348+
"""
349+
mock_client = Mock()
350+
get_response = Mock(return_value=Mock(status_code=500))
262351

263-
# Properly initialize middleware
264352
middleware = PosthogContextMiddleware(get_response)
265-
middleware.client = mock_client # Override with mock client
353+
middleware.client = mock_client
266354

267-
request = MockRequest()
355+
def get_response_simulating_django(request):
356+
# Simulates Django behavior: view exception converted to error response,
357+
# never propagates to middleware's context manager
358+
return Mock(status_code=500)
268359

269-
# Should capture exception and re-raise
270-
with self.assertRaises(ValueError):
271-
middleware(request)
360+
middleware._sync_get_response = get_response_simulating_django
272361

273-
# Verify exception was captured by middleware
274-
mock_client.capture_exception.assert_called_once()
275-
captured_exception = mock_client.capture_exception.call_args[0][0]
276-
self.assertIsInstance(captured_exception, ValueError)
277-
self.assertEqual(str(captured_exception), "Test exception")
362+
request = MockRequest()
363+
364+
response = middleware(request)
365+
self.assertEqual(response.status_code, 500)
366+
367+
# Context manager didn't capture anything - exception was intercepted by Django
368+
mock_client.capture_exception.assert_not_called()
369+
370+
# Verify process_exception hook exists and captures exceptions when called
371+
if hasattr(middleware, "process_exception"):
372+
exception = ValueError("View error")
373+
middleware.process_exception(request, exception)
374+
mock_client.capture_exception.assert_called_once_with(exception)
375+
else:
376+
self.fail(
377+
"process_exception missing - view exceptions will not be captured!"
378+
)
278379

279380

280381
class TestPosthogContextMiddlewareAsync(unittest.TestCase):

0 commit comments

Comments
 (0)