Skip to content
Merged
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: 'integration_tests/django5/pyproject.toml'

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

- name: Run Django 5 middleware integration tests
working-directory: integration_tests/django5
run: |
uv run pytest test_middleware.py test_exception_capture.py --verbose
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ pyrightconfig.json
.env
.DS_Store
posthog-python-references.json
.claude/settings.local.json
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 6.7.14 - 2025-11-03

- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior

# 6.7.13 - 2025-11-02

- fix(llma): cache cost calculation in the LangChain callback
Expand Down
4 changes: 4 additions & 0 deletions integration_tests/django5/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
db.sqlite3
*.pyc
__pycache__/
.pytest_cache/
23 changes: 23 additions & 0 deletions integration_tests/django5/manage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""

import os
import sys


def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)


if __name__ == "__main__":
main()
19 changes: 19 additions & 0 deletions integration_tests/django5/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[project]
name = "test-django5"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"django~=5.2.7",
"uvicorn[standard]~=0.38.0",
"posthog",
"pytest~=8.4.2",
"pytest-asyncio~=1.2.0",
"pytest-django~=4.11.1",
"httpx~=0.28.1",
]

[tool.uv]
required-version = ">=0.5"

[tool.uv.sources]
posthog = { path = "../..", editable = true }
111 changes: 111 additions & 0 deletions integration_tests/django5/test_exception_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
Test that verifies exception capture functionality.

These tests verify that exceptions are actually captured to PostHog, not just that
500 responses are returned.

Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier).
With process_exception(), Django calls this method to capture exceptions before
converting them to 500 responses.
"""

import os
import django

# Setup Django before importing anything else
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
django.setup()

import pytest # noqa: E402
from httpx import AsyncClient, ASGITransport # noqa: E402
from django.core.asgi import get_asgi_application # noqa: E402


@pytest.fixture(scope="session")
def asgi_app():
"""Shared ASGI application for all tests."""
return get_asgi_application()


@pytest.mark.asyncio
async def test_async_exception_is_captured(asgi_app):
"""
Test that async view exceptions are captured to PostHog.

The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
"""
from unittest.mock import patch

# Track captured exceptions
captured = []

def mock_capture(exception, **kwargs):
"""Mock capture_exception to record calls."""
captured.append(
{
"exception": exception,
"type": type(exception).__name__,
"message": str(exception),
}
)

# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-exception")

# Django returns 500
assert response.status_code == 500

# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"

# Verify it's the right exception
exception_data = captured[0]
assert exception_data["type"] == "ValueError"
assert "Test exception from Django 5 async view" in exception_data["message"]


@pytest.mark.asyncio
async def test_sync_exception_is_captured(asgi_app):
"""
Test that sync view exceptions are captured to PostHog.

The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
"""
from unittest.mock import patch

# Track captured exceptions
captured = []

def mock_capture(exception, **kwargs):
"""Mock capture_exception to record calls."""
captured.append(
{
"exception": exception,
"type": type(exception).__name__,
"message": str(exception),
}
)

# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-exception")

# Django returns 500
assert response.status_code == 500

# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"

# Verify it's the right exception
exception_data = captured[0]
assert exception_data["type"] == "ValueError"
assert "Test exception from Django 5 sync view" in exception_data["message"]
170 changes: 170 additions & 0 deletions integration_tests/django5/test_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Tests for PostHog Django middleware in async context.

These tests verify that the middleware correctly handles:
1. Async user access (request.auser() in Django 5)
2. Exception capture in both sync and async views
3. No SynchronousOnlyOperation errors in async context

Tests run directly against the ASGI application without needing a server.
"""

import os
import django

# Setup Django before importing anything else
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
django.setup()

import pytest # noqa: E402
from httpx import AsyncClient, ASGITransport # noqa: E402
from django.core.asgi import get_asgi_application # noqa: E402


@pytest.fixture(scope="session")
def asgi_app():
"""Shared ASGI application for all tests."""
return get_asgi_application()


@pytest.mark.asyncio
async def test_async_user_access(asgi_app):
"""
Test that middleware can access request.user in async context.

In Django 5, this requires using await request.auser() instead of request.user
to avoid SynchronousOnlyOperation error.

Without authentication, request.user is AnonymousUser which doesn't
trigger the lazy loading bug. This test verifies the middleware works
in the common case.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-user")

assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert "django_version" in data


@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
async def test_async_authenticated_user_access(asgi_app):
"""
Test that middleware can access an authenticated user in async context.

This is the critical test that triggers the SynchronousOnlyOperation bug
in v6.7.11. When AuthenticationMiddleware sets request.user to a
SimpleLazyObject wrapping a database query, accessing user.pk or user.email
in async context causes the error.

In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False)
which triggers the lazy object evaluation synchronously.

The fix uses await request.auser() instead to avoid this.
"""
from django.contrib.auth import get_user_model
from django.test import Client
from asgiref.sync import sync_to_async
from django.test import override_settings

# Create a test user (must use sync_to_async since we're in async test)
User = get_user_model()

@sync_to_async
def create_or_get_user():
user, created = User.objects.get_or_create(
username="testuser",
defaults={
"email": "test@example.com",
},
)
if created:
user.set_password("testpass123")
user.save()
return user

user = await create_or_get_user()

# Create a session with authenticated user (sync operation)
@sync_to_async
def create_session():
client = Client()
client.force_login(user)
return client.cookies.get("sessionid")

session_cookie = await create_session()

if not session_cookie:
pytest.skip("Could not create authenticated session")

# Make request with session cookie - this should trigger the bug in v6.7.11
# Disable exception capture to see the SynchronousOnlyOperation clearly
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
async with AsyncClient(
transport=ASGITransport(app=asgi_app),
base_url="http://testserver",
cookies={"sessionid": session_cookie.value},
) as ac:
response = await ac.get("/test/async-user")

assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["user_authenticated"]


@pytest.mark.asyncio
async def test_sync_user_access(asgi_app):
"""
Test that middleware works with sync views.

This should always work regardless of middleware version.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-user")

assert response.status_code == 200
data = response.json()
assert data["status"] == "success"


@pytest.mark.asyncio
async def test_async_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from async views.

The middleware's process_exception() method captures view exceptions to PostHog
before Django converts them to 500 responses. This test verifies the exception
causes a 500 response. See test_exception_capture.py for tests that verify
actual exception capture to PostHog.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-exception")

# Django returns 500 for unhandled exceptions
assert response.status_code == 500


@pytest.mark.asyncio
async def test_sync_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from sync views.

The middleware's process_exception() method captures view exceptions to PostHog.
This test verifies the exception causes a 500 response.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-exception")

# Django returns 500 for unhandled exceptions
assert response.status_code == 500
Empty file.
Loading
Loading