Skip to content

Commit

Permalink
[FSSDK-10765] enhancement: Implement UPS request batching for decideF…
Browse files Browse the repository at this point in the history
…orKeys (#353)

* user profile tracker created

* lib/optimizely.rb -> Added user_profile_tracker require
lib/optimizely.rb -> Updated decide_for_keys method
lib/optimizely.rb -> Enhanced decision-making logic
lib/optimizely.rb -> Integrated UserProfileTracker usage
lib/optimizely.rb -> Refined decision reasons handling
lib/optimizely/user_profile_tracker.rb -> New user profile tracker class

* Implementation complete. Unit Tests are failing.

* lib/optimizely.rb -> Made optional parameter explicit
lib/optimizely/decision_service.rb -> Added user profile tracker usage
lib/optimizely/decision_service.rb -> Clarified handling of user profiles
lib/optimizely/user_profile_tracker.rb -> Fixed user ID reference in error
spec/decision_service_spec.rb -> Adjusted tests for user profile tracker

* lib/optimizely/decision_service.rb -> Simplified decision logging
lib/optimizely/user_profile_tracker.rb -> Improved user profile lookup handling
spec/project_spec.rb -> Updated mocks for decision service calls

* lib/optimizely/decision_service.rb -> Removed user profile tracker instantiation.
lib/optimizely/user_profile_tracker.rb -> Improved error logging message.
spec/decision_service_spec.rb -> Refactored user profile tracking in tests.
spec/project_spec.rb -> Updated decision service method stubs.
spec/user_profile_tracker.rb -> Updated lookup, update and save tests for user_profile_tracker

* spec/user_profile_tracker_spec.rb -> Updated error messages in tests.

* spec/user_profile_tracker_spec.rb -> linting fix

* linting fixes

* Update README.md

* Update README.md

* Trigger checks

* Trigger checks

* Trigger checks

* Trigger checks

* lib/optimizely/user_profile_tracker.rb -> Added user profile init check.

* lib/optimizely/decision_service.rb -> Updated user profile tracker initialization.

* lib/optimizely/decision_service.rb -> Update user profile save method

---------

Co-authored-by: Matjaz Pirnovar <[email protected]>
  • Loading branch information
FarhanAnjum-opti and Mat001 authored Jan 9, 2025
1 parent 39e8e7e commit 69b2453
Show file tree
Hide file tree
Showing 8 changed files with 402 additions and 222 deletions.
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 = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) 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
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

0 comments on commit 69b2453

Please sign in to comment.