Skip to content

Commit 69b2453

Browse files
[FSSDK-10765] enhancement: Implement UPS request batching for decideForKeys (#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]>
1 parent 39e8e7e commit 69b2453

8 files changed

+402
-222
lines changed

lib/optimizely.rb

+119-55
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
require_relative 'optimizely/odp/lru_cache'
4343
require_relative 'optimizely/odp/odp_manager'
4444
require_relative 'optimizely/helpers/sdk_settings'
45+
require_relative 'optimizely/user_profile_tracker'
4546

4647
module Optimizely
4748
class Project
@@ -172,65 +173,18 @@ def create_user_context(user_id, attributes = nil)
172173
OptimizelyUserContext.new(self, user_id, attributes)
173174
end
174175

175-
def decide(user_context, key, decide_options = [])
176-
# raising on user context as it is internal and not provided directly by the user.
177-
raise if user_context.class != OptimizelyUserContext
178-
179-
reasons = []
180-
181-
# check if SDK is ready
182-
unless is_valid
183-
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
184-
reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
185-
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
186-
end
187-
188-
# validate that key is a string
189-
unless key.is_a?(String)
190-
@logger.log(Logger::ERROR, 'Provided key is invalid')
191-
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
192-
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
193-
end
194-
195-
# validate that key maps to a feature flag
196-
config = project_config
197-
feature_flag = config.get_feature_flag_from_key(key)
198-
unless feature_flag
199-
@logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
200-
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
201-
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
202-
end
203-
204-
# merge decide_options and default_decide_options
205-
if decide_options.is_a? Array
206-
decide_options += @default_decide_options
207-
else
208-
@logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
209-
decide_options = @default_decide_options
210-
end
211-
176+
def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config)
212177
# Create Optimizely Decision Result.
213178
user_id = user_context.user_id
214179
attributes = user_context.user_attributes
215180
variation_key = nil
216181
feature_enabled = false
217182
rule_key = nil
218-
flag_key = key
219183
all_variables = {}
220184
decision_event_dispatched = false
185+
feature_flag = config.get_feature_flag_from_key(flag_key)
221186
experiment = nil
222187
decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT']
223-
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
224-
variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
225-
reasons.push(*reasons_received)
226-
227-
if variation
228-
decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
229-
else
230-
decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options)
231-
reasons.push(*reasons_received)
232-
end
233-
234188
# Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent
235189
if decision.is_a?(Optimizely::DecisionService::Decision)
236190
experiment = decision.experiment
@@ -249,7 +203,7 @@ def decide(user_context, key, decide_options = [])
249203
# Generate all variables map if decide options doesn't include excludeVariables
250204
unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES
251205
feature_flag['variables'].each do |variable|
252-
variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id)
206+
variable_value = get_feature_variable_for_variation(flag_key, feature_enabled, variation, variable, user_id)
253207
all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger)
254208
end
255209
end
@@ -281,6 +235,47 @@ def decide(user_context, key, decide_options = [])
281235
)
282236
end
283237

238+
def decide(user_context, key, decide_options = [])
239+
# raising on user context as it is internal and not provided directly by the user.
240+
raise if user_context.class != OptimizelyUserContext
241+
242+
reasons = []
243+
244+
# check if SDK is ready
245+
unless is_valid
246+
@logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message)
247+
reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY)
248+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
249+
end
250+
251+
# validate that key is a string
252+
unless key.is_a?(String)
253+
@logger.log(Logger::ERROR, 'Provided key is invalid')
254+
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
255+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
256+
end
257+
258+
# validate that key maps to a feature flag
259+
config = project_config
260+
feature_flag = config.get_feature_flag_from_key(key)
261+
unless feature_flag
262+
@logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.")
263+
reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key))
264+
return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons)
265+
end
266+
267+
# merge decide_options and default_decide_options
268+
if decide_options.is_a? Array
269+
decide_options += @default_decide_options
270+
else
271+
@logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
272+
decide_options = @default_decide_options
273+
end
274+
275+
decide_options.delete(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) if decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
276+
decide_for_keys(user_context, [key], decide_options, true)[key]
277+
end
278+
284279
def decide_all(user_context, decide_options = [])
285280
# raising on user context as it is internal and not provided directly by the user.
286281
raise if user_context.class != OptimizelyUserContext
@@ -298,7 +293,7 @@ def decide_all(user_context, decide_options = [])
298293
decide_for_keys(user_context, keys, decide_options)
299294
end
300295

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

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

