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
25 changes: 23 additions & 2 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def create_user_context(user_id, attributes = nil)
OptimizelyUserContext.new(self, user_id, attributes)
end

def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config)
def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config, holdout_decision = nil)
# Create Optimizely Decision Result.
user_id = user_context.user_id
attributes = user_context.user_attributes
Expand Down Expand Up @@ -225,6 +225,23 @@ def create_optimizely_decision(user_context, flag_key, decision, reasons, decide
decision_event_dispatched = true
end

if holdout_decision && !decide_options.include?(OptimizelyDecideOption::DISABLE_DECISION_EVENT) && decision_source != Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT']
holdout_experiment = holdout_decision.experiment
holdout_variation = holdout_decision.variation
send_impression(
config,
holdout_experiment,
holdout_variation ? holdout_variation['key'] : '',
flag_key,
holdout_experiment ? holdout_experiment['key'] : '',
holdout_variation ? holdout_variation['featureEnabled'] : false,
Optimizely::DecisionService::DECISION_SOURCES['HOLDOUT'],
user_id,
attributes
)
decision_event_dispatched = true
end

# Generate all variables map if decide options doesn't include excludeVariables
unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
feature_flag['variables'].each do |variable|
Expand Down Expand Up @@ -377,6 +394,7 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
end
decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)

holdout_decisions = {}
flags_without_forced_decision.each_with_index do |flag, i|
decision = decision_list[i].decision
reasons = decision_list[i].reasons
Expand All @@ -390,6 +408,8 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
next
end
flag_decisions[flag_key] = decision
holdout_decision = decision_list[i].holdout_decision
holdout_decisions[flag_key] = holdout_decision if holdout_decision
decision_reasons_dict[flag_key] ||= []
decision_reasons_dict[flag_key].push(*reasons)
end
Expand All @@ -402,7 +422,8 @@ def decide_for_keys(user_context, keys, decide_options = [], ignore_default_opti
flag_decision,
decision_reasons,
decide_options,
config
config,
holdout_decisions[key]
)

enabled_flags_only_missing = !decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
Expand Down
91 changes: 64 additions & 27 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2017-2022, Optimizely and contributors
# Copyright 2017-2022, 2026, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -41,7 +41,7 @@ class DecisionService
Decision = Struct.new(:experiment, :variation, :source, :cmab_uuid)
CmabDecisionResult = Struct.new(:error, :result, :reasons)
VariationResult = Struct.new(:cmab_uuid, :error, :reasons, :variation_id, :holdout_decision)
DecisionResult = Struct.new(:decision, :error, :reasons)
DecisionResult = Struct.new(:decision, :error, :reasons, :holdout_decision)

DECISION_SOURCES = {
'EXPERIMENT' => 'experiment',
Expand Down Expand Up @@ -198,30 +198,38 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt

# Check global holdouts first (flag level) — these apply to all rules across all flags
holdouts = project_config.global_holdouts
exclude_td_holdout_decision = nil

holdouts.each do |holdout|
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
reasons.push(*holdout_decision.reasons)

next unless holdout_decision.decision

message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'."
@logger.log(Logger::INFO, message)
reasons.push(message)
return DecisionResult.new(holdout_decision.decision, false, reasons)
if holdout['exclude_targeted_deliveries'] == true
message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}', but targeted deliveries are excluded."
@logger.log(Logger::INFO, message)
reasons.push(message)
exclude_td_holdout_decision = holdout_decision.decision
Comment thread
esrakartalOpt marked this conversation as resolved.
else
message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'."
@logger.log(Logger::INFO, message)
reasons.push(message)
return DecisionResult.new(holdout_decision.decision, false, reasons)
end
end

# Check if the feature flag has an experiment and the user is bucketed into that experiment
experiment_decision = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
experiment_decision = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options, exclude_td_holdout_decision)
reasons.push(*experiment_decision.reasons)

return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons) if experiment_decision.decision
return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons, exclude_td_holdout_decision) if experiment_decision.decision

# If there's an error (e.g., CMAB error), return immediately without falling back to rollout
return DecisionResult.new(nil, experiment_decision.error, reasons) if experiment_decision.error
return DecisionResult.new(nil, experiment_decision.error, reasons, exclude_td_holdout_decision) if experiment_decision.error

# Check if the feature flag has a rollout and the user is bucketed into that rollout
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context, exclude_td_holdout_decision)
reasons.push(*rollout_decision.reasons)

if rollout_decision.decision
Expand All @@ -235,11 +243,11 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt
reasons.push(message)
end

DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons, exclude_td_holdout_decision)
else
message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
@logger.log(Logger::INFO, message)
DecisionResult.new(nil, false, reasons)
DecisionResult.new(nil, false, reasons, exclude_td_holdout_decision)
end
end

Expand Down Expand Up @@ -325,12 +333,15 @@ def get_variations_for_feature_list(project_config, feature_flags, user_context,
decisions
end

def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [])
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [], global_holdout_decision = nil)
# Gets the variation the user is bucketed into for the feature flag's experiment.
#
# project_config - project_config - Instance of ProjectConfig
# feature_flag - The feature flag the user wants to access
# user_context - Optimizely user context instance
# user_profile_tracker - Tracker for reading and updating user profile of the user
# decide_options - Array of decide options
# global_holdout_decision - Decision from global holdout when excludeTargetedDeliveries is true (nil otherwise)
#
# Returns a DecisionResult containing the decision (or nil if not bucketed),
# an error flag, and an array of decision reasons.
Expand All @@ -355,7 +366,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
end

experiment_id = experiment['id']
variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options, global_holdout_decision)
error = variation_result.error
reasons_received = variation_result.reasons
variation_id = variation_result.variation_id
Expand Down Expand Up @@ -384,13 +395,14 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
DecisionResult.new(nil, false, decide_reasons)
end

