posthoganalytics 6.0.2__py3-none-any.whl → 6.0.4__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.
@@ -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
@@ -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,5 +1,6 @@
1
1
  from typing import TYPE_CHECKING, cast
2
2
  from posthoganalytics import contexts
3
+ from posthoganalytics.client import Client
3
4
 
4
5
  if TYPE_CHECKING:
5
6
  from django.http import HttpRequest, HttpResponse # noqa: F401
@@ -16,7 +17,8 @@ class PosthogContextMiddleware:
16
17
  - Request Method as $request_method
17
18
 
18
19
  The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
19
- `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
20
+ `POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
21
+ global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
20
22
 
21
23
  The middleware behaviour is customisable through 3 additional functions:
22
24
  - `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
@@ -74,6 +76,13 @@ class PosthogContextMiddleware:
74
76
  else:
75
77
  self.capture_exceptions = True
76
78
 
79
+ if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
80
+ settings.POSTHOG_MW_CLIENT, Client
81
+ ):
82
+ self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
83
+ else:
84
+ self.client = None
85
+
77
86
  def extract_tags(self, request):
78
87
  # type: (HttpRequest) -> Dict[str, Any]
79
88
  tags = {}
@@ -153,7 +162,7 @@ class PosthogContextMiddleware:
153
162
  if self.request_filter and not self.request_filter(request):
154
163
  return self.get_response(request)
155
164
 
156
- with contexts.new_context(self.capture_exceptions):
165
+ with contexts.new_context(self.capture_exceptions, client=self.client):
157
166
  for k, v in self.extract_tags(request).items():
158
167
  contexts.tag(k, v)
159
168
 
@@ -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)
@@ -1,4 +1,4 @@
1
- VERSION = "6.0.2"
1
+ VERSION = "6.0.4"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 6.0.2
3
+ Version: 6.0.4
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 env
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
2
  posthoganalytics/args.py,sha256=hRKPQ3cPGyDn4S7Ay9t2NlgoZg1cJ0GeN_Mb6OKtmfo,3145
3
- posthoganalytics/client.py,sha256=OPwclSpmxmTafOchjcXT8uVj3MMdVpzEHMQXOZCGIc4,44691
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=rp23PTgYw4r-Kus-Ga1UbAtkKYXMrz2c5Y-j-a7syGo,7119
14
- posthoganalytics/version.py,sha256=LhkEuiSugQ41V7R7p9bDqodeVOoBsuuHQcdeJeoLeSA,87
13
+ posthoganalytics/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
14
+ posthoganalytics/version.py,sha256=32RMq8t4f1854luLt5ho5jLI7HSIEs-meJIXyzaT6iw,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
@@ -27,7 +27,7 @@ posthoganalytics/ai/openai/openai.py,sha256=iL_cwctaAhPdXNo4EpIZooOWGyjNj0W-OUEo
27
27
  posthoganalytics/ai/openai/openai_async.py,sha256=KxPCd5imF5iZ9VkJ12HjCO2skaF1tHsHveAknIqV93g,23769
28
28
  posthoganalytics/ai/openai/openai_providers.py,sha256=EMuEvdHSOFbrhmfuU0is7pBVWS3ReAUT0PZqgMXdyjk,3884
29
29
  posthoganalytics/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
- posthoganalytics/integrations/django.py,sha256=NUC2XHZxQ3XmfqPaVKrAvVma4284eFqq-x0NQTRdaUU,5998
30
+ posthoganalytics/integrations/django.py,sha256=BdBo6nvY6cjKMYPpKTzvwiICCLIhCOyslgqwFCObPzo,6441
31
31
  posthoganalytics/test/__init__.py,sha256=VYgM6xPbJbvS-xhIcDiBRs0MFC9V_jT65uNeerCz_rM,299
32
32
  posthoganalytics/test/test_before_send.py,sha256=A1_UVMewhHAvO39rZDWfS606vG_X-q0KNXvh5DAKiB8,7930
33
33
  posthoganalytics/test/test_client.py,sha256=msR101AXAyDrglWMJ1MjSw59o6CsXSXnjZ0pwIG-MDk,74571
@@ -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=GYLJp4ud_RP31-NnYJINOY0G0ra-QcGJszpp9MTyYq8,5428
45
- posthoganalytics-6.0.2.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
46
- posthoganalytics-6.0.2.dist-info/METADATA,sha256=KWL0S-06P0RNvr0aWzQawRQ7Y_LQCnRXJMSiJhHK8R8,6028
47
- posthoganalytics-6.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
48
- posthoganalytics-6.0.2.dist-info/top_level.txt,sha256=8QsNIqIkBh1p2TXvKp0Em9ZLZKwe3uIqCETyW4s1GOE,17
49
- posthoganalytics-6.0.2.dist-info/RECORD,,
44
+ posthoganalytics/test/test_utils.py,sha256=sqUTbfweVcxxFRd3WDMFXqPMyU6DvzOBeAOc68Py9aw,9620
45
+ posthoganalytics-6.0.4.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
46
+ posthoganalytics-6.0.4.dist-info/METADATA,sha256=XFHAzPEaUrGKWSVPYHPeJHv-XpITG9_OnpGcQudBThk,6024
47
+ posthoganalytics-6.0.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
48
+ posthoganalytics-6.0.4.dist-info/top_level.txt,sha256=8QsNIqIkBh1p2TXvKp0Em9ZLZKwe3uIqCETyW4s1GOE,17
49
+ posthoganalytics-6.0.4.dist-info/RECORD,,