posthog 6.7.2__py3-none-any.whl → 6.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthog/__init__.py +30 -2
- posthog/ai/anthropic/anthropic.py +4 -5
- posthog/ai/anthropic/anthropic_async.py +33 -70
- posthog/ai/anthropic/anthropic_converter.py +73 -23
- posthog/ai/gemini/gemini.py +11 -10
- posthog/ai/gemini/gemini_converter.py +177 -29
- posthog/ai/langchain/callbacks.py +18 -3
- posthog/ai/openai/openai.py +8 -8
- posthog/ai/openai/openai_async.py +36 -15
- posthog/ai/openai/openai_converter.py +192 -42
- posthog/ai/types.py +2 -19
- posthog/ai/utils.py +124 -118
- posthog/client.py +96 -4
- posthog/contexts.py +81 -0
- posthog/exception_utils.py +192 -0
- posthog/feature_flags.py +26 -10
- posthog/integrations/django.py +157 -19
- posthog/test/test_client.py +43 -0
- posthog/test/test_exception_capture.py +300 -0
- posthog/test/test_feature_flags.py +146 -35
- posthog/test/test_module.py +0 -8
- posthog/version.py +1 -1
- {posthog-6.7.2.dist-info → posthog-6.9.0.dist-info}/METADATA +1 -1
- {posthog-6.7.2.dist-info → posthog-6.9.0.dist-info}/RECORD +27 -27
- {posthog-6.7.2.dist-info → posthog-6.9.0.dist-info}/WHEEL +0 -0
- {posthog-6.7.2.dist-info → posthog-6.9.0.dist-info}/licenses/LICENSE +0 -0
- {posthog-6.7.2.dist-info → posthog-6.9.0.dist-info}/top_level.txt +0 -0
posthog/exception_utils.py
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# 💖open source (under MIT License)
|
|
6
6
|
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
|
7
7
|
|
|
8
|
+
import json
|
|
8
9
|
import linecache
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
@@ -26,6 +27,7 @@ from typing import ( # noqa: F401
|
|
|
26
27
|
Union,
|
|
27
28
|
cast,
|
|
28
29
|
TYPE_CHECKING,
|
|
30
|
+
Pattern,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
|
|
@@ -40,6 +42,42 @@ except ImportError:
|
|
|
40
42
|
|
|
41
43
|
DEFAULT_MAX_VALUE_LENGTH = 1024
|
|
42
44
|
|
|
45
|
+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
|
|
46
|
+
r"(?i).*password.*",
|
|
47
|
+
r"(?i).*secret.*",
|
|
48
|
+
r"(?i).*passwd.*",
|
|
49
|
+
r"(?i).*pwd.*",
|
|
50
|
+
r"(?i).*api_key.*",
|
|
51
|
+
r"(?i).*apikey.*",
|
|
52
|
+
r"(?i).*auth.*",
|
|
53
|
+
r"(?i).*credentials.*",
|
|
54
|
+
r"(?i).*privatekey.*",
|
|
55
|
+
r"(?i).*private_key.*",
|
|
56
|
+
r"(?i).*token.*",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
|
|
60
|
+
|
|
61
|
+
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
|
|
62
|
+
|
|
63
|
+
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class VariableSizeLimiter:
|
|
67
|
+
def __init__(self, max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT):
|
|
68
|
+
self.max_size = max_size
|
|
69
|
+
self.current_size = 0
|
|
70
|
+
|
|
71
|
+
def can_add(self, size):
|
|
72
|
+
return self.current_size + size <= self.max_size
|
|
73
|
+
|
|
74
|
+
def add(self, size):
|
|
75
|
+
self.current_size += size
|
|
76
|
+
|
|
77
|
+
def get_remaining_space(self):
|
|
78
|
+
return self.max_size - self.current_size
|
|
79
|
+
|
|
80
|
+
|
|
43
81
|
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
|
44
82
|
|
|
45
83
|
Event = TypedDict(
|
|
@@ -884,3 +922,157 @@ def strip_string(value, max_length=None):
|
|
|
884
922
|
"rem": [["!limit", "x", max_length - 3, max_length]],
|
|
885
923
|
},
|
|
886
924
|
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _compile_patterns(patterns):
|
|
928
|
+
compiled = []
|
|
929
|
+
for pattern in patterns:
|
|
930
|
+
try:
|
|
931
|
+
compiled.append(re.compile(pattern))
|
|
932
|
+
except:
|
|
933
|
+
pass
|
|
934
|
+
return compiled
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _pattern_matches(name, patterns):
|
|
938
|
+
for pattern in patterns:
|
|
939
|
+
if pattern.search(name):
|
|
940
|
+
return True
|
|
941
|
+
return False
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _serialize_variable_value(value, limiter, max_length=1024):
|
|
945
|
+
try:
|
|
946
|
+
if value is None:
|
|
947
|
+
result = "None"
|
|
948
|
+
elif isinstance(value, bool):
|
|
949
|
+
result = str(value)
|
|
950
|
+
elif isinstance(value, (int, float)):
|
|
951
|
+
result_size = len(str(value))
|
|
952
|
+
if not limiter.can_add(result_size):
|
|
953
|
+
return None
|
|
954
|
+
limiter.add(result_size)
|
|
955
|
+
return value
|
|
956
|
+
elif isinstance(value, str):
|
|
957
|
+
result = value
|
|
958
|
+
else:
|
|
959
|
+
result = json.dumps(value)
|
|
960
|
+
|
|
961
|
+
if len(result) > max_length:
|
|
962
|
+
result = result[: max_length - 3] + "..."
|
|
963
|
+
|
|
964
|
+
result_size = len(result)
|
|
965
|
+
if not limiter.can_add(result_size):
|
|
966
|
+
return None
|
|
967
|
+
limiter.add(result_size)
|
|
968
|
+
|
|
969
|
+
return result
|
|
970
|
+
except Exception:
|
|
971
|
+
try:
|
|
972
|
+
fallback = f"<{type(value).__name__}>"
|
|
973
|
+
fallback_size = len(fallback)
|
|
974
|
+
if not limiter.can_add(fallback_size):
|
|
975
|
+
return None
|
|
976
|
+
limiter.add(fallback_size)
|
|
977
|
+
return fallback
|
|
978
|
+
except Exception:
|
|
979
|
+
fallback = "<unserializable object>"
|
|
980
|
+
fallback_size = len(fallback)
|
|
981
|
+
if not limiter.can_add(fallback_size):
|
|
982
|
+
return None
|
|
983
|
+
limiter.add(fallback_size)
|
|
984
|
+
return fallback
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _is_simple_type(value):
|
|
988
|
+
return isinstance(value, (type(None), bool, int, float, str))
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def serialize_code_variables(
|
|
992
|
+
frame, limiter, mask_patterns=None, ignore_patterns=None, max_length=1024
|
|
993
|
+
):
|
|
994
|
+
if mask_patterns is None:
|
|
995
|
+
mask_patterns = []
|
|
996
|
+
if ignore_patterns is None:
|
|
997
|
+
ignore_patterns = []
|
|
998
|
+
|
|
999
|
+
compiled_mask = _compile_patterns(mask_patterns)
|
|
1000
|
+
compiled_ignore = _compile_patterns(ignore_patterns)
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
local_vars = frame.f_locals.copy()
|
|
1004
|
+
except Exception:
|
|
1005
|
+
return {}
|
|
1006
|
+
|
|
1007
|
+
simple_vars = {}
|
|
1008
|
+
complex_vars = {}
|
|
1009
|
+
|
|
1010
|
+
for name, value in local_vars.items():
|
|
1011
|
+
if _pattern_matches(name, compiled_ignore):
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
if _is_simple_type(value):
|
|
1015
|
+
simple_vars[name] = value
|
|
1016
|
+
else:
|
|
1017
|
+
complex_vars[name] = value
|
|
1018
|
+
|
|
1019
|
+
result = {}
|
|
1020
|
+
|
|
1021
|
+
all_vars = {**simple_vars, **complex_vars}
|
|
1022
|
+
ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
|
|
1023
|
+
|
|
1024
|
+
for name in ordered_names:
|
|
1025
|
+
value = all_vars[name]
|
|
1026
|
+
|
|
1027
|
+
if _pattern_matches(name, compiled_mask):
|
|
1028
|
+
redacted_value = CODE_VARIABLES_REDACTED_VALUE
|
|
1029
|
+
redacted_size = len(redacted_value)
|
|
1030
|
+
if not limiter.can_add(redacted_size):
|
|
1031
|
+
break
|
|
1032
|
+
limiter.add(redacted_size)
|
|
1033
|
+
result[name] = redacted_value
|
|
1034
|
+
else:
|
|
1035
|
+
serialized = _serialize_variable_value(value, limiter, max_length)
|
|
1036
|
+
if serialized is None:
|
|
1037
|
+
break
|
|
1038
|
+
result[name] = serialized
|
|
1039
|
+
|
|
1040
|
+
return result
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def try_attach_code_variables_to_frames(
|
|
1044
|
+
all_exceptions, exc_info, mask_patterns, ignore_patterns
|
|
1045
|
+
):
|
|
1046
|
+
exc_type, exc_value, traceback = exc_info
|
|
1047
|
+
|
|
1048
|
+
if traceback is None:
|
|
1049
|
+
return
|
|
1050
|
+
|
|
1051
|
+
tb_frames = list(iter_stacks(traceback))
|
|
1052
|
+
|
|
1053
|
+
if not tb_frames:
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
limiter = VariableSizeLimiter()
|
|
1057
|
+
|
|
1058
|
+
for exception in all_exceptions:
|
|
1059
|
+
stacktrace = exception.get("stacktrace")
|
|
1060
|
+
if not stacktrace or "frames" not in stacktrace:
|
|
1061
|
+
continue
|
|
1062
|
+
|
|
1063
|
+
serialized_frames = stacktrace["frames"]
|
|
1064
|
+
|
|
1065
|
+
for serialized_frame, tb_item in zip(serialized_frames, tb_frames):
|
|
1066
|
+
if not serialized_frame.get("in_app"):
|
|
1067
|
+
continue
|
|
1068
|
+
|
|
1069
|
+
variables = serialize_code_variables(
|
|
1070
|
+
tb_item.tb_frame,
|
|
1071
|
+
limiter,
|
|
1072
|
+
mask_patterns=mask_patterns,
|
|
1073
|
+
ignore_patterns=ignore_patterns,
|
|
1074
|
+
max_length=1024,
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
if variables:
|
|
1078
|
+
serialized_frame["code_variables"] = variables
|
posthog/feature_flags.py
CHANGED
|
@@ -22,6 +22,18 @@ class InconclusiveMatchError(Exception):
|
|
|
22
22
|
pass
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
class RequiresServerEvaluation(Exception):
|
|
26
|
+
"""
|
|
27
|
+
Raised when feature flag evaluation requires server-side data that is not
|
|
28
|
+
available locally (e.g., static cohorts, experience continuity).
|
|
29
|
+
|
|
30
|
+
This error should propagate immediately to trigger API fallback, unlike
|
|
31
|
+
InconclusiveMatchError which allows trying other conditions.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
25
37
|
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
26
38
|
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
27
39
|
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
@@ -220,14 +232,7 @@ def match_feature_flag_properties(
|
|
|
220
232
|
) or []
|
|
221
233
|
valid_variant_keys = [variant["key"] for variant in flag_variants]
|
|
222
234
|
|
|
223
|
-
|
|
224
|
-
# evaluated first, and the variant override is applied to the first matching condition.
|
|
225
|
-
sorted_flag_conditions = sorted(
|
|
226
|
-
flag_conditions,
|
|
227
|
-
key=lambda condition: 0 if condition.get("variant") else 1,
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
for condition in sorted_flag_conditions:
|
|
235
|
+
for condition in flag_conditions:
|
|
231
236
|
try:
|
|
232
237
|
# if any one condition resolves to True, we can shortcircuit and return
|
|
233
238
|
# the matching variant
|
|
@@ -246,7 +251,12 @@ def match_feature_flag_properties(
|
|
|
246
251
|
else:
|
|
247
252
|
variant = get_matching_variant(flag, distinct_id)
|
|
248
253
|
return variant or True
|
|
254
|
+
except RequiresServerEvaluation:
|
|
255
|
+
# Static cohort or other missing server-side data - must fallback to API
|
|
256
|
+
raise
|
|
249
257
|
except InconclusiveMatchError:
|
|
258
|
+
# Evaluation error (bad regex, invalid date, missing property, etc.)
|
|
259
|
+
# Track that we had an inconclusive match, but try other conditions
|
|
250
260
|
is_inconclusive = True
|
|
251
261
|
|
|
252
262
|
if is_inconclusive:
|
|
@@ -456,8 +466,8 @@ def match_cohort(
|
|
|
456
466
|
# }
|
|
457
467
|
cohort_id = str(property.get("value"))
|
|
458
468
|
if cohort_id not in cohort_properties:
|
|
459
|
-
raise
|
|
460
|
-
"
|
|
469
|
+
raise RequiresServerEvaluation(
|
|
470
|
+
f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
|
461
471
|
)
|
|
462
472
|
|
|
463
473
|
property_group = cohort_properties[cohort_id]
|
|
@@ -510,6 +520,9 @@ def match_property_group(
|
|
|
510
520
|
# OR group
|
|
511
521
|
if matches:
|
|
512
522
|
return True
|
|
523
|
+
except RequiresServerEvaluation:
|
|
524
|
+
# Immediately propagate - this condition requires server-side data
|
|
525
|
+
raise
|
|
513
526
|
except InconclusiveMatchError as e:
|
|
514
527
|
log.debug(f"Failed to compute property {prop} locally: {e}")
|
|
515
528
|
error_matching_locally = True
|
|
@@ -559,6 +572,9 @@ def match_property_group(
|
|
|
559
572
|
return True
|
|
560
573
|
if not matches and negation:
|
|
561
574
|
return True
|
|
575
|
+
except RequiresServerEvaluation:
|
|
576
|
+
# Immediately propagate - this condition requires server-side data
|
|
577
|
+
raise
|
|
562
578
|
except InconclusiveMatchError as e:
|
|
563
579
|
log.debug(f"Failed to compute property {prop} locally: {e}")
|
|
564
580
|
error_matching_locally = True
|
posthog/integrations/django.py
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
from typing import TYPE_CHECKING, cast
|
|
2
|
-
from posthog import contexts
|
|
2
|
+
from posthog import contexts
|
|
3
3
|
from posthog.client import Client
|
|
4
4
|
|
|
5
|
+
try:
|
|
6
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
7
|
+
except ImportError:
|
|
8
|
+
# Fallback for older Django versions without asgiref
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
iscoroutinefunction = asyncio.iscoroutinefunction
|
|
12
|
+
|
|
13
|
+
# No-op fallback for markcoroutinefunction
|
|
14
|
+
# Older Django versions without asgiref typically don't support async middleware anyway
|
|
15
|
+
def markcoroutinefunction(func):
|
|
16
|
+
return func
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
if TYPE_CHECKING:
|
|
6
20
|
from django.http import HttpRequest, HttpResponse # noqa: F401
|
|
7
|
-
from typing import Callable, Dict, Any, Optional # noqa: F401
|
|
21
|
+
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
|
|
8
22
|
|
|
9
23
|
|
|
10
24
|
class PosthogContextMiddleware:
|
|
@@ -31,11 +45,24 @@ class PosthogContextMiddleware:
|
|
|
31
45
|
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
|
|
32
46
|
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
|
|
33
47
|
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
|
|
48
|
+
|
|
49
|
+
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
|
|
50
|
+
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
|
|
51
|
+
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
|
|
34
52
|
"""
|
|
35
53
|
|
|
54
|
+
sync_capable = True
|
|
55
|
+
async_capable = True
|
|
56
|
+
|
|
36
57
|
def __init__(self, get_response):
|
|
37
|
-
# type: (Callable[[HttpRequest], HttpResponse]) -> None
|
|
58
|
+
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
|
|
38
59
|
self.get_response = get_response
|
|
60
|
+
self._is_coroutine = iscoroutinefunction(get_response)
|
|
61
|
+
|
|
62
|
+
# Mark this instance as a coroutine function if get_response is async
|
|
63
|
+
# This is required for Django to correctly detect async middleware
|
|
64
|
+
if self._is_coroutine:
|
|
65
|
+
markcoroutinefunction(self)
|
|
39
66
|
|
|
40
67
|
from django.conf import settings
|
|
41
68
|
|
|
@@ -85,9 +112,18 @@ class PosthogContextMiddleware:
|
|
|
85
112
|
|
|
86
113
|
def extract_tags(self, request):
|
|
87
114
|
# type: (HttpRequest) -> Dict[str, Any]
|
|
88
|
-
tags
|
|
115
|
+
"""Extract tags from request in sync context."""
|
|
116
|
+
user_id, user_email = self.extract_request_user(request)
|
|
117
|
+
return self._build_tags(request, user_id, user_email)
|
|
89
118
|
|
|
90
|
-
|
|
119
|
+
def _build_tags(self, request, user_id, user_email):
|
|
120
|
+
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
|
|
121
|
+
"""
|
|
122
|
+
Build tags dict from request and user info.
|
|
123
|
+
|
|
124
|
+
Centralized tag extraction logic used by both sync and async paths.
|
|
125
|
+
"""
|
|
126
|
+
tags = {}
|
|
91
127
|
|
|
92
128
|
# Extract session ID from X-POSTHOG-SESSION-ID header
|
|
93
129
|
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
|
@@ -139,43 +175,145 @@ class PosthogContextMiddleware:
|
|
|
139
175
|
return tags
|
|
140
176
|
|
|
141
177
|
def extract_request_user(self, request):
|
|
142
|
-
|
|
143
|
-
email
|
|
144
|
-
|
|
178
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
179
|
+
"""Extract user ID and email from request in sync context."""
|
|
145
180
|
user = getattr(request, "user", None)
|
|
181
|
+
return self._resolve_user_details(user)
|
|
146
182
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
pass
|
|
183
|
+
async def aextract_tags(self, request):
|
|
184
|
+
# type: (HttpRequest) -> Dict[str, Any]
|
|
185
|
+
"""
|
|
186
|
+
Async version of extract_tags for use in async request handling.
|
|
152
187
|
|
|
188
|
+
Uses await request.auser() instead of request.user to avoid
|
|
189
|
+
SynchronousOnlyOperation in async context.
|
|
190
|
+
|
|
191
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
192
|
+
"""
|
|
193
|
+
user_id, user_email = await self.aextract_request_user(request)
|
|
194
|
+
return self._build_tags(request, user_id, user_email)
|
|
195
|
+
|
|
196
|
+
async def aextract_request_user(self, request):
|
|
197
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
198
|
+
"""
|
|
199
|
+
Async version of extract_request_user for use in async request handling.
|
|
200
|
+
|
|
201
|
+
Uses await request.auser() instead of request.user to avoid
|
|
202
|
+
SynchronousOnlyOperation in async context.
|
|
203
|
+
|
|
204
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
205
|
+
"""
|
|
206
|
+
auser = getattr(request, "auser", None)
|
|
207
|
+
if callable(auser):
|
|
153
208
|
try:
|
|
154
|
-
|
|
209
|
+
user = await auser()
|
|
210
|
+
return self._resolve_user_details(user)
|
|
155
211
|
except Exception:
|
|
156
|
-
|
|
212
|
+
# If auser() fails, return empty - don't break the request
|
|
213
|
+
# Real errors (permissions, broken auth) will be logged by Django
|
|
214
|
+
return None, None
|
|
215
|
+
|
|
216
|
+
# Fallback for test requests without auser
|
|
217
|
+
return None, None
|
|
218
|
+
|
|
219
|
+
def _resolve_user_details(self, user):
|
|
220
|
+
# type: (Any) -> tuple[Optional[str], Optional[str]]
|
|
221
|
+
"""
|
|
222
|
+
Extract user ID and email from a user object.
|
|
223
|
+
|
|
224
|
+
Handles both authenticated and unauthenticated users, as well as
|
|
225
|
+
legacy Django where is_authenticated was a method.
|
|
226
|
+
"""
|
|
227
|
+
user_id = None
|
|
228
|
+
email = None
|
|
229
|
+
|
|
230
|
+
if user is None:
|
|
231
|
+
return user_id, email
|
|
232
|
+
|
|
233
|
+
# Handle is_authenticated (property in modern Django, method in legacy)
|
|
234
|
+
is_authenticated = getattr(user, "is_authenticated", False)
|
|
235
|
+
if callable(is_authenticated):
|
|
236
|
+
is_authenticated = is_authenticated()
|
|
237
|
+
|
|
238
|
+
if not is_authenticated:
|
|
239
|
+
return user_id, email
|
|
240
|
+
|
|
241
|
+
# Extract user primary key
|
|
242
|
+
user_pk = getattr(user, "pk", None)
|
|
243
|
+
if user_pk is not None:
|
|
244
|
+
user_id = str(user_pk)
|
|
245
|
+
|
|
246
|
+
# Extract user email
|
|
247
|
+
user_email = getattr(user, "email", None)
|
|
248
|
+
if user_email:
|
|
249
|
+
email = str(user_email)
|
|
157
250
|
|
|
158
251
|
return user_id, email
|
|
159
252
|
|
|
160
253
|
def __call__(self, request):
|
|
161
|
-
# type: (HttpRequest) -> HttpResponse
|
|
254
|
+
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
|
|
255
|
+
"""
|
|
256
|
+
Unified entry point for both sync and async request handling.
|
|
257
|
+
|
|
258
|
+
When sync_capable and async_capable are both True, Django passes requests
|
|
259
|
+
without conversion. This method detects the mode and routes accordingly.
|
|
260
|
+
"""
|
|
261
|
+
if self._is_coroutine:
|
|
262
|
+
return self.__acall__(request)
|
|
263
|
+
else:
|
|
264
|
+
# Synchronous path
|
|
265
|
+
if self.request_filter and not self.request_filter(request):
|
|
266
|
+
return self.get_response(request)
|
|
267
|
+
|
|
268
|
+
with contexts.new_context(self.capture_exceptions, client=self.client):
|
|
269
|
+
for k, v in self.extract_tags(request).items():
|
|
270
|
+
contexts.tag(k, v)
|
|
271
|
+
|
|
272
|
+
return self.get_response(request)
|
|
273
|
+
|
|
274
|
+
async def __acall__(self, request):
|
|
275
|
+
# type: (HttpRequest) -> Awaitable[HttpResponse]
|
|
276
|
+
"""
|
|
277
|
+
Asynchronous entry point for async request handling.
|
|
278
|
+
|
|
279
|
+
This method is called when the middleware chain is async.
|
|
280
|
+
Uses aextract_tags() which calls request.auser() to avoid
|
|
281
|
+
SynchronousOnlyOperation when accessing user in async context.
|
|
282
|
+
"""
|
|
162
283
|
if self.request_filter and not self.request_filter(request):
|
|
163
|
-
return self.get_response(request)
|
|
284
|
+
return await self.get_response(request)
|
|
164
285
|
|
|
165
286
|
with contexts.new_context(self.capture_exceptions, client=self.client):
|
|
166
|
-
for k, v in self.
|
|
287
|
+
for k, v in (await self.aextract_tags(request)).items():
|
|
167
288
|
contexts.tag(k, v)
|
|
168
289
|
|
|
169
|
-
return self.get_response(request)
|
|
290
|
+
return await self.get_response(request)
|
|
170
291
|
|
|
171
292
|
def process_exception(self, request, exception):
|
|
293
|
+
# type: (HttpRequest, Exception) -> None
|
|
294
|
+
"""
|
|
295
|
+
Process exceptions from views and downstream middleware.
|
|
296
|
+
|
|
297
|
+
Django calls this WHILE still inside the context created by __call__,
|
|
298
|
+
so request tags have already been extracted and set. This method just
|
|
299
|
+
needs to capture the exception directly.
|
|
300
|
+
|
|
301
|
+
Django converts view exceptions into responses before they propagate through
|
|
302
|
+
the middleware stack, so the context manager in __call__/__acall__ never sees them.
|
|
303
|
+
|
|
304
|
+
Note: Django's process_exception is always synchronous, even for async views.
|
|
305
|
+
"""
|
|
172
306
|
if self.request_filter and not self.request_filter(request):
|
|
173
307
|
return
|
|
174
308
|
|
|
175
309
|
if not self.capture_exceptions:
|
|
176
310
|
return
|
|
177
311
|
|
|
312
|
+
# Context and tags already set by __call__ or __acall__
|
|
313
|
+
# Just capture the exception
|
|
178
314
|
if self.client:
|
|
179
315
|
self.client.capture_exception(exception)
|
|
180
316
|
else:
|
|
317
|
+
from posthog import capture_exception
|
|
318
|
+
|
|
181
319
|
capture_exception(exception)
|
posthog/test/test_client.py
CHANGED
|
@@ -2423,3 +2423,46 @@ class TestClient(unittest.TestCase):
|
|
|
2423
2423
|
batch_data = mock_post.call_args[1]["batch"]
|
|
2424
2424
|
msg = batch_data[0]
|
|
2425
2425
|
self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"])
|
|
2426
|
+
|
|
2427
|
+
@mock.patch(
|
|
2428
|
+
"posthog.client.Client._enqueue", side_effect=Exception("Unexpected error")
|
|
2429
|
+
)
|
|
2430
|
+
def test_methods_handle_exceptions(self, mock_enqueue):
|
|
2431
|
+
"""Test that all decorated methods handle exceptions gracefully."""
|
|
2432
|
+
client = Client("test-key")
|
|
2433
|
+
|
|
2434
|
+
test_cases = [
|
|
2435
|
+
("capture", ["test_event"], {}),
|
|
2436
|
+
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
|
2437
|
+
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
|
2438
|
+
("group_identify", ["group-type", "group-key"], {}),
|
|
2439
|
+
("alias", ["some-id", "new-id"], {}),
|
|
2440
|
+
]
|
|
2441
|
+
|
|
2442
|
+
for method_name, args, kwargs in test_cases:
|
|
2443
|
+
with self.subTest(method=method_name):
|
|
2444
|
+
method = getattr(client, method_name)
|
|
2445
|
+
result = method(*args, **kwargs)
|
|
2446
|
+
self.assertEqual(result, None)
|
|
2447
|
+
|
|
2448
|
+
@mock.patch(
|
|
2449
|
+
"posthog.client.Client._enqueue", side_effect=Exception("Expected error")
|
|
2450
|
+
)
|
|
2451
|
+
def test_debug_flag_re_raises_exceptions(self, mock_enqueue):
|
|
2452
|
+
"""Test that methods re-raise exceptions when debug=True."""
|
|
2453
|
+
client = Client("test-key", debug=True)
|
|
2454
|
+
|
|
2455
|
+
test_cases = [
|
|
2456
|
+
("capture", ["test_event"], {}),
|
|
2457
|
+
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
|
2458
|
+
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
|
|
2459
|
+
("group_identify", ["group-type", "group-key"], {}),
|
|
2460
|
+
("alias", ["some-id", "new-id"], {}),
|
|
2461
|
+
]
|
|
2462
|
+
|
|
2463
|
+
for method_name, args, kwargs in test_cases:
|
|
2464
|
+
with self.subTest(method=method_name):
|
|
2465
|
+
method = getattr(client, method_name)
|
|
2466
|
+
with self.assertRaises(Exception) as cm:
|
|
2467
|
+
method(*args, **kwargs)
|
|
2468
|
+
self.assertEqual(str(cm.exception), "Expected error")
|