def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
def get_variation_for_feature_rollout(project_config, feature_flag, user_context, global_holdout_decision = nil)
# Determine which variation the user is in for a given rollout.
# Returns the variation of the first experiment the user qualifies for.
#
# project_config - project_config - Instance of ProjectConfig
# feature_flag - The feature flag the user wants to access
# user_context - Optimizely user context instance
# global_holdout_decision - Decision from global holdout when excludeTargetedDeliveries is true (nil otherwise)
#
# Returns a DecisionResult containing the decision (or nil if not bucketed),
# an error flag, and an array of decision reasons.
Expand Down Expand Up @@ -418,7 +430,7 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
index = 0
rollout_rules = rollout['experiments']
while index < rollout_rules.length
holdout_decision, variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
holdout_decision, variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context, global_holdout_decision)
decide_reasons.push(*reasons_received)

return DecisionResult.new(holdout_decision, false, decide_reasons) if holdout_decision
Expand All @@ -435,14 +447,17 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
DecisionResult.new(nil, false, decide_reasons)
end

def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
# Determine which variation the user is in for a given rollout.
def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [], global_holdout_decision = nil)
# Determine which variation the user is in for a given experiment rule.
# Returns the variation from experiment rules.
#
# project_config - project_config - Instance of ProjectConfig
# flag_key - The feature flag the user wants to access
# rule - An experiment rule key
# user - Optimizely user context instance
# user_profile_tracker - Tracker for reading and updating user profile of the user
# options - Array of decide options
# global_holdout_decision - Decision from global holdout when excludeTargetedDeliveries is true (nil otherwise)
#
# Returns variation_id and reasons
reasons = []
Expand All @@ -453,7 +468,17 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use
reasons.push(*forced_reasons)
return VariationResult.new(nil, false, reasons, variation['id']) if variation

# Step 2: Local holdout check
# Step 2: Global holdout check (when excludeTargetedDeliveries is true, TD rules skip the holdout)
if global_holdout_decision
return VariationResult.new(nil, false, reasons, nil, global_holdout_decision) if rule['type'] != Helpers::Constants::EXPERIMENT_TYPES['td']

holdout_key = global_holdout_decision.experiment ? global_holdout_decision.experiment['key'] : 'unknown'
message = "Holdout '#{holdout_key}' has excludeTargetedDeliveries enabled, continuing to rollout evaluation."
@logger.log(Logger::INFO, message)
reasons.push(message)
end

# Step 3: Local holdout check
local_holdouts = project_config.get_holdouts_for_rule(rule['id'])
local_holdouts.each do |holdout|
holdout_decision = get_variation_for_holdout(holdout, user, project_config)
Expand All @@ -464,22 +489,24 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use
return VariationResult.new(nil, false, reasons, holdout_variation['id'], holdout_decision.decision)
end

# Step 3: Regular rule evaluation
# Step 4: Regular rule evaluation
variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
variation_result.reasons = reasons + variation_result.reasons
variation_result
end

def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
# Determine which variation the user is in for a given rollout.
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context, global_holdout_decision = nil)
# Determine which variation the user is in for a given delivery rule.
# Returns the variation from delivery rules.
#
# project_config - project_config - Instance of ProjectConfig
# flag_key - The feature flag the user wants to access
# rule - An experiment rule key
# flag_key - The feature flag key
# rules - Array of delivery rules
# rule_index - Index of the current rule
# user_context - Optimizely user context instance
# global_holdout_decision - Decision from global holdout when excludeTargetedDeliveries is true (nil otherwise)
#
# Returns [holdout_decision, variation, skip_to_everyone_else, reasons]
# Returns variation_id, reasons, and skip_to_everyone_else flag
reasons = []
skip_to_everyone_else = false
rule = rules[rule_index]
Expand All @@ -490,15 +517,25 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
reasons.push(*forced_reasons)
return [nil, variation, skip_to_everyone_else, reasons] if variation

# Step 2: Local holdout check
# Step 2: Global holdout check
if global_holdout_decision
return [global_holdout_decision, nil, skip_to_everyone_else, reasons] if rule['type'] != Helpers::Constants::EXPERIMENT_TYPES['td']

holdout_key = global_holdout_decision.experiment ? global_holdout_decision.experiment['key'] : 'unknown'
message = "Holdout '#{holdout_key}' has excludeTargetedDeliveries enabled, continuing to rollout evaluation."
@logger.log(Logger::INFO, message)
reasons.push(message)
end

# Step 3: Local holdout check
local_holdouts = project_config.get_holdouts_for_rule(rule['id'])
local_holdouts.each do |holdout|
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
reasons.push(*holdout_decision.reasons)
return [holdout_decision.decision, nil, skip_to_everyone_else, reasons] if holdout_decision.decision
end

# Step 3: Regular rule evaluation
# Step 4: Regular rule evaluation
user_id = user_context.user_id
attributes = user_context.user_attributes
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
Expand Down
8 changes: 7 additions & 1 deletion lib/optimizely/helpers/constants.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

#
# Copyright 2016-2020, 2022, Optimizely and contributors
# Copyright 2016-2020, 2022, 2026, Optimizely and contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -349,6 +349,9 @@ module Constants
},
'includedRules' => {
'type' => %w[array null]
},
'exclude_targeted_deliveries' => {
'type' => 'boolean'
}
Comment thread
esrakartalOpt marked this conversation as resolved.
}
}
Expand All @@ -369,6 +372,9 @@ module Constants
},
'includedRules' => {
'type' => %w[array null]
},
'exclude_targeted_deliveries' => {
'type' => 'boolean'
}
}
}
Expand Down
Loading
Loading