From f718a18892d4a78a960760d2430412fbbde5b077 Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Tue, 15 Dec 2020 04:34:57 +0500 Subject: [PATCH] feat(Decide): Add Decide API (#274) ## Summary Added new Apis to support the decide feature. Introduced a new `OptimizelyUserContext ` class through `create_user_context` class api. This creates an optimizely instance with memoized user context and exposes the following APIs 1. `set_attribute` 2. `decide` 3. `decide_all` 4. `decide_for_keys` 5. `track_event` ## Test plan 1. Manually tested thoroughly. 2. Added unit tests to cover new functionality. 3. All new and existing FSC tests pass. --- lib/optimizely.rb | 185 +++- lib/optimizely/bucketer.rb | 37 +- .../decide/optimizely_decide_option.rb | 28 + lib/optimizely/decide/optimizely_decision.rb | 60 ++ .../decide/optimizely_decision_message.rb | 26 + lib/optimizely/decision_service.rb | 190 ++-- lib/optimizely/helpers/constants.rb | 1 + lib/optimizely/optimizely_user_context.rb | 107 +++ spec/decision_service_spec.rb | 56 +- spec/optimizely_user_context_spec.rb | 83 ++ spec/project_spec.rb | 878 ++++++++++++++++++ 11 files changed, 1523 insertions(+), 128 deletions(-) create mode 100644 lib/optimizely/decide/optimizely_decide_option.rb create mode 100644 lib/optimizely/decide/optimizely_decision.rb create mode 100644 lib/optimizely/decide/optimizely_decision_message.rb create mode 100644 lib/optimizely/optimizely_user_context.rb create mode 100644 spec/optimizely_user_context_spec.rb diff --git a/lib/optimizely.rb b/lib/optimizely.rb index 827ebc70..504eeaf7 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -19,6 +19,9 @@ require_relative 'optimizely/config/datafile_project_config' require_relative 'optimizely/config_manager/http_project_config_manager' require_relative 'optimizely/config_manager/static_project_config_manager' +require_relative 'optimizely/decide/optimizely_decide_option' +require_relative 'optimizely/decide/optimizely_decision' +require_relative 'optimizely/decide/optimizely_decision_message' require_relative 'optimizely/decision_service' require_relative 'optimizely/error_handler' require_relative 'optimizely/event_builder' @@ -34,9 +37,12 @@ require_relative 'optimizely/logger' require_relative 'optimizely/notification_center' require_relative 'optimizely/optimizely_config' +require_relative 'optimizely/optimizely_user_context' module Optimizely class Project + include Optimizely::Decide + attr_reader :notification_center # @api no-doc attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher, @@ -67,12 +73,21 @@ def initialize( sdk_key = nil, config_manager = nil, notification_center = nil, - event_processor = nil + event_processor = nil, + default_decide_options = [] ) @logger = logger || NoOpLogger.new @error_handler = error_handler || NoOpErrorHandler.new @event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler) @user_profile_service = user_profile_service + @default_decide_options = [] + + if default_decide_options.is_a? Array + @default_decide_options = default_decide_options.clone + else + @logger.log(Logger::DEBUG, 'Provided default decide options is not an array.') + @default_decide_options = [] + end begin validate_instantiation_options @@ -107,6 +122,174 @@ def initialize( end end + # Create a context of the user for which decision APIs will be called. + # + # A user context will be created successfully even when the SDK is not fully configured yet. + # + # @param user_id - The user ID to be used for bucketing. + # @param attributes - A Hash representing user attribute names and values. + # + # @return [OptimizelyUserContext] An OptimizelyUserContext associated with this OptimizelyClient. + # @return [nil] If user attributes are not in valid format. + + def create_user_context(user_id, attributes = nil) + # We do not check for is_valid here as a user context can be created successfully + # even when the SDK is not fully configured. + + # validate user_id + return nil unless Optimizely::Helpers::Validator.inputs_valid?( + { + user_id: user_id + }, @logger, Logger::ERROR + ) + + # validate attributes + return nil unless user_inputs_valid?(attributes) + + user_context = OptimizelyUserContext.new(self, user_id, attributes) + user_context + end + + def decide(user_context, key, decide_options = []) + # raising on user context as it is internal and not provided directly by the user. + raise if user_context.class != OptimizelyUserContext + + reasons = [] + + # check if SDK is ready + unless is_valid + @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message) + reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # validate that key is a string + unless key.is_a?(String) + @logger.log(Logger::ERROR, 'Provided key is invalid') + reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # validate that key maps to a feature flag + config = project_config + feature_flag = config.get_feature_flag_from_key(key) + unless feature_flag + @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.") + reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # merge decide_options and default_decide_options + if decide_options.is_a? Array + decide_options += @default_decide_options + else + @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.') + decide_options = @default_decide_options + end + + # Create Optimizely Decision Result. + user_id = user_context.user_id + attributes = user_context.user_attributes + variation_key = nil + feature_enabled = false + rule_key = nil + flag_key = key + all_variables = {} + decision_event_dispatched = false + experiment = nil + decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + + decision = @decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options, reasons) + + # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent + if decision.is_a?(Optimizely::DecisionService::Decision) + experiment = decision.experiment + rule_key = experiment['key'] + variation = decision['variation'] + variation_key = variation['key'] + feature_enabled = variation['featureEnabled'] + decision_source = decision.source + end + + unless decide_options.include? OptimizelyDecideOption::DISABLE_DECISION_EVENT + if decision_source == Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] || config.send_flag_decisions + send_impression(config, experiment, variation_key || '', flag_key, rule_key || '', feature_enabled, decision_source, user_id, attributes) + decision_event_dispatched = true + end + 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| + variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id) + all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger) + end + end + + should_include_reasons = decide_options.include? OptimizelyDecideOption::INCLUDE_REASONS + + # Send notification + @notification_center.send_notifications( + NotificationCenter::NOTIFICATION_TYPES[:DECISION], + Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'], + user_id, (attributes || {}), + flag_key: flag_key, + enabled: feature_enabled, + variables: all_variables, + variation_key: variation_key, + rule_key: rule_key, + reasons: should_include_reasons ? reasons : [], + decision_event_dispatched: decision_event_dispatched + ) + + OptimizelyDecision.new( + variation_key: variation_key, + enabled: feature_enabled, + variables: all_variables, + rule_key: rule_key, + flag_key: flag_key, + user_context: user_context, + reasons: should_include_reasons ? reasons : [] + ) + end + + def decide_all(user_context, decide_options = []) + # raising on user context as it is internal and not provided directly by the user. + raise if user_context.class != OptimizelyUserContext + + # check if SDK is ready + unless is_valid + @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_all').message) + return {} + end + + keys = [] + project_config.feature_flags.each do |feature_flag| + keys.push(feature_flag['key']) + end + decide_for_keys(user_context, keys, decide_options) + end + + def decide_for_keys(user_context, keys, decide_options = []) + # raising on user context as it is internal and not provided directly by the user. + raise if user_context.class != OptimizelyUserContext + + # check if SDK is ready + unless is_valid + @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide_for_keys').message) + return {} + end + + enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY) + + decisions = {} + keys.each do |key| + decision = decide(user_context, key, decide_options) + decisions[key] = decision unless enabled_flags_only && !decision.enabled + end + decisions + end + # Buckets visitor and sends impression event to Optimizely. # # @param experiment_key - Experiment which needs to be activated. diff --git a/lib/optimizely/bucketer.rb b/lib/optimizely/bucketer.rb index c3fddead..1a4994f6 100644 --- a/lib/optimizely/bucketer.rb +++ b/lib/optimizely/bucketer.rb @@ -35,7 +35,7 @@ def initialize(logger) @bucket_seed = HASH_SEED end - def bucket(project_config, experiment, bucketing_id, user_id) + def bucket(project_config, experiment, bucketing_id, user_id, decide_reasons = nil) # Determines ID of variation to be shown for a given experiment key and user ID. # # project_config - Instance of ProjectConfig @@ -58,29 +58,29 @@ def bucket(project_config, experiment, bucketing_id, user_id) bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations) # return if the user is not bucketed into any experiment unless bucketed_experiment_id - @logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.") + message = "User '#{user_id}' is in no experiment." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) return nil end # return if the user is bucketed into a different experiment than the one specified if bucketed_experiment_id != experiment_id - @logger.log( - Logger::INFO, - "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}." - ) + message = "User '#{user_id}' is not in experiment '#{experiment_key}' of group #{group_id}." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) return nil end # continue bucketing if the user is bucketed into the experiment specified - @logger.log( - Logger::INFO, - "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}." - ) + message = "User '#{user_id}' is in experiment '#{experiment_key}' of group #{group_id}." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) end end traffic_allocations = experiment['trafficAllocation'] - variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations) + variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations, decide_reasons) if variation_id && variation_id != '' variation = project_config.get_variation_from_id(experiment_key, variation_id) return variation @@ -88,16 +88,15 @@ def bucket(project_config, experiment, bucketing_id, user_id) # Handle the case when the traffic range is empty due to sticky bucketing if variation_id == '' - @logger.log( - Logger::DEBUG, - 'Bucketed into an empty traffic range. Returning nil.' - ) + message = 'Bucketed into an empty traffic range. Returning nil.' + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) end nil end - def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) + def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations, decide_reasons = nil) # Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations. # # bucketing_id - String A customer-assigned value user to generate bucketing key @@ -108,8 +107,10 @@ def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) # Returns entity ID corresponding to the provided bucket value or nil if no match is found. bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id) bucket_value = generate_bucket_value(bucketing_key) - @logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' "\ - "with bucketing ID: '#{bucketing_id}'.") + + message = "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) traffic_allocations.each do |traffic_allocation| current_end_of_range = traffic_allocation['endOfRange'] diff --git a/lib/optimizely/decide/optimizely_decide_option.rb b/lib/optimizely/decide/optimizely_decide_option.rb new file mode 100644 index 00000000..f89dcd51 --- /dev/null +++ b/lib/optimizely/decide/optimizely_decide_option.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright 2020, 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. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +module Optimizely + module Decide + module OptimizelyDecideOption + DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT' + ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY' + IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' + INCLUDE_REASONS = 'INCLUDE_REASONS' + EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' + end + end +end diff --git a/lib/optimizely/decide/optimizely_decision.rb b/lib/optimizely/decide/optimizely_decision.rb new file mode 100644 index 00000000..06b109b3 --- /dev/null +++ b/lib/optimizely/decide/optimizely_decision.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Copyright 2020, 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. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'json' + +module Optimizely + module Decide + class OptimizelyDecision + attr_reader :variation_key, :enabled, :variables, :rule_key, :flag_key, :user_context, :reasons + + def initialize( + variation_key: nil, + enabled: nil, + variables: nil, + rule_key: nil, + flag_key: nil, + user_context: nil, + reasons: nil + ) + @variation_key = variation_key + @enabled = enabled || false + @variables = variables || {} + @rule_key = rule_key + @flag_key = flag_key + @user_context = user_context + @reasons = reasons || [] + end + + def as_json + { + variation_key: @variation_key, + enabled: @enabled, + variables: @variables, + rule_key: @rule_key, + flag_key: @flag_key, + user_context: @user_context.as_json, + reasons: @reasons + } + end + + def to_json(*args) + as_json.to_json(*args) + end + end + end +end diff --git a/lib/optimizely/decide/optimizely_decision_message.rb b/lib/optimizely/decide/optimizely_decision_message.rb new file mode 100644 index 00000000..d38c7aec --- /dev/null +++ b/lib/optimizely/decide/optimizely_decision_message.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Copyright 2020, 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. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +module Optimizely + module Decide + module OptimizelyDecisionMessage + SDK_NOT_READY = 'Optimizely SDK not configured properly yet.' + FLAG_KEY_INVALID = 'No flag was found for key "%s".' + VARIABLE_VALUE_INVALID = 'Variable value for key "%s" is invalid or wrong type.' + end + end +end diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 2b8e0d67..77df3635 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -52,7 +52,7 @@ def initialize(logger, user_profile_service = nil) @forced_variation_map = {} end - def get_variation(project_config, experiment_key, user_id, attributes = nil) + def get_variation(project_config, experiment_key, user_id, attributes = nil, decide_options = [], decide_reasons = nil) # Determines variation into which user will be bucketed. # # project_config - project_config - Instance of ProjectConfig @@ -64,65 +64,68 @@ def get_variation(project_config, experiment_key, user_id, attributes = nil) # (nil if experiment is inactive or user does not meet audience conditions) # By default, the bucketing ID should be the user ID - bucketing_id = get_bucketing_id(user_id, attributes) + bucketing_id = get_bucketing_id(user_id, attributes, decide_reasons) # Check to make sure experiment is active experiment = project_config.get_experiment_from_key(experiment_key) return nil if experiment.nil? experiment_id = experiment['id'] unless project_config.experiment_running?(experiment) - @logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.") + message = "Experiment '#{experiment_key}' is not running." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) return nil end # Check if a forced variation is set for the user - forced_variation = get_forced_variation(project_config, experiment_key, user_id) + forced_variation = get_forced_variation(project_config, experiment_key, user_id, decide_reasons) return forced_variation['id'] if forced_variation # Check if user is in a white-listed variation - whitelisted_variation_id = get_whitelisted_variation_id(project_config, experiment_key, user_id) + whitelisted_variation_id = get_whitelisted_variation_id(project_config, experiment_key, user_id, decide_reasons) return whitelisted_variation_id if whitelisted_variation_id - # Check for saved bucketing decisions - user_profile = get_user_profile(user_id) - saved_variation_id = get_saved_variation_id(project_config, experiment_id, user_profile) - if saved_variation_id - @logger.log( - Logger::INFO, - "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." - ) - return saved_variation_id + should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE + # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService + unless should_ignore_user_profile_service + user_profile = get_user_profile(user_id, decide_reasons) + saved_variation_id = get_saved_variation_id(project_config, experiment_id, user_profile, decide_reasons) + if saved_variation_id + message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) + return saved_variation_id + end end # Check audience conditions unless Audience.user_meets_audience_conditions?(project_config, experiment, attributes, @logger) - @logger.log( - Logger::INFO, - "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." - ) + message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) return nil end # Bucket normally - variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id) + variation = @bucketer.bucket(project_config, experiment, bucketing_id, user_id, decide_reasons) variation_id = variation ? variation['id'] : nil + message = '' if variation_id variation_key = variation['key'] - @logger.log( - Logger::INFO, - "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'." - ) + message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_key}'." else - @logger.log(Logger::INFO, "User '#{user_id}' is in no variation.") + message = "User '#{user_id}' is in no variation." end + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) # Persist bucketing decision - save_user_profile(user_profile, experiment_id, variation_id) + save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service variation_id end - def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil) + def get_variation_for_feature(project_config, feature_flag, user_id, attributes = nil, decide_options = [], decide_reasons = nil) # Get the variation the user is bucketed into for the given FeatureFlag. # # project_config - project_config - Instance of ProjectConfig @@ -133,15 +136,15 @@ def get_variation_for_feature(project_config, feature_flag, user_id, attributes # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature) # check if the feature is being experiment on and whether the user is bucketed into the experiment - decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes) + decision = get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes, decide_options, decide_reasons) return decision unless decision.nil? - decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes) + decision = get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes, decide_reasons) decision end - def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil) + def get_variation_for_feature_experiment(project_config, feature_flag, user_id, attributes = nil, decide_options = [], decide_reasons = nil) # Gets the variation the user is bucketed into for the feature flag's experiment. # # project_config - project_config - Instance of ProjectConfig @@ -153,10 +156,9 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, # or nil if the user is not bucketed into any of the experiments on the feature feature_flag_key = feature_flag['key'] if feature_flag['experimentIds'].empty? - @logger.log( - Logger::DEBUG, - "The feature flag '#{feature_flag_key}' is not used in any experiments." - ) + message = "The feature flag '#{feature_flag_key}' is not used in any experiments." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end @@ -164,15 +166,14 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, feature_flag['experimentIds'].each do |experiment_id| experiment = project_config.experiment_id_map[experiment_id] unless experiment - @logger.log( - Logger::DEBUG, - "Feature flag experiment with ID '#{experiment_id}' is not in the datafile." - ) + message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end experiment_key = experiment['key'] - variation_id = get_variation(project_config, experiment_key, user_id, attributes) + variation_id = get_variation(project_config, experiment_key, user_id, attributes, decide_options, decide_reasons) next unless variation_id @@ -181,15 +182,14 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_id, return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']) end - @logger.log( - Logger::INFO, - "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." - ) + message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) nil end - def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil) + def get_variation_for_feature_rollout(project_config, feature_flag, user_id, attributes = nil, decide_reasons = nil) # Determine which variation the user is in for a given rollout. # Returns the variation of the first experiment the user qualifies for. # @@ -199,23 +199,21 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_id, att # attributes - Hash representing user attributes # # Returns the Decision struct or nil if not bucketed into any of the targeting rules - bucketing_id = get_bucketing_id(user_id, attributes) + bucketing_id = get_bucketing_id(user_id, attributes, decide_reasons) rollout_id = feature_flag['rolloutId'] if rollout_id.nil? || rollout_id.empty? feature_flag_key = feature_flag['key'] - @logger.log( - Logger::DEBUG, - "Feature flag '#{feature_flag_key}' is not used in a rollout." - ) + message = "Feature flag '#{feature_flag_key}' is not used in a rollout." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end rollout = project_config.get_rollout_from_id(rollout_id) if rollout.nil? - @logger.log( - Logger::DEBUG, - "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'" - ) + message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'" + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end @@ -231,21 +229,19 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_id, att # Check that user meets audience conditions for targeting rule unless Audience.user_meets_audience_conditions?(project_config, rollout_rule, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key) - @logger.log( - Logger::DEBUG, - "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." - ) + message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) # move onto the next targeting rule next end - @logger.log( - Logger::DEBUG, - "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." - ) + message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) # Evaluate if user satisfies the traffic allocation for this rollout rule - variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id) + variation = @bucketer.bucket(project_config, rollout_rule, bucketing_id, user_id, decide_reasons) return Decision.new(rollout_rule, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil? break @@ -256,18 +252,17 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_id, att logging_key = 'Everyone Else' # Check that user meets audience conditions for last rule unless Audience.user_meets_audience_conditions?(project_config, everyone_else_experiment, attributes, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key) - @logger.log( - Logger::DEBUG, - "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." - ) + message = "User '#{user_id}' does not meet the audience conditions for targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end - @logger.log( - Logger::DEBUG, - "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." - ) - variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id) + message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) + + variation = @bucketer.bucket(project_config, everyone_else_experiment, bucketing_id, user_id, decide_reasons) return Decision.new(everyone_else_experiment, variation, DECISION_SOURCES['ROLLOUT']) unless variation.nil? nil @@ -311,7 +306,7 @@ def set_forced_variation(project_config, experiment_key, user_id, variation_key) true end - def get_forced_variation(project_config, experiment_key, user_id) + def get_forced_variation(project_config, experiment_key, user_id, decide_reasons = nil) # Gets the forced variation for the given user and experiment. # # project_config - Instance of ProjectConfig @@ -321,7 +316,9 @@ def get_forced_variation(project_config, experiment_key, user_id) # Returns Variation The variation which the given user and experiment should be forced into unless @forced_variation_map.key? user_id - @logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.") + message = "User '#{user_id}' is not in the forced variation map." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end @@ -333,8 +330,9 @@ def get_forced_variation(project_config, experiment_key, user_id) return nil if experiment_id.nil? || experiment_id.empty? unless experiment_to_variation_map.key? experiment_id - @logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' "\ - 'in the forced variation map.') + message = "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map." + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) return nil end @@ -347,15 +345,16 @@ def get_forced_variation(project_config, experiment_key, user_id) # this case is logged in get_variation_from_id return nil if variation_key.empty? - @logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' "\ - "and user '#{user_id}' in the forced variation map") + message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map" + @logger.log(Logger::DEBUG, message) + decide_reasons&.push(message) variation end private - def get_whitelisted_variation_id(project_config, experiment_key, user_id) + def get_whitelisted_variation_id(project_config, experiment_key, user_id, decide_reasons = nil) # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation # # project_config - project_config - Instance of ProjectConfig @@ -375,21 +374,20 @@ def get_whitelisted_variation_id(project_config, experiment_key, user_id) whitelisted_variation_id = project_config.get_variation_id_from_key(experiment_key, whitelisted_variation_key) unless whitelisted_variation_id - @logger.log( - Logger::INFO, - "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile." - ) + message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) return nil end - @logger.log( - Logger::INFO, - "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'." - ) + message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) + whitelisted_variation_id end - def get_saved_variation_id(project_config, experiment_id, user_profile) + def get_saved_variation_id(project_config, experiment_id, user_profile, decide_reasons = nil) # Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile # # project_config - project_config - Instance of ProjectConfig @@ -405,14 +403,14 @@ def get_saved_variation_id(project_config, experiment_id, user_profile) variation_id = decision[:variation_id] return variation_id if project_config.variation_id_exists?(experiment_id, variation_id) - @logger.log( - Logger::INFO, - "User '#{user_profile['user_id']}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user." - ) + message = "User '#{user_profile['user_id']}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user." + @logger.log(Logger::INFO, message) + decide_reasons&.push(message) + nil end - def get_user_profile(user_id) + def get_user_profile(user_id, decide_reasons = nil) # Determine if a user is forced into a variation for the given experiment and return the ID of that variation # # user_id - String ID for the user @@ -429,7 +427,9 @@ def get_user_profile(user_id) begin user_profile = @user_profile_service.lookup(user_id) || user_profile rescue => e - @logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.") + message = "Error while looking up user profile for user ID '#{user_id}': #{e}." + @logger.log(Logger::ERROR, message) + decide_reasons&.push(message) end user_profile @@ -456,7 +456,7 @@ def save_user_profile(user_profile, experiment_id, variation_id) end end - def get_bucketing_id(user_id, attributes) + def get_bucketing_id(user_id, attributes, decide_reasons = nil) # Gets the Bucketing Id for Bucketing # # user_id - String user ID @@ -470,7 +470,9 @@ def get_bucketing_id(user_id, attributes) if bucketing_id return bucketing_id if bucketing_id.is_a?(String) - @logger.log(Logger::WARN, 'Bucketing ID attribute is not a string. Defaulted to user ID.') + message = 'Bucketing ID attribute is not a string. Defaulted to user ID.' + @logger.log(Logger::WARN, message) + decide_reasons&.push(message) end user_id end diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index ed5e1c6d..45dede71 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -369,6 +369,7 @@ module Constants 'FEATURE' => 'feature', 'FEATURE_TEST' => 'feature-test', 'FEATURE_VARIABLE' => 'feature-variable', + 'FLAG' => 'flag', 'ALL_FEATURE_VARIABLES' => 'all-feature-variables' }.freeze diff --git a/lib/optimizely/optimizely_user_context.rb b/lib/optimizely/optimizely_user_context.rb new file mode 100644 index 00000000..928470a2 --- /dev/null +++ b/lib/optimizely/optimizely_user_context.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# +# Copyright 2020, 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. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'json' + +module Optimizely + class OptimizelyUserContext + # Representation of an Optimizely User Context using which APIs are to be called. + + attr_reader :user_id + + def initialize(optimizely_client, user_id, user_attributes) + @attr_mutex = Mutex.new + @optimizely_client = optimizely_client + @user_id = user_id + @user_attributes = user_attributes.nil? ? {} : user_attributes.clone + end + + def clone + OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes) + end + + def user_attributes + @attr_mutex.synchronize { @user_attributes.clone } + end + + # Set an attribute for a given key + # + # @param key - An attribute key + # @param value - An attribute value + + def set_attribute(attribute_key, attribute_value) + @attr_mutex.synchronize { @user_attributes[attribute_key] = attribute_value } + end + + # Returns a decision result (OptimizelyDecision) for a given flag key and a user context, which contains all data required to deliver the flag. + # + # If the SDK finds an error, it'll return a `decision` with nil for `variation_key`. The decision will include an error message in `reasons` + # + # @param key -A flag key for which a decision will be made + # @param options - A list of options for decision making. + # + # @return [OptimizelyDecision] A decision result + + def decide(key, options = nil) + @optimizely_client&.decide(clone, key, options) + end + + # Returns a hash of decision results (OptimizelyDecision) for multiple flag keys and a user context. + # + # If the SDK finds an error for a key, the response will include a decision for the key showing `reasons` for the error. + # The SDK will always return hash of decisions. When it can not process requests, it'll return an empty hash after logging the errors. + # + # @param keys - A list of flag keys for which the decisions will be made. + # @param options - A list of options for decision making. + # + # @return - Hash of decisions containing flag keys as hash keys and corresponding decisions as their values. + + def decide_for_keys(keys, options = nil) + @optimizely_client&.decide_for_keys(clone, keys, options) + end + + # Returns a hash of decision results (OptimizelyDecision) for all active flag keys. + # + # @param options - A list of options for decision making. + # + # @return - Hash of decisions containing flag keys as hash keys and corresponding decisions as their values. + + def decide_all(options = nil) + @optimizely_client&.decide_all(clone, options) + end + + # Track an event + # + # @param event_key - Event key representing the event which needs to be recorded. + + def track_event(event_key, event_tags = nil) + @optimizely_client&.track(event_key, @user_id, user_attributes, event_tags) + end + + def as_json + { + user_id: @user_id, + attributes: @user_attributes + } + end + + def to_json(*args) + as_json.to_json(*args) + end + end +end diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index 3d9537e8..7cdc65b5 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -181,7 +181,7 @@ # bucketing should have occured experiment = config.get_experiment_from_key('test_experiment') # since we do not pass bucketing id attribute, bucketer will recieve user id as the bucketing id - expect(decision_service.bucketer).to have_received(:bucket).once.with(config, experiment, 'forced_user_with_invalid_variation', 'forced_user_with_invalid_variation') + expect(decision_service.bucketer).to have_received(:bucket).once.with(config, experiment, 'forced_user_with_invalid_variation', 'forced_user_with_invalid_variation', nil) end describe 'when a UserProfile service is provided' do @@ -337,6 +337,32 @@ expect(spy_logger).to have_received(:log).once .with(Logger::ERROR, "Error while saving user profile for user ID 'test_user': uncaught throw :SaveError.") end + + describe 'IGNORE_USER_PROFILE_SERVICE decide option' do + it 'should ignore user profile service if this option is set' do + allow(spy_user_profile_service).to receive(:lookup) + .with('test_user').once.and_return(nil) + + expect(decision_service.get_variation(config, 'test_experiment', 'test_user', nil, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])).to eq('111128') + + expect(decision_service.bucketer).to have_received(:bucket) + expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) + expect(spy_user_profile_service).not_to have_received(:lookup) + expect(spy_user_profile_service).not_to have_received(:save) + end + + it 'should not ignore user profile service if this option is not set' do + allow(spy_user_profile_service).to receive(:lookup) + .with('test_user').once.and_return(nil) + + expect(decision_service.get_variation(config, 'test_experiment', 'test_user')).to eq('111128') + + expect(decision_service.bucketer).to have_received(:bucket) + expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) + expect(spy_user_profile_service).to have_received(:lookup) + expect(spy_user_profile_service).to have_received(:save) + end + end end end @@ -372,13 +398,13 @@ # make sure the user is not bucketed into the feature experiment allow(decision_service).to receive(:get_variation) - .with(config, multivariate_experiment['key'], 'user_1', user_attributes) + .with(config, multivariate_experiment['key'], 'user_1', user_attributes, [], nil) .and_return(nil) end it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['multi_variate_feature'] - expect(decision_service.get_variation_for_feature_experiment(config, feature_flag, 'user_1', user_attributes)).to eq(nil) + expect(decision_service.get_variation_for_feature_experiment(config, feature_flag, 'user_1', user_attributes, [], nil)).to eq(nil) expect(spy_logger).to have_received(:log).once .with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.") @@ -431,10 +457,10 @@ mutex_exp = config.experiment_key_map['group1_exp1'] mutex_exp2 = config.experiment_key_map['group1_exp2'] allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp['key'], user_id, user_attributes) + .with(config, mutex_exp['key'], user_id, user_attributes, [], nil) .and_return(nil) allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp2['key'], user_id, user_attributes) + .with(config, mutex_exp2['key'], user_id, user_attributes, [], nil) .and_return(nil) end @@ -493,9 +519,9 @@ expected_decision = Optimizely::DecisionService::Decision.new(rollout_experiment, variation, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']) allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(config, rollout_experiment, user_id, user_id) + .with(config, rollout_experiment, user_id, user_id, nil) .and_return(variation) - expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes)).to eq(expected_decision) + expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes, nil)).to eq(expected_decision) end end @@ -508,13 +534,13 @@ allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(config, rollout['experiments'][0], user_id, user_id) + .with(config, rollout['experiments'][0], user_id, user_id, nil) .and_return(nil) allow(decision_service.bucketer).to receive(:bucket) - .with(config, everyone_else_experiment, user_id, user_id) + .with(config, everyone_else_experiment, user_id, user_id, nil) .and_return(nil) - expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes)).to eq(nil) + expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes, nil)).to eq(nil) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once @@ -533,13 +559,13 @@ expected_decision = Optimizely::DecisionService::Decision.new(everyone_else_experiment, variation, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']) allow(Optimizely::Audience).to receive(:user_meets_audience_conditions?).and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(config, rollout['experiments'][0], user_id, user_id) + .with(config, rollout['experiments'][0], user_id, user_id, nil) .and_return(nil) allow(decision_service.bucketer).to receive(:bucket) - .with(config, everyone_else_experiment, user_id, user_id) + .with(config, everyone_else_experiment, user_id, user_id, nil) .and_return(variation) - expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes)).to eq(expected_decision) + expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes, nil)).to eq(expected_decision) # make sure we only checked the audience for the first rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?).once @@ -564,10 +590,10 @@ .with(config, everyone_else_experiment, user_attributes, spy_logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', 'Everyone Else') .and_return(true) allow(decision_service.bucketer).to receive(:bucket) - .with(config, everyone_else_experiment, user_id, user_id) + .with(config, everyone_else_experiment, user_id, user_id, nil) .and_return(variation) - expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes)).to eq(expected_decision) + expect(decision_service.get_variation_for_feature_rollout(config, feature_flag, user_id, user_attributes, nil)).to eq(expected_decision) # verify we tried to bucket in all targeting rules and the everyone else rule expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) diff --git a/spec/optimizely_user_context_spec.rb b/spec/optimizely_user_context_spec.rb new file mode 100644 index 00000000..ed8b8bf6 --- /dev/null +++ b/spec/optimizely_user_context_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# +# Copyright 2020, 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. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'spec_helper' +require 'optimizely' +require 'optimizely/optimizely_user_context' + +describe 'Optimizely' do + let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY } + let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON } + let(:config_body_invalid_JSON) { OptimizelySpec::INVALID_CONFIG_BODY_JSON } + let(:error_handler) { Optimizely::RaiseErrorHandler.new } + let(:spy_logger) { spy('logger') } + let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } + + describe '#initialize' do + it 'should set passed value as expected' do + user_id = 'test_user' + attributes = {' browser' => 'firefox'} + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, user_id, attributes) + + expect(user_context_obj.instance_variable_get(:@optimizely_client)). to eq(project_instance) + expect(user_context_obj.instance_variable_get(:@user_id)). to eq(user_id) + expect(user_context_obj.instance_variable_get(:@user_attributes)). to eq(attributes) + end + + it 'should set user attributes to empty hash when passed nil' do + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, 'test_user', nil) + expect(user_context_obj.instance_variable_get(:@user_attributes)). to eq({}) + end + end + + describe '#set_attribute' do + it 'should add attribute key and value is attributes hash' do + user_id = 'test_user' + attributes = {' browser' => 'firefox'} + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, user_id, attributes) + user_context_obj.set_attribute('id', 49) + + expected_attributes = attributes + expected_attributes['id'] = 49 + expect(user_context_obj.instance_variable_get(:@user_attributes)). to eq(expected_attributes) + end + + it 'should override attribute value if key already exists in hash' do + user_id = 'test_user' + attributes = {' browser' => 'firefox', 'color' => ' red'} + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, user_id, attributes) + user_context_obj.set_attribute('browser', 'chrome') + + expected_attributes = attributes + expected_attributes['browser'] = 'chrome' + + expect(user_context_obj.instance_variable_get(:@user_attributes)). to eq(expected_attributes) + end + + it 'should not alter original attributes object when attrubute is modified in the user context' do + user_id = 'test_user' + original_attributes = {'browser' => 'firefox'} + user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, user_id, original_attributes) + user_context_obj.set_attribute('id', 49) + expect(user_context_obj.instance_variable_get(:@user_attributes)). to eq( + 'browser' => 'firefox', + 'id' => 49 + ) + expect(original_attributes).to eq('browser' => 'firefox') + end + end +end diff --git a/spec/project_spec.rb b/spec/project_spec.rb index a99ff292..b91685ce 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -23,6 +23,7 @@ require 'optimizely/event/batch_event_processor' require 'optimizely/exceptions' require 'optimizely/helpers/validator' +require 'optimizely/optimizely_user_context' require 'optimizely/version' describe 'Optimizely' do @@ -135,6 +136,35 @@ class InvalidErrorHandler; end end end + describe '#create_user_context' do + it 'should log and return nil when user ID is non string' do + expect(project_instance.create_user_context(nil)).to eq(nil) + expect(project_instance.create_user_context(5)).to eq(nil) + expect(project_instance.create_user_context(5.5)).to eq(nil) + expect(project_instance.create_user_context(true)).to eq(nil) + expect(project_instance.create_user_context({})).to eq(nil) + expect(project_instance.create_user_context([])).to eq(nil) + expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'User ID is invalid').exactly(6).times + end + + it 'should return nil when attributes are invalid' do + expect(Optimizely::Helpers::Validator).to receive(:attributes_valid?).once.with('invalid') + expect(error_handler).to receive(:handle_error).once.with(Optimizely::InvalidAttributeFormatError) + expect(project_instance.create_user_context( + 'test_user', + 'invalid' + )).to eq(nil) + expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided attributes are in an invalid format.') + end + + it 'should return OptimizelyUserContext with valid user ID and attributes' do + expect(project_instance.create_user_context( + 'test_user', + 'browser' => 'chrome' + )).to be_instance_of(Optimizely::OptimizelyUserContext) + end + end + describe '#activate' do before(:example) do allow(Time).to receive(:now).and_return(time_now) @@ -3464,4 +3494,852 @@ def callback(_args); end .to eq(nil) end end + + describe '#decide' do + describe 'should return empty decision object with correct reason when sdk is not ready' do + it 'when sdk is not ready' do + invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + user_context = project_instance.create_user_context('user1') + decision = invalid_project.decide(user_context, 'dummy_flag') + expect(decision.as_json).to eq( + enabled: false, + flag_key: 'dummy_flag', + reasons: ['Optimizely SDK not configured properly yet.'], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {}, + variation_key: nil + ) + end + + it 'when flag key is invalid' do + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 123) + expect(decision.as_json).to eq( + enabled: false, + flag_key: 123, + reasons: ['No flag was found for key "123".'], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {}, + variation_key: nil + ) + end + + it 'when flag key is not available' do + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'not_found_key') + expect(decision.as_json).to eq( + enabled: false, + flag_key: 'not_found_key', + reasons: ['No flag was found for key "not_found_key".'], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {}, + variation_key: nil + ) + end + end + + describe 'should return correct decision object' do + it 'when user is bucketed into a feature experiment' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: true, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred', + rule_key: 'test_experiment_multivariate', + reasons: [], + decision_event_dispatched: true + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred' + ) + end + + it 'when user is bucketed into a rollout and send_flag_decisions is true' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + allow(Time).to receive(:now).and_return(time_now) + allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: true, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred', + rule_key: 'test_experiment_multivariate', + reasons: [], + decision_event_dispatched: true + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + ) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred' + ) + expected_params = { + account_id: '12001', + project_id: '111001', + revision: '42', + client_name: 'ruby-sdk', + client_version: '3.7.0', + anonymize_ip: false, + enrich_decisions: true, + visitors: [{ + snapshots: [{ + events: [{ + entity_id: '4', + uuid: 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + key: 'campaign_activated', + timestamp: (time_now.to_f * 1000).to_i + }], + decisions: [{ + campaign_id: '4', + experiment_id: '122230', + variation_id: '122231', + metadata: { + flag_key: 'multi_variate_feature', + rule_key: 'test_experiment_multivariate', + rule_type: 'rollout', + variation_key: 'Fred', + enabled: true + } + }] + }], + visitor_id: 'user1', + attributes: [{ + entity_id: '$opt_bot_filtering', + key: '$opt_bot_filtering', + type: 'custom', + value: true + }] + }] + } + expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers)) + end + + it 'when user is bucketed into a rollout and send_flag_decisions is false' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: true, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred', + rule_key: 'test_experiment_multivariate', + reasons: [], + decision_event_dispatched: false + ) + allow(project_config).to receive(:send_flag_decisions).and_return(false) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] + ) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred' + ) + expect(project_instance.event_dispatcher).to have_received(:dispatch_event).exactly(0).times + end + + it 'when decision service returns nil and send_flag_decisions is false' do + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [], + decision_event_dispatched: false + ) + allow(project_config).to receive(:send_flag_decisions).and_return(false) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = nil + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + expect(project_instance.event_dispatcher).to have_received(:dispatch_event).exactly(0).times + end + + it 'when decision service returns nil and send_flag_decisions is true' do + allow(Time).to receive(:now).and_return(time_now) + allow(SecureRandom).to receive(:uuid).and_return('a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [], + decision_event_dispatched: true + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = nil + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + expected_params = { + account_id: '12001', + project_id: '111001', + revision: '42', + client_name: 'ruby-sdk', + client_version: '3.7.0', + anonymize_ip: false, + enrich_decisions: true, + visitors: [{ + snapshots: [{ + events: [{ + entity_id: '', + uuid: 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + key: 'campaign_activated', + timestamp: (time_now.to_f * 1000).to_i + }], + decisions: [{ + campaign_id: '', + experiment_id: '', + variation_id: '', + metadata: { + flag_key: 'multi_variate_feature', + rule_key: '', + rule_type: 'rollout', + variation_key: '', + enabled: false + } + }] + }], + visitor_id: 'user1', + attributes: [{ + entity_id: '$opt_bot_filtering', + key: '$opt_bot_filtering', + type: 'custom', + value: true + }] + }] + } + expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers)) + end + end + + describe 'decide options' do + describe 'DISABLE_DECISION_EVENT' do + it 'should send event when option is not set' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + project_instance.decide(user_context, 'multi_variate_feature') + end + + it 'should not send event when option is set' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(project_instance.event_dispatcher).not_to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]) + end + end + + describe 'EXCLUDE_VARIABLES' do + it 'should exclude variables if set' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES]) + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {}, + variation_key: 'Fred' + ) + end + + it 'should include variables if not set' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred' + ) + end + end + + describe 'INCLUDE_REASONS' do + it 'should include reasons when the option is set' do + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [ + "User 'user1' is not in the forced variation map.", + "User 'user1' does not meet the conditions to be in experiment 'test_experiment_multivariate'.", + "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", + "Feature flag 'multi_variate_feature' is not used in a rollout." + ], + decision_event_dispatched: true + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [ + "User 'user1' is not in the forced variation map.", + "User 'user1' does not meet the conditions to be in experiment 'test_experiment_multivariate'.", + "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", + "Feature flag 'multi_variate_feature' is not used in a rollout." + ], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + end + + it 'should not include reasons when the option is not set' do + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [], + decision_event_dispatched: true + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_context = project_instance.create_user_context('user1') + decision = project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + end + end + + it 'should pass on decide options to internal methods' do + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = project_instance.create_user_context('user1') + + expect(project_instance.decision_service).to receive(:get_variation_for_feature) + .with(anything, anything, anything, anything, [], []).once + project_instance.decide(user_context, 'multi_variate_feature') + + expect(project_instance.decision_service).to receive(:get_variation_for_feature) + .with(anything, anything, anything, anything, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT], []).once + project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]) + + expect(project_instance.decision_service).to receive(:get_variation_for_feature) + .with(anything, anything, anything, anything, [ + Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT, + Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES, + Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY, + Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, + Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS, + Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES + ], []).once + project_instance + .decide(user_context, 'multi_variate_feature', [ + Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT, + Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES, + Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY, + Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, + Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS, + Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES + ]) + end + end + end + + describe '#decide_all' do + it 'should get empty object when sdk is not ready' do + invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + user_context = project_instance.create_user_context('user1') + decisions = invalid_project.decide_all(user_context) + expect(decisions).to eq({}) + end + + it 'should get all the decisions' do + stub_request(:post, impression_log_url) + user_context = project_instance.create_user_context('user1') + decisions = project_instance.decide_all(user_context) + expect(decisions.length).to eq(10) + expect(decisions['boolean_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'boolean_single_variable_feature', + reasons: [], + rule_key: '177776', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'boolean_variable' => false}, + variation_key: '177778' + ) + expect(decisions['integer_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'integer_single_variable_feature', + reasons: [], + rule_key: 'test_experiment_integer_feature', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'integer_variable' => 42}, + variation_key: 'control' + ) + end + + it 'should get only enabled decisions for keys when ENABLED_FLAGS_ONLY is true' do + stub_request(:post, impression_log_url) + user_context = project_instance.create_user_context('user1') + decisions = project_instance.decide_all(user_context, [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY]) + expect(decisions.length).to eq(6) + expect(decisions['boolean_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'boolean_single_variable_feature', + reasons: [], + rule_key: '177776', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'boolean_variable' => false}, + variation_key: '177778' + ) + expect(decisions['integer_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'integer_single_variable_feature', + reasons: [], + rule_key: 'test_experiment_integer_feature', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'integer_variable' => 42}, + variation_key: 'control' + ) + end + end + + describe '#decide_for_keys' do + it 'should get empty object when sdk is not ready' do + keys = %w[ + boolean_single_variable_feature + integer_single_variable_feature + boolean_feature + empty_feature + ] + invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + user_context = project_instance.create_user_context('user1') + decisions = invalid_project.decide_for_keys(user_context, keys) + expect(decisions).to eq({}) + end + + it 'should get all the decisions for keys' do + keys = %w[ + boolean_single_variable_feature + integer_single_variable_feature + boolean_feature + empty_feature + ] + stub_request(:post, impression_log_url) + user_context = project_instance.create_user_context('user1') + decisions = project_instance.decide_for_keys(user_context, keys) + expect(decisions.length).to eq(4) + expect(decisions['boolean_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'boolean_single_variable_feature', + reasons: [], + rule_key: '177776', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'boolean_variable' => false}, + variation_key: '177778' + ) + expect(decisions['integer_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'integer_single_variable_feature', + reasons: [], + rule_key: 'test_experiment_integer_feature', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'integer_variable' => 42}, + variation_key: 'control' + ) + end + + it 'should get only enabled decisions for keys when ENABLED_FLAGS_ONLY is true' do + keys = %w[ + boolean_single_variable_feature + integer_single_variable_feature + boolean_feature + empty_feature + ] + stub_request(:post, impression_log_url) + user_context = project_instance.create_user_context('user1') + decisions = project_instance.decide_for_keys(user_context, keys, [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY]) + expect(decisions.length).to eq(2) + expect(decisions['boolean_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'boolean_single_variable_feature', + reasons: [], + rule_key: '177776', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'boolean_variable' => false}, + variation_key: '177778' + ) + expect(decisions['integer_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'integer_single_variable_feature', + reasons: [], + rule_key: 'test_experiment_integer_feature', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'integer_variable' => 42}, + variation_key: 'control' + ) + end + + it 'should get only enabled decisions for keys when ENABLED_FLAGS_ONLY is true in default_decide_options' do + custom_project_instance = Optimizely::Project.new( + config_body_JSON, nil, spy_logger, error_handler, + false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY] + ) + keys = %w[ + boolean_single_variable_feature + integer_single_variable_feature + boolean_feature + empty_feature + ] + stub_request(:post, impression_log_url) + user_context = custom_project_instance.create_user_context('user1') + decisions = custom_project_instance.decide_for_keys(user_context, keys) + expect(decisions.length).to eq(2) + expect(decisions['boolean_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'boolean_single_variable_feature', + reasons: [], + rule_key: '177776', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'boolean_variable' => false}, + variation_key: '177778' + ) + expect(decisions['integer_single_variable_feature'].as_json).to eq( + enabled: true, + flag_key: 'integer_single_variable_feature', + reasons: [], + rule_key: 'test_experiment_integer_feature', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'integer_variable' => 42}, + variation_key: 'control' + ) + end + end + + describe 'default_decide_options' do + describe 'EXCLUDE_VARIABLES' do + it 'should include variables when the option is not set in default_decide_options' do + custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = custom_project_instance.create_user_context('user1') + decision = custom_project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'F', 'rest_of_name' => 'red'}, + variation_key: 'Fred' + ) + end + + it 'should exclude variables when the option is set in default_decide_options' do + custom_project_instance = Optimizely::Project.new( + config_body_JSON, nil, spy_logger, error_handler, + false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES] + ) + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = custom_project_instance.create_user_context('user1') + decision = custom_project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: true, + reasons: [], + rule_key: 'test_experiment_multivariate', + user_context: {attributes: {}, user_id: 'user1'}, + variables: {}, + variation_key: 'Fred' + ) + end + end + + describe 'INCLUDE_REASONS' do + it 'should include reasons when the option is set in default_decide_options' do + custom_project_instance = Optimizely::Project.new( + config_body_JSON, nil, spy_logger, error_handler, + false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS] + ) + expect(custom_project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(custom_project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [ + "User 'user1' is not in the forced variation map.", + "User 'user1' does not meet the conditions to be in experiment 'test_experiment_multivariate'.", + "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", + "Feature flag 'multi_variate_feature' is not used in a rollout." + ], + decision_event_dispatched: true + ) + allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_context = custom_project_instance.create_user_context('user1') + decision = custom_project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [ + "User 'user1' is not in the forced variation map.", + "User 'user1' does not meet the conditions to be in experiment 'test_experiment_multivariate'.", + "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", + "Feature flag 'multi_variate_feature' is not used in a rollout." + ], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + end + + it 'should not include reasons when the option is not set in default_decide_options' do + custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + expect(custom_project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) + expect(custom_project_instance.notification_center).to receive(:send_notifications) + .once.with( + Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], + 'flag', + 'user1', + {}, + flag_key: 'multi_variate_feature', + enabled: false, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil, + rule_key: nil, + reasons: [], + decision_event_dispatched: true + ) + allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + user_context = custom_project_instance.create_user_context('user1') + decision = custom_project_instance.decide(user_context, 'multi_variate_feature') + expect(decision.as_json).to include( + flag_key: 'multi_variate_feature', + enabled: false, + reasons: [], + rule_key: nil, + user_context: {attributes: {}, user_id: 'user1'}, + variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'}, + variation_key: nil + ) + end + end + + describe 'DISABLE_DECISION_EVENT' do + it 'should send event when option is not set in default_decide_options' do + custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = custom_project_instance.create_user_context('user1') + custom_project_instance.decide(user_context, 'multi_variate_feature') + end + + it 'should not send event when option is set in default_decide_options' do + custom_project_instance = Optimizely::Project.new( + config_body_JSON, nil, spy_logger, error_handler, + false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT] + ) + experiment_to_return = config_body['experiments'][3] + variation_to_return = experiment_to_return['variations'][0] + expect(custom_project_instance.event_dispatcher).not_to receive(:dispatch_event).with(instance_of(Optimizely::Event)) + decision_to_return = Optimizely::DecisionService::Decision.new( + experiment_to_return, + variation_to_return, + Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] + ) + allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + user_context = custom_project_instance.create_user_context('user1') + custom_project_instance.decide(user_context, 'multi_variate_feature') + end + end + end end