Skip to content

Commit f581293

Browse files
committed
fix(django): handle request.user in async middleware context
Fixes SynchronousOnlyOperation when accessing request.user in ASGI deployments. Django's request.user is a lazy object that triggers DB access when touched. In async context, this raises SynchronousOnlyOperation. The middleware now uses request.auser() in async paths to avoid blocking calls. Changes: - Add aextract_tags() and aextract_request_user() methods - Update __acall__() to use async versions - Add test verifying user extraction works in async context - Follow Django's naming convention for async methods (auser, asave, etc.) The issue was introduced in v6.7.5 (PR #328) but only became apparent after v6.7.10 (PR #348) made ASGI functional enough for users to discover it. Fixes #355
1 parent 720d20d commit f581293

File tree

2 files changed

+142
-1
lines changed

2 files changed

+142
-1
lines changed

posthog/integrations/django.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,101 @@ def extract_request_user(self, request):
184184

185185
return user_id, email
186186

187+
async def aextract_tags(self, request):
188+
# type: (HttpRequest) -> Dict[str, Any]
189+
"""
190+
Async version of extract_tags for use in async request handling.
191+
192+
Uses await request.auser() instead of request.user to avoid
193+
SynchronousOnlyOperation in async context.
194+
195+
Follows Django's naming convention for async methods (auser, asave, etc.).
196+
"""
197+
tags = {}
198+
199+
(user_id, user_email) = await self.aextract_request_user(request)
200+
201+
# Extract session ID from X-POSTHOG-SESSION-ID header
202+
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
203+
if session_id:
204+
contexts.set_context_session(session_id)
205+
206+
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
207+
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
208+
if distinct_id:
209+
contexts.identify_context(distinct_id)
210+
211+
# Extract user email
212+
if user_email:
213+
tags["email"] = user_email
214+
215+
# Extract current URL
216+
absolute_url = request.build_absolute_uri()
217+
if absolute_url:
218+
tags["$current_url"] = absolute_url
219+
220+
# Extract request method
221+
if request.method:
222+
tags["$request_method"] = request.method
223+
224+
# Extract request path
225+
if request.path:
226+
tags["$request_path"] = request.path
227+
228+
# Extract IP address
229+
ip_address = request.headers.get("X-Forwarded-For")
230+
if ip_address:
231+
tags["$ip_address"] = ip_address
232+
233+
# Extract user agent
234+
user_agent = request.headers.get("User-Agent")
235+
if user_agent:
236+
tags["$user_agent"] = user_agent
237+
238+
# Apply extra tags if configured
239+
if self.extra_tags:
240+
extra = self.extra_tags(request)
241+
if extra:
242+
tags.update(extra)
243+
244+
# Apply tag mapping if configured
245+
if self.tag_map:
246+
tags = self.tag_map(tags)
247+
248+
return tags
249+
250+
async def aextract_request_user(self, request):
251+
"""
252+
Async version of extract_request_user for use in async request handling.
253+
254+
Uses await request.auser() instead of request.user to avoid
255+
SynchronousOnlyOperation in async context.
256+
257+
Follows Django's naming convention for async methods (auser, asave, etc.).
258+
"""
259+
user_id = None
260+
email = None
261+
262+
# In async context, use auser() instead of user attribute
263+
if hasattr(request, "auser"):
264+
user = await request.auser()
265+
else:
266+
# Fallback for non-Django or test requests
267+
user = getattr(request, "user", None)
268+
269+
if user and getattr(user, "is_authenticated", False):
270+
try:
271+
user_id = str(user.pk)
272+
except Exception:
273+
pass
274+
275+
try:
276+
email = str(user.email)
277+
except Exception:
278+
pass
279+
280+
return user_id, email
281+
187282
def __call__(self, request):
188283
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
189284
"""
@@ -211,12 +306,14 @@ async def __acall__(self, request):
211306
Asynchronous entry point for async request handling.
212307
213308
This method is called when the middleware chain is async.
309+
Uses aextract_tags() which calls request.auser() to avoid
310+
SynchronousOnlyOperation when accessing user in async context.
214311
"""
215312
if self.request_filter and not self.request_filter(request):
216313
return await self.get_response(request)
217314

218315
with contexts.new_context(self.capture_exceptions, client=self.client):
219-
for k, v in self.extract_tags(request).items():
316+
for k, v in (await self.aextract_tags(request)).items():
220317
contexts.tag(k, v)
221318

222319
return await self.get_response(request)

posthog/test/integrations/test_middleware.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,50 @@ async def raise_exception(request):
499499

500500
asyncio.run(run_test())
501501

502+
def test_async_middleware_with_authenticated_user(self):
503+
"""
504+
Test that async middleware correctly extracts user info in async context.
505+
506+
Django's request.user is a SimpleLazyObject that defers DB access.
507+
In async context, accessing it directly raises SynchronousOnlyOperation.
508+
The middleware should use request.auser() instead.
509+
510+
This tests the fix for issue #355.
511+
"""
512+
513+
async def run_test():
514+
mock_response = Mock()
515+
mock_user = Mock()
516+
mock_user.is_authenticated = True
517+
mock_user.pk = 123
518+
mock_user.email = "test@example.com"
519+
520+
async def async_get_response(request):
521+
# Verify user info was extracted and set as distinct_id
522+
distinct_id = get_context_distinct_id()
523+
self.assertEqual(distinct_id, "123")
524+
return mock_response
525+
526+
middleware = PosthogContextMiddleware(async_get_response)
527+
middleware.client = Mock()
528+
529+
request = MockRequest(
530+
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
531+
)
532+
533+
# Mock auser() to return authenticated user
534+
async def mock_auser():
535+
return mock_user
536+
537+
request.auser = mock_auser
538+
539+
with new_context():
540+
result = middleware(request)
541+
response = await result
542+
self.assertEqual(response, mock_response)
543+
544+
asyncio.run(run_test())
545+
502546

503547
class TestPosthogContextMiddlewareHybrid(unittest.TestCase):
504548
"""Test hybrid middleware behavior with mixed sync/async chains"""

0 commit comments

Comments
 (0)