Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: CI
on:
- pull_request

permissions:
contents: read

jobs:
code-quality:
name: Code quality checks
Expand Down Expand Up @@ -68,3 +71,34 @@ jobs:
- name: Run posthog tests
run: |
pytest --verbose --timeout=30

django5-integration:
name: Django 5 integration tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 1

- name: Set up Python 3.12
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.12

- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'test_project_django5/pyproject.toml'

- name: Install Django 5 test project dependencies
shell: bash
working-directory: test_project_django5
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync

- name: Run Django 5 middleware integration tests
working-directory: test_project_django5
run: |
uv run pytest test_middleware.py test_exception_capture.py --verbose
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Unreleased
# 6.7.12 - 2025-10-31

- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior

# 6.7.11 - 2025-10-28

Expand Down
3 changes: 1 addition & 2 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import sys
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Optional, Union
from typing import Any, Dict, Optional, Union
from typing_extensions import Unpack
from uuid import uuid4

Expand Down Expand Up @@ -60,7 +60,6 @@
SizeLimitedDict,
clean,
guess_timezone,
remove_trailing_slash,
system_context,
)
from posthog.version import VERSION
Expand Down
94 changes: 81 additions & 13 deletions posthog/integrations/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,18 @@ def __init__(self, get_response):

def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
"""Extract tags from request in sync context."""
user_id, user_email = self.extract_request_user(request)
return self._build_tags(request, user_id, user_email)

def _build_tags(self, request, user_id, user_email):
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
"""
Build tags dict from request and user info.

(user_id, user_email) = self.extract_request_user(request)
Centralized tag extraction logic used by both sync and async paths.
"""
tags = {}

# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
Expand Down Expand Up @@ -166,21 +175,78 @@ def extract_tags(self, request):
return tags

def extract_request_user(self, request):
user_id = None
email = None

# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
"""Extract user ID and email from request in sync context."""
user = getattr(request, "user", None)
return self._resolve_user_details(user)

if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception:
pass
async def aextract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""
Async version of extract_tags for use in async request handling.

Uses await request.auser() instead of request.user to avoid
SynchronousOnlyOperation in async context.

Follows Django's naming convention for async methods (auser, asave, etc.).
"""
user_id, user_email = await self.aextract_request_user(request)
return self._build_tags(request, user_id, user_email)

async def aextract_request_user(self, request):
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
"""
Async version of extract_request_user for use in async request handling.

Uses await request.auser() instead of request.user to avoid
SynchronousOnlyOperation in async context.

Follows Django's naming convention for async methods (auser, asave, etc.).
"""
auser = getattr(request, "auser", None)
if callable(auser):
try:
email = str(user.email)
user = await auser()
return self._resolve_user_details(user)
except Exception:
pass
# If auser() fails, return empty - don't break the request
# Real errors (permissions, broken auth) will be logged by Django
return None, None

# Fallback for test requests without auser
return None, None

def _resolve_user_details(self, user):
# type: (Any) -> tuple[Optional[str], Optional[str]]
"""
Extract user ID and email from a user object.

Handles both authenticated and unauthenticated users, as well as
legacy Django where is_authenticated was a method.
"""
user_id = None
email = None

if user is None:
return user_id, email

# Handle is_authenticated (property in modern Django, method in legacy)
is_authenticated = getattr(user, "is_authenticated", False)
if callable(is_authenticated):
is_authenticated = is_authenticated()

if not is_authenticated:
return user_id, email

# Extract user primary key
user_pk = getattr(user, "pk", None)
if user_pk is not None:
user_id = str(user_pk)

# Extract user email
user_email = getattr(user, "email", None)
if user_email:
email = str(user_email)

return user_id, email

Expand Down Expand Up @@ -211,12 +277,14 @@ async def __acall__(self, request):
Asynchronous entry point for async request handling.

This method is called when the middleware chain is async.
Uses aextract_tags() which calls request.auser() to avoid
SynchronousOnlyOperation when accessing user in async context.
"""
if self.request_filter and not self.request_filter(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():
for k, v in (await self.aextract_tags(request)).items():
contexts.tag(k, v)

return await self.get_response(request)
Expand Down
1 change: 0 additions & 1 deletion posthog/test/ai/anthropic/test_anthropic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
from unittest.mock import patch

import pytest
Expand Down
Loading
Loading