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.
- 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_converter.py +6 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/client.py +216 -49
- posthoganalytics/exception_utils.py +71 -15
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +121 -21
- posthoganalytics/test/test_exception_capture.py +120 -2
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +157 -18
- 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.0.dist-info → posthoganalytics-7.4.0.dist-info}/METADATA +2 -1
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/RECORD +25 -22
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
208
|
+
if _is_multimodal_enabled():
|
|
209
|
+
return item
|
|
210
|
+
|
|
189
211
|
return {
|
|
190
212
|
**item,
|
|
191
213
|
"source": {
|
posthoganalytics/client.py
CHANGED
|
@@ -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
|
-
|
|
299
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
1150
|
-
consumer.
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1188
|
-
self.
|
|
1189
|
-
|
|
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
|
-
#
|
|
1192
|
-
if self.
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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 =
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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] + "..."
|
|
@@ -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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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(
|
|
977
|
-
return
|
|
1011
|
+
limiter.add(result_size)
|
|
1012
|
+
return result
|
|
978
1013
|
except Exception:
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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(
|
|
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
|
|