@@ -79,7 +79,7 @@ def start_polling_for_definitions(self):
7979 )
8080 self ._sync_polling_task .start ()
8181 else :
82- logging .warning ("A polling task is already running" )
82+ logger .warning ("A polling task is already running" )
8383
8484 def stop_polling_for_definitions (self ):
8585 """
@@ -90,7 +90,7 @@ def stop_polling_for_definitions(self):
9090 self ._sync_stop_event .set ()
9191 self ._sync_polling_task = None
9292 else :
93- logging .info ("There is no polling task to cancel." )
93+ logger .info ("There is no polling task to cancel." )
9494
9595 async def astart_polling_for_definitions (self ):
9696 """
@@ -105,7 +105,7 @@ async def astart_polling_for_definitions(self):
105105 self ._astart_continuous_polling ()
106106 )
107107 else :
108- logging .error ("A polling task is already running" )
108+ logger .error ("A polling task is already running" )
109109
110110 async def astop_polling_for_definitions (self ):
111111 """
@@ -115,21 +115,21 @@ async def astop_polling_for_definitions(self):
115115 self ._async_polling_task .cancel ()
116116 self ._async_polling_task = None
117117 else :
118- logging .info ("There is no polling task to cancel." )
118+ logger .info ("There is no polling task to cancel." )
119119
120120 async def _astart_continuous_polling (self ):
121- logging .info (
121+ logger .info (
122122 f"Initialized async polling for flag definition updates every '{ self ._config .polling_interval_in_seconds } ' seconds"
123123 )
124124 try :
125125 while True :
126126 await asyncio .sleep (self ._config .polling_interval_in_seconds )
127127 await self ._afetch_flag_definitions ()
128128 except asyncio .CancelledError :
129- logging .info ("Async polling was cancelled" )
129+ logger .info ("Async polling was cancelled" )
130130
131131 def _start_continuous_polling (self ):
132- logging .info (
132+ logger .info (
133133 f"Initialized sync polling for flag definition updates every '{ self ._config .polling_interval_in_seconds } ' seconds"
134134 )
135135 while not self ._sync_stop_event .is_set ():
@@ -146,6 +146,22 @@ def are_flags_ready(self) -> bool:
146146 """
147147 return self ._are_flags_ready
148148
149+ def get_all_variants (self , context : Dict [str , Any ]) -> Dict [str , SelectedVariant ]:
150+ """
151+ Gets the selected variant for all feature flags that the current user context is in the rollout for.
152+ Exposure events are not automatically tracked when this method is used.
153+ :param Dict[str, Any] context: The user context to evaluate against the feature flags
154+ """
155+ variants : Dict [str , SelectedVariant ] = {}
156+ fallback = SelectedVariant (variant_key = None , variant_value = None )
157+
158+ for flag_key in self ._flag_definitions .keys ():
159+ variant = self .get_variant (flag_key , fallback , context , report_exposure = False )
160+ if variant .variant_key is not None :
161+ variants [flag_key ] = variant
162+
163+ return variants
164+
149165 def get_variant_value (
150166 self , flag_key : str , fallback_value : Any , context : Dict [str , Any ]
151167 ) -> Any :
@@ -206,16 +222,28 @@ def get_variant(
206222 flag_definition , context_value , flag_key , rollout
207223 )
208224
209- if report_exposure and selected_variant is not None :
210- end_time = time .perf_counter ()
211- self ._track_exposure (flag_key , selected_variant , end_time - start_time , context )
225+ if selected_variant is not None :
226+ if report_exposure :
227+ end_time = time .perf_counter ()
228+ self ._track_exposure (flag_key , selected_variant , context , end_time - start_time )
212229 return selected_variant
213230
214- logger .info (
231+ logger .debug (
215232 f"{ flag_definition .context } context { context_value } not eligible for any rollout for flag: { flag_key } "
216233 )
217234 return fallback_value
218235
236+ def track_exposure_event (self , flag_key : str , variant : SelectedVariant , context : Dict [str , Any ]):
237+ """
238+ Manually tracks a feature flagging exposure event to Mixpanel.
239+ This is intended to provide flexibility for when individual exposure events are reported when using `get_all_variants` for the user at once with exposure event reporting
240+
241+ :param str flag_key: The key of the feature flag
242+ :param SelectedVariant variant: The selected variant for the feature flag
243+ :param Dict[str, Any] context: The user context used to evaluate the feature flag
244+ """
245+ self ._track_exposure (flag_key , variant , context )
246+
219247 def _get_variant_override_for_test_user (
220248 self , flag_definition : ExperimentationFlag , context : Dict [str , Any ]
221249 ) -> Optional [SelectedVariant ]:
@@ -244,10 +272,9 @@ def _get_assigned_variant(
244272 ):
245273 return variant
246274
247-
248- hash_input = str (context_value ) + flag_name
249-
250- variant_hash = normalized_hash (hash_input , "variant" )
275+ stored_salt = flag_definition .hash_salt if flag_definition .hash_salt is not None else ""
276+ salt = flag_name + stored_salt + "variant"
277+ variant_hash = normalized_hash (str (context_value ), salt )
251278
252279 variants = [variant .model_copy (deep = True ) for variant in flag_definition .ruleset .variants ]
253280 if rollout .variant_splits :
@@ -275,13 +302,16 @@ def _get_assigned_rollout(
275302 context_value : Any ,
276303 context : Dict [str , Any ],
277304 ) -> Optional [Rollout ]:
278- hash_input = str (context_value ) + flag_definition .key
305+ for index , rollout in enumerate (flag_definition .ruleset .rollout ):
306+ salt = None
307+ if flag_definition .hash_salt is not None :
308+ salt = flag_definition .key + flag_definition .hash_salt + str (index )
309+ else :
310+ salt = flag_definition .key + "rollout"
279311
280- rollout_hash = normalized_hash (hash_input , "rollout" )
312+ rollout_hash = normalized_hash (str ( context_value ), salt )
281313
282- for rollout in flag_definition .ruleset .rollout :
283- if (
284- rollout_hash < rollout .rollout_percentage
314+ if (rollout_hash < rollout .rollout_percentage
285315 and self ._is_runtime_evaluation_satisfied (rollout , context )
286316 ):
287317 return rollout
@@ -352,7 +382,7 @@ def _handle_response(
352382 self , response : httpx .Response , start_time : datetime , end_time : datetime
353383 ) -> None :
354384 request_duration : timedelta = end_time - start_time
355- logging . info (
385+ logger . debug (
356386 f"Request started at '{ start_time .isoformat ()} ', completed at '{ end_time .isoformat ()} ', duration: '{ request_duration .total_seconds ():.3f} s'"
357387 )
358388
@@ -378,24 +408,26 @@ def _track_exposure(
378408 self ,
379409 flag_key : str ,
380410 variant : SelectedVariant ,
381- latency_in_seconds : float ,
382411 context : Dict [str , Any ],
412+ latency_in_seconds : Optional [float ]= None ,
383413 ):
384414 if distinct_id := context .get ("distinct_id" ):
385415 properties = {
386416 "Experiment name" : flag_key ,
387417 "Variant name" : variant .variant_key ,
388418 "$experiment_type" : "feature_flag" ,
389419 "Flag evaluation mode" : "local" ,
390- "Variant fetch latency (ms)" : latency_in_seconds * 1000 ,
391420 "$experiment_id" : variant .experiment_id ,
392421 "$is_experiment_active" : variant .is_experiment_active ,
393422 "$is_qa_tester" : variant .is_qa_tester ,
394423 }
395424
425+ if latency_in_seconds is not None :
426+ properties ["Variant fetch latency (ms)" ] = latency_in_seconds * 1000
427+
396428 self ._tracker (distinct_id , EXPOSURE_EVENT , properties )
397429 else :
398- logging .error (
430+ logger .error (
399431 "Cannot track exposure event without a distinct_id in the context"
400432 )
401433
@@ -406,11 +438,11 @@ def __enter__(self):
406438 return self
407439
408440 async def __aexit__ (self , exc_type , exc_val , exc_tb ):
409- logging .info ("Exiting the LocalFeatureFlagsProvider and cleaning up resources" )
441+ logger .info ("Exiting the LocalFeatureFlagsProvider and cleaning up resources" )
410442 await self .astop_polling_for_definitions ()
411443 await self ._async_client .aclose ()
412444
413445 def __exit__ (self , exc_type , exc_val , exc_tb ):
414- logging .info ("Exiting the LocalFeatureFlagsProvider and cleaning up resources" )
446+ logger .info ("Exiting the LocalFeatureFlagsProvider and cleaning up resources" )
415447 self .stop_polling_for_definitions ()
416448 self ._sync_client .close ()
0 commit comments