Skip to content

Commit 505e5ad

Browse files
Merge branch 'master' into feat(llma)/new-ingestion-pipeline
2 parents d34bcda + b6dbff1 commit 505e5ad

27 files changed

+3459
-143
lines changed

.github/workflows/generate-references.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ jobs:
77
docs-generation:
88
name: Generate references
99
runs-on: ubuntu-latest
10+
permissions:
11+
contents: write
1012
steps:
1113
- name: Checkout the repository
1214
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
1315
with:
1416
fetch-depth: 0
15-
token: ${{ secrets.POSTHOG_BOT_PAT }}
1617

1718
- name: Set up Python
1819
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55

.github/workflows/release.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ jobs:
1212
release:
1313
name: Publish release
1414
runs-on: ubuntu-latest
15-
env:
16-
TWINE_USERNAME: __token__
17-
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
15+
permissions:
16+
contents: write
17+
id-token: write
1818
steps:
1919
- name: Checkout the repository
2020
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
2121
with:
2222
fetch-depth: 0
23-
token: ${{ secrets.POSTHOG_BOT_PAT }}
2423

2524
- name: Set up Python
2625
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
@@ -40,12 +39,12 @@ jobs:
4039
run: uv sync --extra dev
4140

4241
- name: Push releases to PyPI
42+
env:
43+
TWINE_USERNAME: __token__
4344
run: uv run make release && uv run make release_analytics
4445

4546
- name: Create GitHub release
4647
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
47-
env:
48-
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_PAT }}
4948
with:
5049
tag_name: v${{ env.REPO_VERSION }}
5150
release_name: ${{ env.REPO_VERSION }}

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# 7.3.1 - 2025-12-06
2+
3+
fix: remove unused $exception_message and $exception_type
4+
5+
# 7.3.0 - 2025-12-05
6+
7+
feat: improve code variables capture masking
8+
9+
# 7.2.0 - 2025-12-01
10+
11+
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
12+
13+
# 7.1.0 - 2025-11-26
14+
15+
Add support for the async version of Gemini.
16+
117
# 7.0.2 - 2025-11-18
218

319
Add support for Python 3.14.

