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
19 changes: 13 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 28 additions & 2 deletions posthog/exception_integrations/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django has been normalizing header keys since 2.2 (source: ChatGPT)

tracestate = headers.get("Tracestate")
Comment on lines +70 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Header case sensitivity change may break existing integrations. HTTP headers should be case-insensitive per RFC 2616. Use case-insensitive lookup like .get('traceparent') or .get('Traceparent')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests were failing with traceparent so I went with the capitalized version which works (even when specified as traceparent in the request)


# Extract the distinct_id from tracestate
distinct_id = None
Expand All @@ -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)
61 changes: 56 additions & 5 deletions posthog/test/exception_integrations/test_django.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -25,19 +49,22 @@ def test_request_extractor_with_no_trace():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
}


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",
}


Expand All @@ -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",
}


Expand All @@ -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",
}
2 changes: 1 addition & 1 deletion posthog/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = "4.5.0"
VERSION = "4.6.0"

if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
Loading