311-
enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
306+
# merge decide_options and default_decide_options
307+
unless ignore_default_options
308+
if decide_options.is_a?(Array)
309+
decide_options += @default_decide_options
310+
else
311+
@logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.')
312+
decide_options = @default_decide_options
313+
end
314+
end
315+
316+
# enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
312317

313318
decisions = {}
319+
valid_keys = []
320+
decision_reasons_dict = {}
321+
config = project_config
322+
return decisions unless config
323+
324+
flags_without_forced_decision = []
325+
flag_decisions = {}
326+
314327
keys.each do |key|
315-
decision = decide(user_context, key, decide_options)
316-
decisions[key] = decision unless enabled_flags_only && !decision.enabled
328+
# Retrieve the feature flag from the project's feature flag key map
329+
feature_flag = config.feature_flag_key_map[key]
330+
331+
# If the feature flag is nil, create a default OptimizelyDecision and move to the next key
332+
if feature_flag.nil?
333+
decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, [])
334+
next
335+
end
336+
valid_keys.push(key)
337+
decision_reasons = []
338+
decision_reasons_dict[key] = decision_reasons
339+
340+
config = project_config
341+
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil)
342+
variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context)
343+
decision_reasons_dict[key].push(*reasons_received)
344+
if variation
345+
decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'])
346+
flag_decisions[key] = decision
347+
else
348+
flags_without_forced_decision.push(feature_flag)
349+
end
317350
end
351+
decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options)
352+
353+
flags_without_forced_decision.each_with_index do |flag, i|
354+
decision = decision_list[i][0]
355+
reasons = decision_list[i][1]
356+
flag_key = flag['key']
357+
flag_decisions[flag_key] = decision
358+
decision_reasons_dict[flag_key] ||= []
359+
decision_reasons_dict[flag_key].push(*reasons)
360+
end
361+
valid_keys.each do |key|
362+
flag_decision = flag_decisions[key]
363+
decision_reasons = decision_reasons_dict[key]
364+
optimizely_decision = create_optimizely_decision(
365+
user_context,
366+
key,
367+
flag_decision,
368+
decision_reasons,
369+
decide_options,
370+
config
371+
)
372+
373+
enabled_flags_only_missing = !decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY)
374+
is_enabled = optimizely_decision.enabled
375+
376+
decisions[key] = optimizely_decision if enabled_flags_only_missing || is_enabled
377+
end
378+
318379
decisions
319380
end
320381

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

9611022
user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
962-
variation_id, = @decision_service.get_variation(config, experiment_id, user_context)
1023+
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
1024+
user_profile_tracker.load_user_profile
1025+
variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker)
1026+
user_profile_tracker.save_user_profile
9631027
variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil?
9641028
variation_key = variation['key'] if variation
9651029
decision_notification_type = if config.feature_experiment?(experiment_id)

lib/optimizely/decision_service.rb

+48-22
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,20 @@ def initialize(logger, user_profile_service = nil)
5252
@forced_variation_map = {}
5353
end
5454

