diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d3a330..9b9afbb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 6.7.10 - 2025-10-24 + +- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments +- fix(django): Exception capture works correctly via context manager (addresses #329) + # 6.7.9 - 2025-10-22 - fix(flags): multi-condition flags with static cohorts returning wrong variants diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 1709ee6a..2cbe0635 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -3,13 +3,19 @@ from posthog.client import Client try: - from asgiref.sync import iscoroutinefunction + from asgiref.sync import iscoroutinefunction, markcoroutinefunction except ImportError: - # Fallback for older Django versions + # Fallback for older Django versions without asgiref import asyncio iscoroutinefunction = asyncio.iscoroutinefunction + # No-op fallback for markcoroutinefunction + # Older Django versions without asgiref typically don't support async middleware anyway + def markcoroutinefunction(func): + return func + + if TYPE_CHECKING: from django.http import HttpRequest, HttpResponse # noqa: F401 from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401 @@ -39,26 +45,24 @@ class PosthogContextMiddleware: See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to associate all events captured in the middleware context with the same distinct ID and session as currently active on the frontend. See the documentation for `set_context_session` and `identify_context` for more details. + + This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware + detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly. + This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode. """ - # Django middleware capability flags sync_capable = True async_capable = True def __init__(self, get_response): # type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None + self.get_response = get_response self._is_coroutine = iscoroutinefunction(get_response) - self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]] - self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]] + # Mark this instance as a coroutine function if get_response is async + # This is required for Django to correctly detect async middleware if self._is_coroutine: - self._async_get_response = cast( - "Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response - ) - else: - self._sync_get_response = cast( - "Callable[[HttpRequest], HttpResponse]", get_response - ) + markcoroutinefunction(self) from django.conf import settings @@ -181,40 +185,38 @@ def extract_request_user(self, request): return user_id, email def __call__(self, request): - # type: (HttpRequest) -> HttpResponse - # Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may - # as well return something semi-meaningful - if self._is_coroutine: - raise RuntimeError( - "PosthogContextMiddleware received sync call but get_response is async" - ) + # type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]] + """ + Unified entry point for both sync and async request handling. - if self.request_filter and not self.request_filter(request): - assert self._sync_get_response is not None - return self._sync_get_response(request) + When sync_capable and async_capable are both True, Django passes requests + without conversion. This method detects the mode and routes accordingly. + """ + if self._is_coroutine: + return self.__acall__(request) + else: + # Synchronous path + if self.request_filter and not self.request_filter(request): + return self.get_response(request) - with contexts.new_context(self.capture_exceptions, client=self.client): - for k, v in self.extract_tags(request).items(): - contexts.tag(k, v) + with contexts.new_context(self.capture_exceptions, client=self.client): + for k, v in self.extract_tags(request).items(): + contexts.tag(k, v) - assert self._sync_get_response is not None - return self._sync_get_response(request) + return self.get_response(request) async def __acall__(self, request): - # type: (HttpRequest) -> HttpResponse + # type: (HttpRequest) -> Awaitable[HttpResponse] + """ + Asynchronous entry point for async request handling. + + This method is called when the middleware chain is async. + """ if self.request_filter and not self.request_filter(request): - if self._async_get_response is not None: - return await self._async_get_response(request) - else: - assert self._sync_get_response is not None - return self._sync_get_response(request) + return await self.get_response(request) with contexts.new_context(self.capture_exceptions, client=self.client): for k, v in self.extract_tags(request).items(): contexts.tag(k, v) - if self._async_get_response is not None: - return await self._async_get_response(request) - else: - assert self._sync_get_response is not None - return self._sync_get_response(request) + return await self.get_response(request) diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index f774c379..777e13f9 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -4,7 +4,21 @@ get_context_distinct_id, ) import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch +import asyncio + +# Configure Django settings before importing middleware +import django +from django.conf import settings + +if not settings.configured: + settings.configure( + DEBUG=True, + SECRET_KEY="test-secret-key", + INSTALLED_APPS=[], + MIDDLEWARE=[], + ) + django.setup() from posthog.integrations.django import PosthogContextMiddleware @@ -38,14 +52,33 @@ def create_middleware( request_filter=None, tag_map=None, capture_exceptions=True, + get_response=None, ): - """Helper to create middleware instance without calling __init__""" - middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware) - middleware.get_response = Mock() - middleware.extra_tags = extra_tags - middleware.request_filter = request_filter - middleware.tag_map = tag_map - middleware.capture_exceptions = capture_exceptions + """Helper to create middleware instance with mock Django settings""" + if get_response is None: + get_response = Mock() + + with patch("django.conf.settings") as mock_settings: + # Configure mock settings + mock_settings.POSTHOG_MW_EXTRA_TAGS = extra_tags + mock_settings.POSTHOG_MW_REQUEST_FILTER = request_filter + mock_settings.POSTHOG_MW_TAG_MAP = tag_map + mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions + mock_settings.POSTHOG_MW_CLIENT = None + + # Make hasattr work correctly + def mock_hasattr(obj, name): + return name in [ + "POSTHOG_MW_EXTRA_TAGS", + "POSTHOG_MW_REQUEST_FILTER", + "POSTHOG_MW_TAG_MAP", + "POSTHOG_MW_CAPTURE_EXCEPTIONS", + "POSTHOG_MW_CLIENT", + ] + + with patch("builtins.hasattr", side_effect=mock_hasattr): + middleware = PosthogContextMiddleware(get_response) + return middleware def test_extract_tags_basic(self): @@ -169,5 +202,246 @@ def extra_tags_func(request): self.assertEqual(tags["$request_method"], "PATCH") +class TestPosthogContextMiddlewareSync(unittest.TestCase): + """Test synchronous middleware behavior""" + + def test_sync_middleware_call(self): + """Test that sync middleware correctly processes requests""" + mock_response = Mock() + get_response = Mock(return_value=mock_response) + + # Create middleware with sync get_response + middleware = PosthogContextMiddleware(get_response) + + # Verify sync mode detected + self.assertFalse(middleware._is_coroutine) + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, + method="GET", + path="/test", + ) + + with new_context(): + response = middleware(request) + + # Verify response returned + self.assertEqual(response, mock_response) + get_response.assert_called_once_with(request) + + def test_sync_middleware_with_filter(self): + """Test sync middleware respects request filter""" + mock_response = Mock() + get_response = Mock(return_value=mock_response) + + # Create middleware with request filter that filters all requests + request_filter = lambda req: False + middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware) + middleware.get_response = get_response + middleware._is_coroutine = False + middleware.request_filter = request_filter + middleware.capture_exceptions = True + middleware.client = None + + request = MockRequest() + + # Should skip context creation and return response directly + response = middleware(request) + self.assertEqual(response, mock_response) + get_response.assert_called_once_with(request) + + def test_sync_middleware_exception_capture(self): + """Test that sync middleware captures exceptions during request processing""" + mock_client = Mock() + + # Make get_response raise an exception + def raise_exception(request): + raise ValueError("Test exception") + + get_response = Mock(side_effect=raise_exception) + + # Properly initialize middleware + middleware = PosthogContextMiddleware(get_response) + middleware.client = mock_client # Override with mock client + + request = MockRequest() + + # Should capture exception and re-raise + with self.assertRaises(ValueError): + middleware(request) + + # Verify exception was captured by middleware + mock_client.capture_exception.assert_called_once() + captured_exception = mock_client.capture_exception.call_args[0][0] + self.assertIsInstance(captured_exception, ValueError) + self.assertEqual(str(captured_exception), "Test exception") + + +class TestPosthogContextMiddlewareAsync(unittest.TestCase): + """Test asynchronous middleware behavior""" + + def test_async_middleware_detection(self): + """Test that async get_response is correctly detected""" + + async def async_get_response(request): + return Mock() + + middleware = PosthogContextMiddleware(async_get_response) + + # Verify async mode detected + self.assertTrue(middleware._is_coroutine) + + def test_async_middleware_call(self): + """Test that async middleware correctly processes requests""" + + async def run_test(): + mock_response = Mock() + + async def async_get_response(request): + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "async-session"}, + method="POST", + path="/async-test", + ) + + with new_context(): + # Call should return the coroutine from __acall__ + result = middleware(request) + + # Verify it's a coroutine + self.assertTrue(asyncio.iscoroutine(result)) + + # Await the result + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_with_filter(self): + """Test async middleware respects request filter""" + + async def run_test(): + mock_response = Mock() + + async def async_get_response(request): + return mock_response + + # Properly initialize middleware + middleware = PosthogContextMiddleware(async_get_response) + # Override request filter after initialization + middleware.request_filter = lambda req: False + + request = MockRequest() + + # Should skip context creation and return response directly + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_context_propagation(self): + """Test that async middleware properly propagates context""" + + async def run_test(): + mock_response = Mock() + + async def async_get_response(request): + # Verify context is available during async processing + session_id = get_context_session_id() + self.assertEqual(session_id, "async-session-123") + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "async-session-123"}, + method="GET", + ) + + with new_context(): + result = middleware(request) + await result + + asyncio.run(run_test()) + + def test_async_middleware_exception_capture(self): + """Test that async middleware captures exceptions during request processing""" + + async def run_test(): + mock_client = Mock() + + # Make async_get_response raise an exception + async def raise_exception(request): + raise ValueError("Async test exception") + + # Properly initialize middleware + middleware = PosthogContextMiddleware(raise_exception) + middleware.client = mock_client # Override with mock client + + request = MockRequest() + + # Should capture exception and re-raise + with self.assertRaises(ValueError): + result = middleware(request) + await result + + # Verify exception was captured by middleware + mock_client.capture_exception.assert_called_once() + captured_exception = mock_client.capture_exception.call_args[0][0] + self.assertIsInstance(captured_exception, ValueError) + self.assertEqual(str(captured_exception), "Async test exception") + + asyncio.run(run_test()) + + +class TestPosthogContextMiddlewareHybrid(unittest.TestCase): + """Test hybrid middleware behavior with mixed sync/async chains""" + + def test_hybrid_flags_set(self): + """Test that both capability flags are set""" + self.assertTrue(PosthogContextMiddleware.sync_capable) + self.assertTrue(PosthogContextMiddleware.async_capable) + + def test_sync_to_async_routing(self): + """Test that __call__ routes to __acall__ when async""" + + async def run_test(): + async def async_get_response(request): + return Mock() + + middleware = PosthogContextMiddleware(async_get_response) + + # Verify routing happens + request = MockRequest() + result = middleware(request) + + # Should be a coroutine from __acall__ + self.assertTrue(asyncio.iscoroutine(result)) + await result # Clean up + + asyncio.run(run_test()) + + def test_sync_path_direct_return(self): + """Test that sync path returns directly without coroutine""" + mock_response = Mock() + + def sync_get_response(request): + return mock_response + + middleware = PosthogContextMiddleware(sync_get_response) + + request = MockRequest() + result = middleware(request) + + # Should NOT be a coroutine + self.assertFalse(asyncio.iscoroutine(result)) + self.assertEqual(result, mock_response) + + if __name__ == "__main__": unittest.main() diff --git a/posthog/version.py b/posthog/version.py index 9e373b3c..cbd2fa1c 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "6.7.9" +VERSION = "6.7.10" if __name__ == "__main__": print(VERSION, end="") # noqa: T201