posthog 7.0.1__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.
- posthog/__init__.py +10 -0
- posthog/ai/gemini/__init__.py +3 -0
- posthog/ai/gemini/gemini.py +1 -1
- posthog/ai/gemini/gemini_async.py +423 -0
- posthog/ai/gemini/gemini_converter.py +87 -21
- posthog/ai/openai/openai_converter.py +6 -0
- posthog/ai/sanitization.py +27 -5
- posthog/client.py +213 -47
- posthog/exception_utils.py +49 -4
- posthog/flag_definition_cache.py +127 -0
- posthog/request.py +203 -23
- posthog/test/test_client.py +121 -21
- posthog/test/test_exception_capture.py +45 -1
- posthog/test/test_feature_flag_result.py +441 -2
- posthog/test/test_feature_flags.py +157 -18
- posthog/test/test_flag_definition_cache.py +612 -0
- posthog/test/test_request.py +536 -0
- posthog/test/test_utils.py +4 -1
- posthog/types.py +40 -0
- posthog/version.py +1 -1
- {posthog-7.0.1.dist-info → posthog-7.4.0.dist-info}/METADATA +2 -1
- {posthog-7.0.1.dist-info → posthog-7.4.0.dist-info}/RECORD +25 -22
- {posthog-7.0.1.dist-info → posthog-7.4.0.dist-info}/WHEEL +0 -0
- {posthog-7.0.1.dist-info → posthog-7.4.0.dist-info}/licenses/LICENSE +0 -0
- {posthog-7.0.1.dist-info → posthog-7.4.0.dist-info}/top_level.txt +0 -0
posthog/ai/sanitization.py
CHANGED
|
@@ -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": {
|
posthog/client.py
CHANGED
|
@@ -28,10 +28,17 @@ from posthog.feature_flags import (
|
|
|
28
28
|
RequiresServerEvaluation,
|
|
29
29
|
match_feature_flag_properties,
|
|
30
30
|
)
|
|
31
|
+
from posthog.flag_definition_cache import (
|
|
32
|
+
FlagDefinitionCacheData,
|
|
33
|
+
FlagDefinitionCacheProvider,
|
|
34
|
+
)
|
|
31
35
|
from posthog.poller import Poller
|
|
32
36
|
from posthog.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 posthog.contexts import (
|
|
|
49
56
|
)
|
|
50
57
|
from posthog.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
|
|
@@ -622,7 +633,28 @@ class Client(object):
|
|
|
622
633
|
if flag_options["should_send"]:
|
|
623
634
|
try:
|
|
624
635
|
if flag_options["only_evaluate_locally"] is True:
|
|
625
|
-
#
|
|
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
|
|
626
658
|
feature_variants = self.get_all_flags(
|
|
627
659
|
distinct_id,
|
|
628
660
|
groups=(groups or {}),
|
|
@@ -633,7 +665,7 @@ class Client(object):
|
|
|
633
665
|
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
634
666
|
)
|
|
635
667
|
else:
|
|
636
|
-
#
|
|
668
|
+
# Fall back to remote evaluation
|
|
637
669
|
feature_variants = self.get_feature_variants(
|
|
638
670
|
distinct_id,
|
|
639
671
|
groups,
|
|
@@ -979,10 +1011,6 @@ class Client(object):
|
|
|
979
1011
|
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
|
980
1012
|
|
|
981
1013
|
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
1014
|
"$exception_list": all_exceptions_with_trace_and_in_app,
|
|
987
1015
|
**properties,
|
|
988
1016
|
}
|
|
@@ -1147,17 +1175,25 @@ class Client(object):
|
|
|
1147
1175
|
posthog.join()
|
|
1148
1176
|
```
|
|
1149
1177
|
"""
|
|
1150
|
-
|
|
1151
|
-
consumer.
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
|
1157
1186
|
|
|
1158
1187
|
if self.poller:
|
|
1159
1188
|
self.poller.stop()
|
|
1160
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
|
+
|
|
1161
1197
|
def shutdown(self):
|
|
1162
1198
|
"""
|
|
1163
1199
|
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
|
|
@@ -1173,7 +1209,71 @@ class Client(object):
|
|
|
1173
1209
|
if self.exception_capture:
|
|
1174
1210
|
self.exception_capture.close()
|
|
1175
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
|
+
|
|
1176
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."""
|
|
1177
1277
|
try:
|
|
1178
1278
|
# Store old flags to detect changes
|
|
1179
1279
|
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
|
|
@@ -1183,19 +1283,41 @@ class Client(object):
|
|
|
1183
1283
|
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
|
1184
1284
|
self.host,
|
|
1185
1285
|
timeout=10,
|
|
1286
|
+
etag=self._flags_etag,
|
|
1186
1287
|
)
|
|
1187
1288
|
|
|
1188
|
-
|
|
1189
|
-
self.
|
|
1190
|
-
|
|
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)
|
|
1191
1307
|
|
|
1192
|
-
#
|
|
1193
|
-
if self.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
|
1199
1321
|
|
|
1200
1322
|
except APIError as e:
|
|
1201
1323
|
if e.status == 401:
|
|
@@ -1295,7 +1417,8 @@ class Client(object):
|
|
|
1295
1417
|
flag_filters = feature_flag.get("filters") or {}
|
|
1296
1418
|
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
|
1297
1419
|
if aggregation_group_type_index is not None:
|
|
1298
|
-
|
|
1420
|
+
group_type_mapping = self.group_type_mapping or {}
|
|
1421
|
+
group_name = group_type_mapping.get(str(aggregation_group_type_index))
|
|
1299
1422
|
|
|
1300
1423
|
if not group_name:
|
|
1301
1424
|
self.log.warning(
|
|
@@ -1387,6 +1510,19 @@ class Client(object):
|
|
|
1387
1510
|
return None
|
|
1388
1511
|
return bool(response)
|
|
1389
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
|
+
|
|
1390
1526
|
def _get_feature_flag_result(
|
|
1391
1527
|
self,
|
|
1392
1528
|
key: str,
|
|
@@ -1419,6 +1555,8 @@ class Client(object):
|
|
|
1419
1555
|
flag_result = None
|
|
1420
1556
|
flag_details = None
|
|
1421
1557
|
request_id = None
|
|
1558
|
+
evaluated_at = None
|
|
1559
|
+
feature_flag_error: Optional[str] = None
|
|
1422
1560
|
|
|
1423
1561
|
flag_value = self._locally_evaluate_flag(
|
|
1424
1562
|
key, distinct_id, groups, person_properties, group_properties
|
|
@@ -1443,14 +1581,24 @@ class Client(object):
|
|
|
1443
1581
|
)
|
|
1444
1582
|
elif not only_evaluate_locally:
|
|
1445
1583
|
try:
|
|
1446
|
-
flag_details, request_id =
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
+
)
|
|
1453
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
|
+
|
|
1454
1602
|
flag_result = FeatureFlagResult.from_flag_details(
|
|
1455
1603
|
flag_details, override_match_value
|
|
1456
1604
|
)
|
|
@@ -1464,19 +1612,26 @@ class Client(object):
|
|
|
1464
1612
|
self.log.debug(
|
|
1465
1613
|
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
|
|
1466
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)
|
|
1467
1631
|
except Exception as e:
|
|
1468
1632
|
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
|
|
1633
|
+
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
|
|
1634
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1480
1635
|
|
|
1481
1636
|
if send_feature_flag_events:
|
|
1482
1637
|
self._capture_feature_flag_called(
|
|
@@ -1488,7 +1643,9 @@ class Client(object):
|
|
|
1488
1643
|
groups,
|
|
1489
1644
|
disable_geoip,
|
|
1490
1645
|
request_id,
|
|
1646
|
+
evaluated_at,
|
|
1491
1647
|
flag_details,
|
|
1648
|
+
feature_flag_error,
|
|
1492
1649
|
)
|
|
1493
1650
|
|
|
1494
1651
|
return flag_result
|
|
@@ -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",
|
posthog/exception_utils.py
CHANGED
|
@@ -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
|
|