posthoganalytics 7.0.1__py3-none-any.whl → 7.4.1__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.
- posthoganalytics/__init__.py +10 -0
- posthoganalytics/ai/gemini/__init__.py +3 -0
- posthoganalytics/ai/gemini/gemini.py +1 -1
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +87 -21
- posthoganalytics/ai/openai/openai.py +27 -2
- posthoganalytics/ai/openai/openai_async.py +27 -2
- posthoganalytics/ai/openai/openai_converter.py +6 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/ai/utils.py +2 -2
- posthoganalytics/client.py +224 -58
- posthoganalytics/exception_utils.py +49 -4
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +207 -22
- posthoganalytics/test/test_exception_capture.py +45 -1
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +166 -73
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_request.py +536 -0
- posthoganalytics/test/test_utils.py +4 -1
- posthoganalytics/types.py +40 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/METADATA +2 -1
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/RECORD +28 -25
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-7.0.1.dist-info → posthoganalytics-7.4.1.dist-info}/top_level.txt +0 -0
posthoganalytics/client.py
CHANGED
|
@@ -2,6 +2,7 @@ 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
8
|
from typing_extensions import Unpack
|
|
@@ -28,10 +29,17 @@ from posthoganalytics.feature_flags import (
|
|
|
28
29
|
RequiresServerEvaluation,
|
|
29
30
|
match_feature_flag_properties,
|
|
30
31
|
)
|
|
32
|
+
from posthoganalytics.flag_definition_cache import (
|
|
33
|
+
FlagDefinitionCacheData,
|
|
34
|
+
FlagDefinitionCacheProvider,
|
|
35
|
+
)
|
|
31
36
|
from posthoganalytics.poller import Poller
|
|
32
37
|
from posthoganalytics.request import (
|
|
33
38
|
DEFAULT_HOST,
|
|
34
39
|
APIError,
|
|
40
|
+
QuotaLimitError,
|
|
41
|
+
RequestsConnectionError,
|
|
42
|
+
RequestsTimeout,
|
|
35
43
|
batch_post,
|
|
36
44
|
determine_server_host,
|
|
37
45
|
flags,
|
|
@@ -49,6 +57,7 @@ from posthoganalytics.contexts import (
|
|
|
49
57
|
)
|
|
50
58
|
from posthoganalytics.types import (
|
|
51
59
|
FeatureFlag,
|
|
60
|
+
FeatureFlagError,
|
|
52
61
|
FeatureFlagResult,
|
|
53
62
|
FlagMetadata,
|
|
54
63
|
FlagsAndPayloads,
|
|
@@ -184,6 +193,7 @@ class Client(object):
|
|
|
184
193
|
before_send=None,
|
|
185
194
|
flag_fallback_cache_url=None,
|
|
186
195
|
enable_local_evaluation=True,
|
|
196
|
+
flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None,
|
|
187
197
|
capture_exception_code_variables=False,
|
|
188
198
|
code_variables_mask_patterns=None,
|
|
189
199
|
code_variables_ignore_patterns=None,
|
|
@@ -222,8 +232,8 @@ class Client(object):
|
|
|
222
232
|
self.timeout = timeout
|
|
223
233
|
self._feature_flags = None # private variable to store flags
|
|
224
234
|
self.feature_flags_by_key = None
|
|
225
|
-
self.group_type_mapping = None
|
|
226
|
-
self.cohorts = None
|
|
235
|
+
self.group_type_mapping: Optional[dict[str, str]] = None
|
|
236
|
+
self.cohorts: Optional[dict[str, Any]] = None
|
|
227
237
|
self.poll_interval = poll_interval
|
|
228
238
|
self.feature_flags_request_timeout_seconds = (
|
|
229
239
|
feature_flags_request_timeout_seconds
|
|
@@ -232,6 +242,8 @@ class Client(object):
|
|
|
232
242
|
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
|
233
243
|
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
|
|
234
244
|
self.flag_definition_version = 0
|
|
245
|
+
self._flags_etag: Optional[str] = None
|
|
246
|
+
self._flag_definition_cache_provider = flag_definition_cache_provider
|
|
235
247
|
self.disabled = disabled
|
|
236
248
|
self.disable_geoip = disable_geoip
|
|
237
249
|
self.historical_migration = historical_migration
|
|
@@ -622,7 +634,28 @@ class Client(object):
|
|
|
622
634
|
if flag_options["should_send"]:
|
|
623
635
|
try:
|
|
624
636
|
if flag_options["only_evaluate_locally"] is True:
|
|
625
|
-
#
|
|
637
|
+
# Local evaluation explicitly requested
|
|
638
|
+
feature_variants = self.get_all_flags(
|
|
639
|
+
distinct_id,
|
|
640
|
+
groups=(groups or {}),
|
|
641
|
+
person_properties=flag_options["person_properties"],
|
|
642
|
+
group_properties=flag_options["group_properties"],
|
|
643
|
+
disable_geoip=disable_geoip,
|
|
644
|
+
only_evaluate_locally=True,
|
|
645
|
+
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
646
|
+
)
|
|
647
|
+
elif flag_options["only_evaluate_locally"] is False:
|
|
648
|
+
# Remote evaluation explicitly requested
|
|
649
|
+
feature_variants = self.get_feature_variants(
|
|
650
|
+
distinct_id,
|
|
651
|
+
groups,
|
|
652
|
+
person_properties=flag_options["person_properties"],
|
|
653
|
+
group_properties=flag_options["group_properties"],
|
|
654
|
+
disable_geoip=disable_geoip,
|
|
655
|
+
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
656
|
+
)
|
|
657
|
+
elif self.feature_flags:
|
|
658
|
+
# Local flags available, prefer local evaluation
|
|
626
659
|
feature_variants = self.get_all_flags(
|
|
627
660
|
distinct_id,
|
|
628
661
|
groups=(groups or {}),
|
|
@@ -633,7 +666,7 @@ class Client(object):
|
|
|
633
666
|
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
634
667
|
)
|
|
635
668
|
else:
|
|
636
|
-
#
|
|
669
|
+
# Fall back to remote evaluation
|
|
637
670
|
feature_variants = self.get_feature_variants(
|
|
638
671
|
distinct_id,
|
|
639
672
|
groups,
|
|
@@ -647,15 +680,6 @@ class Client(object):
|
|
|
647
680
|
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
|
|
648
681
|
)
|
|
649
682
|
|
|
650
|
-
elif self.feature_flags and event != "$feature_flag_called":
|
|
651
|
-
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
|
652
|
-
feature_variants = self.get_all_flags(
|
|
653
|
-
distinct_id,
|
|
654
|
-
groups=(groups or {}),
|
|
655
|
-
disable_geoip=disable_geoip,
|
|
656
|
-
only_evaluate_locally=True,
|
|
657
|
-
)
|
|
658
|
-
|
|
659
683
|
for feature, variant in (feature_variants or {}).items():
|
|
660
684
|
extra_properties[f"$feature/{feature}"] = variant
|
|
661
685
|
|
|
@@ -979,10 +1003,6 @@ class Client(object):
|
|
|
979
1003
|
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
|
980
1004
|
|
|
981
1005
|
properties = {
|
|
982
|
-
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
|
|
983
|
-
"$exception_message": all_exceptions_with_trace_and_in_app[0].get(
|
|
984
|
-
"value"
|
|
985
|
-
),
|
|
986
1006
|
"$exception_list": all_exceptions_with_trace_and_in_app,
|
|
987
1007
|
**properties,
|
|
988
1008
|
}
|
|
@@ -1147,17 +1167,25 @@ class Client(object):
|
|
|
1147
1167
|
posthog.join()
|
|
1148
1168
|
```
|
|
1149
1169
|
"""
|
|
1150
|
-
|
|
1151
|
-
consumer.
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1170
|
+
if self.consumers:
|
|
1171
|
+
for consumer in self.consumers:
|
|
1172
|
+
consumer.pause()
|
|
1173
|
+
try:
|
|
1174
|
+
consumer.join()
|
|
1175
|
+
except RuntimeError:
|
|
1176
|
+
# consumer thread has not started
|
|
1177
|
+
pass
|
|
1157
1178
|
|
|
1158
1179
|
if self.poller:
|
|
1159
1180
|
self.poller.stop()
|
|
1160
1181
|
|
|
1182
|
+
# Shutdown the cache provider (release locks, cleanup)
|
|
1183
|
+
if self._flag_definition_cache_provider:
|
|
1184
|
+
try:
|
|
1185
|
+
self._flag_definition_cache_provider.shutdown()
|
|
1186
|
+
except Exception as e:
|
|
1187
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
|
|
1188
|
+
|
|
1161
1189
|
def shutdown(self):
|
|
1162
1190
|
"""
|
|
1163
1191
|
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
|
|
@@ -1173,7 +1201,71 @@ class Client(object):
|
|
|
1173
1201
|
if self.exception_capture:
|
|
1174
1202
|
self.exception_capture.close()
|
|
1175
1203
|
|
|
1204
|
+
def _update_flag_state(
|
|
1205
|
+
self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
|
|
1206
|
+
) -> None:
|
|
1207
|
+
"""Update internal flag state from cache data and invalidate evaluation cache if changed."""
|
|
1208
|
+
self.feature_flags = data["flags"]
|
|
1209
|
+
self.group_type_mapping = data["group_type_mapping"]
|
|
1210
|
+
self.cohorts = data["cohorts"]
|
|
1211
|
+
|
|
1212
|
+
# Invalidate evaluation cache if flag definitions changed
|
|
1213
|
+
if (
|
|
1214
|
+
self.flag_cache
|
|
1215
|
+
and old_flags_by_key is not None
|
|
1216
|
+
and old_flags_by_key != (self.feature_flags_by_key or {})
|
|
1217
|
+
):
|
|
1218
|
+
old_version = self.flag_definition_version
|
|
1219
|
+
self.flag_definition_version += 1
|
|
1220
|
+
self.flag_cache.invalidate_version(old_version)
|
|
1221
|
+
|
|
1176
1222
|
def _load_feature_flags(self):
|
|
1223
|
+
should_fetch = True
|
|
1224
|
+
if self._flag_definition_cache_provider:
|
|
1225
|
+
try:
|
|
1226
|
+
should_fetch = (
|
|
1227
|
+
self._flag_definition_cache_provider.should_fetch_flag_definitions()
|
|
1228
|
+
)
|
|
1229
|
+
except Exception as e:
|
|
1230
|
+
self.log.error(
|
|
1231
|
+
f"[FEATURE FLAGS] Cache provider should_fetch error: {e}"
|
|
1232
|
+
)
|
|
1233
|
+
# Fail-safe: fetch from API if cache provider errors
|
|
1234
|
+
should_fetch = True
|
|
1235
|
+
|
|
1236
|
+
# If not fetching, try to get from cache
|
|
1237
|
+
if not should_fetch and self._flag_definition_cache_provider:
|
|
1238
|
+
try:
|
|
1239
|
+
cached_data = (
|
|
1240
|
+
self._flag_definition_cache_provider.get_flag_definitions()
|
|
1241
|
+
)
|
|
1242
|
+
if cached_data:
|
|
1243
|
+
self.log.debug(
|
|
1244
|
+
"[FEATURE FLAGS] Using cached flag definitions from external cache"
|
|
1245
|
+
)
|
|
1246
|
+
self._update_flag_state(
|
|
1247
|
+
cached_data, old_flags_by_key=self.feature_flags_by_key or {}
|
|
1248
|
+
)
|
|
1249
|
+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
|
1250
|
+
return
|
|
1251
|
+
else:
|
|
1252
|
+
# Emergency fallback: if cache is empty and we have no flags, fetch anyway.
|
|
1253
|
+
# There's really no other way of recovering in this case.
|
|
1254
|
+
if not self.feature_flags:
|
|
1255
|
+
self.log.debug(
|
|
1256
|
+
"[FEATURE FLAGS] Cache empty and no flags loaded, falling back to API fetch"
|
|
1257
|
+
)
|
|
1258
|
+
should_fetch = True
|
|
1259
|
+
except Exception as e:
|
|
1260
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider get error: {e}")
|
|
1261
|
+
# Fail-safe: fetch from API if cache provider errors
|
|
1262
|
+
should_fetch = True
|
|
1263
|
+
|
|
1264
|
+
if should_fetch:
|
|
1265
|
+
self._fetch_feature_flags_from_api()
|
|
1266
|
+
|
|
1267
|
+
def _fetch_feature_flags_from_api(self):
|
|
1268
|
+
"""Fetch feature flags from the PostHog API."""
|
|
1177
1269
|
try:
|
|
1178
1270
|
# Store old flags to detect changes
|
|
1179
1271
|
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
|
|
@@ -1183,19 +1275,41 @@ class Client(object):
|
|
|
1183
1275
|
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
|
1184
1276
|
self.host,
|
|
1185
1277
|
timeout=10,
|
|
1278
|
+
etag=self._flags_etag,
|
|
1186
1279
|
)
|
|
1187
1280
|
|
|
1188
|
-
|
|
1189
|
-
self.
|
|
1190
|
-
self.cohorts = response["cohorts"] or {}
|
|
1281
|
+
# Update stored ETag (clear if server stops sending one)
|
|
1282
|
+
self._flags_etag = response.etag
|
|
1191
1283
|
|
|
1192
|
-
#
|
|
1193
|
-
if
|
|
1194
|
-
self.
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
self.
|
|
1198
|
-
|
|
1284
|
+
# If 304 Not Modified, flags haven't changed - skip processing
|
|
1285
|
+
if response.not_modified:
|
|
1286
|
+
self.log.debug(
|
|
1287
|
+
"[FEATURE FLAGS] Flags not modified (304), using cached data"
|
|
1288
|
+
)
|
|
1289
|
+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
|
1290
|
+
return
|
|
1291
|
+
|
|
1292
|
+
if response.data is None:
|
|
1293
|
+
self.log.error(
|
|
1294
|
+
"[FEATURE FLAGS] Unexpected empty response data in non-304 response"
|
|
1295
|
+
)
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
self._update_flag_state(response.data, old_flags_by_key=old_flags_by_key)
|
|
1299
|
+
|
|
1300
|
+
# Store in external cache if provider is configured
|
|
1301
|
+
if self._flag_definition_cache_provider:
|
|
1302
|
+
try:
|
|
1303
|
+
self._flag_definition_cache_provider.on_flag_definitions_received(
|
|
1304
|
+
{
|
|
1305
|
+
"flags": self.feature_flags or [],
|
|
1306
|
+
"group_type_mapping": self.group_type_mapping or {},
|
|
1307
|
+
"cohorts": self.cohorts or {},
|
|
1308
|
+
}
|
|
1309
|
+
)
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")
|
|
1312
|
+
# Flags are already in memory, so continue normally
|
|
1199
1313
|
|
|
1200
1314
|
except APIError as e:
|
|
1201
1315
|
if e.status == 401:
|
|
@@ -1295,7 +1409,8 @@ class Client(object):
|
|
|
1295
1409
|
flag_filters = feature_flag.get("filters") or {}
|
|
1296
1410
|
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
|
1297
1411
|
if aggregation_group_type_index is not None:
|
|
1298
|
-
|
|
1412
|
+
group_type_mapping = self.group_type_mapping or {}
|
|
1413
|
+
group_name = group_type_mapping.get(str(aggregation_group_type_index))
|
|
1299
1414
|
|
|
1300
1415
|
if not group_name:
|
|
1301
1416
|
self.log.warning(
|
|
@@ -1387,6 +1502,19 @@ class Client(object):
|
|
|
1387
1502
|
return None
|
|
1388
1503
|
return bool(response)
|
|
1389
1504
|
|
|
1505
|
+
def _get_stale_flag_fallback(
|
|
1506
|
+
self, distinct_id: ID_TYPES, key: str
|
|
1507
|
+
) -> Optional[FeatureFlagResult]:
|
|
1508
|
+
"""Returns a stale cached flag value if available, otherwise None."""
|
|
1509
|
+
if self.flag_cache:
|
|
1510
|
+
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
|
|
1511
|
+
if stale_result:
|
|
1512
|
+
self.log.info(
|
|
1513
|
+
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
|
1514
|
+
)
|
|
1515
|
+
return stale_result
|
|
1516
|
+
return None
|
|
1517
|
+
|
|
1390
1518
|
def _get_feature_flag_result(
|
|
1391
1519
|
self,
|
|
1392
1520
|
key: str,
|
|
@@ -1419,6 +1547,8 @@ class Client(object):
|
|
|
1419
1547
|
flag_result = None
|
|
1420
1548
|
flag_details = None
|
|
1421
1549
|
request_id = None
|
|
1550
|
+
evaluated_at = None
|
|
1551
|
+
feature_flag_error: Optional[str] = None
|
|
1422
1552
|
|
|
1423
1553
|
flag_value = self._locally_evaluate_flag(
|
|
1424
1554
|
key, distinct_id, groups, person_properties, group_properties
|
|
@@ -1443,14 +1573,24 @@ class Client(object):
|
|
|
1443
1573
|
)
|
|
1444
1574
|
elif not only_evaluate_locally:
|
|
1445
1575
|
try:
|
|
1446
|
-
flag_details, request_id =
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1576
|
+
flag_details, request_id, evaluated_at, errors_while_computing = (
|
|
1577
|
+
self._get_feature_flag_details_from_server(
|
|
1578
|
+
key,
|
|
1579
|
+
distinct_id,
|
|
1580
|
+
groups,
|
|
1581
|
+
person_properties,
|
|
1582
|
+
group_properties,
|
|
1583
|
+
disable_geoip,
|
|
1584
|
+
)
|
|
1453
1585
|
)
|
|
1586
|
+
errors = []
|
|
1587
|
+
if errors_while_computing:
|
|
1588
|
+
errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
|
|
1589
|
+
if flag_details is None:
|
|
1590
|
+
errors.append(FeatureFlagError.FLAG_MISSING)
|
|
1591
|
+
if errors:
|
|
1592
|
+
feature_flag_error = ",".join(errors)
|
|
1593
|
+
|
|
1454
1594
|
flag_result = FeatureFlagResult.from_flag_details(
|
|
1455
1595
|
flag_details, override_match_value
|
|
1456
1596
|
)
|
|
@@ -1464,19 +1604,26 @@ class Client(object):
|
|
|
1464
1604
|
self.log.debug(
|
|
1465
1605
|
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
|
|
1466
1606
|
)
|
|
1607
|
+
except QuotaLimitError as e:
|
|
1608
|
+
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
|
|
1609
|
+
feature_flag_error = FeatureFlagError.QUOTA_LIMITED
|
|
1610
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1611
|
+
except RequestsTimeout as e:
|
|
1612
|
+
self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
|
|
1613
|
+
feature_flag_error = FeatureFlagError.TIMEOUT
|
|
1614
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1615
|
+
except RequestsConnectionError as e:
|
|
1616
|
+
self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
|
|
1617
|
+
feature_flag_error = FeatureFlagError.CONNECTION_ERROR
|
|
1618
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1619
|
+
except APIError as e:
|
|
1620
|
+
self.log.warning(f"[FEATURE FLAGS] API error: {e}")
|
|
1621
|
+
feature_flag_error = FeatureFlagError.api_error(e.status)
|
|
1622
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1467
1623
|
except Exception as e:
|
|
1468
1624
|
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
if self.flag_cache:
|
|
1472
|
-
stale_result = self.flag_cache.get_stale_cached_flag(
|
|
1473
|
-
distinct_id, key
|
|
1474
|
-
)
|
|
1475
|
-
if stale_result:
|
|
1476
|
-
self.log.info(
|
|
1477
|
-
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
|
1478
|
-
)
|
|
1479
|
-
flag_result = stale_result
|
|
1625
|
+
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
|
|
1626
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1480
1627
|
|
|
1481
1628
|
if send_feature_flag_events:
|
|
1482
1629
|
self._capture_feature_flag_called(
|
|
@@ -1488,7 +1635,9 @@ class Client(object):
|
|
|
1488
1635
|
groups,
|
|
1489
1636
|
disable_geoip,
|
|
1490
1637
|
request_id,
|
|
1638
|
+
evaluated_at,
|
|
1491
1639
|
flag_details,
|
|
1640
|
+
feature_flag_error,
|
|
1492
1641
|
)
|
|
1493
1642
|
|
|
1494
1643
|
return flag_result
|
|
@@ -1640,7 +1789,7 @@ class Client(object):
|
|
|
1640
1789
|
person_properties=None,
|
|
1641
1790
|
group_properties=None,
|
|
1642
1791
|
only_evaluate_locally=False,
|
|
1643
|
-
send_feature_flag_events=
|
|
1792
|
+
send_feature_flag_events=False,
|
|
1644
1793
|
disable_geoip=None,
|
|
1645
1794
|
):
|
|
1646
1795
|
"""
|
|
@@ -1654,7 +1803,7 @@ class Client(object):
|
|
|
1654
1803
|
person_properties: A dictionary of person properties.
|
|
1655
1804
|
group_properties: A dictionary of group properties.
|
|
1656
1805
|
only_evaluate_locally: Whether to only evaluate locally.
|
|
1657
|
-
send_feature_flag_events:
|
|
1806
|
+
send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
|
|
1658
1807
|
disable_geoip: Whether to disable GeoIP for this request.
|
|
1659
1808
|
|
|
1660
1809
|
Examples:
|
|
@@ -1670,6 +1819,14 @@ class Client(object):
|
|
|
1670
1819
|
Category:
|
|
1671
1820
|
Feature flags
|
|
1672
1821
|
"""
|
|
1822
|
+
if send_feature_flag_events:
|
|
1823
|
+
warnings.warn(
|
|
1824
|
+
"send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
|
|
1825
|
+
"in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
|
|
1826
|
+
DeprecationWarning,
|
|
1827
|
+
stacklevel=2,
|
|
1828
|
+
)
|
|
1829
|
+
|
|
1673
1830
|
feature_flag_result = self._get_feature_flag_result(
|
|
1674
1831
|
key,
|
|
1675
1832
|
distinct_id,
|
|
@@ -1691,9 +1848,10 @@ class Client(object):
|
|
|
1691
1848
|
person_properties: dict[str, str],
|
|
1692
1849
|
group_properties: dict[str, str],
|
|
1693
1850
|
disable_geoip: Optional[bool],
|
|
1694
|
-
) -> tuple[Optional[FeatureFlag], Optional[str]]:
|
|
1851
|
+
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
|
|
1695
1852
|
"""
|
|
1696
|
-
Calls /flags and returns the flag details
|
|
1853
|
+
Calls /flags and returns the flag details, request id, evaluated at timestamp,
|
|
1854
|
+
and whether there were errors while computing flags.
|
|
1697
1855
|
"""
|
|
1698
1856
|
resp_data = self.get_flags_decision(
|
|
1699
1857
|
distinct_id,
|
|
@@ -1704,9 +1862,11 @@ class Client(object):
|
|
|
1704
1862
|
flag_keys_to_evaluate=[key],
|
|
1705
1863
|
)
|
|
1706
1864
|
request_id = resp_data.get("requestId")
|
|
1865
|
+
evaluated_at = resp_data.get("evaluatedAt")
|
|
1866
|
+
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
|
|
1707
1867
|
flags = resp_data.get("flags")
|
|
1708
1868
|
flag_details = flags.get(key) if flags else None
|
|
1709
|
-
return flag_details, request_id
|
|
1869
|
+
return flag_details, request_id, evaluated_at, errors_while_computing
|
|
1710
1870
|
|
|
1711
1871
|
def _capture_feature_flag_called(
|
|
1712
1872
|
self,
|
|
@@ -1718,7 +1878,9 @@ class Client(object):
|
|
|
1718
1878
|
groups: Dict[str, str],
|
|
1719
1879
|
disable_geoip: Optional[bool],
|
|
1720
1880
|
request_id: Optional[str],
|
|
1881
|
+
evaluated_at: Optional[int],
|
|
1721
1882
|
flag_details: Optional[FeatureFlag],
|
|
1883
|
+
feature_flag_error: Optional[str] = None,
|
|
1722
1884
|
):
|
|
1723
1885
|
feature_flag_reported_key = (
|
|
1724
1886
|
f"{key}_{'::null::' if response is None else str(response)}"
|
|
@@ -1741,6 +1903,8 @@ class Client(object):
|
|
|
1741
1903
|
|
|
1742
1904
|
if request_id:
|
|
1743
1905
|
properties["$feature_flag_request_id"] = request_id
|
|
1906
|
+
if evaluated_at:
|
|
1907
|
+
properties["$feature_flag_evaluated_at"] = evaluated_at
|
|
1744
1908
|
if isinstance(flag_details, FeatureFlag):
|
|
1745
1909
|
if flag_details.reason and flag_details.reason.description:
|
|
1746
1910
|
properties["$feature_flag_reason"] = flag_details.reason.description
|
|
@@ -1751,6 +1915,8 @@ class Client(object):
|
|
|
1751
1915
|
)
|
|
1752
1916
|
if flag_details.metadata.id:
|
|
1753
1917
|
properties["$feature_flag_id"] = flag_details.metadata.id
|
|
1918
|
+
if feature_flag_error:
|
|
1919
|
+
properties["$feature_flag_error"] = feature_flag_error
|
|
1754
1920
|
|
|
1755
1921
|
self.capture(
|
|
1756
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
|
|
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
|
-
|
|
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
|
-
|
|
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] + "..."
|
|
@@ -1043,7 +1075,9 @@ def serialize_code_variables(
|
|
|
1043
1075
|
limiter.add(redacted_size)
|
|
1044
1076
|
result[name] = redacted_value
|
|
1045
1077
|
else:
|
|
1046
|
-
serialized = _serialize_variable_value(
|
|
1078
|
+
serialized = _serialize_variable_value(
|
|
1079
|
+
value, limiter, max_length, compiled_mask
|
|
1080
|
+
)
|
|
1047
1081
|
if serialized is None:
|
|
1048
1082
|
break
|
|
1049
1083
|
result[name] = serialized
|
|
@@ -1053,6 +1087,17 @@ def serialize_code_variables(
|
|
|
1053
1087
|
|
|
1054
1088
|
def try_attach_code_variables_to_frames(
|
|
1055
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
|
|
1056
1101
|
):
|
|
1057
1102
|
exc_type, exc_value, traceback = exc_info
|
|
1058
1103
|
|