posthoganalytics 6.7.5__py3-none-any.whl → 7.4.3__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.
Files changed (37) hide show
  1. posthoganalytics/__init__.py +84 -7
  2. posthoganalytics/ai/anthropic/anthropic_async.py +30 -67
  3. posthoganalytics/ai/anthropic/anthropic_converter.py +40 -0
  4. posthoganalytics/ai/gemini/__init__.py +3 -0
  5. posthoganalytics/ai/gemini/gemini.py +1 -1
  6. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  7. posthoganalytics/ai/gemini/gemini_converter.py +160 -24
  8. posthoganalytics/ai/langchain/callbacks.py +55 -11
  9. posthoganalytics/ai/openai/openai.py +27 -2
  10. posthoganalytics/ai/openai/openai_async.py +49 -5
  11. posthoganalytics/ai/openai/openai_converter.py +130 -0
  12. posthoganalytics/ai/sanitization.py +27 -5
  13. posthoganalytics/ai/types.py +1 -0
  14. posthoganalytics/ai/utils.py +32 -2
  15. posthoganalytics/client.py +338 -90
  16. posthoganalytics/contexts.py +81 -0
  17. posthoganalytics/exception_utils.py +250 -2
  18. posthoganalytics/feature_flags.py +26 -10
  19. posthoganalytics/flag_definition_cache.py +127 -0
  20. posthoganalytics/integrations/django.py +149 -50
  21. posthoganalytics/request.py +203 -23
  22. posthoganalytics/test/test_client.py +250 -22
  23. posthoganalytics/test/test_exception_capture.py +418 -0
  24. posthoganalytics/test/test_feature_flag_result.py +441 -2
  25. posthoganalytics/test/test_feature_flags.py +306 -102
  26. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  27. posthoganalytics/test/test_module.py +0 -8
  28. posthoganalytics/test/test_request.py +536 -0
  29. posthoganalytics/test/test_utils.py +4 -1
  30. posthoganalytics/types.py +40 -0
  31. posthoganalytics/version.py +1 -1
  32. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
  33. posthoganalytics-7.4.3.dist-info/RECORD +57 -0
  34. posthoganalytics-6.7.5.dist-info/RECORD +0 -54
  35. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
  36. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
  37. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
@@ -2,43 +2,62 @@ import atexit
2
2
  import logging
3
3
  import os
4
4
  import sys
5
+ import warnings
5
6
  from datetime import datetime, timedelta
6
7
  from typing import Any, Dict, Optional, Union
7
- from typing_extensions import Unpack
8
8
  from uuid import uuid4
9
9
 
10
10
  from dateutil.tz import tzutc
11
11
  from six import string_types
12
+ from typing_extensions import Unpack
12
13
 
13
- from posthoganalytics.args import OptionalCaptureArgs, OptionalSetArgs, ID_TYPES, ExceptionArg
14
+ from posthoganalytics.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
14
15
  from posthoganalytics.consumer import Consumer
16
+ from posthoganalytics.contexts import (
17
+ _get_current_context,
18
+ get_capture_exception_code_variables_context,
19
+ get_code_variables_ignore_patterns_context,
20
+ get_code_variables_mask_patterns_context,
21
+ get_context_distinct_id,
22
+ get_context_session_id,
23
+ new_context,
24
+ )
15
25
  from posthoganalytics.exception_capture import ExceptionCapture
16
26
  from posthoganalytics.exception_utils import (
27
+ DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
28
+ DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
17
29
  exc_info_from_error,
30
+ exception_is_already_captured,
18
31
  exceptions_from_error_tuple,
19
32
  handle_in_app,
20
- exception_is_already_captured,
21
33
  mark_exception_as_captured,
34
+ try_attach_code_variables_to_frames,
35
+ )
36
+ from posthoganalytics.feature_flags import (
37
+ InconclusiveMatchError,
38
+ RequiresServerEvaluation,
39
+ match_feature_flag_properties,
40
+ )
41
+ from posthoganalytics.flag_definition_cache import (
42
+ FlagDefinitionCacheData,
43
+ FlagDefinitionCacheProvider,
22
44
  )
