Skip to content

Commit a833955

Browse files
authored
chore(flags): roll 10% of posthog-python /decide traffic (and all of PostHog's personal SDK traffic) to /flags (#218)
* init * moved the constants * formatting * mypy * fr do some damn formatting * don't exclude posthog * make it a set * differentiate * fix AI test * use the same type everywhere * ready to release
1 parent 58fbe05 commit a833955

File tree

7 files changed

+241
-33
lines changed

7 files changed

+241
-33
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 3.24.2 – 2025-04-15
2+
3+
1. Roll out new /flags endpoint to 10% of /decide traffic
4+
15
## 3.24.1 – 2025-04-11
26

37
1. Add `log_captured_exceptions` option to proxy setup
@@ -13,7 +17,7 @@
1317
## 3.22.0 – 2025-03-26
1418

1519
1. Add more information to `$feature_flag_called` events.
16-
2. Support for the `/decide?v=3` endpoint which contains more information about feature flags.
20+
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
1721

1822
## 3.21.0 – 2025-03-17
1923

posthog/client.py

Lines changed: 144 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import atexit
2+
import hashlib
23
import logging
34
import numbers
45
import os
@@ -18,14 +19,23 @@
1819
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
1920
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
2021
from posthog.poller import Poller
21-
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
22+
from posthog.request import (
23+
DEFAULT_HOST,
24+
APIError,
25+
batch_post,
26+
decide,
27+
determine_server_host,
28+
flags,
29+
get,
30+
remote_config,
31+
)
2232
from posthog.types import (
23-
DecideResponse,
2433
FeatureFlag,
2534
FlagMetadata,
2635
FlagsAndPayloads,
36+
FlagsResponse,
2737
FlagValue,
28-
normalize_decide_response,
38+
normalize_flags_response,
2939
to_flags_and_payloads,
3040
to_payloads,
3141
to_values,
@@ -42,6 +52,76 @@
4252
ID_TYPES = (numbers.Number, string_types, UUID)
4353
MAX_DICT_SIZE = 50_000
4454

55+
# TODO: Get rid of these when you're done rolling out `/flags` to all customers
56+
ROLLOUT_PERCENTAGE = 0.1
57+
INCLUDED_HASHES = set({"c4c6803067869081a8c4686780f32de979ade862c6af9ff9ebe5b7161e18362f"}) # this is PostHog's API key
58+
# Explicitly excluding all the API tokens associated with the top 10 customers; we'll get to them soon, but don't want to rollout to them just yet
59+
EXCLUDED_HASHES = set(
60+
{
61+
"5fbb169efa185c2a78d43574b01b56c66d7bb594b310f72702e1f167e4e283a9",
62+
"374be8e6556709787d472e276ebe3c46c0ab4b868ec99f4c96168a44df8307df",
63+
"6c8a2d5e9dbd4c71854aebca3026fe50045b05e19a16780dccea5439625ee1b4",
64+
"0f1fa079412bb39b5fce8d96af3539925ede61cbc561ffcd38e27c8e8ae64edb",
65+
"e3bdce3350e62638ffbf79872c2fd69ef6cbbd35712d9faf735f874cf77ccbfc",
66+
"f96fe01cdf22f1ec75bc7c897e9605e6431fb5d8f6a8bb9d0e8fce2b0a1384a6",
67+
"6859b51ac773ea98e146bae47e98759f97ec64c253b9c0524ab56793cc5b6c75",
68+
"06b28c04e490ce1c9c017396b8b8e16fce1176a8b5de131a99d9af4df1d0fbc9",
69+
"d9c0afa45a34c9f3c1e615bfa77394b79ad7b434ea46856e3503445d5974d640",
70+
"320eb50509e2c58a50d80fac848ee0b86290c848a173a0402abdbb760b794595",
71+
"7380abb65605420dd6e61534c8eecaa6f14d25a6f90ec2edba811f7383123ded",
72+
"3182881fa027d1c8e4eea108df66dcb0387e375d1e4b551c3a3579fdb1e696d1",
73+
"d685aeb7d02ec757c4cbe591050a168d34be2f5305d9071d9695ed773057ef16",
74+
"875ab92bec4da51cf229145565364e98347fafaa2316a4a8e20f5d852bc95aed",
75+
"4a0d726e4b56d6f6d0407faf5396847146084bbabd042ca0dedba2873d8f9236",
76+
"a9dc6415c1ccd1874ed1cd303e3d5bf92ddb17ac2af968abed14a51dfb0c53be",
77+
"5f10a055c9e379869a159306b1d7242fec25584ce895f677f82a13133741c7f1",
78+
"e3e7608bbda7c15bf82fd7e2945ca74052f8b99e2090962318b6ef983c0ddb16",
79+
"7f0cbd50e11b475f6c2ed50e620c473e4bfc8df1f4c5174b49ecee1fcec6853e",
80+
"03004fb2209e6e4186c4364c71e5abc9cf272caf83cf58fb538c42684fd42fb0",
81+
"8721e8bf608c5eb4d74eeaf26fe588b4e5414742e0494ca7e67a89e1a297332b",
82+
"ac0d5c7daee8d2f89d5b3861fba0b9a0e560b0eb6944e974f37cdc52274f2d1f",
83+
"6581d65cf0c4c536122beb5d581ba2b128ed44b7528c07d4ec7837ea33d0cffe",
84+
"d0c2d4e122ecd4520af7bac133b09fde357622f20aa5a0f7a9328d25c9e9f28e",
85+
"d09de64bec03c750493b0771c9f2731204bc9a5f0479628848803e2ccded9aca",
86+
"a9f483f0cdc028a5e05d03d7ab683738f09a940c0173d9e6b004fbe85738a1f5",
87+
"2ffb5817a9fc465b9bb37b9112393cc1a274185f7f18618192421b7511b98830",
88+
"a6785a722fdb0f975a1a30302f8312709ae069358c901c609f4898a9ae14bdf2",
89+
"3d9ba35cab44358cf47c867f48c95f75b9ad54ca5407ed19576da55a085d3a8f",
90+
"ff59d2907ecb66f4d4a1705435460124a390d8cf7762dc7860d4b4171f832976",
91+
"aac9e8036d3e0efed49cd5fbea19ea8354c4e1dfc95a1585300c5178189e5bac",
92+
"1e7fa74813f733e35ea820f8272c6562b4b0c70429f1b549605cc9e8016f632e",
93+
"2cb74b224cb20b8e5a5a52f3fe5ca62672e5c77ce7f30223698bb4d4abff2293",
94+
"17a90589bfe29f40f826e2df4753c0bce17a05f4c04b9a0924304e7418aba9e8",
95+
"0925e4c5bc65ced02c65aa3afba5eaa98aed288d193f719a8fbaebafdeafc1ce",
96+
"a0308973730b505f1d6af7cd2f39c69bd86ea2a35b9d27118910e1c58d9a6a1a",
97+
"c780092461636d6d62179723f03cbfe4a7b5808a6b46de749d8b32c3384f1e74",
98+
"65d6083548c27387f9381ff2aa37581a41ba1d5e6162afdc18cf8130be528052",
99+
"e2241631d1211e15688735ec6d9f56b4839e65d2095f278630c884bd49f00be8",
100+
"f2d9e1c10371912c32e9eba18f348782345ff70d383ae8b38bc9e6b12c7841e7",
101+
"57411b20e1c406ac4339718287b3eaa83635291fb593c9a4068dd08ec1d03692",
102+
"06e91ecd6b2a9a02234951ab3a5a95aeb84ef34499a5001629aaa13d907ba1dc",
103+
"4d2f47e99000f6820307e525fcf972421335a86f39b6ada1c93d67410520af49",
104+
"538d3b1415c3feccbe68d59b5ad9ed35aa418fc64658ff603855494abf75f647",
105+
"68b11387ac9f805bdbea486b9d3e0724856180646f2b12617a81174d5c27833c",
106+
"a74797287c3d29f92fc729c2a8b3f17638cb273388e12cb8ffd972bcfbcdfdb8",
107+
"b53d2b6551ebd8d68321dbd2727a299b1d23ff15853be02fffb0c54f1f0e1349",
108+
"abad9dc57c9cb9a244b89b11f0a9123baf924a6908443dd8527cf6b411bbb33a",
109+
"d17b55c7d72052d76d76a039e1ceb613d443401d30eae91ac903a07d5ee0d2d2",
110+
"274a08018c6e4609dedc37e31aea589c527cd7b93242d305591c3f5313408ee8",
111+
"75ed9cca6d877ea218647d6021b89c5959156eed2ce4ccad29d4e497d9cd0119",
112+
"4862317bab4b4efc876a810b92a6841bcf6ba69ac7aa7ff792358862528e7fa8",
113+
"f0498fff4318e52729573a8bf451d7b978c5242af51ec8b1699798090bc00d32",
114+
"a6a3435402f66a94eefd07b16297f6b4a61e26992e8ed7742de2e49d7ea71104",
115+
"72d8ede07d3ef0fd8eb0cd7261d29f4f33b3554e06a726db151138a25a01b539",
116+
"937c4aae120326c861eb3ec23371e029d3cea21f5849e4d52d75e47e06473e5c",
117+
"e0138f35502faac574232bbbaab7ad769e2dcd449b596e32454368cb3cc035f9",
118+
"084e32dc89830d7bb120492ed55cc543de0405c7ae3d0c16c8f64ab07c44506d",
119+
"d59f0ce1670146019b2c77b56ff8faca6346adfcc93443712a613a89298e3fb9",
120+
"b99bd54a29c2e9adc17527f9df539415a1c0a83293f72e3e0c8744c5677ea1a1",
121+
"c252a61d3c19f58062ca9fe2b13dfe378bc11380705cec703d9d8d0a0e167995",
122+
}
123+
)
124+
45125

46126
def get_os_info():
47127
"""
@@ -97,6 +177,43 @@ def system_context() -> dict[str, Any]:
97177
}
98178

99179

180+
def is_token_in_rollout(
181+
token: str,
182+
percentage: float = 0,
183+
included_hashes: Optional[set[str]] = None,
184+
excluded_hashes: Optional[set[str]] = None,
185+
) -> bool:
186+
"""
187+
Determines if a token should be included in a rollout based on:
188+
1. If its hash matches any included_hashes provided
189+
2. If its hash falls within the percentage rollout
190+
191+
Args:
192+
token: String to hash (usually API key)
193+
percentage: Float between 0 and 1 representing rollout percentage
194+
included_hashes: Optional set of specific SHA1 hashes to match against
195+
excluded_hashes: Optional set of specific SHA1 hashes to exclude from rollout
196+
Returns:
197+
bool: True if token should be included in rollout
198+
"""
199+
# First generate SHA1 hash of token
200+
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
201+
202+
# Check if hash matches any included hashes
203+
if included_hashes and token_hash in included_hashes:
204+
return True
205+
206+
# Check if hash matches any excluded hashes
207+
if excluded_hashes and token_hash in excluded_hashes:
208+
return False
209+
210+
# Convert first 8 chars of hash to int and divide by max value to get number between 0-1
211+
hash_int = int(token_hash[:8], 16)
212+
hash_float = hash_int / 0xFFFFFFFF
213+
214+
return hash_float < percentage
215+
216+
100217
class Client(object):
101218
"""Create a new PostHog client."""
102219

@@ -263,7 +380,7 @@ def get_feature_variants(
263380
"""
264381
Get feature flag variants for a distinct_id by calling decide.
265382
"""
266-
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
383+
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
267384
return to_values(resp_data) or {}
268385

269386
def get_feature_payloads(
@@ -272,7 +389,7 @@ def get_feature_payloads(
272389
"""
273390
Get feature flag payloads for a distinct_id by calling decide.
274391
"""
275-
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
392+
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
276393
return to_payloads(resp_data) or {}
277394

278395
def get_feature_flags_and_payloads(
@@ -281,12 +398,15 @@ def get_feature_flags_and_payloads(
281398
"""
282399
Get feature flags and payloads for a distinct_id by calling decide.
283400
"""
284-
resp = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
401+
resp = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
285402
return to_flags_and_payloads(resp)
286403

287-
def get_decide(
404+
def get_flags_decision(
288405
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
289-
) -> DecideResponse:
406+
) -> FlagsResponse:
407+
"""
408+
Get feature flags decision, using either flags() or decide() API based on rollout.
409+
"""
290410
require("distinct_id", distinct_id, ID_TYPES)
291411

292412
if disable_geoip is None:
@@ -304,9 +424,21 @@ def get_decide(
304424
"group_properties": group_properties,
305425
"disable_geoip": disable_geoip,
306426
}
307-
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
308427

309-
return normalize_decide_response(resp_data)
428+
use_flags = is_token_in_rollout(
429+
self.api_key, ROLLOUT_PERCENTAGE, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
430+
)
431+
432+
if use_flags:
433+
resp_data = flags(
434+
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
435+
)
436+
else:
437+
resp_data = decide(
438+
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
439+
)
440+
441+
return normalize_flags_response(resp_data)
310442

311443
def capture(
312444
self,
@@ -970,7 +1102,7 @@ def _get_feature_flag_details_from_decide(
9701102
"""
9711103
Calls /decide and returns the flag details and request id
9721104
"""
973-
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
1105+
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
9741106
request_id = resp_data.get("requestId")
9751107
flags = resp_data.get("flags")
9761108
flag_details = flags.get(key) if flags else None
@@ -1101,7 +1233,7 @@ def get_all_flags_and_payloads(
11011233

11021234
if fallback_to_decide and not only_evaluate_locally:
11031235
try:
1104-
decide_response = self.get_decide(
1236+
decide_response = self.get_flags_decision(
11051237
distinct_id,
11061238
groups=groups,
11071239
person_properties=person_properties,

posthog/request.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def post(
5252
url = remove_trailing_slash(host or DEFAULT_HOST) + path
5353
body["api_key"] = api_key
5454
data = json.dumps(body, cls=DatetimeSerializer)
55-
log.debug("making request: %s", data)
55+
log.debug("making request: %s to url: %s", data, url)
5656
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
5757
if gzip:
5858
headers["Content-Encoding"] = "gzip"
@@ -106,6 +106,12 @@ def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout
106106
return _process_response(res, success_message="Feature flags decided successfully")
107107

108108

109+
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
110+
"""Post the `kwargs to the flags API endpoint"""
111+
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
112+
return _process_response(res, success_message="Feature flags evaluated successfully")
113+
114+
109115
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
110116
"""Get remote config flag value from remote_config API endpoint"""
111117
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)

posthog/test/test_client.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
import time
23
import unittest
34
from datetime import datetime
@@ -7,7 +8,7 @@
78
import six
89
from parameterized import parameterized
910

10-
from posthog.client import Client
11+
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
1112
from posthog.request import APIError
1213
from posthog.test.test_utils import FAKE_TEST_API_KEY
1314
from posthog.types import FeatureFlag, LegacyFlagMetadata
@@ -1235,7 +1236,7 @@ def test_get_decide_returns_normalized_decide_response(self, patch_decide):
12351236
groups = {"test_group_type": "test_group_id"}
12361237
person_properties = {"test_property": "test_value"}
12371238

1238-
response = client.get_decide(distinct_id, groups, person_properties)
1239+
response = client.get_flags_decision(distinct_id, groups, person_properties)
12391240

12401241
assert response == {
12411242
"flags": {
@@ -1270,3 +1271,68 @@ def test_get_decide_returns_normalized_decide_response(self, patch_decide):
12701271
"errorsWhileComputingFlags": False,
12711272
"requestId": "test-id",
12721273
}
1274+
1275+
@mock.patch("posthog.client.flags")
1276+
@mock.patch("posthog.client.decide")
1277+
def test_get_flags_decision_rollout(self, patch_decide, patch_flags):
1278+
# Set up mock responses
1279+
decide_response = {
1280+
"featureFlags": {"flag1": True},
1281+
"featureFlagPayloads": {},
1282+
"errorsWhileComputingFlags": False,
1283+
}
1284+
flags_response = {
1285+
"featureFlags": {"flag2": True},
1286+
"featureFlagPayloads": {},
1287+
"errorsWhileComputingFlags": False,
1288+
}
1289+
patch_decide.return_value = decide_response
1290+
patch_flags.return_value = flags_response
1291+
1292+
client = Client(FAKE_TEST_API_KEY)
1293+
1294+
# Test 0% rollout - should use decide
1295+
with mock.patch("posthog.client.is_token_in_rollout", return_value=False) as mock_rollout:
1296+
client.get_flags_decision("distinct_id")
1297+
mock_rollout.assert_called_with(
1298+
FAKE_TEST_API_KEY, 0.1, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
1299+
)
1300+
patch_decide.assert_called_once()
1301+
patch_flags.assert_not_called()
1302+
patch_decide.reset_mock()
1303+
patch_flags.reset_mock()
1304+
1305+
# Test 100% rollout - should use flags
1306+
with mock.patch("posthog.client.is_token_in_rollout", return_value=True) as mock_rollout:
1307+
client.get_flags_decision("distinct_id")
1308+
mock_rollout.assert_called_with(
1309+
FAKE_TEST_API_KEY, 0.1, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
1310+
)
1311+
patch_flags.assert_called_once()
1312+
patch_decide.assert_not_called()
1313+
1314+
def test_token_rollout_calculation(self):
1315+
# Test specific hash inclusion
1316+
token = "test_token"
1317+
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
1318+
included_hashes = {token_hash}
1319+
1320+
# Should be included due to specific hash, even with 0% rollout
1321+
self.assertTrue(expr=is_token_in_rollout(token, percentage=0.0, included_hashes=included_hashes))
1322+
1323+
# Should not be included with 0% rollout and no specific hash
1324+
self.assertFalse(is_token_in_rollout(token, percentage=0.0))
1325+
1326+
# Should be included with 100% rollout regardless of specific hash
1327+
self.assertTrue(is_token_in_rollout(token, percentage=1.0))
1328+
self.assertTrue(is_token_in_rollout(token, percentage=1.0, included_hashes=included_hashes))
1329+
1330+
# Test deterministic behavior - same token should always give same result
1331+
hash_float = int(token_hash[:8], 16) / 0xFFFFFFFF
1332+
percentage = hash_float + 0.1 # Just above the hash value
1333+
1334+
self.assertTrue(is_token_in_rollout(token, percentage))
1335+
self.assertFalse(is_token_in_rollout(token, percentage - 0.2)) # Just below the hash value
1336+
1337+
# Test that the token exclusion works correctly
1338+
self.assertFalse(is_token_in_rollout(token, percentage=1.0, excluded_hashes={token_hash}))

0 commit comments

Comments
 (0)