posthoganalytics 7.0.0__py3-none-any.whl → 7.4.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.
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import re
2
3
  from typing import Any
3
4
  from urllib.parse import urlparse
@@ -5,6 +6,15 @@ from urllib.parse import urlparse
5
6
  REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
6
7
 
7
8
 
9
+ def _is_multimodal_enabled() -> bool:
10
+ """Check if multimodal capture is enabled via environment variable."""
11
+ return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
12
+ "true",
13
+ "1",
14
+ "yes",
15
+ )
16
+
17
+
8
18
  def is_base64_data_url(text: str) -> bool:
9
19
  return re.match(r"^data:([^;]+);base64,", text) is not None
10
20
 
@@ -27,6 +37,9 @@ def is_raw_base64(text: str) -> bool:
27
37
 
28
38
 
29
39
  def redact_base64_data_url(value: Any) -> Any:
40
+ if _is_multimodal_enabled():
41
+ return value
42
+
30
43
  if not isinstance(value, str):
31
44
  return value
32
45
 
@@ -83,6 +96,11 @@ def sanitize_openai_image(item: Any) -> Any:
83
96
  },
84
97
  }
85
98
 
99
+ if item.get("type") == "audio" and "data" in item:
100
+ if _is_multimodal_enabled():
101
+ return item
102
+ return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
103
+
86
104
  return item
87
105
 
88
106
 
@@ -100,6 +118,9 @@ def sanitize_openai_response_image(item: Any) -> Any:
100
118
 
101
119
 
102
120
  def sanitize_anthropic_image(item: Any) -> Any:
121
+ if _is_multimodal_enabled():
122
+ return item
123
+
103
124
  if not isinstance(item, dict):
104
125
  return item
105
126
 
@@ -109,8 +130,6 @@ def sanitize_anthropic_image(item: Any) -> Any:
109
130
  and item["source"].get("type") == "base64"
110
131
  and "data" in item["source"]
111
132
  ):
