Skip to content

Commit 250bd42

Browse files
oliverb123daibhin
andauthored
feat(err): add django middleware (#263)
* fix exactly-once capture * add middleware * fix typing * ignore unreacable * Revert "ignore unreacable" This reverts commit 0458f0e. * add unreachable ignore * move unreachable ignore * switch to use request.headers * clarify comment * Update posthog/integrations/django.py Co-authored-by: David Newell <[email protected]> * explain typle * fix comment * explain that tags become properties * fix tests --------- Co-authored-by: David Newell <[email protected]>
1 parent 579cc56 commit 250bd42

File tree

5 files changed

+331
-5
lines changed

5 files changed

+331
-5
lines changed

posthog/client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
exc_info_from_error,
2020
exceptions_from_error_tuple,
2121
handle_in_app,
22+
exception_is_already_captured,
23+
mark_exception_as_captured,
2224
)
2325
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
2426
from posthog.poller import Poller
@@ -652,9 +654,7 @@ def capture_exception(
652654
properties = properties or {}
653655

654656
# Check if this exception has already been captured
655-
if exception is not None and hasattr(
656-
exception, "__posthog_exception_captured"
657-
):
657+
if exception is not None and exception_is_already_captured(exception):
658658
self.log.debug("Exception already captured, skipping")
659659
return
660660

@@ -708,8 +708,8 @@ def capture_exception(
708708
)
709709

710710
# Mark the exception as captured to prevent duplicate captures
711-
if exception is not None and isinstance(exception, BaseException):
712-
setattr(exception, "__posthog_exception_captured", True)
711+
if exception is not None:
712+
mark_exception_as_captured(exception)
713713

714714
return res
715715
except Exception as e:

posthog/exception_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,31 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
779779
return frames
780780

781781

782+
def exception_is_already_captured(error):
783+
# type: (Union[BaseException, ExcInfo]) -> bool
784+
if isinstance(error, BaseException):
785+
return hasattr(error, "__posthog_exception_captured")
786+
# Autocaptured exceptions are passed as a tuple from our system hooks,
787+
# the second item is the exception value (the first is the exception type)
788+
elif isinstance(error, tuple) and len(error) > 1:
789+
return error[1] is not None and hasattr(
790+
error[1], "__posthog_exception_captured"
791+
)
792+
else:
793+
return False # type: ignore[unreachable]
794+
795+
796+
def mark_exception_as_captured(error):
797+
# type: (Union[BaseException, ExcInfo]) -> None
798+
if isinstance(error, BaseException):
799+
setattr(error, "__posthog_exception_captured", True)
800+
# Autocaptured exceptions are passed as a tuple from our system hooks,
801+
# the second item is the exception value (the first is the exception type)
802+
elif isinstance(error, tuple) and len(error) > 1:
803+
if error[1] is not None:
804+
setattr(error[1], "__posthog_exception_captured", True)
805+
806+
782807
def exc_info_from_error(error):
783808
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
784809
if isinstance(error, tuple) and len(error) == 3:

posthog/integrations/__init__.py

Whitespace-only changes.

posthog/integrations/django.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from typing import TYPE_CHECKING, cast
2+
from posthog import scopes
3+
4+
if TYPE_CHECKING:
5+
from django.http import HttpRequest, HttpResponse # noqa: F401
6+
from typing import Callable, Dict, Any, Optional # noqa: F401
7+
8+
9+
class PosthogContextMiddleware:
10+
"""Middleware to automatically track Django requests.
11+
12+
This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers:
13+
- Session ID as $session_id, (extracted from `X-POSTHOG-SESSION-ID`)
14+
- Distinct ID as $distinct_id, (extracted from `X-POSTHOG-DISTINCT-ID`)
15+
- Request URL as $current_url
16+
- Request Method as $request_method
17+
18+
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
19+
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
20+
21+
The middleware behaviour is customisable through 3 additional functions:
22+
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
23+
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
24+
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
25+
26+
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
27+
28+
Context tags are automatically included as properties on all events captured within a context, including exceptions.
29+
See the context documentation for more information.
30+
"""
31+
32+
def __init__(self, get_response):
33+
# type: (Callable[[HttpRequest], HttpResponse]) -> None
34+
self.get_response = get_response
35+
36+
from django.conf import settings
37+
38+
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
39+
settings.POSTHOG_MW_EXTRA_TAGS
40+
):
41+
self.extra_tags = cast(
42+
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
43+
settings.POSTHOG_MW_EXTRA_TAGS,
44+
)
45+
else:
46+
self.extra_tags = None
47+
48+
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
49+
settings.POSTHOG_MW_REQUEST_FILTER
50+
):
51+
self.request_filter = cast(
52+
"Optional[Callable[[HttpRequest], bool]]",
53+
settings.POSTHOG_MW_REQUEST_FILTER,
54+
)
55+
else:
56+
self.request_filter = None
57+
58+
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
59+
settings.POSTHOG_MW_TAG_MAP
60+
):
61+
self.tag_map = cast(
62+
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
63+
settings.POSTHOG_MW_TAG_MAP,
64+
)
65+
else:
66+
self.tag_map = None
67+
68+
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
69+
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
70+
):
71+
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
72+
else:
73+
self.capture_exceptions = True
74+
75+
def extract_tags(self, request):
76+
# type: (HttpRequest) -> Dict[str, Any]
77+
tags = {}
78+
79+
# Extract session ID from X-POSTHOG-SESSION-ID header
80+
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
81+
if session_id:
82+
tags["$session_id"] = session_id
83+
84+
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header
85+
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID")
86+
if distinct_id:
87+
tags["$distinct_id"] = distinct_id
88+
89+
# Extract current URL
90+
absolute_url = request.build_absolute_uri()
91+
if absolute_url:
92+
tags["$current_url"] = absolute_url
93+
94+
# Extract request method
95+
if request.method:
96+
tags["$request_method"] = request.method
97+
98+
# Apply extra tags if configured
99+
if self.extra_tags:
100+
extra = self.extra_tags(request)
101+
if extra:
102+
tags.update(extra)
103+
104+
# Apply tag mapping if configured
105+
if self.tag_map:
106+
tags = self.tag_map(tags)
107+
108+
return tags
109+
110+
def __call__(self, request):
111+
# type: (HttpRequest) -> HttpResponse
112+
if self.request_filter and not self.request_filter(request):
113+
return self.get_response(request)
114+
115+
with scopes.new_context(self.capture_exceptions):
116+
for k, v in self.extract_tags(request).items():
117+
scopes.tag(k, v)
118+
119+
return self.get_response(request)
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import unittest
2+
from unittest.mock import Mock
3+
4+
from posthog.integrations.django import PosthogContextMiddleware
5+
6+
7+
class MockRequest:
8+
"""Mock Django HttpRequest object"""
9+
10+
def __init__(
11+
self,
12+
headers=None,
13+
method="GET",
14+
path="/test",
15+
host="example.com",
16+
is_secure=False,
17+
):
18+
self.headers = headers or {}
19+
self.method = method
20+
self.path = path
21+
self._host = host
22+
self._is_secure = is_secure
23+
24+
def build_absolute_uri(self):
25+
scheme = "https" if self._is_secure else "http"
26+
return f"{scheme}://{self._host}{self.path}"
27+
28+
29+
class TestPosthogContextMiddleware(unittest.TestCase):
30+
def create_middleware(
31+
self,
32+
extra_tags=None,
33+
request_filter=None,
34+
tag_map=None,
35+
capture_exceptions=True,
36+
):
37+
"""Helper to create middleware instance without calling __init__"""
38+
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
39+
middleware.get_response = Mock()
40+
middleware.extra_tags = extra_tags
41+
middleware.request_filter = request_filter
42+
middleware.tag_map = tag_map
43+
middleware.capture_exceptions = capture_exceptions
44+
return middleware
45+
46+
def test_extract_tags_basic(self):
47+
"""Test basic tag extraction from request"""
48+
middleware = self.create_middleware()
49+
request = MockRequest(
50+
headers={
51+
"X-POSTHOG-SESSION-ID": "session-123",
52+
"X-POSTHOG-DISTINCT-ID": "user-456",
53+
},
54+
method="POST",
55+
path="/api/test",
56+
host="example.com",
57+
is_secure=True,
58+
)
59+
60+
tags = middleware.extract_tags(request)
61+
62+
self.assertEqual(tags["$session_id"], "session-123")
63+
self.assertEqual(tags["$distinct_id"], "user-456")
64+
self.assertEqual(tags["$current_url"], "https://example.com/api/test")
65+
self.assertEqual(tags["$request_method"], "POST")
66+
67+
def test_extract_tags_missing_headers(self):
68+
"""Test tag extraction when PostHog headers are missing"""
69+
middleware = self.create_middleware()
70+
request = MockRequest(headers={}, method="GET", path="/home")
71+
72+
tags = middleware.extract_tags(request)
73+
74+
self.assertNotIn("$session_id", tags)
75+
self.assertNotIn("$distinct_id", tags)
76+
self.assertEqual(tags["$current_url"], "http://example.com/home")
77+
self.assertEqual(tags["$request_method"], "GET")
78+
79+
def test_extract_tags_partial_headers(self):
80+
"""Test tag extraction with only some PostHog headers present"""
81+
middleware = self.create_middleware()
82+
request = MockRequest(
83+
headers={"X-POSTHOG-SESSION-ID": "session-only"}, method="PUT"
84+
)
85+
86+
tags = middleware.extract_tags(request)
87+
88+
self.assertEqual(tags["$session_id"], "session-only")
89+
self.assertNotIn("$distinct_id", tags)
90+
self.assertEqual(tags["$request_method"], "PUT")
91+
92+
def test_extract_tags_with_extra_tags(self):
93+
"""Test tag extraction with extra_tags function"""
94+
95+
def extra_tags_func(request):
96+
return {"custom_tag": "custom_value", "user_id": "789"}
97+
98+
middleware = self.create_middleware(extra_tags=extra_tags_func)
99+
request = MockRequest(
100+
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
101+
)
102+
103+
tags = middleware.extract_tags(request)
104+
105+
self.assertEqual(tags["$session_id"], "session-123")
106+
self.assertEqual(tags["custom_tag"], "custom_value")
107+
self.assertEqual(tags["user_id"], "789")
108+
109+
def test_extract_tags_with_tag_map(self):
110+
"""Test tag extraction with tag_map function"""
111+
112+
def tag_map_func(tags):
113+
# Remove session_id and add a mapped version
114+
if "$session_id" in tags:
115+
tags["mapped_session"] = f"mapped_{tags['$session_id']}"
116+
del tags["$session_id"]
117+
return tags
118+
119+
middleware = self.create_middleware(tag_map=tag_map_func)
120+
request = MockRequest(
121+
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
122+
)
123+
124+
tags = middleware.extract_tags(request)
125+
126+
self.assertNotIn("$session_id", tags)
127+
self.assertEqual(tags["mapped_session"], "mapped_session-123")
128+
129+
def test_extract_tags_with_both_extra_and_map(self):
130+
"""Test tag extraction with both extra_tags and tag_map"""
131+
132+
def extra_tags_func(request):
133+
return {"extra": "value"}
134+
135+
def tag_map_func(tags):
136+
tags["modified"] = True
137+
return tags
138+
139+
middleware = self.create_middleware(
140+
extra_tags=extra_tags_func, tag_map=tag_map_func
141+
)
142+
request = MockRequest(
143+
headers={"X-POSTHOG-DISTINCT-ID": "user-123"}, method="DELETE"
144+
)
145+
146+
tags = middleware.extract_tags(request)
147+
148+
self.assertEqual(tags["$distinct_id"], "user-123")
149+
self.assertEqual(tags["extra"], "value")
150+
self.assertEqual(tags["modified"], True)
151+
self.assertEqual(tags["$request_method"], "DELETE")
152+
153+
def test_extract_tags_extra_tags_returns_none(self):
154+
"""Test tag extraction when extra_tags returns None"""
155+
156+
def extra_tags_func(request):
157+
return None
158+
159+
middleware = self.create_middleware(extra_tags=extra_tags_func)
160+
request = MockRequest(method="GET")
161+
162+
tags = middleware.extract_tags(request)
163+
164+
self.assertEqual(tags["$request_method"], "GET")
165+
# Should not crash when extra_tags returns None
166+
167+
def test_extract_tags_extra_tags_returns_empty_dict(self):
168+
"""Test tag extraction when extra_tags returns empty dict"""
169+
170+
def extra_tags_func(request):
171+
return {}
172+
173+
middleware = self.create_middleware(extra_tags=extra_tags_func)
174+
request = MockRequest(method="PATCH")
175+
176+
tags = middleware.extract_tags(request)
177+
178+
self.assertEqual(tags["$request_method"], "PATCH")
179+
180+
181+
if __name__ == "__main__":
182+
unittest.main()

0 commit comments

Comments
 (0)