23
- from posthoganalytics.feature_flags import InconclusiveMatchError, match_feature_flag_properties
24
45
  from posthoganalytics.poller import Poller
25
46
  from posthoganalytics.request import (
26
47
  DEFAULT_HOST,
27
48
  APIError,
49
+ QuotaLimitError,
50
+ RequestsConnectionError,
51
+ RequestsTimeout,
28
52
  batch_post,
29
53
  determine_server_host,
30
54
  flags,
31
55
  get,
32
56
  remote_config,
33
57
  )
34
- from posthoganalytics.contexts import (
35
- _get_current_context,
36
- get_context_distinct_id,
37
- get_context_session_id,
38
- new_context,
39
- )
40
58
  from posthoganalytics.types import (
41
59
  FeatureFlag,
60
+ FeatureFlagError,
42
61
  FeatureFlagResult,
43
62
  FlagMetadata,
44
63
  FlagsAndPayloads,
@@ -56,7 +75,6 @@ from posthoganalytics.utils import (
56
75
  SizeLimitedDict,
57
76
  clean,
58
77
  guess_timezone,
59
- remove_trailing_slash,
60
78
  system_context,
61
79
  )
62
80
  from posthoganalytics.version import VERSION
@@ -99,6 +117,34 @@ def add_context_tags(properties):
99
117
  return properties
100
118
 
101
119
 
120
+ def no_throw(default_return=None):
121
+ """
122
+ Decorator to prevent raising exceptions from public API methods.
123
+ Note that this doesn't prevent errors from propagating via `on_error`.
124
+ Exceptions will still be raised if the debug flag is enabled.
125
+
126
+ Args:
127
+ default_return: Value to return on exception (default: None)
128
+ """
129
+
130
+ def decorator(func):
131
+ from functools import wraps
132
+
133
+ @wraps(func)
134
+ def wrapper(self, *args, **kwargs):
135
+ try:
136
+ return func(self, *args, **kwargs)
137
+ except Exception as e:
138
+ if self.debug:
139
+ raise e
140
+ self.log.exception(f"Error in {func.__name__}: {e}")
141
+ return default_return
142
+
143
+ return wrapper
144
+
145
+ return decorator
146
+
147
+
102
148
  class Client(object):
103
149
  """
104
150
  This is the SDK reference for the PostHog Python SDK.
@@ -147,6 +193,11 @@ class Client(object):
147
193
  before_send=None,
148
194
  flag_fallback_cache_url=None,
149
195
  enable_local_evaluation=True,
196
+ flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None,
197
+ capture_exception_code_variables=False,
198
+ code_variables_mask_patterns=None,
199
+ code_variables_ignore_patterns=None,
200
+ in_app_modules: list[str] | None = None,
150
201
  ):
151
202
  """
152
203
  Initialize a new PostHog client instance.
@@ -182,8 +233,8 @@ class Client(object):
182
233
  self.timeout = timeout
183
234
  self._feature_flags = None # private variable to store flags
184
235
  self.feature_flags_by_key = None
185
- self.group_type_mapping = None
186
- self.cohorts = None
236
+ self.group_type_mapping: Optional[dict[str, str]] = None
237
+ self.cohorts: Optional[dict[str, Any]] = None
187
238
  self.poll_interval = poll_interval
188
239
  self.feature_flags_request_timeout_seconds = (
189
240
  feature_flags_request_timeout_seconds
@@ -192,6 +243,8 @@ class Client(object):
192
243
  self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
193
244
  self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
194
245
  self.flag_definition_version = 0
246
+ self._flags_etag: Optional[str] = None
247
+ self._flag_definition_cache_provider = flag_definition_cache_provider
195
248
  self.disabled = disabled
196
249
  self.disable_geoip = disable_geoip
197
250
  self.historical_migration = historical_migration
@@ -202,6 +255,19 @@ class Client(object):
202
255
  self.privacy_mode = privacy_mode
203
256
  self.enable_local_evaluation = enable_local_evaluation
204
257
 
258
+ self.capture_exception_code_variables = capture_exception_code_variables
259
+ self.code_variables_mask_patterns = (
260
+ code_variables_mask_patterns
261
+ if code_variables_mask_patterns is not None
262
+ else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
263
+ )
264
+ self.code_variables_ignore_patterns = (
265
+ code_variables_ignore_patterns
266
+ if code_variables_ignore_patterns is not None
267
+ else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
268
+ )
269
+ self.in_app_modules = in_app_modules
270
+
205
271
  if project_root is None:
206
272
  try:
207
273
  project_root = os.getcwd()
@@ -243,8 +309,9 @@ class Client(object):
243
309
  # to call flush().
244
310
  if send:
245
311
  atexit.register(self.join)
246
- for n in range(thread):
247
- self.consumers = []
312
+
313
+ self.consumers = []
314
+ for _ in range(thread):
248
315
  consumer = Consumer(
249
316
  self.queue,
250
317
  self.api_key,
@@ -481,6 +548,7 @@ class Client(object):
481
548
 
482
549
  return normalize_flags_response(resp_data)
483
550
 
551
+ @no_throw()
484
552
  def capture(
485
553
  self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
486
554
  ) -> Optional[str]:
@@ -568,7 +636,28 @@ class Client(object):
568
636
  if flag_options["should_send"]:
569
637
  try:
570
638
  if flag_options["only_evaluate_locally"] is True:
571
- # Only use local evaluation
639
+ # Local evaluation explicitly requested
640
+ feature_variants = self.get_all_flags(
641
+ distinct_id,
642
+ groups=(groups or {}),
643
+ person_properties=flag_options["person_properties"],
644
+ group_properties=flag_options["group_properties"],
645
+ disable_geoip=disable_geoip,
646
+ only_evaluate_locally=True,
647
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
648
+ )
649
+ elif flag_options["only_evaluate_locally"] is False:
650
+ # Remote evaluation explicitly requested
651
+ feature_variants = self.get_feature_variants(
652
+ distinct_id,
653
+ groups,
654
+ person_properties=flag_options["person_properties"],
655
+ group_properties=flag_options["group_properties"],
656
+ disable_geoip=disable_geoip,
657
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
658
+ )
659
+ elif self.feature_flags:
660
+ # Local flags available, prefer local evaluation
572
661
  feature_variants = self.get_all_flags(
573
662
  distinct_id,
574
663
  groups=(groups or {}),
@@ -579,7 +668,7 @@ class Client(object):
579
668
  flag_keys_to_evaluate=flag_options["flag_keys_filter"],
580
669
  )
581
670
  else:
582
- # Default behavior - use remote evaluation
671
+ # Fall back to remote evaluation
583
672
  feature_variants = self.get_feature_variants(
584
673
  distinct_id,
585
674
  groups,
@@ -593,15 +682,6 @@ class Client(object):
593
682
  f"[FEATURE FLAGS] Unable to get feature variants: {e}"
594
683
  )
595
684
 
596
- elif self.feature_flags and event != "$feature_flag_called":
597
- # Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
598
- feature_variants = self.get_all_flags(
599
- distinct_id,
600
- groups=(groups or {}),
601
- disable_geoip=disable_geoip,
602
- only_evaluate_locally=True,
603
- )
604
-
605
685
  for feature, variant in (feature_variants or {}).items():
606
686
  extra_properties[f"$feature/{feature}"] = variant
607
687
 
@@ -657,6 +737,7 @@ class Client(object):
657
737
  f"Expected bool or dict."
658
738
  )
659
739
 
740
+ @no_throw()
660
741
  def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
661
742
  """
662
743
  Set properties on a person profile.
@@ -671,25 +752,13 @@ class Client(object):
671
752
  Examples:
672
753
  ```python
673
754
  # Set with distinct id
674
- posthog.capture(
675
- 'event_name',
676
- distinct_id='user-distinct-id',
677
- properties={
678
- '$set': {'name': 'Max Hedgehog'},
679
- '$set_once': {'initial_url': '/blog'}
680
- }
681
- )
682
- ```
683
- ```python
684
- # Set using context
685
- from posthoganalytics import new_context, identify_context
686
- with new_context():
687
- identify_context('user-distinct-id')
688
- posthog.capture('event_name')
755
+ posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
689
756
  ```
690
757
 
691
758
  Category:
692
759
  Identification
760
+
761
+ Note: This method will not raise exceptions. Errors are logged.
693
762
  """
694
763
  distinct_id = kwargs.get("distinct_id", None)
695
764
  properties = kwargs.get("properties", None)
@@ -716,6 +785,7 @@ class Client(object):
716
785
 
717
786
  return self._enqueue(msg, disable_geoip)
718
787
 
788
+ @no_throw()
719
789
  def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
720
790
  """
721
791
  Set properties on a person profile only if they haven't been set before.
@@ -734,6 +804,8 @@ class Client(object):
734
804
 
735
805
  Category:
736
806
  Identification
807
+
808
+ Note: This method will not raise exceptions. Errors are logged.
737
809
  """
738
810
  distinct_id = kwargs.get("distinct_id", None)
739
811
  properties = kwargs.get("properties", None)
@@ -759,6 +831,7 @@ class Client(object):
759
831
 
760
832
  return self._enqueue(msg, disable_geoip)
761
833
 
834
+ @no_throw()
762
835
  def group_identify(
763
836
  self,
764
837
  group_type: str,
@@ -791,6 +864,8 @@ class Client(object):
791
864
 
792
865
  Category:
793
866
  Identification
867
+
868
+ Note: This method will not raise exceptions. Errors are logged.
794
869
  """
795
870
  properties = properties or {}
796
871
 
@@ -815,6 +890,7 @@ class Client(object):
815
890
 
816
891
  return self._enqueue(msg, disable_geoip)
817
892
 
893
+ @no_throw()
818
894
  def alias(
819
895
  self,
820
896
  previous_id: str,
@@ -840,6 +916,8 @@ class Client(object):
840
916
 
841
917
  Category:
842
918
  Identification
919
+
920
+ Note: This method will not raise exceptions. Errors are logged.
843
921
  """
844
922
  (distinct_id, personless) = get_identity_state(distinct_id)
845
923
 
@@ -922,20 +1000,44 @@ class Client(object):
922
1000
  "values": all_exceptions_with_trace,
923
1001
  },
924
1002
  },
1003
+ in_app_include=self.in_app_modules,
925
1004
  project_root=self.project_root,
926
1005
  )
