diff --git a/CHANGELOG.md b/CHANGELOG.md index 31fd5ffc..c31d2e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -## 4.5.0- 2025-06-09 +## 4.6.0 - 2025-06-09 + +- feat: add additional user and request context to captured exceptions via the Django integration +- feat: Add `setup()` function to initialise default client + +## 4.5.0 - 2025-06-09 - feat: add before_send callback (#249) @@ -17,11 +22,13 @@ ## 4.3.2 - 2025-06-06 1. Add context management: - - New context manager with `posthog.new_context()` - - Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()` - - Function decorator: - - `@posthog.scoped` - Creates context and captures exceptions thrown within the function - - Automatic deduplication of exceptions to ensure each exception is only captured once + +- New context manager with `posthog.new_context()` +- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()` +- Function decorator: + - `@posthog.scoped` - Creates context and captures exceptions thrown within the function +- Automatic deduplication of exceptions to ensure each exception is only captured once + 2. fix: feature flag request use geoip_disable (#235) 3. chore: pin actions versions (#210) 4. fix: opinionated setup and clean fn fix (#240) diff --git a/posthog/__init__.py b/posthog/__init__.py index 3b69182b..6a520dcf 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -580,8 +580,7 @@ def shutdown(): _proxy("join") -def _proxy(method, *args, **kwargs): - """Create an analytics client if one doesn't exist and send to it.""" +def setup(): global default_client if not default_client: default_client = Client( @@ -610,6 +609,11 @@ def _proxy(method, *args, **kwargs): default_client.disabled = disabled default_client.debug = debug + +def _proxy(method, *args, **kwargs): + """Create an analytics client if one doesn't exist and send to it.""" + setup() + fn = getattr(default_client, method) return fn(*args, **kwargs) diff --git a/posthog/exception_integrations/django.py b/posthog/exception_integrations/django.py index 355d1be8..96ed8ada 100644 --- a/posthog/exception_integrations/django.py +++ b/posthog/exception_integrations/django.py @@ -67,8 +67,8 @@ def extract_person_data(self): headers = self.headers() # Extract traceparent and tracestate headers - traceparent = headers.get("traceparent") - tracestate = headers.get("tracestate") + traceparent = headers.get("Traceparent") + tracestate = headers.get("Tracestate") # Extract the distinct_id from tracestate distinct_id = None @@ -80,12 +80,38 @@ def extract_person_data(self): distinct_id = match.group(1) return { + **self.user(), "distinct_id": distinct_id, "ip": headers.get("X-Forwarded-For"), "user_agent": headers.get("User-Agent"), "traceparent": traceparent, + "$request_path": self.request.path, } + def user(self): + user_data: dict[str, str] = {} + + user = getattr(self.request, "user", None) + + if user is None or not user.is_authenticated: + return user_data + + try: + user_id = str(user.pk) + if user_id: + user_data.setdefault("$user_id", user_id) + except Exception: + pass + + try: + email = str(user.email) + if email: + user_data.setdefault("email", email) + except Exception: + pass + + return user_data + def headers(self): # type: () -> Dict[str, str] return dict(self.request.headers) diff --git a/posthog/test/exception_integrations/test_django.py b/posthog/test/exception_integrations/test_django.py index d05ba3c2..f08995cc 100644 --- a/posthog/test/exception_integrations/test_django.py +++ b/posthog/test/exception_integrations/test_django.py @@ -1,20 +1,44 @@ from posthog.exception_integrations.django import DjangoRequestExtractor +from django.test import RequestFactory +from django.conf import settings +from django.core.management import call_command +import django DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" +# setup a test app +if not settings.configured: + settings.configure( + SECRET_KEY="test", + DEFAULT_CHARSET="utf-8", + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + ) + django.setup() + + call_command("migrate", verbosity=0, interactive=False) + def mock_request_factory(override_headers): - class Request: - META = {} - # TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names - headers = { + factory = RequestFactory( + headers={ "User-Agent": DEFAULT_USER_AGENT, "Referrer": "http://example.com", "X-Forwarded-For": "193.4.5.12", **(override_headers or {}), } + ) - return Request() + request = factory.get("/api/endpoint") + return request def test_request_extractor_with_no_trace(): @@ -25,6 +49,7 @@ def test_request_extractor_with_no_trace(): "user_agent": DEFAULT_USER_AGENT, "traceparent": None, "distinct_id": None, + "$request_path": "/api/endpoint", } @@ -32,12 +57,14 @@ def test_request_extractor_with_trace(): request = mock_request_factory( {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"} ) + extractor = DjangoRequestExtractor(request) assert extractor.extract_person_data() == { "ip": "193.4.5.12", "user_agent": DEFAULT_USER_AGENT, "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", "distinct_id": None, + "$request_path": "/api/endpoint", } @@ -54,6 +81,7 @@ def test_request_extractor_with_tracestate(): "user_agent": DEFAULT_USER_AGENT, "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", "distinct_id": "1234", + "$request_path": "/api/endpoint", } @@ -67,4 +95,27 @@ def test_request_extractor_with_complicated_tracestate(): "user_agent": DEFAULT_USER_AGENT, "traceparent": None, "distinct_id": "alohaMountainsXUYZ", + "$request_path": "/api/endpoint", + } + + +def test_request_extractor_with_request_user(): + from django.contrib.auth.models import User + + user = User.objects.create_user( + username="test", email="test@posthog.com", password="top_secret" + ) + + request = mock_request_factory(None) + request.user = user + + extractor = DjangoRequestExtractor(request) + assert extractor.extract_person_data() == { + "ip": "193.4.5.12", + "user_agent": DEFAULT_USER_AGENT, + "traceparent": None, + "distinct_id": None, + "$request_path": "/api/endpoint", + "email": "test@posthog.com", + "$user_id": "1", } diff --git a/posthog/version.py b/posthog/version.py index b996a2ab..f216527f 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "4.5.0" +VERSION = "4.6.0" if __name__ == "__main__": print(VERSION, end="") # noqa: T201