55-
def get_variation(project_config, experiment_id, user_context, decide_options = [])
55+
def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = [])
5656
# Determines variation into which user will be bucketed.
5757
#
5858
# project_config - project_config - Instance of ProjectConfig
5959
# experiment_id - Experiment for which visitor variation needs to be determined
6060
# user_context - Optimizely user context instance
61+
# user_profile_tracker: Tracker for reading and updating user profile of the user.
62+
# reasons: Decision reasons.
6163
#
6264
# Returns variation ID where visitor will be bucketed
6365
# (nil if experiment is inactive or user does not meet audience conditions)
64-
66+
user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker)
6567
decide_reasons = []
68+
decide_reasons.push(*reasons)
6669
user_id = user_context.user_id
6770
attributes = user_context.user_attributes
6871
# By default, the bucketing ID should be the user ID
@@ -92,10 +95,8 @@ def get_variation(project_config, experiment_id, user_context, decide_options =
9295

9396
should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
9497
# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
95-
unless should_ignore_user_profile_service
96-
user_profile, reasons_received = get_user_profile(user_id)
97-
decide_reasons.push(*reasons_received)
98-
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
98+
unless should_ignore_user_profile_service && user_profile_tracker
99+
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)
99100
decide_reasons.push(*reasons_received)
100101
if saved_variation_id
101102
message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
@@ -131,7 +132,7 @@ def get_variation(project_config, experiment_id, user_context, decide_options =
131132
decide_reasons.push(message)
132133

133134
# Persist bucketing decision
134-
save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
135+
user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
135136
[variation_id, decide_reasons]
136137
end
137138

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

147-
decide_reasons = []
148-
149-
# check if the feature is being experiment on and whether the user is bucketed into the experiment
150-
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options)
151-
decide_reasons.push(*reasons_received)
152-
return decision, decide_reasons unless decision.nil?
153-
154-
decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
155-
decide_reasons.push(*reasons_received)
156-
157-
[decision, decide_reasons]
150+
def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
151+
# Returns the list of experiment/variation the user is bucketed in for the given list of features.
152+
#
153+
# Args:
154+
# project_config: Instance of ProjectConfig.
155+
# feature_flags: Array of features for which we are determining if it is enabled or not for the given user.
156+
# user_context: User context for user.
157+
# decide_options: Decide options.
158+
#
159+
# Returns:
160+
# Array of Decision struct.
161+
ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
162+
user_profile_tracker = nil
163+
unless ignore_ups && @user_profile_service
164+
user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
165+
user_profile_tracker.load_user_profile
166+
end
167+
decisions = []
168+
feature_flags.each do |feature_flag|
169+
decide_reasons = []
170+
# check if the feature is being experiment on and whether the user is bucketed into the experiment
171+
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
172+
decide_reasons.push(*reasons_received)
173+
if decision
174+
decisions << [decision, decide_reasons]
175+
else
176+
# Proceed to rollout if the decision is nil
177+
rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
178+
decide_reasons.push(*reasons_received)
179+
decisions << [rollout_decision, decide_reasons]
180+
end
181+
end
182+
user_profile_tracker&.save_user_profile
183+
decisions
158184
end
159185

160-
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
186+
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [])
161187
# Gets the variation the user is bucketed into for the feature flag's experiment.
162188
#
163189
# project_config - project_config - Instance of ProjectConfig
@@ -187,7 +213,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
187213
end
188214

189215
experiment_id = experiment['id']
190-
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
216+
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
191217
decide_reasons.push(*reasons_received)
192218

193219
next unless variation_id
@@ -252,7 +278,7 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
252278
[nil, decide_reasons]
253279
end
254280

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

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

273-
variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
299+
variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
274300
reasons.push(*response_reasons)
275301

276302
[variation_id, reasons]

lib/optimizely/helpers/validator.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@ def inputs_valid?(variables, logger = NoOpLogger.new, level = Logger::ERROR)
122122

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

125-
is_valid = true
125+
is_valid = true # rubocop:disable Lint/UselessAssignment
126126
if variables.include? :user_id
127127
# Empty str is a valid user ID.
128128
unless variables[:user_id].is_a?(String)
129-
is_valid = false
129+
is_valid = false # rubocop:disable Lint/UselessAssignment
130130
logger.log(level, "#{Constants::INPUT_VARIABLES['USER_ID']} is invalid")
131131
end
132132
variables.delete :user_id

lib/optimizely/optimizely_factory.rb

-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists
142142
notification_center = nil,
143143
settings = nil
144144
)
145-
146145
error_handler ||= NoOpErrorHandler.new
147146
logger ||= NoOpLogger.new
148147
notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(logger, error_handler)

0 commit comments

Comments
 (0)