Skip to content
Merged
68 changes: 48 additions & 20 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,11 @@ class VariationResult(TypedDict):
variation: Optional[Union[entities.Variation, VariationDict]]


class DecisionResult(TypedDict):
"""
A TypedDict representing the result of a decision process.
class _DecisionResultOptional(TypedDict, total=False):
holdout_decision: Decision

Attributes:
decision (Decision): The decision object containing the outcome of the evaluation.
error (bool): Indicates whether an error occurred during the decision process.
reasons (List[str]): A list of reasons explaining the decision or any errors encountered.
"""

class DecisionResult(_DecisionResultOptional):
decision: Decision
error: bool
reasons: List[str]
Expand Down Expand Up @@ -610,7 +606,6 @@ def get_variation_for_rollout(
return Decision(experiment=rule, variation=forced_decision_variation,
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons

# Check local holdouts targeting this specific delivery rule (FSSDK-12369)
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
for holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
Expand Down Expand Up @@ -751,14 +746,16 @@ def get_decision_for_flag(
reasons = decide_reasons.copy() if decide_reasons else []
user_id = user_context.user_id

global_holdout_result: DecisionResult | None = None
global_holdout_key: str | None = None

# Check global holdouts (flag level — before any rules are evaluated)
global_holdouts = project_config.get_global_holdouts()
for holdout in global_holdouts:
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
reasons.extend(holdout_decision['reasons'])

decision = holdout_decision['decision']
# Check if user was bucketed into holdout (has a variation)
if decision.variation is None:
continue

Expand All @@ -768,18 +765,33 @@ def get_decision_for_flag(
)
self.logger.info(message)
reasons.append(message)
return {
'decision': holdout_decision['decision'],
'error': False,
'reasons': reasons
}

# If no global holdout decision, check experiments then rollouts
if not holdout.exclude_targeted_deliveries:
return {
'decision': holdout_decision['decision'],
'error': False,
'reasons': reasons
}

message = (
f"Holdout '{holdout.key}' excludes targeted deliveries. "
f"Targeted delivery rules will be evaluated."
)
self.logger.info(message)
reasons.append(message)
global_holdout_result = holdout_decision
global_holdout_key = holdout.key
break

# Check experiments then rollouts
if feature_flag.experimentIds:
for experiment_id in feature_flag.experimentIds:
experiment = project_config.get_experiment_from_id(experiment_id)

if experiment:
if global_holdout_result is not None and experiment.type != enums.ExperimentTypes.td:
continue

# Check for forced decision
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
feature_flag.key, experiment.key)
Expand All @@ -790,13 +802,15 @@ def get_decision_for_flag(
if forced_decision_variation:
decision = Decision(experiment, forced_decision_variation,
enums.DecisionSources.FEATURE_TEST, None)
return {
result: DecisionResult = {
'decision': decision,
'error': False,
'reasons': reasons
}
if global_holdout_result is not None:
result['holdout_decision'] = global_holdout_result['decision']
return result

# Check local holdouts targeting this specific experiment rule (FSSDK-12369)
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
for holdout in local_holdouts:
local_holdout_decision = self.get_variation_for_holdout(
Expand Down Expand Up @@ -837,11 +851,22 @@ def get_decision_for_flag(
decision = Decision(experiment, variation_result['variation'],
enums.DecisionSources.FEATURE_TEST,
variation_result['cmab_uuid'])
return {
result = {
'decision': decision,
'error': False,
'reasons': reasons
}
if global_holdout_result is not None:
result['holdout_decision'] = global_holdout_result['decision']
return result

if global_holdout_result is not None:
message = (
f"Holdout \"{global_holdout_key}\" has excludeTargetedDeliveries enabled, "
f"continuing to rollout evaluation."
)
self.logger.info(message)
reasons.append(message)

# If no experiment decision, check rollouts
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
Expand All @@ -863,11 +888,14 @@ def get_decision_for_flag(
else:
self.logger.debug(f'User "{user_id}" not bucketed into any rollout for feature "{feature_flag.key}".')

return {
final_result: DecisionResult = {
'decision': rollout_decision,
'error': False,
'reasons': reasons
}
if global_holdout_result is not None:
final_result['holdout_decision'] = global_holdout_result['decision']
return final_result

def get_variation_for_holdout(
self,
Expand Down
2 changes: 2 additions & 0 deletions optimizely/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ def __init__(
audienceIds: list[str],
audienceConditions: Optional[Sequence[str | list[str]]] = None,
includedRules: Optional[list[str]] = None,
excludeTargetedDeliveries: bool = False,
**kwargs: Any
):
self.id = id
Expand All @@ -236,6 +237,7 @@ def __init__(
# Per-rule targeting for local holdouts. Scope comes from the datafile
# section, not this field; ProjectConfig strips it on 'holdouts' entries.
self.included_rules: Optional[list[str]] = includedRules
self.exclude_targeted_deliveries: bool = excludeTargetedDeliveries

def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
"""Returns audienceConditions if present, otherwise audienceIds.
Expand Down
28 changes: 26 additions & 2 deletions optimizely/optimizely.py
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,8 @@ def _create_optimizely_decision(
flag_decision: Decision,
decision_reasons: Optional[list[str]],
decide_options: list[str],
project_config: ProjectConfig
project_config: ProjectConfig,
holdout_decision: Optional[Decision] = None
) -> OptimizelyDecision:
user_id = user_context.user_id
feature_enabled = False
Expand All @@ -1264,6 +1265,26 @@ def _create_optimizely_decision(

feature_flag = project_config.feature_key_map.get(flag_key)

# Send holdout impression when user was bucketed into a holdout bypassed due to exclude_targeted_deliveries
if (holdout_decision is not None
and decision_source != DecisionSources.HOLDOUT
and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
self._send_impression_event(
project_config,
holdout_decision.experiment,
holdout_decision.variation,
flag_key,
holdout_rule_key,
str(DecisionSources.HOLDOUT),
holdout_enabled,
user_id,
attributes,
holdout_decision.cmab_uuid
)
decision_event_dispatched = True

# Send impression event if Decision came from a feature
if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
if (decision_source == DecisionSources.FEATURE_TEST or
Expand Down Expand Up @@ -1448,11 +1469,13 @@ def _decide_for_keys(
user_context,
merged_decide_options
)
holdout_decisions: dict[str, Optional[Decision]] = {}
for i in range(0, len(flags_without_forced_decision)):
decision = decision_list[i]['decision']
reasons = decision_list[i]['reasons']
error = decision_list[i]['error']
flag_key = flags_without_forced_decision[i].key
holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
# store error decision against key and remove key from valid keys
if error:
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
Expand All @@ -1472,7 +1495,8 @@ def _decide_for_keys(
flag_decision,
decision_reasons,
merged_decide_options,
project_config
project_config,
holdout_decision=holdout_decisions.get(key)
)
enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
is_enabled = optimizely_decision.enabled
Expand Down
Loading
Loading