posthoganalytics 6.0.1__py3-none-any.whl → 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthoganalytics/args.py +1 -1
- posthoganalytics/client.py +162 -2
- posthoganalytics/test/test_utils.py +123 -0
- posthoganalytics/utils.py +262 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-6.0.1.dist-info → posthoganalytics-6.0.3.dist-info}/METADATA +2 -2
- {posthoganalytics-6.0.1.dist-info → posthoganalytics-6.0.3.dist-info}/RECORD +10 -10
- {posthoganalytics-6.0.1.dist-info → posthoganalytics-6.0.3.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.0.1.dist-info → posthoganalytics-6.0.3.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.0.1.dist-info → posthoganalytics-6.0.3.dist-info}/top_level.txt +0 -0
posthoganalytics/args.py
CHANGED
|
@@ -22,7 +22,7 @@ class OptionalCaptureArgs(TypedDict):
|
|
|
22
22
|
error ID if you capture an exception).
|
|
23
23
|
groups: Group identifiers to associate with this event (format: {group_type: group_key})
|
|
24
24
|
send_feature_flags: Whether to include currently active feature flags in the event properties.
|
|
25
|
-
Defaults to
|
|
25
|
+
Defaults to False
|
|
26
26
|
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
|
|
27
27
|
"""
|
|
28
28
|
|
posthoganalytics/client.py
CHANGED
|
@@ -50,6 +50,8 @@ from posthoganalytics.types import (
|
|
|
50
50
|
to_values,
|
|
51
51
|
)
|
|
52
52
|
from posthoganalytics.utils import (
|
|
53
|
+
FlagCache,
|
|
54
|
+
RedisFlagCache,
|
|
53
55
|
SizeLimitedDict,
|
|
54
56
|
clean,
|
|
55
57
|
guess_timezone,
|
|
@@ -95,7 +97,30 @@ def add_context_tags(properties):
|
|
|
95
97
|
|
|
96
98
|
|
|
97
99
|
class Client(object):
|
|
98
|
-
"""Create a new PostHog client.
|
|
100
|
+
"""Create a new PostHog client.
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
Basic usage:
|
|
104
|
+
>>> client = Client("your-api-key")
|
|
105
|
+
|
|
106
|
+
With memory-based feature flag fallback cache:
|
|
107
|
+
>>> client = Client(
|
|
108
|
+
... "your-api-key",
|
|
109
|
+
... flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
|
|
110
|
+
... )
|
|
111
|
+
|
|
112
|
+
With Redis fallback cache for high-scale applications:
|
|
113
|
+
>>> client = Client(
|
|
114
|
+
... "your-api-key",
|
|
115
|
+
... flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
|
|
116
|
+
... )
|
|
117
|
+
|
|
118
|
+
With Redis authentication:
|
|
119
|
+
>>> client = Client(
|
|
120
|
+
... "your-api-key",
|
|
121
|
+
... flag_fallback_cache_url="redis://username:password@localhost:6379/0/?ttl=300"
|
|
122
|
+
... )
|
|
123
|
+
"""
|
|
99
124
|
|
|
100
125
|
log = logging.getLogger("posthog")
|
|
101
126
|
|
|
@@ -126,6 +151,7 @@ class Client(object):
|
|
|
126
151
|
project_root=None,
|
|
127
152
|
privacy_mode=False,
|
|
128
153
|
before_send=None,
|
|
154
|
+
flag_fallback_cache_url=None,
|
|
129
155
|
):
|
|
130
156
|
self.queue = queue.Queue(max_queue_size)
|
|
131
157
|
|
|
@@ -151,6 +177,8 @@ class Client(object):
|
|
|
151
177
|
)
|
|
152
178
|
self.poller = None
|
|
153
179
|
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
|
180
|
+
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
|
|
181
|
+
self.flag_definition_version = 0
|
|
154
182
|
self.disabled = disabled
|
|
155
183
|
self.disable_geoip = disable_geoip
|
|
156
184
|
self.historical_migration = historical_migration
|
|
@@ -522,7 +550,7 @@ class Client(object):
|
|
|
522
550
|
):
|
|
523
551
|
distinct_id = kwargs.get("distinct_id", None)
|
|
524
552
|
properties = kwargs.get("properties", None)
|
|
525
|
-
send_feature_flags = kwargs.get("send_feature_flags",
|
|
553
|
+
send_feature_flags = kwargs.get("send_feature_flags", False)
|
|
526
554
|
disable_geoip = kwargs.get("disable_geoip", None)
|
|
527
555
|
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
|
|
528
556
|
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
|
|
@@ -707,6 +735,9 @@ class Client(object):
|
|
|
707
735
|
|
|
708
736
|
def _load_feature_flags(self):
|
|
709
737
|
try:
|
|
738
|
+
# Store old flags to detect changes
|
|
739
|
+
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
|
|
740
|
+
|
|
710
741
|
response = get(
|
|
711
742
|
self.personal_api_key,
|
|
712
743
|
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
|
@@ -718,6 +749,14 @@ class Client(object):
|
|
|
718
749
|
self.group_type_mapping = response["group_type_mapping"] or {}
|
|
719
750
|
self.cohorts = response["cohorts"] or {}
|
|
720
751
|
|
|
752
|
+
# Check if flag definitions changed and update version
|
|
753
|
+
if self.flag_cache and old_flags_by_key != (
|
|
754
|
+
self.feature_flags_by_key or {}
|
|
755
|
+
):
|
|
756
|
+
old_version = self.flag_definition_version
|
|
757
|
+
self.flag_definition_version += 1
|
|
758
|
+
self.flag_cache.invalidate_version(old_version)
|
|
759
|
+
|
|
721
760
|
except APIError as e:
|
|
722
761
|
if e.status == 401:
|
|
723
762
|
self.log.error(
|
|
@@ -739,6 +778,10 @@ class Client(object):
|
|
|
739
778
|
self.group_type_mapping = {}
|
|
740
779
|
self.cohorts = {}
|
|
741
780
|
|
|
781
|
+
# Clear flag cache when quota limited
|
|
782
|
+
if self.flag_cache:
|
|
783
|
+
self.flag_cache.clear()
|
|
784
|
+
|
|
742
785
|
if self.debug:
|
|
743
786
|
raise APIError(
|
|
744
787
|
status=402,
|
|
@@ -889,6 +932,12 @@ class Client(object):
|
|
|
889
932
|
flag_result = FeatureFlagResult.from_value_and_payload(
|
|
890
933
|
key, lookup_match_value, payload
|
|
891
934
|
)
|
|
935
|
+
|
|
936
|
+
# Cache successful local evaluation
|
|
937
|
+
if self.flag_cache and flag_result:
|
|
938
|
+
self.flag_cache.set_cached_flag(
|
|
939
|
+
distinct_id, key, flag_result, self.flag_definition_version
|
|
940
|
+
)
|
|
892
941
|
elif not only_evaluate_locally:
|
|
893
942
|
try:
|
|
894
943
|
flag_details, request_id = self._get_feature_flag_details_from_decide(
|
|
@@ -902,12 +951,30 @@ class Client(object):
|
|
|
902
951
|
flag_result = FeatureFlagResult.from_flag_details(
|
|
903
952
|
flag_details, override_match_value
|
|
904
953
|
)
|
|
954
|
+
|
|
955
|
+
# Cache successful remote evaluation
|
|
956
|
+
if self.flag_cache and flag_result:
|
|
957
|
+
self.flag_cache.set_cached_flag(
|
|
958
|
+
distinct_id, key, flag_result, self.flag_definition_version
|
|
959
|
+
)
|
|
960
|
+
|
|
905
961
|
self.log.debug(
|
|
906
962
|
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
|
|
907
963
|
)
|
|
908
964
|
except Exception as e:
|
|
909
965
|
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
|
910
966
|
|
|
967
|
+
# Fallback to cached value if remote evaluation fails
|
|
968
|
+
if self.flag_cache:
|
|
969
|
+
stale_result = self.flag_cache.get_stale_cached_flag(
|
|
970
|
+
distinct_id, key
|
|
971
|
+
)
|
|
972
|
+
if stale_result:
|
|
973
|
+
self.log.info(
|
|
974
|
+
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
|
975
|
+
)
|
|
976
|
+
flag_result = stale_result
|
|
977
|
+
|
|
911
978
|
if send_feature_flag_events:
|
|
912
979
|
self._capture_feature_flag_called(
|
|
913
980
|
distinct_id,
|
|
@@ -1278,6 +1345,99 @@ class Client(object):
|
|
|
1278
1345
|
"featureFlagPayloads": payloads,
|
|
1279
1346
|
}, fallback_to_decide
|
|
1280
1347
|
|
|
1348
|
+
def _initialize_flag_cache(self, cache_url):
|
|
1349
|
+
"""Initialize feature flag cache for graceful degradation during service outages.
|
|
1350
|
+
|
|
1351
|
+
When enabled, the cache stores flag evaluation results and serves them as fallback
|
|
1352
|
+
when the PostHog API is unavailable. This ensures your application continues to
|
|
1353
|
+
receive flag values even during outages.
|
|
1354
|
+
|
|
1355
|
+
Args:
|
|
1356
|
+
cache_url: Cache configuration URL. Examples:
|
|
1357
|
+
- None: Disable caching
|
|
1358
|
+
- "memory://local/?ttl=300&size=10000": Memory cache with TTL and size
|
|
1359
|
+
- "redis://localhost:6379/0/?ttl=300": Redis cache with TTL
|
|
1360
|
+
- "redis://username:password@host:port/?ttl=300": Redis with auth
|
|
1361
|
+
|
|
1362
|
+
Example usage:
|
|
1363
|
+
# Memory cache
|
|
1364
|
+
client = Client(
|
|
1365
|
+
"your-api-key",
|
|
1366
|
+
flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
# Redis cache
|
|
1370
|
+
client = Client(
|
|
1371
|
+
"your-api-key",
|
|
1372
|
+
flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# Normal evaluation - cache is populated
|
|
1376
|
+
flag_value = client.get_feature_flag("my-flag", "user123")
|
|
1377
|
+
|
|
1378
|
+
# During API outage - returns cached value instead of None
|
|
1379
|
+
flag_value = client.get_feature_flag("my-flag", "user123") # Uses cache
|
|
1380
|
+
"""
|
|
1381
|
+
if not cache_url:
|
|
1382
|
+
return None
|
|
1383
|
+
|
|
1384
|
+
try:
|
|
1385
|
+
from urllib.parse import urlparse, parse_qs
|
|
1386
|
+
except ImportError:
|
|
1387
|
+
from urlparse import urlparse, parse_qs
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
parsed = urlparse(cache_url)
|
|
1391
|
+
scheme = parsed.scheme.lower()
|
|
1392
|
+
query_params = parse_qs(parsed.query)
|
|
1393
|
+
ttl = int(query_params.get("ttl", [300])[0])
|
|
1394
|
+
|
|
1395
|
+
if scheme == "memory":
|
|
1396
|
+
size = int(query_params.get("size", [10000])[0])
|
|
1397
|
+
return FlagCache(size, ttl)
|
|
1398
|
+
|
|
1399
|
+
elif scheme == "redis":
|
|
1400
|
+
try:
|
|
1401
|
+
# Not worth importing redis if we're not using it
|
|
1402
|
+
import redis
|
|
1403
|
+
|
|
1404
|
+
redis_url = f"{parsed.scheme}://"
|
|
1405
|
+
if parsed.username or parsed.password:
|
|
1406
|
+
redis_url += f"{parsed.username or ''}:{parsed.password or ''}@"
|
|
1407
|
+
redis_url += (
|
|
1408
|
+
f"{parsed.hostname or 'localhost'}:{parsed.port or 6379}"
|
|
1409
|
+
)
|
|
1410
|
+
if parsed.path:
|
|
1411
|
+
redis_url += parsed.path
|
|
1412
|
+
|
|
1413
|
+
client = redis.from_url(redis_url)
|
|
1414
|
+
|
|
1415
|
+
# Test connection before using it
|
|
1416
|
+
client.ping()
|
|
1417
|
+
|
|
1418
|
+
return RedisFlagCache(client, default_ttl=ttl)
|
|
1419
|
+
|
|
1420
|
+
except ImportError:
|
|
1421
|
+
self.log.warning(
|
|
1422
|
+
"[FEATURE FLAGS] Redis not available, flag caching disabled"
|
|
1423
|
+
)
|
|
1424
|
+
return None
|
|
1425
|
+
except Exception as e:
|
|
1426
|
+
self.log.warning(
|
|
1427
|
+
f"[FEATURE FLAGS] Redis connection failed: {e}, flag caching disabled"
|
|
1428
|
+
)
|
|
1429
|
+
return None
|
|
1430
|
+
else:
|
|
1431
|
+
raise ValueError(
|
|
1432
|
+
f"Unknown cache URL scheme: {scheme}. Supported schemes: memory, redis"
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
except Exception as e:
|
|
1436
|
+
self.log.warning(
|
|
1437
|
+
f"[FEATURE FLAGS] Failed to parse cache URL '{cache_url}': {e}"
|
|
1438
|
+
)
|
|
1439
|
+
return None
|
|
1440
|
+
|
|
1281
1441
|
def feature_flag_definitions(self):
|
|
1282
1442
|
return self.feature_flags
|
|
1283
1443
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import time
|
|
1
2
|
import unittest
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from datetime import date, datetime, timedelta
|
|
@@ -12,6 +13,7 @@ from pydantic import BaseModel
|
|
|
12
13
|
from pydantic.v1 import BaseModel as BaseModelV1
|
|
13
14
|
|
|
14
15
|
from posthoganalytics import utils
|
|
16
|
+
from posthoganalytics.types import FeatureFlagResult
|
|
15
17
|
|
|
16
18
|
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
|
17
19
|
FAKE_TEST_API_KEY = "random_key"
|
|
@@ -173,3 +175,124 @@ class TestUtils(unittest.TestCase):
|
|
|
173
175
|
"inner_optional": None,
|
|
174
176
|
},
|
|
175
177
|
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestFlagCache(unittest.TestCase):
|
|
181
|
+
def setUp(self):
|
|
182
|
+
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
|
|
183
|
+
self.flag_result = FeatureFlagResult.from_value_and_payload(
|
|
184
|
+
"test-flag", True, None
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def test_cache_basic_operations(self):
|
|
188
|
+
distinct_id = "user123"
|
|
189
|
+
flag_key = "test-flag"
|
|
190
|
+
flag_version = 1
|
|
191
|
+
|
|
192
|
+
# Test cache miss
|
|
193
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
|
194
|
+
assert result is None
|
|
195
|
+
|
|
196
|
+
# Test cache set and hit
|
|
197
|
+
self.cache.set_cached_flag(
|
|
198
|
+
distinct_id, flag_key, self.flag_result, flag_version
|
|
199
|
+
)
|
|
200
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
|
201
|
+
assert result is not None
|
|
202
|
+
assert result.get_value()
|
|
203
|
+
|
|
204
|
+
def test_cache_ttl_expiration(self):
|
|
205
|
+
distinct_id = "user123"
|
|
206
|
+
flag_key = "test-flag"
|
|
207
|
+
flag_version = 1
|
|
208
|
+
|
|
209
|
+
# Set flag in cache
|
|
210
|
+
self.cache.set_cached_flag(
|
|
211
|
+
distinct_id, flag_key, self.flag_result, flag_version
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Should be available immediately
|
|
215
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
|
216
|
+
assert result is not None
|
|
217
|
+
|
|
218
|
+
# Wait for TTL to expire (1 second + buffer)
|
|
219
|
+
time.sleep(1.1)
|
|
220
|
+
|
|
221
|
+
# Should be expired
|
|
222
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
|
223
|
+
assert result is None
|
|
224
|
+
|
|
225
|
+
def test_cache_version_invalidation(self):
|
|
226
|
+
distinct_id = "user123"
|
|
227
|
+
flag_key = "test-flag"
|
|
228
|
+
old_version = 1
|
|
229
|
+
new_version = 2
|
|
230
|
+
|
|
231
|
+
# Set flag with old version
|
|
232
|
+
self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
|
|
233
|
+
|
|
234
|
+
# Should hit with old version
|
|
235
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
|
236
|
+
assert result is not None
|
|
237
|
+
|
|
238
|
+
# Should miss with new version
|
|
239
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
|
|
240
|
+
assert result is None
|
|
241
|
+
|
|
242
|
+
# Invalidate old version
|
|
243
|
+
self.cache.invalidate_version(old_version)
|
|
244
|
+
|
|
245
|
+
# Should miss even with old version after invalidation
|
|
246
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
|
247
|
+
assert result is None
|
|
248
|
+
|
|
249
|
+
def test_stale_cache_functionality(self):
|
|
250
|
+
distinct_id = "user123"
|
|
251
|
+
flag_key = "test-flag"
|
|
252
|
+
flag_version = 1
|
|
253
|
+
|
|
254
|
+
# Set flag in cache
|
|
255
|
+
self.cache.set_cached_flag(
|
|
256
|
+
distinct_id, flag_key, self.flag_result, flag_version
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Wait for TTL to expire
|
|
260
|
+
time.sleep(1.1)
|
|
261
|
+
|
|
262
|
+
# Should not get fresh cache
|
|
263
|
+
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
|
264
|
+
assert result is None
|
|
265
|
+
|
|
266
|
+
# Should get stale cache (within 1 hour default)
|
|
267
|
+
stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
|
|
268
|
+
assert stale_result is not None
|
|
269
|
+
assert stale_result.get_value()
|
|
270
|
+
|
|
271
|
+
def test_lru_eviction(self):
|
|
272
|
+
# Cache has max_size=3, so adding 4 users should evict the LRU one
|
|
273
|
+
flag_version = 1
|
|
274
|
+
|
|
275
|
+
# Add 3 users
|
|
276
|
+
for i in range(3):
|
|
277
|
+
user_id = f"user{i}"
|
|
278
|
+
self.cache.set_cached_flag(
|
|
279
|
+
user_id, "test-flag", self.flag_result, flag_version
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Access user0 to make it recently used
|
|
283
|
+
self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
|
284
|
+
|
|
285
|
+
# Add 4th user, should evict user1 (least recently used)
|
|
286
|
+
self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
|
|
287
|
+
|
|
288
|
+
# user0 should still be there (was recently accessed)
|
|
289
|
+
result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
|
290
|
+
assert result is not None
|
|
291
|
+
|
|
292
|
+
# user2 should still be there (was recently added)
|
|
293
|
+
result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
|
|
294
|
+
assert result is not None
|
|
295
|
+
|
|
296
|
+
# user3 should be there (just added)
|
|
297
|
+
result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
|
|
298
|
+
assert result is not None
|
posthoganalytics/utils.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
import numbers
|
|
3
4
|
import re
|
|
5
|
+
import time
|
|
4
6
|
from collections import defaultdict
|
|
5
7
|
from dataclasses import asdict, is_dataclass
|
|
6
8
|
from datetime import date, datetime, timezone
|
|
@@ -157,6 +159,266 @@ class SizeLimitedDict(defaultdict):
|
|
|
157
159
|
super().__setitem__(key, value)
|
|
158
160
|
|
|
159
161
|
|
|
162
|
+
class FlagCacheEntry:
|
|
163
|
+
def __init__(self, flag_result, flag_definition_version, timestamp=None):
|
|
164
|
+
self.flag_result = flag_result
|
|
165
|
+
self.flag_definition_version = flag_definition_version
|
|
166
|
+
self.timestamp = timestamp or time.time()
|
|
167
|
+
|
|
168
|
+
def is_valid(self, current_time, ttl, current_flag_version):
|
|
169
|
+
time_valid = (current_time - self.timestamp) < ttl
|
|
170
|
+
version_valid = self.flag_definition_version == current_flag_version
|
|
171
|
+
return time_valid and version_valid
|
|
172
|
+
|
|
173
|
+
def is_stale_but_usable(self, current_time, max_stale_age=3600):
|
|
174
|
+
return (current_time - self.timestamp) < max_stale_age
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class FlagCache:
|
|
178
|
+
def __init__(self, max_size=10000, default_ttl=300):
|
|
179
|
+
self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry}
|
|
180
|
+
self.access_times = {} # distinct_id -> last_access_time
|
|
181
|
+
self.max_size = max_size
|
|
182
|
+
self.default_ttl = default_ttl
|
|
183
|
+
|
|
184
|
+
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
|
185
|
+
current_time = time.time()
|
|
186
|
+
|
|
187
|
+
if distinct_id not in self.cache:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
user_flags = self.cache[distinct_id]
|
|
191
|
+
if flag_key not in user_flags:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
entry = user_flags[flag_key]
|
|
195
|
+
if entry.is_valid(current_time, self.default_ttl, current_flag_version):
|
|
196
|
+
self.access_times[distinct_id] = current_time
|
|
197
|
+
return entry.flag_result
|
|
198
|
+
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600):
|
|
202
|
+
current_time = time.time()
|
|
203
|
+
|
|
204
|
+
if distinct_id not in self.cache:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
user_flags = self.cache[distinct_id]
|
|
208
|
+
if flag_key not in user_flags:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
entry = user_flags[flag_key]
|
|
212
|
+
if entry.is_stale_but_usable(current_time, max_stale_age):
|
|
213
|
+
return entry.flag_result
|
|
214
|
+
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def set_cached_flag(
|
|
218
|
+
self, distinct_id, flag_key, flag_result, flag_definition_version
|
|
219
|
+
):
|
|
220
|
+
current_time = time.time()
|
|
221
|
+
|
|
222
|
+
# Evict LRU users if we're at capacity
|
|
223
|
+
if distinct_id not in self.cache and len(self.cache) >= self.max_size:
|
|
224
|
+
self._evict_lru()
|
|
225
|
+
|
|
226
|
+
# Initialize user cache if needed
|
|
227
|
+
if distinct_id not in self.cache:
|
|
228
|
+
self.cache[distinct_id] = {}
|
|
229
|
+
|
|
230
|
+
# Store the flag result
|
|
231
|
+
self.cache[distinct_id][flag_key] = FlagCacheEntry(
|
|
232
|
+
flag_result, flag_definition_version, current_time
|
|
233
|
+
)
|
|
234
|
+
self.access_times[distinct_id] = current_time
|
|
235
|
+
|
|
236
|
+
def invalidate_version(self, old_version):
|
|
237
|
+
users_to_remove = []
|
|
238
|
+
|
|
239
|
+
for distinct_id, user_flags in self.cache.items():
|
|
240
|
+
flags_to_remove = []
|
|
241
|
+
for flag_key, entry in user_flags.items():
|
|
242
|
+
if entry.flag_definition_version == old_version:
|
|
243
|
+
flags_to_remove.append(flag_key)
|
|
244
|
+
|
|
245
|
+
# Remove invalidated flags
|
|
246
|
+
for flag_key in flags_to_remove:
|
|
247
|
+
del user_flags[flag_key]
|
|
248
|
+
|
|
249
|
+
# Remove user entirely if no flags remain
|
|
250
|
+
if not user_flags:
|
|
251
|
+
users_to_remove.append(distinct_id)
|
|
252
|
+
|
|
253
|
+
# Clean up empty users
|
|
254
|
+
for distinct_id in users_to_remove:
|
|
255
|
+
del self.cache[distinct_id]
|
|
256
|
+
if distinct_id in self.access_times:
|
|
257
|
+
del self.access_times[distinct_id]
|
|
258
|
+
|
|
259
|
+
def _evict_lru(self):
|
|
260
|
+
if not self.access_times:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Remove 20% of least recently used entries
|
|
264
|
+
sorted_users = sorted(self.access_times.items(), key=lambda x: x[1])
|
|
265
|
+
to_remove = max(1, len(sorted_users) // 5)
|
|
266
|
+
|
|
267
|
+
for distinct_id, _ in sorted_users[:to_remove]:
|
|
268
|
+
if distinct_id in self.cache:
|
|
269
|
+
del self.cache[distinct_id]
|
|
270
|
+
if distinct_id in self.access_times:
|
|
271
|
+
del self.access_times[distinct_id]
|
|
272
|
+
|
|
273
|
+
def clear(self):
|
|
274
|
+
self.cache.clear()
|
|
275
|
+
self.access_times.clear()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class RedisFlagCache:
|
|
279
|
+
def __init__(
|
|
280
|
+
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
|
|
281
|
+
):
|
|
282
|
+
self.redis = redis_client
|
|
283
|
+
self.default_ttl = default_ttl
|
|
284
|
+
self.stale_ttl = stale_ttl
|
|
285
|
+
self.key_prefix = key_prefix
|
|
286
|
+
self.version_key = f"{key_prefix}version"
|
|
287
|
+
|
|
288
|
+
def _get_cache_key(self, distinct_id, flag_key):
|
|
289
|
+
return f"{self.key_prefix}{distinct_id}:{flag_key}"
|
|
290
|
+
|
|
291
|
+
def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None):
|
|
292
|
+
if timestamp is None:
|
|
293
|
+
timestamp = time.time()
|
|
294
|
+
|
|
295
|
+
# Use clean to make flag_result JSON-serializable for cross-platform compatibility
|
|
296
|
+
serialized_result = clean(flag_result)
|
|
297
|
+
|
|
298
|
+
entry = {
|
|
299
|
+
"flag_result": serialized_result,
|
|
300
|
+
"flag_version": flag_definition_version,
|
|
301
|
+
"timestamp": timestamp,
|
|
302
|
+
}
|
|
303
|
+
return json.dumps(entry)
|
|
304
|
+
|
|
305
|
+
def _deserialize_entry(self, data):
|
|
306
|
+
try:
|
|
307
|
+
entry = json.loads(data)
|
|
308
|
+
flag_result = entry["flag_result"]
|
|
309
|
+
return FlagCacheEntry(
|
|
310
|
+
flag_result=flag_result,
|
|
311
|
+
flag_definition_version=entry["flag_version"],
|
|
312
|
+
timestamp=entry["timestamp"],
|
|
313
|
+
)
|
|
314
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
315
|
+
# If deserialization fails, treat as cache miss
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
|
319
|
+
try:
|
|
320
|
+
cache_key = self._get_cache_key(distinct_id, flag_key)
|
|
321
|
+
data = self.redis.get(cache_key)
|
|
322
|
+
|
|
323
|
+
if data:
|
|
324
|
+
entry = self._deserialize_entry(data)
|
|
325
|
+
if entry and entry.is_valid(
|
|
326
|
+
time.time(), self.default_ttl, current_flag_version
|
|
327
|
+
):
|
|
328
|
+
return entry.flag_result
|
|
329
|
+
|
|
330
|
+
return None
|
|
331
|
+
except Exception:
|
|
332
|
+
# Redis error - return None to fall back to normal evaluation
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None):
|
|
336
|
+
try:
|
|
337
|
+
if max_stale_age is None:
|
|
338
|
+
max_stale_age = self.stale_ttl
|
|
339
|
+
|
|
340
|
+
cache_key = self._get_cache_key(distinct_id, flag_key)
|
|
341
|
+
data = self.redis.get(cache_key)
|
|
342
|
+
|
|
343
|
+
if data:
|
|
344
|
+
entry = self._deserialize_entry(data)
|
|
345
|
+
if entry and entry.is_stale_but_usable(time.time(), max_stale_age):
|
|
346
|
+
return entry.flag_result
|
|
347
|
+
|
|
348
|
+
return None
|
|
349
|
+
except Exception:
|
|
350
|
+
# Redis error - return None
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def set_cached_flag(
|
|
354
|
+
self, distinct_id, flag_key, flag_result, flag_definition_version
|
|
355
|
+
):
|
|
356
|
+
try:
|
|
357
|
+
cache_key = self._get_cache_key(distinct_id, flag_key)
|
|
358
|
+
serialized_entry = self._serialize_entry(
|
|
359
|
+
flag_result, flag_definition_version
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Set with TTL for automatic cleanup (use stale_ttl for total lifetime)
|
|
363
|
+
self.redis.setex(cache_key, self.stale_ttl, serialized_entry)
|
|
364
|
+
|
|
365
|
+
# Update the current version
|
|
366
|
+
self.redis.set(self.version_key, flag_definition_version)
|
|
367
|
+
|
|
368
|
+
except Exception:
|
|
369
|
+
# Redis error - silently fail, don't break flag evaluation
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
def invalidate_version(self, old_version):
|
|
373
|
+
try:
|
|
374
|
+
# For Redis, we use a simple approach: scan for keys with old version
|
|
375
|
+
# and delete them. This could be expensive with many keys, but it's
|
|
376
|
+
# necessary for correctness.
|
|
377
|
+
|
|
378
|
+
cursor = 0
|
|
379
|
+
pattern = f"{self.key_prefix}*"
|
|
380
|
+
|
|
381
|
+
while True:
|
|
382
|
+
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
|
383
|
+
|
|
384
|
+
for key in keys:
|
|
385
|
+
if key.decode() == self.version_key:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
data = self.redis.get(key)
|
|
390
|
+
if data:
|
|
391
|
+
entry_dict = json.loads(data)
|
|
392
|
+
if entry_dict.get("flag_version") == old_version:
|
|
393
|
+
self.redis.delete(key)
|
|
394
|
+
except (json.JSONDecodeError, KeyError):
|
|
395
|
+
# If we can't parse the entry, delete it to be safe
|
|
396
|
+
self.redis.delete(key)
|
|
397
|
+
|
|
398
|
+
if cursor == 0:
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
except Exception:
|
|
402
|
+
# Redis error - silently fail
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
def clear(self):
|
|
406
|
+
try:
|
|
407
|
+
# Delete all keys matching our pattern
|
|
408
|
+
cursor = 0
|
|
409
|
+
pattern = f"{self.key_prefix}*"
|
|
410
|
+
|
|
411
|
+
while True:
|
|
412
|
+
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
|
413
|
+
if keys:
|
|
414
|
+
self.redis.delete(*keys)
|
|
415
|
+
if cursor == 0:
|
|
416
|
+
break
|
|
417
|
+
except Exception:
|
|
418
|
+
# Redis error - silently fail
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
|
|
160
422
|
def convert_to_datetime_aware(date_obj):
|
|
161
423
|
if date_obj.tzinfo is None:
|
|
162
424
|
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
posthoganalytics/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: posthoganalytics
|
|
3
|
-
Version: 6.0.
|
|
3
|
+
Version: 6.0.3
|
|
4
4
|
Summary: Integrate PostHog into any python application.
|
|
5
5
|
Home-page: https://github.com/posthog/posthog-python
|
|
6
6
|
Author: Posthog
|
|
@@ -107,7 +107,7 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
|
|
|
107
107
|
```bash
|
|
108
108
|
uv python install 3.9.19
|
|
109
109
|
uv python pin 3.9.19
|
|
110
|
-
uv venv
|
|
110
|
+
uv venv
|
|
111
111
|
source env/bin/activate
|
|
112
112
|
uv sync --extra dev --extra test
|
|
113
113
|
pre-commit install
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
posthoganalytics/__init__.py,sha256=kijo4odBr7QnGezx-o2yAzZlbg51sP4u6SXC8TADwr0,16692
|
|
2
|
-
posthoganalytics/args.py,sha256=
|
|
3
|
-
posthoganalytics/client.py,sha256=
|
|
2
|
+
posthoganalytics/args.py,sha256=hRKPQ3cPGyDn4S7Ay9t2NlgoZg1cJ0GeN_Mb6OKtmfo,3145
|
|
3
|
+
posthoganalytics/client.py,sha256=mPUTTfz0hfIhUTdD1ngQ-JXtYJ_zIeTyhM08Dm0G480,50751
|
|
4
4
|
posthoganalytics/consumer.py,sha256=CiNbJBdyW9jER3ZYCKbX-JFmEDXlE1lbDy1MSl43-a0,4617
|
|
5
5
|
posthoganalytics/contexts.py,sha256=B3Y62sX7w-MCqNqgguUceQnKn5RCBFIqen3VeR3qems,9020
|
|
6
6
|
posthoganalytics/exception_capture.py,sha256=1VHBfffrXXrkK0PT8iVgKPpj_R1pGAzG5f3Qw0WF79w,1783
|
|
@@ -10,8 +10,8 @@ posthoganalytics/poller.py,sha256=jBz5rfH_kn_bBz7wCB46Fpvso4ttx4uzqIZWvXBCFmQ,59
|
|
|
10
10
|
posthoganalytics/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
posthoganalytics/request.py,sha256=TaeySYpcvHMf5Ftf5KqqlO0VPJpirKBCRrThlS04Kew,6124
|
|
12
12
|
posthoganalytics/types.py,sha256=INxWBOEQc0xgPcap6FdQNSU7zuQBmKShYaGzyuHKql8,9128
|
|
13
|
-
posthoganalytics/utils.py,sha256
|
|
14
|
-
posthoganalytics/version.py,sha256=
|
|
13
|
+
posthoganalytics/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
|
|
14
|
+
posthoganalytics/version.py,sha256=65DkL33wptG_S3mMCSglSwcvQWT2uqT8FwNKosP44_8,87
|
|
15
15
|
posthoganalytics/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
posthoganalytics/ai/utils.py,sha256=5-2XfmetCs0v9otBoux7-IEG933wAnKLSGS6oYLqCkw,19529
|
|
17
17
|
posthoganalytics/ai/anthropic/__init__.py,sha256=fFhDOiRzTXzGQlgnrRDL-4yKC8EYIl8NW4a2QNR6xRU,368
|
|
@@ -41,9 +41,9 @@ posthoganalytics/test/test_module.py,sha256=viqaAWA_uHt8r20fHIeME6IQkeXmQ8ZyrJTt
|
|
|
41
41
|
posthoganalytics/test/test_request.py,sha256=Zc0VbkjpVmj8mKokQm9rzdgTr0b1U44vvMYSkB_IQLs,4467
|
|
42
42
|
posthoganalytics/test/test_size_limited_dict.py,sha256=-5IQjIEr_-Dql24M0HusdR_XroOMrtgiT0v6ZQCRvzo,774
|
|
43
43
|
posthoganalytics/test/test_types.py,sha256=bRPHdwVpP7hu7emsplU8UVyzSQptv6PaG5lAoOD_BtM,7595
|
|
44
|
-
posthoganalytics/test/test_utils.py,sha256=
|
|
45
|
-
posthoganalytics-6.0.
|
|
46
|
-
posthoganalytics-6.0.
|
|
47
|
-
posthoganalytics-6.0.
|
|
48
|
-
posthoganalytics-6.0.
|
|
49
|
-
posthoganalytics-6.0.
|
|
44
|
+
posthoganalytics/test/test_utils.py,sha256=sqUTbfweVcxxFRd3WDMFXqPMyU6DvzOBeAOc68Py9aw,9620
|
|
45
|
+
posthoganalytics-6.0.3.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
|
|
46
|
+
posthoganalytics-6.0.3.dist-info/METADATA,sha256=EPpHk0WvqHtRnzCqhAuyrivFQV9ftS3yIGFZMmJ3qfk,6024
|
|
47
|
+
posthoganalytics-6.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
48
|
+
posthoganalytics-6.0.3.dist-info/top_level.txt,sha256=8QsNIqIkBh1p2TXvKp0Em9ZLZKwe3uIqCETyW4s1GOE,17
|
|
49
|
+
posthoganalytics-6.0.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|