Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FSSDK-10765] enhancement: Implement UPS request batching for decideForKeys #353

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
174 changes: 119 additions & 55 deletions lib/optimizely.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
require_relative 'optimizely/odp/lru_cache'
require_relative 'optimizely/odp/odp_manager'
require_relative 'optimizely/helpers/sdk_settings'
require_relative 'optimizely/user_profile_tracker'

module Optimizely
class Project
Expand Down Expand Up @@ -172,65 +173,18 @@ def create_user_context(user_id, attributes = nil)
OptimizelyUserContext.new(self, user_id, attributes)
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

def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config)
# 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
feature_flag = config.get_feature_flag_from_key(flag_key)
experiment = nil
decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
reasons.push(*reasons_received)

if variation
decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
else
decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options)
reasons.push(*reasons_received)
end

# 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
Expand All @@ -249,7 +203,7 @@ def decide(user_context, key, decide_options = [])
# 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)
variable_value = get_feature_variable_for_variation(flag_key, feature_enabled, variation, variable, user_id)
all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
end
end
Expand Down Expand Up @@ -281,6 +235,47 @@ def decide(user_context, key, decide_options = [])
)
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

decide_options.delete(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) if decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
decide_for_keys(user_context, [key], decide_options, true)[key]
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
Expand All @@ -298,7 +293,7 @@ def decide_all(user_context, decide_options = [])
decide_for_keys(user_context, keys, decide_options)
end

def decide_for_keys(user_context, keys, decide_options = [])
def decide_for_keys(user_context, keys, decide_options = [], ignore_default_options = false) # rubocop:disable Style/OptionalBooleanParameter
# raising on user context as it is internal and not provided directly by the user.
raise if user_context.class != OptimizelyUserContext

Expand All @@ -308,13 +303,79 @@ def decide_for_keys(user_context, keys, decide_options = [])
return {}
end

enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
# merge decide_options and default_decide_options
unless ignore_default_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
end

# enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)

decisions = {}
valid_keys = []
decision_reasons_dict = {}
config = project_config
return decisions unless config

flags_without_forced_decision = []
flag_decisions = {}

keys.each do |key|
decision = decide(user_context, key, decide_options)
decisions[key] = decision unless enabled_flags_only && !decision.enabled
# Retrieve the feature flag from the project's feature flag key map
feature_flag = config.feature_flag_key_map[key]

# If the feature flag is nil, create a default OptimizelyDecision and move to the next key
if feature_flag.nil?
decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, [])
next
end
valid_keys.push(key)
decision_reasons = []
decision_reasons_dict[key] = decision_reasons

config = project_config
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
decision_reasons_dict[key].push(*reasons_received)
if variation
decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
flag_decisions[key] = decision
else
flags_without_forced_decision.push(feature_flag)
end
end
decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)

flags_without_forced_decision.each_with_index do |flag, i|
decision = decision_list[i][0]
reasons = decision_list[i][1]
flag_key = flag['key']
flag_decisions[flag_key] = decision
decision_reasons_dict[flag_key] ||= []
decision_reasons_dict[flag_key].push(*reasons)
end
valid_keys.each do |key|
flag_decision = flag_decisions[key]
decision_reasons = decision_reasons_dict[key]
optimizely_decision = create_optimizely_decision(
user_context,
key,
flag_decision,
decision_reasons,
decide_options,
config
)

enabled_flags_only_missing = !decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
is_enabled = optimizely_decision.enabled

decisions[key] = optimizely_decision if enabled_flags_only_missing || is_enabled
end

decisions
end

Expand Down Expand Up @@ -959,7 +1020,10 @@ def get_variation_with_config(experiment_key, user_id, attributes, config)
return nil unless user_inputs_valid?(attributes)

user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
variation_id, = @decision_service.get_variation(config, experiment_id, user_context)
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
user_profile_tracker.load_user_profile
variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
user_profile_tracker.save_user_profile
variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
variation_key = variation['key'] if variation
decision_notification_type = if config.feature_experiment?(experiment_id)
Expand Down
70 changes: 48 additions & 22 deletions lib/optimizely/decision_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,20 @@ def initialize(logger, user_profile_service = nil)
@forced_variation_map = {}
end

