Skip to content

Commit 36275f0

Browse files
committed
fix(django): make middleware truly hybrid-compatible with sync and async Django stacks
Addresses issues where PR #328 forced entire middleware chains into async mode, breaking sync-only Django deployments. Changes: - Keep __call__ as sync method that conditionally routes to __acall__ for async paths - Use markcoroutinefunction() to properly mark instances when async is detected - Detect async/sync at init time via iscoroutinefunction(get_response) - Restore process_exception method that was removed in #328 (fixes #329 regression) - Add comprehensive test coverage for sync, async, and hybrid middleware behavior - Add Django settings configuration to test suite This implementation follows Django's recommended hybrid middleware pattern where both sync_capable and async_capable are True, allowing Django to pass requests without conversion while the middleware adapts based on the detected mode. Fixes #329 Related to #328
1 parent 02e82a6 commit 36275f0

File tree

3 files changed

+312
-37
lines changed

3 files changed

+312
-37
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Unreleased
2+
3+
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
4+
- fix(django): Restore exception capturing via `process_exception` method (regression from 6.7.5)
5+
16
# 6.7.9 - 2025-10-22
27

38
- fix(flags): multi-condition flags with static cohorts returning wrong variants

posthog/integrations/django.py

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from posthog.client import Client
44

55
try:
6-
from asgiref.sync import iscoroutinefunction
6+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
77
except ImportError:
88
# Fallback for older Django versions
99
import asyncio
1010

1111
iscoroutinefunction = asyncio.iscoroutinefunction
1212

13+
# Basic markcoroutinefunction implementation for older Django
14+
def markcoroutinefunction(func):
15+
func._is_coroutine = asyncio.coroutines._is_coroutine
16+
return func
17+
18+
1319
if TYPE_CHECKING:
1420
from django.http import HttpRequest, HttpResponse # noqa: F401
1521
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
@@ -39,26 +45,24 @@ class PosthogContextMiddleware:
3945
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
4046
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
4147
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
48+
49+
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
50+
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
51+
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
4252
"""
4353

44-
# Django middleware capability flags
4554
sync_capable = True
4655
async_capable = True
4756

4857
def __init__(self, get_response):
4958
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
59+
self.get_response = get_response
5060
self._is_coroutine = iscoroutinefunction(get_response)
51-
self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]]
52-
self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]]
5361

62+
# Mark this instance as a coroutine function if get_response is async
63+
# This is required for Django to correctly detect async middleware
5464
if self._is_coroutine:
55-
self._async_get_response = cast(
56-
"Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response
57-
)
58-
else:
59-
self._sync_get_response = cast(
60-
"Callable[[HttpRequest], HttpResponse]", get_response
61-
)
65+
markcoroutinefunction(self)
6266

6367
from django.conf import settings
6468

@@ -181,40 +185,59 @@ def extract_request_user(self, request):
181185
return user_id, email
182186

183187
def __call__(self, request):
184-
# type: (HttpRequest) -> HttpResponse
185-
# Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may
186-
# as well return something semi-meaningful
188+
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
189+
"""
190+
Unified entry point for both sync and async request handling.
191+
192+
When sync_capable and async_capable are both True, Django passes requests
193+
without conversion. This method detects the mode and routes accordingly.
194+
"""
187195
if self._is_coroutine:
188-
raise RuntimeError(
189-
"PosthogContextMiddleware received sync call but get_response is async"
190-
)
196+
return self.__acall__(request)
197+
else:
198+
# Synchronous path
199+
if self.request_filter and not self.request_filter(request):
200+
return self.get_response(request)
201+
202+
with contexts.new_context(self.capture_exceptions, client=self.client):
203+
for k, v in self.extract_tags(request).items():
204+
contexts.tag(k, v)
191205

206+
return self.get_response(request)
207+
208+
async def __acall__(self, request):
209+
# type: (HttpRequest) -> Awaitable[HttpResponse]
210+
"""
211+
Asynchronous entry point for async request handling.
212+
213+
This method is called when the middleware chain is async.
214+
"""
192215
if self.request_filter and not self.request_filter(request):
193-
assert self._sync_get_response is not None
194-
return self._sync_get_response(request)
216+
return await self.get_response(request)
195217

196218
with contexts.new_context(self.capture_exceptions, client=self.client):
197219
for k, v in self.extract_tags(request).items():
198220
contexts.tag(k, v)
199221

200-
assert self._sync_get_response is not None
201-
return self._sync_get_response(request)
222+
return await self.get_response(request)
202223

203-
async def __acall__(self, request):
204-
# type: (HttpRequest) -> HttpResponse
224+
def process_exception(self, request, exception):
225+
"""
226+
Process exceptions raised during request handling.
227+
228+
This method is called by Django when an exception is raised during
229+
request processing. It captures the exception and sends it to PostHog
230+
if exception capturing is enabled.
231+
"""
205232
if self.request_filter and not self.request_filter(request):
206-
if self._async_get_response is not None:
207-
return await self._async_get_response(request)
208-
else:
209-
assert self._sync_get_response is not None
210-
return self._sync_get_response(request)
233+
return
211234

212-
with contexts.new_context(self.capture_exceptions, client=self.client):
213-
for k, v in self.extract_tags(request).items():
214-
contexts.tag(k, v)
235+
if not self.capture_exceptions:
236+
return
237+
238+
if self.client:
239+
self.client.capture_exception(exception)
240+
else:
241+
from posthog import capture_exception
215242

216-
if self._async_get_response is not None:
217-
return await self._async_get_response(request)
218-
else:
219-
assert self._sync_get_response is not None
220-
return self._sync_get_response(request)
243+
capture_exception(exception)

0 commit comments

Comments
 (0)