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
57 changes: 37 additions & 20 deletions lib/optimizely/config/datafile_project_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ class DatafileProjectConfig < ProjectConfig
:group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
:variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
:variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map,
:global_holdouts, :rule_holdouts_map
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :local_holdouts,
:holdout_id_map, :global_holdouts, :rule_holdouts_map
# Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
attr_reader :anonymize_ip

Expand Down Expand Up @@ -71,7 +71,10 @@ def initialize(datafile, logger, error_handler)
@send_flag_decisions = config.fetch('sendFlagDecisions', false)
@integrations = config.fetch('integrations', [])
@region = config.fetch('region', 'US')
# `holdouts` carries only global holdouts; `localHoldouts` (new top-level section) carries
# only rule-scoped local holdouts. Section membership is the sole signal for scope.
@holdouts = config.fetch('holdouts', [])
@local_holdouts = config.fetch('localHoldouts', [])

# Default to US region if not specified
@region = 'US' if @region.nil? || @region.empty?
Expand Down Expand Up @@ -118,25 +121,38 @@ def initialize(datafile, logger, error_handler)
@global_holdouts = []
@rule_holdouts_map = {}

# Section membership determines scope: entries in `holdouts` are global, entries in
# `localHoldouts` are local. Any `includedRules` on a `holdouts` entry is stripped/ignored.
@holdouts.each do |holdout|
next unless holdout['status'] == 'Running'

# Ensure holdout has layerId field (holdouts don't have campaigns)
holdout['layerId'] ||= ''
# Strip includedRules from global-section entries — section membership is the sole signal.
holdout.delete('includedRules')

@holdout_id_map[holdout['id']] = holdout
@global_holdouts << holdout
end

# Build global vs local holdout mappings
# A holdout is global when includedRules is nil/absent (applies to all rules)
# A holdout is local when includedRules is a non-nil array (applies only to specified rules)
if holdout_global?(holdout)
@global_holdouts << holdout
else
included_rules = holdout['includedRules'] || []
included_rules.each do |rule_id|
@rule_holdouts_map[rule_id] ||= []
@rule_holdouts_map[rule_id] << holdout
end
@local_holdouts.each do |holdout|
next unless holdout['status'] == 'Running'

# Local holdouts without includedRules are invalid — log and skip (do not fall back to global).
included_rules = holdout['includedRules']
if included_rules.nil? || !included_rules.is_a?(Array) || included_rules.empty?
@logger.log(
Logger::ERROR,
"Local holdout '#{holdout['key'] || holdout['id']}' is missing or has empty 'includedRules'; skipping."
)
next
end

holdout['layerId'] ||= ''
@holdout_id_map[holdout['id']] = holdout
included_rules.each do |rule_id|
@rule_holdouts_map[rule_id] ||= []
@rule_holdouts_map[rule_id] << holdout
end
end

Expand Down Expand Up @@ -217,10 +233,11 @@ def initialize(datafile, logger, error_handler)
# Generate flag_variation_map after injection so it includes everyone-else variations
@flag_variation_map = generate_feature_variation_map(@feature_flags)

# Adding Holdout variations in variation id and key maps
return unless @holdouts && !@holdouts.empty?
# Adding Holdout variations in variation id and key maps (both global and local sections).
all_holdouts = (@holdouts || []) + (@local_holdouts || [])
return if all_holdouts.empty?

@holdouts.each do |holdout|
all_holdouts.each do |holdout|
next unless holdout['status'] == 'Running'

holdout_key = holdout['key']
Expand Down Expand Up @@ -659,7 +676,7 @@ def get_holdout(holdout_id)
end

def get_holdouts_for_rule(rule_id)
# Returns running local holdouts that target a specific rule ID.
# Returns running local holdouts (from the `localHoldouts` section) that target the rule.
# Local holdouts apply only to the rules listed in their includedRules array.
#
# rule_id - String ID of the experiment/delivery rule
Expand All @@ -669,9 +686,9 @@ def get_holdouts_for_rule(rule_id)
end

def holdout_global?(holdout)
# Determines whether a holdout is global (applies to all rules) or local (applies to specific rules).
# A holdout is global when includedRules is nil or absent from the datafile.
# A holdout with an empty array [] is a local holdout with no matching rules (NOT global).
# Returns true when the holdout came from the global `holdouts` section.
# Section membership is the sole signal for scope; `ProjectConfig` strips `includedRules`
# from `holdouts`-section entries at parse time, so absence of the key is equivalent.
#
# holdout - Holdout hash from the datafile
#
Expand Down
20 changes: 20 additions & 0 deletions lib/optimizely/helpers/constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,26 @@ module Constants
}
}
}
},
'localHoldouts' => {
'type' => 'array',
'items' => {
'type' => 'object',
'properties' => {
'id' => {
'type' => 'string'
},
'key' => {
'type' => 'string'
},
'status' => {
'type' => 'string'
},
'includedRules' => {
'type' => %w[array null]
}
}
}
}
},
'required' => %w[
Expand Down
Loading
Loading