examples/redis_flag_cache.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
Redis-based distributed cache for PostHog feature flag definitions.
3+
4+
This example demonstrates how to implement a FlagDefinitionCacheProvider
5+
using Redis for multi-instance deployments (leader election pattern).
6+
7+
Usage:
8+
import redis
9+
from posthog import Posthog
10+
11+
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
12+
cache = RedisFlagCache(redis_client, service_key="my-service")
13+
14+
posthog = Posthog(
15+
"<project_api_key>",
16+
personal_api_key="<personal_api_key>",
17+
flag_definition_cache_provider=cache,
18+
)
19+
20+
Requirements:
21+
pip install redis
22+
"""
23+
24+
import json
25+
import uuid
26+
27+
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
28+
from redis import Redis
29+
from typing import Optional
30+
31+
32+
class RedisFlagCache(FlagDefinitionCacheProvider):
33+
"""
34+
A distributed cache for PostHog feature flag definitions using Redis.
35+
36+
In a multi-instance deployment (e.g., multiple serverless functions or containers),
37+
we want only ONE instance to poll PostHog for flag updates, while all instances
38+
share the cached results. This prevents N instances from making N redundant API calls.
39+
40+
The implementation uses leader election:
41+
- One instance "wins" and becomes responsible for fetching
42+
- Other instances read from the shared cache
43+
- If the leader dies, the lock expires (TTL) and another instance takes over
44+
45+
Uses Lua scripts for atomic operations, following Redis distributed lock best practices:
46+
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
47+
"""
48+
49+
LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval
50+
CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
51+
52+
# Lua script: acquire lock if free, or extend if we own it
53+
_LUA_TRY_LEAD = """
54+
local current = redis.call('GET', KEYS[1])
55+
if current == false then
56+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
57+
return 1
58+
elseif current == ARGV[1] then
59+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
60+
return 1
61+
end
62+
return 0
63+
"""
64+
65+
# Lua script: release lock only if we own it
66+
_LUA_STOP_LEAD = """
67+
if redis.call('GET', KEYS[1]) == ARGV[1] then
68+
return redis.call('DEL', KEYS[1])
69+
end
70+
return 0
71+
"""
72+
73+
def __init__(self, redis: Redis[str], service_key: str):
74+
"""
75+
Initialize the Redis flag cache.
76+
77+
Args:
78+
redis: A redis-py client instance. Must be configured with
79+
decode_responses=True for correct string handling.
80+
service_key: A unique identifier for this service/environment.
81+
Used to scope Redis keys, allowing multiple services
82+
or environments to share the same Redis instance.
83+
Examples: "my-api-prod", "checkout-service", "staging".
84+
85+
Redis Keys Created:
86+
- posthog:flags:{service_key} - Cached flag definitions (JSON)
87+
- posthog:flags:{service_key}:lock - Leader election lock
88+
89+
Example:
90+
redis_client = redis.Redis(
91+
host='localhost',
92+
port=6379,
93+
decode_responses=True
94+
)
95+
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
96+
"""
97+
self._redis = redis
98+
self._cache_key = f"posthog:flags:{service_key}"
99+
self._lock_key = f"posthog:flags:{service_key}:lock"
100+
self._instance_id = str(uuid.uuid4())
101+
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
102+
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
103+
104+
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
105+
"""
106+
Retrieve cached flag definitions from Redis.
107+
108+
Returns:
109+
Cached flag definitions if available, None otherwise.
110+
"""
111+
cached = self._redis.get(self._cache_key)
112+
return json.loads(cached) if cached else None
113+
114+
def should_fetch_flag_definitions(self) -> bool:
115+
"""
116+
Determines if this instance should fetch flag definitions from PostHog.
117+
118+
Atomically either:
119+
- Acquires the lock if no one holds it, OR
120+
- Extends the lock TTL if we already hold it
121+
122+
Returns:
123+
True if this instance is the leader and should fetch, False otherwise.
124+
"""
125+
result = self._try_lead(
126+
keys=[self._lock_key],
127+
args=[self._instance_id, self.LOCK_TTL_MS],
128+
)
129+
return result == 1
130+
131+
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
132+
"""
133+
Store fetched flag definitions in Redis.
134+
135+
Args:
136+
data: The flag definitions to cache.
137+
"""
138+
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
139+
140+
def shutdown(self) -> None:
141+
"""
142+
Release leadership if we hold it. Safe to call even if not the leader.
143+
"""
144+
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])

mypy-baseline.txt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,9 @@ posthog/client.py:0: error: Incompatible types in assignment (expression has typ
2626
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
2727
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
2828
posthog/client.py:0: error: Statement is unreachable [unreachable]
29-
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
30-
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
31-
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
32-
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
3329
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
3430
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
3531
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
36-
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
3732
posthog/client.py:0: error: Statement is unreachable [unreachable]
3833
posthog/client.py:0: error: Statement is unreachable [unreachable]
3934
posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef]

posthog/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@
2222
InconclusiveMatchError as InconclusiveMatchError,
2323
RequiresServerEvaluation as RequiresServerEvaluation,
2424
)
25+
from posthog.flag_definition_cache import (
26+
FlagDefinitionCacheData as FlagDefinitionCacheData,
27+
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
28+
)
29+
from posthog.request import (
30+
disable_connection_reuse as disable_connection_reuse,
31+
enable_keep_alive as enable_keep_alive,
32+
set_socket_options as set_socket_options,
33+
SocketOptions as SocketOptions,
34+
)
2535
from posthog.types import (
2636
FeatureFlag,
2737
FlagsAndPayloads,

posthog/ai/gemini/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .gemini import Client
2+
from .gemini_async import AsyncClient
23
from .gemini_converter import (
34
format_gemini_input,
45
format_gemini_response,
@@ -9,12 +10,14 @@
910
# Create a genai-like module for perfect drop-in replacement
1011
class _GenAI:
1112
Client = Client
13+
AsyncClient = AsyncClient
1214

1315

1416
genai = _GenAI()
1517

1618
__all__ = [
1719
"Client",
20+
"AsyncClient",
1821
"genai",
1922
"format_gemini_input",
2023
"format_gemini_response",

posthog/ai/gemini/gemini.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def _generate_content_streaming(
304304

305305
def generator():
306306
nonlocal usage_stats
307-
nonlocal accumulated_content # noqa: F824
307+
nonlocal accumulated_content
308308
try:
309309
for chunk in response:
310310
# Extract usage stats from chunk

0 commit comments

Comments
 (0)