Skip to content

Commit 7a86004

Browse files
authored
Updated salt support and convenience methods for flag providers (#147)
* Updated salt support and convenience methods for flag providers * update salt * fixing copilot comments * update logger
1 parent c801c77 commit 7a86004

File tree

6 files changed

+342
-65
lines changed

6 files changed

+342
-65
lines changed

mixpanel/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .flags.remote_feature_flags import RemoteFeatureFlagsProvider
3131
from .flags.types import LocalFlagsConfig, RemoteFlagsConfig
3232

33-
__version__ = '5.0.0b2'
33+
__version__ = '5.0.0'
3434

3535
logger = logging.getLogger(__name__)
3636

mixpanel/flags/local_feature_flags.py

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)