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.
@@ -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
- # Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
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 InconclusiveMatchError(
460
- "can't match cohort without a given cohort property value"
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
@@ -1,10 +1,24 @@
1
1
  from typing import TYPE_CHECKING, cast
2
- from posthog import contexts, capture_exception
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
- (user_id, user_email) = self.extract_request_user(request)
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
- user_id = None
143
- email = None
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
- if user and getattr(user, "is_authenticated", False):
148
- try:
149
- user_id = str(user.pk)
150
- except Exception:
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
- email = str(user.email)
209
+ user = await auser()
210
+ return self._resolve_user_details(user)
155
211
  except Exception:
156
- pass
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.extract_tags(request).items():
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)
@@ -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")