927
1006
  all_exceptions_with_trace_and_in_app = event["exception"]["values"]
928
1007
 
929
1008
  properties = {
930
- "$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
931
- "$exception_message": all_exceptions_with_trace_and_in_app[0].get(
932
- "value"
933
- ),
934
1009
  "$exception_list": all_exceptions_with_trace_and_in_app,
935
- "$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
936
1010
  **properties,
937
1011
  }
938
1012
 
1013
+ context_enabled = get_capture_exception_code_variables_context()
1014
+ context_mask = get_code_variables_mask_patterns_context()
1015
+ context_ignore = get_code_variables_ignore_patterns_context()
1016
+
1017
+ enabled = (
1018
+ context_enabled
1019
+ if context_enabled is not None
1020
+ else self.capture_exception_code_variables
1021
+ )
1022
+ mask_patterns = (
1023
+ context_mask
1024
+ if context_mask is not None
1025
+ else self.code_variables_mask_patterns
1026
+ )
1027
+ ignore_patterns = (
1028
+ context_ignore
1029
+ if context_ignore is not None
1030
+ else self.code_variables_ignore_patterns
1031
+ )
1032
+
1033
+ if enabled:
1034
+ try_attach_code_variables_to_frames(
1035
+ all_exceptions_with_trace_and_in_app,
1036
+ exc_info,
1037
+ mask_patterns=mask_patterns,
1038
+ ignore_patterns=ignore_patterns,
1039
+ )
1040
+
939
1041
  if self.log_captured_exceptions:
940
1042
  self.log.exception(exception, extra=kwargs)
941
1043
 
@@ -1068,17 +1170,25 @@ class Client(object):
1068
1170
  posthog.join()
1069
1171
  ```
1070
1172
  """
1071
- for consumer in self.consumers:
1072
- consumer.pause()
1073
- try:
1074
- consumer.join()
1075
- except RuntimeError:
1076
- # consumer thread has not started
1077
- pass
1173
+ if self.consumers:
1174
+ for consumer in self.consumers:
1175
+ consumer.pause()
1176
+ try:
1177
+ consumer.join()
1178
+ except RuntimeError:
1179
+ # consumer thread has not started
1180
+ pass
1078
1181
 
1079
1182
  if self.poller:
1080
1183
  self.poller.stop()
1081
1184
 
1185
+ # Shutdown the cache provider (release locks, cleanup)
1186
+ if self._flag_definition_cache_provider:
1187
+ try:
1188
+ self._flag_definition_cache_provider.shutdown()
1189
+ except Exception as e:
1190
+ self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
1191
+
1082
1192
  def shutdown(self):
1083
1193
  """
1084
1194
  Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
@@ -1094,7 +1204,71 @@ class Client(object):
1094
1204
  if self.exception_capture:
1095
1205
  self.exception_capture.close()
1096
1206
 
1207
+ def _update_flag_state(
1208
+ self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
1209
+ ) -> None:
1210
+ """Update internal flag state from cache data and invalidate evaluation cache if changed."""
1211
+ self.feature_flags = data["flags"]
1212
+ self.group_type_mapping = data["group_type_mapping"]
1213
+ self.cohorts = data["cohorts"]
1214
+
1215
+ # Invalidate evaluation cache if flag definitions changed
1216
+ if (
1217
+ self.flag_cache
1218
+ and old_flags_by_key is not None
1219
+ and old_flags_by_key != (self.feature_flags_by_key or {})
1220
+ ):
1221
+ old_version = self.flag_definition_version
1222
+ self.flag_definition_version += 1
1223
+ self.flag_cache.invalidate_version(old_version)
1224
+
1097
1225
  def _load_feature_flags(self):
1226
+ should_fetch = True
1227
+ if self._flag_definition_cache_provider:
1228
+ try:
1229
+ should_fetch = (
1230
+ self._flag_definition_cache_provider.should_fetch_flag_definitions()
1231
+ )
1232
+ except Exception as e:
1233
+ self.log.error(
1234
+ f"[FEATURE FLAGS] Cache provider should_fetch error: {e}"
1235
+ )
1236
+ # Fail-safe: fetch from API if cache provider errors
1237
+ should_fetch = True
1238
+
1239
+ # If not fetching, try to get from cache
1240
+ if not should_fetch and self._flag_definition_cache_provider:
1241
+ try:
1242
+ cached_data = (
1243
+ self._flag_definition_cache_provider.get_flag_definitions()
1244
+ )
1245
+ if cached_data:
1246
+ self.log.debug(
1247
+ "[FEATURE FLAGS] Using cached flag definitions from external cache"
1248
+ )
1249
+ self._update_flag_state(
1250
+ cached_data, old_flags_by_key=self.feature_flags_by_key or {}
1251
+ )
1252
+ self._last_feature_flag_poll = datetime.now(tz=tzutc())
1253
+ return
1254
+ else:
1255
+ # Emergency fallback: if cache is empty and we have no flags, fetch anyway.
1256
+ # There's really no other way of recovering in this case.
1257
+ if not self.feature_flags:
1258
+ self.log.debug(
1259
+ "[FEATURE FLAGS] Cache empty and no flags loaded, falling back to API fetch"
1260
+ )
1261
+ should_fetch = True
1262
+ except Exception as e:
1263
+ self.log.error(f"[FEATURE FLAGS] Cache provider get error: {e}")
1264
+ # Fail-safe: fetch from API if cache provider errors
1265
+ should_fetch = True
1266
+
1267
+ if should_fetch:
1268
+ self._fetch_feature_flags_from_api()
1269
+
1270
+ def _fetch_feature_flags_from_api(self):
1271
+ """Fetch feature flags from the PostHog API."""
1098
1272
  try:
1099
1273
  # Store old flags to detect changes
1100
1274
  old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
@@ -1104,19 +1278,41 @@ class Client(object):
1104
1278
  f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
1105
1279
  self.host,
1106
1280
  timeout=10,
1281
+ etag=self._flags_etag,
1107
1282
  )
1108
1283
 
1109
- self.feature_flags = response["flags"] or []
1110
- self.group_type_mapping = response["group_type_mapping"] or {}
1111
- self.cohorts = response["cohorts"] or {}
1284
+ # Update stored ETag (clear if server stops sending one)
1285
+ self._flags_etag = response.etag
1286
+
1287
+ # If 304 Not Modified, flags haven't changed - skip processing
1288
+ if response.not_modified:
1289
+ self.log.debug(
1290
+ "[FEATURE FLAGS] Flags not modified (304), using cached data"
1291
+ )
1292
+ self._last_feature_flag_poll = datetime.now(tz=tzutc())
1293
+ return
1294
+
1295
+ if response.data is None:
1296
+ self.log.error(
1297
+ "[FEATURE FLAGS] Unexpected empty response data in non-304 response"
1298
+ )
1299
+ return
1112
1300
 
1113
- # Check if flag definitions changed and update version
1114
- if self.flag_cache and old_flags_by_key != (
1115
- self.feature_flags_by_key or {}
1116
- ):
1117
- old_version = self.flag_definition_version
1118
- self.flag_definition_version += 1
1119
- self.flag_cache.invalidate_version(old_version)
1301
+ self._update_flag_state(response.data, old_flags_by_key=old_flags_by_key)
1302
+
1303
+ # Store in external cache if provider is configured
1304
+ if self._flag_definition_cache_provider:
1305
+ try:
1306
+ self._flag_definition_cache_provider.on_flag_definitions_received(
1307
+ {
1308
+ "flags": self.feature_flags or [],
1309
+ "group_type_mapping": self.group_type_mapping or {},
1310
+ "cohorts": self.cohorts or {},
1311
+ }
1312
+ )
1313
+ except Exception as e:
1314
+ self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")
1315
+ # Flags are already in memory, so continue normally
1120
1316
 
1121
1317
  except APIError as e:
1122
1318
  if e.status == 401:
@@ -1216,7 +1412,8 @@ class Client(object):
1216
1412
  flag_filters = feature_flag.get("filters") or {}
1217
1413
  aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
1218
1414
  if aggregation_group_type_index is not None:
1219
- group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
1415
+ group_type_mapping = self.group_type_mapping or {}
1416
+ group_name = group_type_mapping.get(str(aggregation_group_type_index))
1220
1417
 
1221
1418
  if not group_name:
1222
1419
  self.log.warning(
@@ -1308,6 +1505,19 @@ class Client(object):
1308
1505
  return None
1309
1506
  return bool(response)
1310
1507
 
1508
+ def _get_stale_flag_fallback(
1509
+ self, distinct_id: ID_TYPES, key: str
1510
+ ) -> Optional[FeatureFlagResult]:
1511
+ """Returns a stale cached flag value if available, otherwise None."""
1512
+ if self.flag_cache:
1513
+ stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
1514
+ if stale_result:
1515
+ self.log.info(
1516
+ f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1517
+ )
1518
+ return stale_result
1519
+ return None
1520
+
1311
1521
  def _get_feature_flag_result(
1312
1522
  self,
1313
1523
  key: str,
@@ -1340,6 +1550,8 @@ class Client(object):
1340
1550
  flag_result = None
1341
1551
  flag_details = None
1342
1552
  request_id = None
1553
+ evaluated_at = None
1554
+ feature_flag_error: Optional[str] = None
1343
1555
 
1344
1556
  flag_value = self._locally_evaluate_flag(
1345
1557
  key, distinct_id, groups, person_properties, group_properties
@@ -1364,14 +1576,24 @@ class Client(object):
1364
1576
  )
1365
1577
  elif not only_evaluate_locally:
1366
1578
  try:
1367
- flag_details, request_id = self._get_feature_flag_details_from_server(
1368
- key,
1369
- distinct_id,
1370
- groups,
1371
- person_properties,
1372
- group_properties,
1373
- disable_geoip,
1579
+ flag_details, request_id, evaluated_at, errors_while_computing = (
1580
+ self._get_feature_flag_details_from_server(
1581
+ key,
1582
+ distinct_id,
1583
+ groups,
1584
+ person_properties,
1585
+ group_properties,
1586
+ disable_geoip,
1587
+ )
1374
1588
  )
1589
+ errors = []
1590
+ if errors_while_computing:
1591
+ errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
1592
+ if flag_details is None:
1593
+ errors.append(FeatureFlagError.FLAG_MISSING)
1594
+ if errors:
1595
+ feature_flag_error = ",".join(errors)
1596
+
1375
1597
  flag_result = FeatureFlagResult.from_flag_details(
1376
1598
  flag_details, override_match_value
1377
1599
  )
@@ -1385,19 +1607,26 @@ class Client(object):
1385
1607
  self.log.debug(
1386
1608
  f"Successfully computed flag remotely: #{key} -> #{flag_result}"
1387
1609
  )
1610
+ except QuotaLimitError as e:
1611
+ self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
1612
+ feature_flag_error = FeatureFlagError.QUOTA_LIMITED
1613
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1614
+ except RequestsTimeout as e:
1615
+ self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
1616
+ feature_flag_error = FeatureFlagError.TIMEOUT
1617
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1618
+ except RequestsConnectionError as e:
1619
+ self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
1620
+ feature_flag_error = FeatureFlagError.CONNECTION_ERROR
1621
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1622
+ except APIError as e:
1623
+ self.log.warning(f"[FEATURE FLAGS] API error: {e}")
1624
+ feature_flag_error = FeatureFlagError.api_error(e.status)
1625
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1388
1626
  except Exception as e:
1389
1627
  self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
1390
-
1391
- # Fallback to cached value if remote evaluation fails
1392
- if self.flag_cache:
1393
- stale_result = self.flag_cache.get_stale_cached_flag(
1394
- distinct_id, key
1395
- )
1396
- if stale_result:
1397
- self.log.info(
1398
- f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1399
- )
1400
- flag_result = stale_result
1628
+ feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
1629
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1401
1630
 
1402
1631
  if send_feature_flag_events:
1403
1632
  self._capture_feature_flag_called(
@@ -1409,7 +1638,9 @@ class Client(object):
1409
1638
  groups,
1410
1639
  disable_geoip,
1411
1640
  request_id,
1641
+ evaluated_at,
1412
1642
  flag_details,
1643
+ feature_flag_error,
1413
1644
  )
1414
1645
 
1415
1646
  return flag_result
@@ -1543,7 +1774,7 @@ class Client(object):
1543
1774
  self.log.debug(
1544
1775
  f"Successfully computed flag locally: {key} -> {response}"
1545
1776
  )
1546
- except InconclusiveMatchError as e:
1777
+ except (RequiresServerEvaluation, InconclusiveMatchError) as e:
1547
1778
  self.log.debug(f"Failed to compute flag {key} locally: {e}")
1548
1779
  except Exception as e:
1549
1780
  self.log.exception(
@@ -1561,7 +1792,7 @@ class Client(object):
1561
1792
  person_properties=None,
1562
1793
  group_properties=None,
1563
1794
  only_evaluate_locally=False,
1564
- send_feature_flag_events=True,
1795
+ send_feature_flag_events=False,
1565
1796
  disable_geoip=None,
1566
1797
  ):
1567
1798
  """
@@ -1575,7 +1806,7 @@ class Client(object):
1575
1806
  person_properties: A dictionary of person properties.
1576
1807
  group_properties: A dictionary of group properties.
1577
1808
  only_evaluate_locally: Whether to only evaluate locally.
1578
- send_feature_flag_events: Whether to send feature flag events.
1809
+ send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
1579
1810
  disable_geoip: Whether to disable GeoIP for this request.
1580
1811
 
1581
1812
  Examples:
@@ -1591,6 +1822,14 @@ class Client(object):
1591
1822
  Category:
1592
1823
  Feature flags
1593
1824
  """
1825
+ if send_feature_flag_events:
1826
+ warnings.warn(
1827
+ "send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
1828
+ "in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
1829
+ DeprecationWarning,
1830
+ stacklevel=2,
1831
+ )
1832
+
1594
1833
  feature_flag_result = self._get_feature_flag_result(
1595
1834
  key,
1596
1835
  distinct_id,
@@ -1612,9 +1851,10 @@ class Client(object):
1612
1851
  person_properties: dict[str, str],
1613
1852
  group_properties: dict[str, str],
1614
1853
  disable_geoip: Optional[bool],
1615
- ) -> tuple[Optional[FeatureFlag], Optional[str]]:
1854
+ ) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
1616
1855
  """
1617
- Calls /flags and returns the flag details and request id
1856
+ Calls /flags and returns the flag details, request id, evaluated at timestamp,
1857
+ and whether there were errors while computing flags.
1618
1858
  """
1619
1859
  resp_data = self.get_flags_decision(
1620
1860
  distinct_id,
@@ -1625,9 +1865,11 @@ class Client(object):
1625
1865
  flag_keys_to_evaluate=[key],
1626
1866
  )
1627
1867
  request_id = resp_data.get("requestId")
1868
+ evaluated_at = resp_data.get("evaluatedAt")
1869
+ errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
1628
1870
  flags = resp_data.get("flags")
1629
1871
  flag_details = flags.get(key) if flags else None
1630
- return flag_details, request_id
1872
+ return flag_details, request_id, evaluated_at, errors_while_computing
1631
1873
 
1632
1874
  def _capture_feature_flag_called(
1633
1875
  self,
@@ -1639,7 +1881,9 @@ class Client(object):
1639
1881
  groups: Dict[str, str],
1640
1882
  disable_geoip: Optional[bool],
1641
1883
  request_id: Optional[str],
1884
+ evaluated_at: Optional[int],
1642
1885
  flag_details: Optional[FeatureFlag],
1886
+ feature_flag_error: Optional[str] = None,
1643
1887
  ):
1644
1888
  feature_flag_reported_key = (
1645
1889
  f"{key}_{'::null::' if response is None else str(response)}"
@@ -1662,6 +1906,8 @@ class Client(object):
1662
1906
 
1663
1907
  if request_id:
1664
1908
  properties["$feature_flag_request_id"] = request_id
1909
+ if evaluated_at:
1910
+ properties["$feature_flag_evaluated_at"] = evaluated_at
1665
1911
  if isinstance(flag_details, FeatureFlag):
1666
1912
  if flag_details.reason and flag_details.reason.description:
1667
1913
  properties["$feature_flag_reason"] = flag_details.reason.description
@@ -1672,6 +1918,8 @@ class Client(object):
1672
1918
  )
1673
1919
  if flag_details.metadata.id:
1674
1920
  properties["$feature_flag_id"] = flag_details.metadata.id
1921
+ if feature_flag_error:
1922
+ properties["$feature_flag_error"] = feature_flag_error
1675
1923
 
1676
1924
  self.capture(
1677
1925
  "$feature_flag_called",
@@ -1937,9 +2185,9 @@ class Client(object):
1937
2185
  return None
1938
2186
 
1939
2187
  try:
1940
- from urllib.parse import urlparse, parse_qs
2188
+ from urllib.parse import parse_qs, urlparse
1941
2189
  except ImportError:
1942
- from urlparse import urlparse, parse_qs
2190
+ from urlparse import parse_qs, urlparse
1943
2191
 
1944
2192
  try:
1945
2193
  parsed = urlparse(cache_url)