diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index cfb67f24..763e9fa6 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -180,7 +180,6 @@ def initialize(datafile, logger, error_handler) @all_segments.concat Audience.get_segments(audience['conditions']) end - @flag_variation_map = generate_feature_variation_map(@feature_flags) @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map) @all_experiments.each do |id, exp| variations = exp.fetch('variations') @@ -205,8 +204,33 @@ def initialize(datafile, logger, error_handler) feature_flag['experimentIds'].each do |experiment_id| @experiment_feature_map[experiment_id] = [feature_flag['id']] end + + # Feature Rollout support: inject the "everyone else" variation + # into any experiment with type == "feature_rollout" + everyone_else_variation = get_everyone_else_variation(feature_flag) + next if everyone_else_variation.nil? + + feature_flag['experimentIds'].each do |exp_id| + experiment = @experiment_id_map[exp_id] + next unless experiment && experiment['type'] == 'feature_rollout' + + experiment['variations'].push(everyone_else_variation) + experiment['trafficAllocation'].push( + 'entityId' => everyone_else_variation['id'], + 'endOfRange' => 10_000 + ) + @variation_key_map[experiment['key']][everyone_else_variation['key']] = everyone_else_variation + @variation_id_map[experiment['key']][everyone_else_variation['id']] = everyone_else_variation + @variation_id_map_by_experiment_id[exp_id][everyone_else_variation['id']] = everyone_else_variation + @variation_key_map_by_experiment_id[exp_id][everyone_else_variation['key']] = everyone_else_variation + variation_variables = everyone_else_variation['variables'] + @variation_id_to_variable_usage_map[everyone_else_variation['id']] = generate_key_map(variation_variables, 'id') if variation_variables + end end + # 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? @@ -690,6 +714,38 @@ def get_holdout(holdout_id) private + def get_everyone_else_variation(feature_flag) + # Get the "everyone else" variation for a feature flag. + # + # The "everyone else" rule is the last experiment in the flag's rollout, + # and its first variation is the "everyone else" variation. + # + # feature_flag - Feature flag hash + # + # Returns the "everyone else" variation hash, or nil if not available. + + rollout_id = feature_flag['rolloutId'] + return nil if rollout_id.nil? || rollout_id.empty? + + rollout = @rollout_id_map[rollout_id] + return nil if rollout.nil? + + experiments = rollout['experiments'] + return nil if experiments.nil? || experiments.empty? + + everyone_else_rule = experiments.last + variations = everyone_else_rule['variations'] + return nil if variations.nil? || variations.empty? + + variation = variations.first + { + 'id' => variation['id'], + 'key' => variation['key'], + 'featureEnabled' => variation['featureEnabled'] == true, + 'variables' => variation.fetch('variables', []) + } + end + def generate_feature_variation_map(feature_flags) flag_variation_map = {} feature_flags.each do |flag| diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 4334f56d..869f402c 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -205,6 +205,9 @@ module Constants 'cmab' => { 'type' => 'object' }, + 'type' => { + 'type' => 'string' + }, 'holdouts' => { 'type' => 'array' } diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index ea47fe6f..506497d3 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -1801,4 +1801,504 @@ end end end + + describe 'Feature Rollout support' do + def build_datafile(experiments: [], rollouts: [], feature_flags: []) + { + 'version' => '4', + 'accountId' => '12001', + 'projectId' => '111001', + 'revision' => '1', + 'experiments' => experiments, + 'events' => [], + 'attributes' => [], + 'audiences' => [], + 'groups' => [], + 'rollouts' => rollouts, + 'featureFlags' => feature_flags + } + end + + it 'should parse experiment type field from datafile' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [{'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true}], + 'type' => 'feature_rollout' + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + expect(experiment['type']).to eq('feature_rollout') + end + + it 'should set experiment type to nil when type field is missing' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_ab', + 'key' => 'ab_test_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [{'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true}] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_ab'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_ab'] + expect(experiment['type']).to be_nil + end + + it 'should inject everyone else variation into feature_rollout experiments' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_targeted_rule', + 'key' => 'rollout_targeted_rule', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['audience_1'], + 'trafficAllocation' => [{'entityId' => 'targeted_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var', 'id' => 'targeted_var', 'featureEnabled' => true} + ] + }, + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + # Should now have 2 variations: original + everyone else + expect(experiment['variations'].length).to eq(2) + + variation_ids = experiment['variations'].map { |v| v['id'] } + expect(variation_ids).to include('everyone_else_var') + + # Verify traffic allocation was appended with endOfRange=10000 + expect(experiment['trafficAllocation'].length).to eq(2) + last_allocation = experiment['trafficAllocation'].last + expect(last_allocation['entityId']).to eq('everyone_else_var') + expect(last_allocation['endOfRange']).to eq(10_000) + end + + it 'should update all variation maps after injection' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + + expect(config.variation_key_map['feature_rollout_exp']).to have_key('everyone_else_var') + expect(config.variation_id_map['feature_rollout_exp']).to have_key('everyone_else_var') + expect(config.variation_id_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') + expect(config.variation_key_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') + end + + it 'should not modify non-feature_rollout experiments' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_ab', + 'key' => 'ab_test_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true} + ], + 'type' => 'a/b' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_ab'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_ab'] + + expect(experiment['variations'].length).to eq(1) + expect(experiment['trafficAllocation'].length).to eq(1) + end + + it 'should not modify feature_rollout experiment when rolloutId is empty' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + expect(experiment['variations'].length).to eq(1) + expect(experiment['trafficAllocation'].length).to eq(1) + end + + it 'should use the LAST rollout rule for everyone else variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'fr_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'fr_var', 'id' => 'fr_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'targeted_rule_1', + 'key' => 'targeted_rule_1', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['aud_1'], + 'trafficAllocation' => [{'entityId' => 'targeted_var_1', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var_1', 'id' => 'targeted_var_1', 'featureEnabled' => true} + ] + }, + { + 'id' => 'targeted_rule_2', + 'key' => 'targeted_rule_2', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['aud_2'], + 'trafficAllocation' => [{'entityId' => 'targeted_var_2', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var_2', 'id' => 'targeted_var_2', 'featureEnabled' => true} + ] + }, + { + 'id' => 'everyone_else_rule', + 'key' => 'everyone_else_rule', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'correct_everyone_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'correct_everyone_var', 'id' => 'correct_everyone_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + variation_ids = experiment['variations'].map { |v| v['id'] } + expect(variation_ids).to include('correct_everyone_var') + expect(variation_ids).not_to include('targeted_var_1') + expect(variation_ids).not_to include('targeted_var_2') + end + + it 'should preserve featureEnabled value on injected variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } + expect(injected).not_to be_nil + expect(injected['featureEnabled']).to eq(false) + end + + it 'should propagate variables from the everyone else variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + { + 'key' => 'everyone_else_var', + 'id' => 'everyone_else_var', + 'featureEnabled' => false, + 'variables' => [ + {'id' => 'var_1', 'value' => 'default_value'} + ] + } + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } + expect(injected).not_to be_nil + expect(injected['variables']).to eq([{'id' => 'var_1', 'value' => 'default_value'}]) + + # Verify variation_id_to_variable_usage_map is populated + variable_usage = config.variation_id_to_variable_usage_map['everyone_else_var'] + expect(variable_usage).not_to be_nil + expect(variable_usage).to have_key('var_1') + end + end end diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 824e2ac7..6d7ff5c3 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -2062,8 +2062,6 @@ def self.deep_clone(obj) new_obj.map! do |val| deep_clone(val) end - else - new_obj end end end