11import atexit
2- import hashlib
32import logging
43import numbers
4+ from collections import defaultdict
55from datetime import datetime , timedelta
6+ from tokenize import group
67from uuid import UUID , uuid4
78
89from dateutil .tz import tzutc
910from six import string_types
1011
1112from posthog .consumer import Consumer
13+ from posthog .feature_flags import InconclusiveMatchError , match_feature_flag_properties
1214from posthog .poller import Poller
1315from posthog .request import APIError , batch_post , decide , get
14- from posthog .utils import clean , guess_timezone
16+ from posthog .utils import SizeLimitedDict , clean , guess_timezone
1517from posthog .version import VERSION
1618
1719try :
2123
2224
2325ID_TYPES = (numbers .Number , string_types , UUID )
24- __LONG_SCALE__ = float ( 0xFFFFFFFFFFFFFFF )
26+ MAX_DICT_SIZE = 100_000
2527
2628
2729class Client (object ):
@@ -64,8 +66,10 @@ def __init__(
6466 self .gzip = gzip
6567 self .timeout = timeout
6668 self .feature_flags = None
69+ self .group_type_mapping = None
6770 self .poll_interval = poll_interval
6871 self .poller = None
72+ self .distinct_ids_feature_flags_reported = SizeLimitedDict (MAX_DICT_SIZE , set )
6973
7074 # personal_api_key: This should be a generated Personal API Key, private
7175 self .personal_api_key = personal_api_key
@@ -120,8 +124,7 @@ def identify(self, distinct_id=None, properties=None, context=None, timestamp=No
120124
121125 return self ._enqueue (msg )
122126
123- def get_feature_variants (self , distinct_id , groups = None ):
124- assert self .personal_api_key , "You have to specify a personal_api_key to use feature flags."
127+ def get_feature_variants (self , distinct_id , groups = None , person_properties = None , group_properties = None ):
125128 require ("distinct_id" , distinct_id , ID_TYPES )
126129
127130 if groups :
@@ -131,8 +134,9 @@ def get_feature_variants(self, distinct_id, groups=None):
131134
132135 request_data = {
133136 "distinct_id" : distinct_id ,
134- "personal_api_key" : self .personal_api_key ,
135137 "groups" : groups ,
138+ "person_properties" : person_properties ,
139+ "group_properties" : group_properties ,
136140 }
137141 resp_data = decide (self .api_key , self .host , timeout = 10 , ** request_data )
138142 return resp_data ["featureFlags" ]
@@ -352,8 +356,12 @@ def shutdown(self):
352356
353357 def _load_feature_flags (self ):
354358 try :
355- flags = get (self .personal_api_key , f"/api/feature_flag/?token={ self .api_key } " , self .host )["results" ]
356- self .feature_flags = [flag for flag in flags if flag ["active" ]]
359+ response = get (
360+ self .personal_api_key , f"/api/feature_flag/local_evaluation/?token={ self .api_key } " , self .host
361+ )
362+ self .feature_flags = [flag for flag in response ["flags" ] if flag ["active" ]]
363+ self .group_type_mapping = response ["group_type_mapping" ]
364+
357365 except APIError as e :
358366 if e .status == 401 :
359367 raise APIError (
@@ -384,7 +392,51 @@ def load_feature_flags(self):
384392 self .poller = Poller (interval = timedelta (seconds = self .poll_interval ), execute = self ._load_feature_flags )
385393 self .poller .start ()
386394
387- def feature_enabled (self , key , distinct_id , default = False , * , groups = {}):
395+ def _compute_flag_locally (self , feature_flag , distinct_id , * , groups = {}, person_properties = {}, group_properties = {}):
396+
397+ if feature_flag .get ("ensure_experience_continuity" , False ):
398+ raise InconclusiveMatchError ("Flag has experience continuity enabled" )
399+
400+ flag_filters = feature_flag .get ("filters" ) or {}
401+ aggregation_group_type_index = flag_filters .get ("aggregation_group_type_index" )
402+ if aggregation_group_type_index is not None :
403+ group_name = self .group_type_mapping .get (str (aggregation_group_type_index ))
404+
405+ if not group_name :
406+ self .log .warning (
407+ f"[FEATURE FLAGS] Unknown group type index { aggregation_group_type_index } for feature flag { feature_flag ['key' ]} "
408+ )
409+ # failover to `/decide/`
410+ raise InconclusiveMatchError ("Flag has unknown group type index" )
411+
412+ if group_name not in groups :
413+ # Group flags are never enabled in `groups` aren't passed in
414+ # don't failover to `/decide/`, since response will be the same
415+ self .log .warning (
416+ f"[FEATURE FLAGS] Can't compute group feature flag: { feature_flag ['key' ]} without group names passed in"
417+ )
418+ return False
419+
420+ focused_group_properties = group_properties [group_name ]
421+ return match_feature_flag_properties (feature_flag , groups [group_name ], focused_group_properties )
422+ else :
423+ return match_feature_flag_properties (feature_flag , distinct_id , person_properties )
424+
425+ def feature_enabled (self , key , distinct_id , default = False , * , groups = {}, person_properties = {}, group_properties = {}):
426+ return bool (
427+ self .get_feature_flag (
428+ key ,
429+ distinct_id ,
430+ default ,
431+ groups = groups ,
432+ person_properties = person_properties ,
433+ group_properties = group_properties ,
434+ )
435+ )
436+
437+ def get_feature_flag (
438+ self , key , distinct_id , default = False , * , groups = {}, person_properties = {}, group_properties = {}
439+ ):
388440 require ("key" , key , string_types )
389441 require ("distinct_id" , distinct_id , ID_TYPES )
390442 require ("groups" , groups , dict )
@@ -397,45 +449,78 @@ def feature_enabled(self, key, distinct_id, default=False, *, groups={}):
397449 if self .feature_flags :
398450 for flag in self .feature_flags :
399451 if flag ["key" ] == key :
400- feature_flag = flag
401- if feature_flag .get ("is_simple_flag" ):
402- rollout_percentage = (
403- feature_flag .get ("rollout_percentage" )
404- if feature_flag .get ("rollout_percentage" ) is not None
405- else 100
452+ try :
453+ response = self ._compute_flag_locally (
454+ flag ,
455+ distinct_id ,
456+ groups = groups ,
457+ person_properties = person_properties ,
458+ group_properties = group_properties ,
406459 )
407- response = _hash (key , distinct_id ) <= (rollout_percentage / 100 )
408- if response == None :
460+ except InconclusiveMatchError as e :
461+ # No need to log this, since it's just telling us to fall back to `/decide`
462+ continue
463+ except Exception as e :
464+ self .log .exception (f"[FEATURE FLAGS] Error while computing variant: { e } " )
465+ continue
466+
467+ if response is None :
409468 try :
410- feature_flags = self .get_feature_variants (distinct_id , groups = groups )
469+ feature_flags = self .get_feature_variants (
470+ distinct_id , groups = groups , person_properties = person_properties , group_properties = group_properties
471+ )
472+ response = feature_flags .get (key )
411473 except Exception as e :
412474 self .log .exception (f"[FEATURE FLAGS] Unable to get feature variants: { e } " )
413475 response = default
414- else :
415- response = True if feature_flags .get (key ) else default
416- self .capture (distinct_id , "$feature_flag_called" , {"$feature_flag" : key , "$feature_flag_response" : response })
476+
477+ if key not in self .distinct_ids_feature_flags_reported [distinct_id ]:
478+ self .capture (
479+ distinct_id , "$feature_flag_called" , {"$feature_flag" : key , "$feature_flag_response" : response }
480+ )
481+ self .distinct_ids_feature_flags_reported [distinct_id ].add (key )
417482 return response
418483
419- def get_feature_flag (self , key , distinct_id , groups = {}):
420- require ("key" , key , string_types )
484+ def get_all_flags (self , distinct_id , * , groups = {}, person_properties = {}, group_properties = {}):
421485 require ("distinct_id" , distinct_id , ID_TYPES )
422486 require ("groups" , groups , dict )
423487
424- variants = self .get_feature_variants (distinct_id , groups = groups )
425- self .capture (
426- distinct_id , "$feature_flag_called" , {"$feature_flag" : key , "$feature_flag_response" : variants .get (key )}
427- )
428- return variants .get (key )
488+ if self .feature_flags == None and self .personal_api_key :
489+ self .load_feature_flags ()
490+
491+ response = {}
492+ fallback_to_decide = False
493+
494+ # If loading in previous line failed
495+ if self .feature_flags :
496+ for flag in self .feature_flags :
497+ try :
498+ response [flag ["key" ]] = self ._compute_flag_locally (
499+ flag ,
500+ distinct_id ,
501+ groups = groups ,
502+ person_properties = person_properties ,
503+ group_properties = group_properties ,
504+ )
505+ except InconclusiveMatchError as e :
506+ # No need to log this, since it's just telling us to fall back to `/decide`
507+ fallback_to_decide = True
508+ except Exception as e :
509+ self .log .exception (f"[FEATURE FLAGS] Error while computing variant: { e } " )
510+ fallback_to_decide = True
511+ else :
512+ fallback_to_decide = True
429513
514+ if fallback_to_decide :
515+ try :
516+ feature_flags = self .get_feature_variants (
517+ distinct_id , groups = groups , person_properties = person_properties , group_properties = group_properties
518+ )
519+ response = {** response , ** feature_flags }
520+ except Exception as e :
521+ self .log .exception (f"[FEATURE FLAGS] Unable to get feature variants: { e } " )
430522
431- # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
432- # Given the same distinct_id and key, it'll always return the same float. These floats are
433- # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
434- # we can do _hash(key, distinct_id) < 0.2
435- def _hash (key , distinct_id ):
436- hash_key = "%s.%s" % (key , distinct_id )
437- hash_val = int (hashlib .sha1 (hash_key .encode ("utf-8" )).hexdigest ()[:15 ], 16 )
438- return hash_val / __LONG_SCALE__
523+ return response
439524
440525
441526def require (name , field , data_type ):
0 commit comments