11import atexit
2+ import hashlib
23import logging
34import numbers
45import os
1819from posthog .exception_utils import exc_info_from_error , exceptions_from_error_tuple , handle_in_app
1920from posthog .feature_flags import InconclusiveMatchError , match_feature_flag_properties
2021from 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+ )
2232from 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 ,
4252ID_TYPES = (numbers .Number , string_types , UUID )
4353MAX_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
46126def 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+
100217class 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 ,
0 commit comments