112
- # For Anthropic, if the source type is "base64", we should always redact the data
113
- # The provider is explicitly telling us this is base64 data
114
133
  return {
115
134
  **item,
116
135
  "source": {
@@ -123,6 +142,9 @@ def sanitize_anthropic_image(item: Any) -> Any:
123
142
 
124
143
 
125
144
  def sanitize_gemini_part(part: Any) -> Any:
145
+ if _is_multimodal_enabled():
146
+ return part
147
+
126
148
  if not isinstance(part, dict):
127
149
  return part
128
150
 
@@ -131,8 +153,6 @@ def sanitize_gemini_part(part: Any) -> Any:
131
153
  and isinstance(part["inline_data"], dict)
132
154
  and "data" in part["inline_data"]
133
155
  ):
134
- # For Gemini, the inline_data structure indicates base64 data
135
- # We should redact any string data in this context
136
156
  return {
137
157
  **part,
138
158
  "inline_data": {
@@ -185,7 +205,9 @@ def sanitize_langchain_image(item: Any) -> Any:
185
205
  and isinstance(item.get("source"), dict)
186
206
  and "data" in item["source"]
187
207
  ):
188
- # Anthropic style - raw base64 in structured format, always redact
208
+ if _is_multimodal_enabled():
209
+ return item
210
+
189
211
  return {
190
212
  **item,
191
213
  "source": {
@@ -28,10 +28,17 @@ from posthoganalytics.feature_flags import (
28
28
  RequiresServerEvaluation,
29
29
  match_feature_flag_properties,
30
30
  )
31
+ from posthoganalytics.flag_definition_cache import (
32
+ FlagDefinitionCacheData,
33
+ FlagDefinitionCacheProvider,
34
+ )
31
35
  from posthoganalytics.poller import Poller
32
36
  from posthoganalytics.request import (
33
37
  DEFAULT_HOST,
34
38
  APIError,
39
+ QuotaLimitError,
40
+ RequestsConnectionError,
41
+ RequestsTimeout,
35
42
  batch_post,
36
43
  determine_server_host,
37
44
  flags,
@@ -49,6 +56,7 @@ from posthoganalytics.contexts import (
49
56
  )
50
57
  from posthoganalytics.types import (
51
58
  FeatureFlag,
59
+ FeatureFlagError,
52
60
  FeatureFlagResult,
53
61
  FlagMetadata,
54
62
  FlagsAndPayloads,
@@ -184,6 +192,7 @@ class Client(object):
184
192
  before_send=None,
185
193
  flag_fallback_cache_url=None,
186
194
  enable_local_evaluation=True,
195
+ flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None,
187
196
  capture_exception_code_variables=False,
188
197
  code_variables_mask_patterns=None,
189
198
  code_variables_ignore_patterns=None,
@@ -222,8 +231,8 @@ class Client(object):
222
231
  self.timeout = timeout
223
232
  self._feature_flags = None # private variable to store flags
224
233
  self.feature_flags_by_key = None
225
- self.group_type_mapping = None
226
- self.cohorts = None
234
+ self.group_type_mapping: Optional[dict[str, str]] = None
235
+ self.cohorts: Optional[dict[str, Any]] = None
227
236
  self.poll_interval = poll_interval
228
237
  self.feature_flags_request_timeout_seconds = (
229
238
  feature_flags_request_timeout_seconds
@@ -232,6 +241,8 @@ class Client(object):
232
241
  self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
233
242
  self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
234
243
  self.flag_definition_version = 0
244
+ self._flags_etag: Optional[str] = None
245
+ self._flag_definition_cache_provider = flag_definition_cache_provider
235
246
  self.disabled = disabled
236
247
  self.disable_geoip = disable_geoip
237
248
  self.historical_migration = historical_migration
@@ -295,8 +306,9 @@ class Client(object):
295
306
  # to call flush().
296
307
  if send:
297
308
  atexit.register(self.join)
298
- for n in range(thread):
299
- self.consumers = []
309
+
310
+ self.consumers = []
311
+ for _ in range(thread):
300
312
  consumer = Consumer(
301
313
  self.queue,
302
314
  self.api_key,
@@ -621,7 +633,28 @@ class Client(object):
621
633
  if flag_options["should_send"]:
622
634
  try:
623
635
  if flag_options["only_evaluate_locally"] is True:
624
- # Only use local evaluation
636
+ # Local evaluation explicitly requested
637
+ feature_variants = self.get_all_flags(
638
+ distinct_id,
639
+ groups=(groups or {}),
640
+ person_properties=flag_options["person_properties"],
641
+ group_properties=flag_options["group_properties"],
642
+ disable_geoip=disable_geoip,
643
+ only_evaluate_locally=True,
644
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
645
+ )
646
+ elif flag_options["only_evaluate_locally"] is False:
647
+ # Remote evaluation explicitly requested
648
+ feature_variants = self.get_feature_variants(
649
+ distinct_id,
650
+ groups,
651
+ person_properties=flag_options["person_properties"],
652
+ group_properties=flag_options["group_properties"],
653
+ disable_geoip=disable_geoip,
654
+ flag_keys_to_evaluate=flag_options["flag_keys_filter"],
655
+ )
656
+ elif self.feature_flags:
657
+ # Local flags available, prefer local evaluation
625
658
  feature_variants = self.get_all_flags(
626
659
  distinct_id,
627
660
  groups=(groups or {}),
@@ -632,7 +665,7 @@ class Client(object):
632
665
  flag_keys_to_evaluate=flag_options["flag_keys_filter"],
633
666
  )
634
667
  else:
635
- # Default behavior - use remote evaluation
668
+ # Fall back to remote evaluation
636
669
  feature_variants = self.get_feature_variants(
637
670
  distinct_id,
638
671
  groups,
@@ -978,10 +1011,6 @@ class Client(object):
978
1011
  all_exceptions_with_trace_and_in_app = event["exception"]["values"]
979
1012
 
980
1013
  properties = {
981
- "$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
982
- "$exception_message": all_exceptions_with_trace_and_in_app[0].get(
983
- "value"
984
- ),
985
1014
  "$exception_list": all_exceptions_with_trace_and_in_app,
986
1015
  **properties,
987
1016
  }
@@ -1146,17 +1175,25 @@ class Client(object):
1146
1175
  posthog.join()
1147
1176
  ```
1148
1177
  """
1149
- for consumer in self.consumers:
1150
- consumer.pause()
1151
- try:
1152
- consumer.join()
1153
- except RuntimeError:
1154
- # consumer thread has not started
1155
- pass
1178
+ if self.consumers:
1179
+ for consumer in self.consumers:
1180
+ consumer.pause()
1181
+ try:
1182
+ consumer.join()
1183
+ except RuntimeError:
1184
+ # consumer thread has not started
1185
+ pass
1156
1186
 
1157
1187
  if self.poller:
1158
1188
  self.poller.stop()
1159
1189
 
1190
+ # Shutdown the cache provider (release locks, cleanup)
1191
+ if self._flag_definition_cache_provider:
1192
+ try:
1193
+ self._flag_definition_cache_provider.shutdown()
1194
+ except Exception as e:
1195
+ self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
1196
+
1160
1197
  def shutdown(self):
1161
1198
  """
1162
1199
  Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
@@ -1172,7 +1209,71 @@ class Client(object):
1172
1209
  if self.exception_capture:
1173
1210
  self.exception_capture.close()
1174
1211
 
1212
+ def _update_flag_state(
1213
+ self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
1214
+ ) -> None:
1215
+ """Update internal flag state from cache data and invalidate evaluation cache if changed."""
1216
+ self.feature_flags = data["flags"]
1217
+ self.group_type_mapping = data["group_type_mapping"]
1218
+ self.cohorts = data["cohorts"]
1219
+
1220
+ # Invalidate evaluation cache if flag definitions changed
1221
+ if (
1222
+ self.flag_cache
1223
+ and old_flags_by_key is not None
1224
+ and old_flags_by_key != (self.feature_flags_by_key or {})
1225
+ ):
1226
+ old_version = self.flag_definition_version
1227
+ self.flag_definition_version += 1
1228
+ self.flag_cache.invalidate_version(old_version)
1229
+
1175
1230
  def _load_feature_flags(self):
1231
+ should_fetch = True
1232
+ if self._flag_definition_cache_provider:
1233
+ try:
1234
+ should_fetch = (
1235
+ self._flag_definition_cache_provider.should_fetch_flag_definitions()
1236
+ )
1237
+ except Exception as e:
1238
+ self.log.error(
1239
+ f"[FEATURE FLAGS] Cache provider should_fetch error: {e}"
1240
+ )
1241
+ # Fail-safe: fetch from API if cache provider errors
1242
+ should_fetch = True
1243
+
1244
+ # If not fetching, try to get from cache
1245
+ if not should_fetch and self._flag_definition_cache_provider:
1246
+ try:
1247
+ cached_data = (
1248
+ self._flag_definition_cache_provider.get_flag_definitions()
1249
+ )
1250
+ if cached_data:
1251
+ self.log.debug(
1252
+ "[FEATURE FLAGS] Using cached flag definitions from external cache"
1253
+ )
1254
+ self._update_flag_state(
1255
+ cached_data, old_flags_by_key=self.feature_flags_by_key or {}
1256
+ )
1257
+ self._last_feature_flag_poll = datetime.now(tz=tzutc())
1258
+ return
1259
+ else:
1260
+ # Emergency fallback: if cache is empty and we have no flags, fetch anyway.
1261
+ # There's really no other way of recovering in this case.
1262
+ if not self.feature_flags:
1263
+ self.log.debug(
1264
+ "[FEATURE FLAGS] Cache empty and no flags loaded, falling back to API fetch"
1265
+ )
1266
+ should_fetch = True
1267
+ except Exception as e:
1268
+ self.log.error(f"[FEATURE FLAGS] Cache provider get error: {e}")
1269
+ # Fail-safe: fetch from API if cache provider errors
1270
+ should_fetch = True
1271
+
1272
+ if should_fetch:
1273
+ self._fetch_feature_flags_from_api()
1274
+
1275
+ def _fetch_feature_flags_from_api(self):
1276
+ """Fetch feature flags from the PostHog API."""
1176
1277
  try:
1177
1278
  # Store old flags to detect changes
1178
1279
  old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
@@ -1182,19 +1283,41 @@ class Client(object):
1182
1283
  f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
1183
1284
  self.host,
1184
1285
  timeout=10,
1286
+ etag=self._flags_etag,
1185
1287
  )
1186
1288
 
1187
- self.feature_flags = response["flags"] or []
1188
- self.group_type_mapping = response["group_type_mapping"] or {}
1189
- self.cohorts = response["cohorts"] or {}
1289
+ # Update stored ETag (clear if server stops sending one)
1290
+ self._flags_etag = response.etag
1291
+
1292
+ # If 304 Not Modified, flags haven't changed - skip processing
1293
+ if response.not_modified:
1294
+ self.log.debug(
1295
+ "[FEATURE FLAGS] Flags not modified (304), using cached data"
1296
+ )
1297
+ self._last_feature_flag_poll = datetime.now(tz=tzutc())
1298
+ return
1299
+
1300
+ if response.data is None:
1301
+ self.log.error(
1302
+ "[FEATURE FLAGS] Unexpected empty response data in non-304 response"
1303
+ )
1304
+ return
1305
+
1306
+ self._update_flag_state(response.data, old_flags_by_key=old_flags_by_key)
1190
1307
 
1191
- # Check if flag definitions changed and update version
1192
- if self.flag_cache and old_flags_by_key != (
1193
- self.feature_flags_by_key or {}
1194
- ):
1195
- old_version = self.flag_definition_version
1196
- self.flag_definition_version += 1
1197
- self.flag_cache.invalidate_version(old_version)
1308
+ # Store in external cache if provider is configured
1309
+ if self._flag_definition_cache_provider:
1310
+ try:
1311
+ self._flag_definition_cache_provider.on_flag_definitions_received(
1312
+ {
1313
+ "flags": self.feature_flags or [],
1314
+ "group_type_mapping": self.group_type_mapping or {},
1315
+ "cohorts": self.cohorts or {},
1316
+ }
1317
+ )
1318
+ except Exception as e:
1319
+ self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")
1320
+ # Flags are already in memory, so continue normally
1198
1321
 
1199
1322
  except APIError as e:
1200
1323
  if e.status == 401:
@@ -1294,7 +1417,8 @@ class Client(object):
1294
1417
  flag_filters = feature_flag.get("filters") or {}
1295
1418
  aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
1296
1419
  if aggregation_group_type_index is not None:
1297
- group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
1420
+ group_type_mapping = self.group_type_mapping or {}
1421
+ group_name = group_type_mapping.get(str(aggregation_group_type_index))
1298
1422
 
1299
1423
  if not group_name:
1300
1424
  self.log.warning(
@@ -1386,6 +1510,19 @@ class Client(object):
1386
1510
  return None
1387
1511
  return bool(response)
1388
1512
 
1513
+ def _get_stale_flag_fallback(
1514
+ self, distinct_id: ID_TYPES, key: str
1515
+ ) -> Optional[FeatureFlagResult]:
1516
+ """Returns a stale cached flag value if available, otherwise None."""
1517
+ if self.flag_cache:
1518
+ stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
1519
+ if stale_result:
1520
+ self.log.info(
1521
+ f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1522
+ )
1523
+ return stale_result
1524
+ return None
1525
+
1389
1526
  def _get_feature_flag_result(
1390
1527
  self,
1391
1528
  key: str,
@@ -1418,6 +1555,8 @@ class Client(object):
1418
1555
  flag_result = None
1419
1556
  flag_details = None
1420
1557
  request_id = None
1558
+ evaluated_at = None
1559
+ feature_flag_error: Optional[str] = None
1421
1560
 
1422
1561
  flag_value = self._locally_evaluate_flag(
1423
1562
  key, distinct_id, groups, person_properties, group_properties
@@ -1442,14 +1581,24 @@ class Client(object):
1442
1581
  )
1443
1582
  elif not only_evaluate_locally:
1444
1583
  try:
1445
- flag_details, request_id = self._get_feature_flag_details_from_server(
1446
- key,
1447
- distinct_id,
1448
- groups,
1449
- person_properties,
1450
- group_properties,
1451
- disable_geoip,
1584
+ flag_details, request_id, evaluated_at, errors_while_computing = (
1585
+ self._get_feature_flag_details_from_server(
1586
+ key,
1587
+ distinct_id,
1588
+ groups,
1589
+ person_properties,
1590
+ group_properties,
1591
+ disable_geoip,
1592
+ )
1452
1593
  )
1594
+ errors = []
1595
+ if errors_while_computing:
1596
+ errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
1597
+ if flag_details is None:
1598
+ errors.append(FeatureFlagError.FLAG_MISSING)
1599
+ if errors:
1600
+ feature_flag_error = ",".join(errors)
1601
+
1453
1602
  flag_result = FeatureFlagResult.from_flag_details(
1454
1603
  flag_details, override_match_value
1455
1604
  )
@@ -1463,19 +1612,26 @@ class Client(object):
1463
1612
  self.log.debug(
1464
1613
  f"Successfully computed flag remotely: #{key} -> #{flag_result}"
1465
1614
  )
1615
+ except QuotaLimitError as e:
1616
+ self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
1617
+ feature_flag_error = FeatureFlagError.QUOTA_LIMITED
1618
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1619
+ except RequestsTimeout as e:
1620
+ self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
1621
+ feature_flag_error = FeatureFlagError.TIMEOUT
1622
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1623
+ except RequestsConnectionError as e:
1624
+ self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
1625
+ feature_flag_error = FeatureFlagError.CONNECTION_ERROR
1626
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1627
+ except APIError as e:
1628
+ self.log.warning(f"[FEATURE FLAGS] API error: {e}")
1629
+ feature_flag_error = FeatureFlagError.api_error(e.status)
1630
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1466
1631
  except Exception as e:
1467
1632
  self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
1468
-
1469
- # Fallback to cached value if remote evaluation fails
1470
- if self.flag_cache:
1471
- stale_result = self.flag_cache.get_stale_cached_flag(
1472
- distinct_id, key
1473
- )
1474
- if stale_result:
1475
- self.log.info(
1476
- f"[FEATURE FLAGS] Using stale cached value for flag {key}"
1477
- )
1478
- flag_result = stale_result
1633
+ feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
1634
+ flag_result = self._get_stale_flag_fallback(distinct_id, key)
1479
1635
 
1480
1636
  if send_feature_flag_events:
1481
1637
  self._capture_feature_flag_called(
@@ -1487,7 +1643,9 @@ class Client(object):
1487
1643
  groups,
1488
1644
  disable_geoip,
1489
1645
  request_id,
1646
+ evaluated_at,
1490
1647
  flag_details,
1648
+ feature_flag_error,
1491
1649
  )
1492
1650
 
1493
1651
  return flag_result
@@ -1690,9 +1848,10 @@ class Client(object):
1690
1848
  person_properties: dict[str, str],
1691
1849
  group_properties: dict[str, str],
1692
1850
  disable_geoip: Optional[bool],
1693
- ) -> tuple[Optional[FeatureFlag], Optional[str]]:
1851
+ ) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
1694
1852
  """
1695
- Calls /flags and returns the flag details and request id
1853
+ Calls /flags and returns the flag details, request id, evaluated at timestamp,
1854
+ and whether there were errors while computing flags.
1696
1855
  """
1697
1856
  resp_data = self.get_flags_decision(
1698
1857
  distinct_id,
@@ -1703,9 +1862,11 @@ class Client(object):
1703
1862
  flag_keys_to_evaluate=[key],
1704
1863
  )
1705
1864
  request_id = resp_data.get("requestId")
1865
+ evaluated_at = resp_data.get("evaluatedAt")
1866
+ errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
1706
1867
  flags = resp_data.get("flags")
1707
1868
  flag_details = flags.get(key) if flags else None
1708
- return flag_details, request_id
1869
+ return flag_details, request_id, evaluated_at, errors_while_computing
1709
1870
 
1710
1871
  def _capture_feature_flag_called(
1711
1872
  self,
@@ -1717,7 +1878,9 @@ class Client(object):
1717
1878
  groups: Dict[str, str],
1718
1879
  disable_geoip: Optional[bool],
1719
1880
  request_id: Optional[str],
1881
+ evaluated_at: Optional[int],
1720
1882
  flag_details: Optional[FeatureFlag],
1883
+ feature_flag_error: Optional[str] = None,
1721
1884
  ):
1722
1885
  feature_flag_reported_key = (
1723
1886
  f"{key}_{'::null::' if response is None else str(response)}"
@@ -1740,6 +1903,8 @@ class Client(object):
1740
1903
 
1741
1904
  if request_id:
1742
1905
  properties["$feature_flag_request_id"] = request_id
1906
+ if evaluated_at:
1907
+ properties["$feature_flag_evaluated_at"] = evaluated_at
1743
1908
  if isinstance(flag_details, FeatureFlag):
1744
1909
  if flag_details.reason and flag_details.reason.description:
1745
1910
  properties["$feature_flag_reason"] = flag_details.reason.description
@@ -1750,6 +1915,8 @@ class Client(object):
1750
1915
  )
1751
1916
  if flag_details.metadata.id:
1752
1917
  properties["$feature_flag_id"] = flag_details.metadata.id
1918
+ if feature_flag_error:
1919
+ properties["$feature_flag_error"] = feature_flag_error
1753
1920
 
1754
1921
  self.capture(
1755
1922
  "$feature_flag_called",
@@ -54,6 +54,10 @@ DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
54
54
  r"(?i).*privatekey.*",
55
55
  r"(?i).*private_key.*",
56
56
  r"(?i).*token.*",
57
+ r"(?i).*aws_access_key_id.*",
58
+ r"(?i).*_pass",
59
+ r"(?i)sk_.*",
60
+ r"(?i).*jwt.*",
57
61
  ]
58
62
 
59
63
  DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
@@ -941,7 +945,31 @@ def _pattern_matches(name, patterns):
941
945
  return False
942
946
 
943
947
 
944
- def _serialize_variable_value(value, limiter, max_length=1024):
948
+ def _mask_sensitive_data(value, compiled_mask):
949
+ if not compiled_mask:
950
+ return value
951
+
952
+ if isinstance(value, dict):
953
+ result = {}
954
+ for k, v in value.items():
955
+ key_str = str(k) if not isinstance(k, str) else k
956
+ if _pattern_matches(key_str, compiled_mask):
957
+ result[k] = CODE_VARIABLES_REDACTED_VALUE
958
+ else:
959
+ result[k] = _mask_sensitive_data(v, compiled_mask)
960
+ return result
961
+ elif isinstance(value, (list, tuple)):
962
+ masked_items = [_mask_sensitive_data(item, compiled_mask) for item in value]
963
+ return type(value)(masked_items)
964
+ elif isinstance(value, str):
965
+ if _pattern_matches(value, compiled_mask):
966
+ return CODE_VARIABLES_REDACTED_VALUE
967
+ return value
968
+ else:
969
+ return value
970
+
971
+
972
+ def _serialize_variable_value(value, limiter, max_length=1024, compiled_mask=None):
945
973
  try:
946
974
  if value is None:
947
975
  result = "None"
@@ -954,9 +982,13 @@ def _serialize_variable_value(value, limiter, max_length=1024):
954
982
  limiter.add(result_size)
955
983
  return value
956
984
  elif isinstance(value, str):
957
- result = value
985
+ if compiled_mask and _pattern_matches(value, compiled_mask):
986
+ result = CODE_VARIABLES_REDACTED_VALUE
987
+ else:
988
+ result = value
958
989
  else:
959
- result = json.dumps(value)
990
+ masked_value = _mask_sensitive_data(value, compiled_mask)
991
+ result = json.dumps(masked_value)
960
992
 
961
993
  if len(result) > max_length:
962
994
  result = result[: max_length - 3] + "..."
@@ -969,19 +1001,30 @@ def _serialize_variable_value(value, limiter, max_length=1024):
969
1001
  return result
970
1002
  except Exception:
971
1003
  try:
972
- fallback = f"<{type(value).__name__}>"
973
- fallback_size = len(fallback)
974
- if not limiter.can_add(fallback_size):
1004
+ result = repr(value)
1005
+ if len(result) > max_length:
1006
+ result = result[: max_length - 3] + "..."
1007
+
1008
+ result_size = len(result)
1009
+ if not limiter.can_add(result_size):
975
1010
  return None
976
- limiter.add(fallback_size)
977
- return fallback
1011
+ limiter.add(result_size)
1012
+ return result
978
1013
  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
1014
+ try:
1015
+ fallback = f"<{type(value).__name__}>"
1016
+ fallback_size = len(fallback)
1017
+ if not limiter.can_add(fallback_size):
1018
+ return None
1019
+ limiter.add(fallback_size)
1020
+ return fallback
1021
+ except Exception:
1022
+ fallback = "<unserializable object>"
1023
+ fallback_size = len(fallback)
1024
+ if not limiter.can_add(fallback_size):
1025
+ return None
1026
+ limiter.add(fallback_size)
1027
+ return fallback
985
1028
 
986
1029
 
987
1030
  def _is_simple_type(value):
@@ -1032,7 +1075,9 @@ def serialize_code_variables(
1032
1075
  limiter.add(redacted_size)
1033
1076
  result[name] = redacted_value
1034
1077
  else:
1035
- serialized = _serialize_variable_value(value, limiter, max_length)
1078
+ serialized = _serialize_variable_value(
1079
+ value, limiter, max_length, compiled_mask
1080
+ )
1036
1081
  if serialized is None:
1037
1082
  break
1038
1083
  result[name] = serialized
@@ -1042,6 +1087,17 @@ def serialize_code_variables(
1042
1087
 
1043
1088
  def try_attach_code_variables_to_frames(
1044
1089
  all_exceptions, exc_info, mask_patterns, ignore_patterns
1090
+ ):
1091
+ try:
1092
+ attach_code_variables_to_frames(
1093
+ all_exceptions, exc_info, mask_patterns, ignore_patterns
1094
+ )
1095
+ except Exception:
1096
+ pass
1097
+
1098
+
1099
+ def attach_code_variables_to_frames(
1100
+ all_exceptions, exc_info, mask_patterns, ignore_patterns
1045
1101
  ):
1046
1102
  exc_type, exc_value, traceback = exc_info
1047
1103