Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: DecisionResult = {
'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