def get_variation(project_config, experiment_id, user_context, decide_options = [])
def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = [])
# Determines variation into which user will be bucketed.
#
# project_config - project_config - Instance of ProjectConfig
# experiment_id - Experiment for which visitor variation needs to be determined
# user_context - Optimizely user context instance
# user_profile_tracker: Tracker for reading and updating user profile of the user.
# reasons: Decision reasons.
#
# Returns variation ID where visitor will be bucketed
# (nil if experiment is inactive or user does not meet audience conditions)

user_profile_tracker = nil unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker)
decide_reasons = []
decide_reasons.push(*reasons)
user_id = user_context.user_id
attributes = user_context.user_attributes
# By default, the bucketing ID should be the user ID
Expand Down Expand Up @@ -92,10 +95,8 @@ def get_variation(project_config, experiment_id, user_context, decide_options =

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, reasons_received = get_user_profile(user_id)
decide_reasons.push(*reasons_received)
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
unless should_ignore_user_profile_service && user_profile_tracker
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)
decide_reasons.push(*reasons_received)
if saved_variation_id
message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
Expand Down Expand Up @@ -131,7 +132,7 @@ def get_variation(project_config, experiment_id, user_context, decide_options =
decide_reasons.push(message)

# Persist bucketing decision
save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
[variation_id, decide_reasons]
end

Expand All @@ -143,21 +144,46 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
# user_context - Optimizely user context instance
#
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
end

decide_reasons = []

# check if the feature is being experiment on and whether the user is bucketed into the experiment
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options)
decide_reasons.push(*reasons_received)
return decision, decide_reasons unless decision.nil?

decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
decide_reasons.push(*reasons_received)

[decision, decide_reasons]
def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
# Returns the list of experiment/variation the user is bucketed in for the given list of features.
#
# Args:
# project_config: Instance of ProjectConfig.
# feature_flags: Array of features for which we are determining if it is enabled or not for the given user.
# user_context: User context for user.
# decide_options: Decide options.
#
# Returns:
# Array of Decision struct.
ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
user_profile_tracker = nil
unless ignore_ups && @user_profile_service
user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
user_profile_tracker.load_user_profile
end
decisions = []
feature_flags.each do |feature_flag|
decide_reasons = []
# check if the feature is being experiment on and whether the user is bucketed into the experiment
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
decide_reasons.push(*reasons_received)
if decision
decisions << [decision, decide_reasons]
else
# Proceed to rollout if the decision is nil
rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
decide_reasons.push(*reasons_received)
decisions << [rollout_decision, decide_reasons]
end
end
user_profile_tracker.save_user_profile unless user_profile_tracker
decisions
end

def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [])
# Gets the variation the user is bucketed into for the feature flag's experiment.
#
# project_config - project_config - Instance of ProjectConfig
Expand Down Expand Up @@ -187,7 +213,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
end

experiment_id = experiment['id']
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
decide_reasons.push(*reasons_received)

next unless variation_id
Expand Down Expand Up @@ -252,7 +278,7 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
[nil, decide_reasons]
end

def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = [])
def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
# Determine which variation the user is in for a given rollout.
# Returns the variation from experiment rules.
#
Expand All @@ -270,7 +296,7 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, opt

return [variation['id'], reasons] if variation

variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
reasons.push(*response_reasons)

[variation_id, reasons]
Expand Down
4 changes: 2 additions & 2 deletions lib/optimizely/helpers/validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,11 @@ def inputs_valid?(variables, logger = NoOpLogger.new, level = Logger::ERROR)

return false unless variables.respond_to?(:each) && !variables.empty?

is_valid = true
is_valid = true # rubocop:disable Lint/UselessAssignment
if variables.include? :user_id
# Empty str is a valid user ID.
unless variables[:user_id].is_a?(String)
is_valid = false
is_valid = false # rubocop:disable Lint/UselessAssignment
logger.log(level, "#{Constants::INPUT_VARIABLES['USER_ID']} is invalid")
end
variables.delete :user_id
Expand Down
1 change: 0 additions & 1 deletion lib/optimizely/optimizely_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists
notification_center = nil,
settings = nil
)

error_handler ||= NoOpErrorHandler.new
logger ||= NoOpLogger.new
notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(logger, error_handler)
Expand Down
Loading
Loading