22import hashlib
33import logging
44import re
5+ import warnings
56from typing import Optional
67
78from dateutil import parser
@@ -34,18 +35,18 @@ class RequiresServerEvaluation(Exception):
3435 pass
3536
3637
37- # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
38- # Given the same distinct_id and key, it'll always return the same float. These floats are
38+ # This function takes a bucketing value and a feature flag key and returns a float between 0 and 1.
39+ # Given the same bucketing value and key, it'll always return the same float. These floats are
3940# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
40- # we can do _hash(key, distinct_id ) < 0.2
41- def _hash (key : str , distinct_id : str , salt : str = "" ) -> float :
42- hash_key = f"{ key } .{ distinct_id } { salt } "
41+ # we can do _hash(key, bucketing_value ) < 0.2
42+ def _hash (key : str , bucketing_value : str , salt : str = "" ) -> float :
43+ hash_key = f"{ key } .{ bucketing_value } { salt } "
4344 hash_val = int (hashlib .sha1 (hash_key .encode ("utf-8" )).hexdigest ()[:15 ], 16 )
4445 return hash_val / __LONG_SCALE__
4546
4647
47- def get_matching_variant (flag , distinct_id ):
48- hash_value = _hash (flag ["key" ], distinct_id , salt = "variant" )
48+ def get_matching_variant (flag , bucketing_value ):
49+ hash_value = _hash (flag ["key" ], bucketing_value , salt = "variant" )
4950 for variant in variant_lookup_table (flag ):
5051 if hash_value >= variant ["value_min" ] and hash_value < variant ["value_max" ]:
5152 return variant ["key" ]
@@ -68,7 +69,13 @@ def variant_lookup_table(feature_flag):
6869
6970
7071def evaluate_flag_dependency (
71- property , flags_by_key , evaluation_cache , distinct_id , properties , cohort_properties
72+ property ,
73+ flags_by_key ,
74+ evaluation_cache ,
75+ distinct_id ,
76+ properties ,
77+ cohort_properties ,
78+ device_id = None ,
7279):
7380 """
7481 Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -80,6 +87,7 @@ def evaluate_flag_dependency(
8087 distinct_id: The distinct ID being evaluated
8188 properties: Person properties for evaluation
8289 cohort_properties: Cohort properties for evaluation
90+ device_id: The device ID for bucketing (optional)
8391
8492 Returns:
8593 bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -124,13 +132,27 @@ def evaluate_flag_dependency(
124132 else :
125133 # Recursively evaluate the dependency
126134 try :
135+ dep_flag_filters = dep_flag .get ("filters" ) or {}
136+ dep_aggregation_group_type_index = dep_flag_filters .get (
137+ "aggregation_group_type_index"
138+ )
139+ if dep_aggregation_group_type_index is not None :
140+ # Group flags should continue bucketing by the group key
141+ # from the current evaluation context.
142+ dep_bucketing_value = distinct_id
143+ else :
144+ dep_bucketing_value = resolve_bucketing_value (
145+ dep_flag , distinct_id , device_id
146+ )
127147 dep_result = match_feature_flag_properties (
128148 dep_flag ,
129149 distinct_id ,
130150 properties ,
131- cohort_properties ,
132- flags_by_key ,
133- evaluation_cache ,
151+ cohort_properties = cohort_properties ,
152+ flags_by_key = flags_by_key ,
153+ evaluation_cache = evaluation_cache ,
154+ device_id = device_id ,
155+ bucketing_value = dep_bucketing_value ,
134156 )
135157 evaluation_cache [dep_flag_key ] = dep_result
136158 except InconclusiveMatchError as e :
@@ -215,21 +237,54 @@ def matches_dependency_value(expected_value, actual_value):
215237 return False
216238
217239
240+ def resolve_bucketing_value (flag , distinct_id , device_id = None ):
241+ """Resolve the bucketing value for a flag based on its bucketing_identifier setting.
242+
243+ Returns:
244+ The appropriate identifier string to use for hashing/bucketing.
245+
246+ Raises:
247+ InconclusiveMatchError: If the flag requires device_id but none was provided.
248+ """
249+ flag_filters = flag .get ("filters" ) or {}
250+ bucketing_identifier = flag .get ("bucketing_identifier" ) or flag_filters .get (
251+ "bucketing_identifier"
252+ )
253+ if bucketing_identifier == "device_id" :
254+ if not device_id :
255+ raise InconclusiveMatchError (
256+ "Flag requires device_id for bucketing but none was provided"
257+ )
258+ return device_id
259+ return distinct_id
260+
261+
218262def match_feature_flag_properties (
219263 flag ,
220264 distinct_id ,
221265 properties ,
266+ * ,
222267 cohort_properties = None ,
223268 flags_by_key = None ,
224269 evaluation_cache = None ,
270+ device_id = None ,
271+ bucketing_value = None ,
225272) -> FlagValue :
226- flag_conditions = (flag .get ("filters" ) or {}).get ("groups" ) or []
273+ if bucketing_value is None :
274+ warnings .warn (
275+ "Calling match_feature_flag_properties() without bucketing_value is deprecated. "
276+ "Pass bucketing_value explicitly. This fallback will be removed in a future major release." ,
277+ DeprecationWarning ,
278+ stacklevel = 2 ,
279+ )
280+ bucketing_value = resolve_bucketing_value (flag , distinct_id , device_id )
281+
282+ flag_filters = flag .get ("filters" ) or {}
283+ flag_conditions = flag_filters .get ("groups" ) or []
227284 is_inconclusive = False
228285 cohort_properties = cohort_properties or {}
229286 # Some filters can be explicitly set to null, which require accessing variants like so
230- flag_variants = ((flag .get ("filters" ) or {}).get ("multivariate" ) or {}).get (
231- "variants"
232- ) or []
287+ flag_variants = (flag_filters .get ("multivariate" ) or {}).get ("variants" ) or []
233288 valid_variant_keys = [variant ["key" ] for variant in flag_variants ]
234289
235290 for condition in flag_conditions :
@@ -244,12 +299,14 @@ def match_feature_flag_properties(
244299 cohort_properties ,
245300 flags_by_key ,
246301 evaluation_cache ,
302+ bucketing_value = bucketing_value ,
303+ device_id = device_id ,
247304 ):
248305 variant_override = condition .get ("variant" )
249306 if variant_override and variant_override in valid_variant_keys :
250307 variant = variant_override
251308 else :
252- variant = get_matching_variant (flag , distinct_id )
309+ variant = get_matching_variant (flag , bucketing_value )
253310 return variant or True
254311 except RequiresServerEvaluation :
255312 # Static cohort or other missing server-side data - must fallback to API
@@ -277,6 +334,9 @@ def is_condition_match(
277334 cohort_properties ,
278335 flags_by_key = None ,
279336 evaluation_cache = None ,
337+ * ,
338+ bucketing_value ,
339+ device_id = None ,
280340) -> bool :
281341 rollout_percentage = condition .get ("rollout_percentage" )
282342 if len (condition .get ("properties" ) or []) > 0 :
@@ -290,6 +350,7 @@ def is_condition_match(
290350 flags_by_key ,
291351 evaluation_cache ,
292352 distinct_id ,
353+ device_id = device_id ,
293354 )
294355 elif property_type == "flag" :
295356 matches = evaluate_flag_dependency (
@@ -299,6 +360,7 @@ def is_condition_match(
299360 distinct_id ,
300361 properties ,
301362 cohort_properties ,
363+ device_id = device_id ,
302364 )
303365 else :
304366 matches = match_property (prop , properties )
@@ -308,9 +370,9 @@ def is_condition_match(
308370 if rollout_percentage is None :
309371 return True
310372
311- if rollout_percentage is not None and _hash (feature_flag [ "key" ], distinct_id ) > (
312- rollout_percentage / 100
313- ):
373+ if rollout_percentage is not None and _hash (
374+ feature_flag [ "key" ], bucketing_value
375+ ) > ( rollout_percentage / 100 ) :
314376 return False
315377
316378 return True
@@ -454,6 +516,7 @@ def match_cohort(
454516 flags_by_key = None ,
455517 evaluation_cache = None ,
456518 distinct_id = None ,
519+ device_id = None ,
457520) -> bool :
458521 # Cohort properties are in the form of property groups like this:
459522 # {
@@ -478,6 +541,7 @@ def match_cohort(
478541 flags_by_key ,
479542 evaluation_cache ,
480543 distinct_id ,
544+ device_id = device_id ,
481545 )
482546
483547
@@ -488,6 +552,7 @@ def match_property_group(
488552 flags_by_key = None ,
489553 evaluation_cache = None ,
490554 distinct_id = None ,
555+ device_id = None ,
491556) -> bool :
492557 if not property_group :
493558 return True
@@ -512,6 +577,7 @@ def match_property_group(
512577 flags_by_key ,
513578 evaluation_cache ,
514579 distinct_id ,
580+ device_id = device_id ,
515581 )
516582 if property_group_type == "AND" :
517583 if not matches :
@@ -545,6 +611,7 @@ def match_property_group(
545611 flags_by_key ,
546612 evaluation_cache ,
547613 distinct_id ,
614+ device_id = device_id ,
548615 )
549616 elif prop .get ("type" ) == "flag" :
550617 matches = evaluate_flag_dependency (
@@ -554,6 +621,7 @@ def match_property_group(
554621 distinct_id ,
555622 property_values ,
556623 cohort_properties ,
624+ device_id = device_id ,
557625 )
558626 else :
559627 matches = match_property (prop , property_values )
0 commit comments