optimizely-sdk 5.0.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,8 @@ module Optimizely
29
29
  # 3. Check whitelisting
30
30
  # 4. Check user profile service for past bucketing decisions (sticky bucketing)
31
31
  # 5. Check audience targeting
32
- # 6. Use Murmurhash3 to bucket the user
32
+ # 6. Check cmab service
33
+ # 7. Use Murmurhash3 to bucket the user
33
34
 
34
35
  attr_reader :bucketer
35
36
 
@@ -37,32 +38,39 @@ module Optimizely
37
38
  # This contains all the forced variations set by the user by calling setForcedVariation.
38
39
  attr_reader :forced_variation_map
39
40
 
40
- Decision = Struct.new(:experiment, :variation, :source)
41
+ Decision = Struct.new(:experiment, :variation, :source, :cmab_uuid)
42
+ CmabDecisionResult = Struct.new(:error, :result, :reasons)
43
+ VariationResult = Struct.new(:cmab_uuid, :error, :reasons, :variation_id)
44
+ DecisionResult = Struct.new(:decision, :error, :reasons)
41
45
 
42
46
  DECISION_SOURCES = {
43
47
  'EXPERIMENT' => 'experiment',
44
48
  'FEATURE_TEST' => 'feature-test',
45
- 'ROLLOUT' => 'rollout'
49
+ 'ROLLOUT' => 'rollout',
50
+ 'HOLDOUT' => 'holdout'
46
51
  }.freeze
47
52
 
48
- def initialize(logger, user_profile_service = nil)
53
+ def initialize(logger, cmab_service, user_profile_service = nil)
49
54
  @logger = logger
50
55
  @user_profile_service = user_profile_service
51
56
  @bucketer = Bucketer.new(logger)
52
57
  @forced_variation_map = {}
58
+ @cmab_service = cmab_service
53
59
  end
54
60
 
55
- def get_variation(project_config, experiment_id, user_context, decide_options = [])
61
+ def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = [])
56
62
  # Determines variation into which user will be bucketed.
57
63
  #
58
64
  # project_config - project_config - Instance of ProjectConfig
59
65
  # experiment_id - Experiment for which visitor variation needs to be determined
60
66
  # user_context - Optimizely user context instance
67
+ # user_profile_tracker: Tracker for reading and updating user profile of the user.
68
+ # reasons: Decision reasons.
61
69
  #
62
- # Returns variation ID where visitor will be bucketed
63
- # (nil if experiment is inactive or user does not meet audience conditions)
64
-
70
+ # Returns VariationResult struct
71
+ user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker)
65
72
  decide_reasons = []
73
+ decide_reasons.push(*reasons)
66
74
  user_id = user_context.user_id
67
75
  attributes = user_context.user_attributes
68
76
  # By default, the bucketing ID should be the user ID
@@ -70,38 +78,36 @@ module Optimizely
70
78
  decide_reasons.push(*bucketing_id_reasons)
71
79
  # Check to make sure experiment is active
72
80
  experiment = project_config.get_experiment_from_id(experiment_id)
73
- return nil, decide_reasons if experiment.nil?
81
+ return VariationResult.new(nil, false, decide_reasons, nil) if experiment.nil?
74
82
 
75
83
  experiment_key = experiment['key']
76
84
  unless project_config.experiment_running?(experiment)
77
85
  message = "Experiment '#{experiment_key}' is not running."
78
86
  @logger.log(Logger::INFO, message)
79
87
  decide_reasons.push(message)
80
- return nil, decide_reasons
88
+ return VariationResult.new(nil, false, decide_reasons, nil)
81
89
  end
82
90
 
83
91
  # Check if a forced variation is set for the user
84
92
  forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
85
93
  decide_reasons.push(*reasons_received)
86
- return forced_variation['id'], decide_reasons if forced_variation
94
+ return VariationResult.new(nil, false, decide_reasons, forced_variation['id']) if forced_variation
87
95
 
88
96
  # Check if user is in a white-listed variation
89
97
  whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
90
98
  decide_reasons.push(*reasons_received)
91
- return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
99
+ return VariationResult.new(nil, false, decide_reasons, whitelisted_variation_id) if whitelisted_variation_id
92
100
 
93
101
  should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
94
102
  # 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)
103
+ unless should_ignore_user_profile_service && user_profile_tracker
104
+ saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)
99
105
  decide_reasons.push(*reasons_received)
100
106
  if saved_variation_id
101
107
  message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
102
108
  @logger.log(Logger::INFO, message)
103
109
  decide_reasons.push(message)
104
- return saved_variation_id, decide_reasons
110
+ return VariationResult.new(nil, false, decide_reasons, saved_variation_id)
105
111
  end
106
112
  end
107
113
 
@@ -112,27 +118,45 @@ module Optimizely
112
118
  message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
113
119
  @logger.log(Logger::INFO, message)
114
120
  decide_reasons.push(message)
115
- return nil, decide_reasons
121
+ return VariationResult.new(nil, false, decide_reasons, nil)
116
122
  end
117
123
 
118
- # Bucket normally
119
- variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
120
- decide_reasons.push(*bucket_reasons)
121
- variation_id = variation ? variation['id'] : nil
124
+ # Check if this is a CMAB experiment
125
+ # If so, handle CMAB-specific traffic allocation and decision logic.
126
+ # Otherwise, proceed with standard bucketing logic for non-CMAB experiments.
127
+ if experiment.key?('cmab')
128
+ cmab_decision_result = get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options)
129
+ decide_reasons.push(*cmab_decision_result.reasons)
130
+ if cmab_decision_result.error
131
+ # CMAB decision failed, return error
132
+ return VariationResult.new(nil, true, decide_reasons, nil)
133
+ end
122
134
 
123
- message = ''
124
- if variation_id
125
- variation_key = variation['key']
126
- message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
135
+ cmab_decision = cmab_decision_result.result
136
+ variation_id = cmab_decision&.variation_id
137
+ cmab_uuid = cmab_decision&.cmab_uuid
138
+ variation = variation_id ? project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) : nil
127
139
  else
128
- message = "User '#{user_id}' is in no variation."
140
+ # Bucket normally
141
+ variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
142
+ decide_reasons.push(*bucket_reasons)
143
+ variation_id = variation ? variation['id'] : nil
144
+ cmab_uuid = nil
129
145
  end
146
+
147
+ variation_key = variation['key'] if variation
148
+ message = if variation_id
149
+ "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
150
+ else
151
+ "User '#{user_id}' is in no variation."
152
+ end
153
+
130
154
  @logger.log(Logger::INFO, message)
131
- decide_reasons.push(message)
155
+ decide_reasons.push(message) if message
132
156
 
133
157
  # Persist bucketing decision
134
- save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
135
- [variation_id, decide_reasons]
158
+ user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
159
+ VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
136
160
  end
137
161
 
138
162
  def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
@@ -142,30 +166,175 @@ module Optimizely
142
166
  # feature_flag - The feature flag the user wants to access
143
167
  # user_context - Optimizely user context instance
144
168
  #
145
- # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
169
+ # Returns DecisionResult struct.
170
+ holdouts = project_config.get_holdouts_for_flag(feature_flag['id'])
171
+
172
+ if holdouts && !holdouts.empty?
173
+ # Has holdouts - use get_decision_for_flag which checks holdouts first
174
+ get_decision_for_flag(feature_flag, user_context, project_config, decide_options)
175
+ else
176
+ get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
177
+ end
178
+ end
179
+
180
+ def get_decision_for_flag(feature_flag, user_context, project_config, decide_options = [], user_profile_tracker = nil, decide_reasons = nil)
181
+ # Get the decision for a single feature flag.
182
+ # Processes holdouts, experiments, and rollouts in that order.
183
+ #
184
+ # feature_flag - The feature flag to get a decision for
185
+ # user_context - The user context
186
+ # project_config - The project config
187
+ # decide_options - Array of decide options
188
+ # user_profile_tracker - The user profile tracker
189
+ # decide_reasons - Array of decision reasons to merge
190
+ #
191
+ # Returns a DecisionResult for the feature flag
192
+
193
+ reasons = decide_reasons ? decide_reasons.dup : []
194
+ user_id = user_context.user_id
195
+
196
+ # Check holdouts
197
+ holdouts = project_config.get_holdouts_for_flag(feature_flag['id'])
198
+
199
+ holdouts.each do |holdout|
200
+ holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
201
+ reasons.push(*holdout_decision.reasons)
202
+
203
+ next unless holdout_decision.decision
204
+
205
+ message = "The user '#{user_id}' is bucketed into holdout '#{holdout['key']}' for feature flag '#{feature_flag['key']}'."
206
+ @logger.log(Logger::INFO, message)
207
+ reasons.push(message)
208
+ return DecisionResult.new(holdout_decision.decision, false, reasons)
209
+ end
210
+
211
+ # Check if the feature flag has an experiment and the user is bucketed into that experiment
212
+ experiment_decision = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
213
+ reasons.push(*experiment_decision.reasons)
214
+
215
+ return DecisionResult.new(experiment_decision.decision, experiment_decision.error, reasons) if experiment_decision.decision
216
+
217
+ # Check if the feature flag has a rollout and the user is bucketed into that rollout
218
+ rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
219
+ reasons.push(*rollout_decision.reasons)
220
+
221
+ if rollout_decision.decision
222
+ # Check if this was a forced decision (last reason contains "forced decision map")
223
+ is_forced_decision = reasons.last&.include?('forced decision map')
224
+
225
+ unless is_forced_decision
226
+ # Only add the "bucketed into rollout" message for normal bucketing
227
+ message = "The user '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag['key']}'."
228
+ @logger.log(Logger::INFO, message)
229
+ reasons.push(message)
230
+ end
231
+
232
+ DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
233
+ else
234
+ message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
235
+ @logger.log(Logger::INFO, message)
236
+ DecisionResult.new(nil, false, reasons)
237
+ end
238
+ end
239
+
240
+ def get_variation_for_holdout(holdout, user_context, project_config)
241
+ # Get the variation for holdout
242
+ #
243
+ # holdout - The holdout configuration
244
+ # user_context - The user context
245
+ # project_config - The project config
246
+ #
247
+ # Returns a DecisionResult for the holdout
146
248
 
147
249
  decide_reasons = []
250
+ user_id = user_context.user_id
251
+ attributes = user_context.user_attributes
148
252
 
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?
253
+ if holdout.nil? || holdout['status'].nil? || holdout['status'] != 'Running'
254
+ key = holdout && holdout['key'] ? holdout['key'] : 'unknown'
255
+ message = "Holdout '#{key}' is not running."
256
+ @logger.log(Logger::INFO, message)
257
+ decide_reasons.push(message)
258
+ return DecisionResult.new(nil, false, decide_reasons)
259
+ end
153
260
 
154
- decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
261
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
262
+ decide_reasons.push(*bucketing_id_reasons)
263
+
264
+ # Check audience conditions
265
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, holdout, user_context, @logger)
155
266
  decide_reasons.push(*reasons_received)
156
267
 
157
- [decision, decide_reasons]
268
+ unless user_meets_audience_conditions
269
+ message = "User '#{user_id}' does not meet the conditions for holdout '#{holdout['key']}'."
270
+ @logger.log(Logger::DEBUG, message)
271
+ decide_reasons.push(message)
272
+ return DecisionResult.new(nil, false, decide_reasons)
273
+ end
274
+
275
+ # Bucket user into holdout variation
276
+ variation, bucket_reasons = @bucketer.bucket(project_config, holdout, bucketing_id, user_id)
277
+ decide_reasons.push(*bucket_reasons)
278
+
279
+ if variation && !variation['key'].nil? && !variation['key'].empty?
280
+ message = "The user '#{user_id}' is bucketed into variation '#{variation['key']}' of holdout '#{holdout['key']}'."
281
+ @logger.log(Logger::INFO, message)
282
+ decide_reasons.push(message)
283
+
284
+ holdout_decision = Decision.new(holdout, variation, DECISION_SOURCES['HOLDOUT'], nil)
285
+ DecisionResult.new(holdout_decision, false, decide_reasons)
286
+ else
287
+ message = "The user '#{user_id}' is not bucketed into holdout '#{holdout['key']}'."
288
+ @logger.log(Logger::DEBUG, message)
289
+ decide_reasons.push(message)
290
+ DecisionResult.new(nil, false, decide_reasons)
291
+ end
158
292
  end
159
293
 
160
- def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
294
+ def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
295
+ # Returns the list of experiment/variation the user is bucketed in for the given list of features.
296
+ #
297
+ # Args:
298
+ # project_config: Instance of ProjectConfig.
299
+ # feature_flags: Array of features for which we are determining if it is enabled or not for the given user.
300
+ # user_context: User context for user.
301
+ # decide_options: Decide options.
302
+ #
303
+ # Returns:
304
+ # Array of DecisionResult struct.
305
+ ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
306
+ user_profile_tracker = nil
307
+ unless ignore_ups && @user_profile_service
308
+ user_id = user_context.user_id
309
+ user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
310
+ user_profile_tracker.load_user_profile
311
+ end
312
+
313
+ decisions = []
314
+ feature_flags.each do |feature_flag|
315
+ # check if the feature is being experiment on and whether the user is bucketed into the experiment
316
+ decision_result = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
317
+ # Only process rollout if no experiment decision was found and no error
318
+ if decision_result.decision.nil? && !decision_result.error
319
+ decision_result_rollout = get_variation_for_feature_rollout(project_config, feature_flag, user_context) unless decision_result.decision
320
+ decision_result.decision = decision_result_rollout.decision
321
+ decision_result.reasons.push(*decision_result_rollout.reasons)
322
+ end
323
+ decisions << decision_result
324
+ end
325
+ user_profile_tracker&.save_user_profile
326
+ decisions
327
+ end
328
+
329
+ def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [])
161
330
  # Gets the variation the user is bucketed into for the feature flag's experiment.
162
331
  #
163
332
  # project_config - project_config - Instance of ProjectConfig
164
333
  # feature_flag - The feature flag the user wants to access
165
334
  # user_context - Optimizely user context instance
166
335
  #
167
- # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
168
- # or nil if the user is not bucketed into any of the experiments on the feature
336
+ # Returns a DecisionResult containing the decision (or nil if not bucketed),
337
+ # an error flag, and an array of decision reasons.
169
338
  decide_reasons = []
170
339
  user_id = user_context.user_id
171
340
  feature_flag_key = feature_flag['key']
@@ -173,7 +342,7 @@ module Optimizely
173
342
  message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
174
343
  @logger.log(Logger::DEBUG, message)
175
344
  decide_reasons.push(message)
176
- return nil, decide_reasons
345
+ return DecisionResult.new(nil, false, decide_reasons)
177
346
  end
178
347
 
179
348
  # Evaluate each experiment and return the first bucketed experiment variation
@@ -183,26 +352,34 @@ module Optimizely
183
352
  message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
184
353
  @logger.log(Logger::DEBUG, message)
185
354
  decide_reasons.push(message)
186
- return nil, decide_reasons
355
+ return DecisionResult.new(nil, false, decide_reasons)
187
356
  end
188
357
 
189
358
  experiment_id = experiment['id']
190
- variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
359
+ variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
360
+ error = variation_result.error
361
+ reasons_received = variation_result.reasons
362
+ variation_id = variation_result.variation_id
363
+ cmab_uuid = variation_result.cmab_uuid
191
364
  decide_reasons.push(*reasons_received)
192
365
 
366
+ # If there's an error, return immediately instead of falling back to next experiment
367
+ return DecisionResult.new(nil, error, decide_reasons) if error
368
+
193
369
  next unless variation_id
194
370
 
195
371
  variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
196
372
  variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
197
373
 
198
- return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
374
+ decision = Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'], cmab_uuid)
375
+ return DecisionResult.new(decision, error, decide_reasons)
199
376
  end
200
377
 
201
378
  message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
202
379
  @logger.log(Logger::INFO, message)
203
380
  decide_reasons.push(message)
204
381
 
205
- [nil, decide_reasons]
382
+ DecisionResult.new(nil, false, decide_reasons)
206
383
  end
207
384
 
208
385
  def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
@@ -213,7 +390,8 @@ module Optimizely
213
390
  # feature_flag - The feature flag the user wants to access
214
391
  # user_context - Optimizely user context instance
215
392
  #
216
- # Returns the Decision struct or nil if not bucketed into any of the targeting rules
393
+ # Returns a DecisionResult containing the decision (or nil if not bucketed),
394
+ # an error flag, and an array of decision reasons.
217
395
  decide_reasons = []
218
396
 
219
397
  rollout_id = feature_flag['rolloutId']
@@ -222,7 +400,7 @@ module Optimizely
222
400
  message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
223
401
  @logger.log(Logger::DEBUG, message)
224
402
  decide_reasons.push(message)
225
- return nil, decide_reasons
403
+ return DecisionResult.new(nil, false, decide_reasons)
226
404
  end
227
405
 
228
406
  rollout = project_config.get_rollout_from_id(rollout_id)
@@ -230,10 +408,10 @@ module Optimizely
230
408
  message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
231
409
  @logger.log(Logger::DEBUG, message)
232
410
  decide_reasons.push(message)
233
- return nil, decide_reasons
411
+ return DecisionResult.new(nil, false, decide_reasons)
234
412
  end
235
413
 
236
- return nil, decide_reasons if rollout['experiments'].empty?
414
+ return DecisionResult.new(nil, false, decide_reasons) if rollout['experiments'].empty?
237
415
 
238
416
  index = 0
239
417
  rollout_rules = rollout['experiments']
@@ -242,17 +420,17 @@ module Optimizely
242
420
  decide_reasons.push(*reasons_received)
243
421
  if variation
244
422
  rule = rollout_rules[index]
245
- feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
246
- return [feature_decision, decide_reasons]
423
+ feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'], nil)
424
+ return DecisionResult.new(feature_decision, false, decide_reasons)
247
425
  end
248
426
 
249
427
  index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
250
428
  end
251
429
 
252
- [nil, decide_reasons]
430
+ DecisionResult.new(nil, false, decide_reasons)
253
431
  end
254
432
 
255
- def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = [])
433
+ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
256
434
  # Determine which variation the user is in for a given rollout.
257
435
  # Returns the variation from experiment rules.
258
436
  #
@@ -267,13 +445,11 @@ module Optimizely
267
445
  context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
268
446
  variation, forced_reasons = validated_forced_decision(project_config, context, user)
269
447
  reasons.push(*forced_reasons)
448
+ return VariationResult.new(nil, false, reasons, variation['id']) if variation
270
449
 
271
- return [variation['id'], reasons] if variation
272
-
273
- variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
274
- reasons.push(*response_reasons)
275
-
276
- [variation_id, reasons]
450
+ variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
451
+ variation_result.reasons = reasons + variation_result.reasons
452
+ variation_result
277
453
  end
278
454
 
279
455
  def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
@@ -441,6 +617,51 @@ module Optimizely
441
617
 
442
618
  private
443
619
 
620
+ def get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options = [])
621
+ # Determines the CMAB (Contextual Multi-Armed Bandit) decision for a given experiment and user context.
622
+ #
623
+ # This method first checks if the user is bucketed into the CMAB experiment based on traffic allocation.
624
+ # If the user is not bucketed, it returns a CmabDecisionResult indicating exclusion.
625
+ # If the user is bucketed, it attempts to fetch a CMAB decision using the CMAB service.
626
+ # In case of errors during CMAB decision retrieval, it logs the error and returns a result indicating failure.
627
+ #
628
+ # @param project_config [ProjectConfig] The current project configuration.
629
+ # @param experiment [Hash] The experiment configuration hash.
630
+ # @param user_context [OptimizelyUserContext] The user context object containing user information.
631
+ # @param bucketing_id [String] The bucketing ID used for traffic allocation.
632
+ # @param decide_options [Array] Optional array of decision options.
633
+ #
634
+ # @return [CmabDecisionResult] The result of the CMAB decision process, including decision error status, decision data, and reasons.
635
+ decide_reasons = []
636
+ user_id = user_context.user_id
637
+
638
+ # Check if user is in CMAB traffic allocation
639
+ bucketed_entity_id, bucket_reasons = @bucketer.bucket_to_entity_id(
640
+ project_config, experiment, bucketing_id, user_id
641
+ )
642
+ decide_reasons.push(*bucket_reasons)
643
+ unless bucketed_entity_id
644
+ message = "User \"#{user_context.user_id}\" not in CMAB experiment \"#{experiment['key']}\" due to traffic allocation."
645
+ @logger.log(Logger::INFO, message)
646
+ decide_reasons.push(message)
647
+ return CmabDecisionResult.new(false, nil, decide_reasons)
648
+ end
649
+
650
+ # User is in CMAB allocation, proceed to CMAB decision
651
+ begin
652
+ cmab_decision, reasons = @cmab_service.get_decision(
653
+ project_config, user_context, experiment['id'], decide_options
654
+ )
655
+ decide_reasons.push(*reasons)
656
+ CmabDecisionResult.new(false, cmab_decision, decide_reasons)
657
+ rescue StandardError => e
658
+ error_message = "Failed to fetch CMAB data for experiment #{experiment['key']}."
659
+ decide_reasons.push(error_message)
660
+ @logger&.log(Logger::ERROR, "#{error_message} #{e}")
661
+ CmabDecisionResult.new(true, nil, decide_reasons)
662
+ end
663
+ end
664
+
444
665
  def get_whitelisted_variation_id(project_config, experiment_id, user_id)
445
666
  # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
446
667
  #
@@ -26,7 +26,8 @@ module Optimizely
26
26
  anonymize_ip:,
27
27
  revision:,
28
28
  client_name:,
29
- client_version:
29
+ client_version:,
30
+ region:
30
31
  )
31
32
  @account_id = account_id
32
33
  @project_id = project_id
@@ -34,6 +35,7 @@ module Optimizely
34
35
  @revision = revision
35
36
  @client_name = client_name
36
37
  @client_version = client_version
38
+ @region = region
37
39
  end
38
40
 
39
41
  def as_json
@@ -43,7 +45,8 @@ module Optimizely
43
45
  anonymize_ip: @anonymize_ip,
44
46
  revision: @revision,
45
47
  client_name: @client_name,
46
- client_version: @client_version
48
+ client_version: @client_version,
49
+ region: @region
47
50
  }
48
51
  end
49
52
  end
@@ -28,7 +28,10 @@ module Optimizely
28
28
  # EventFactory builds LogEvent objects from a given user_event.
29
29
  class << self
30
30
  CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom'
31
- ENDPOINT = 'https://logx.optimizely.com/v1/events'
31
+ ENDPOINTS = {
32
+ US: 'https://logx.optimizely.com/v1/events',
33
+ EU: 'https://eu.logx.optimizely.com/v1/events'
34
+ }.freeze
32
35
  POST_HEADERS = {'Content-Type' => 'application/json'}.freeze
33
36
  ACTIVATE_EVENT_KEY = 'campaign_activated'
34
37
 
@@ -67,7 +70,10 @@ module Optimizely
67
70
 
68
71
  builder.with_visitors(visitors)
69
72
  event_batch = builder.build
70
- Event.new(:post, ENDPOINT, event_batch.as_json, POST_HEADERS)
73
+
74
+ endpoint = ENDPOINTS[user_context[:region].to_s.upcase.to_sym] || ENDPOINTS[:US]
75
+
76
+ Event.new(:post, endpoint, event_batch.as_json, POST_HEADERS)
71
77
  end
72
78
 
73
79
  def build_attribute_list(user_attributes, project_config)
@@ -33,6 +33,7 @@ module Optimizely
33
33
  #
34
34
  # Returns Event encapsulating the impression event.
35
35
  event_context = Optimizely::EventContext.new(
36
+ region: project_config.region,
36
37
  account_id: project_config.account_id,
37
38
  project_id: project_config.project_id,
38
39
  anonymize_ip: project_config.anonymize_ip,
@@ -67,6 +68,7 @@ module Optimizely
67
68
  # Returns Event encapsulating the conversion event.
68
69
 
69
70
  event_context = Optimizely::EventContext.new(
71
+ region: project_config.region,
70
72
  account_id: project_config.account_id,
71
73
  project_id: project_config.project_id,
72
74
  anonymize_ip: project_config.anonymize_ip,
@@ -78,7 +78,7 @@ module Optimizely
78
78
  )
79
79
  end
80
80
  # Append Bot Filtering Attribute
81
- if project_config.bot_filtering == true || project_config.bot_filtering == false
81
+ if [true, false].include?(project_config.bot_filtering)
82
82
  visitor_attributes.push(
83
83
  entity_id: Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BOT_FILTERING'],
84
84
  key: Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BOT_FILTERING'],
@@ -101,13 +101,17 @@ module Optimizely
101
101
  revision: project_config.revision,
102
102
  client_name: CLIENT_ENGINE,
103
103
  enrich_decisions: true,
104
- client_version: VERSION
104
+ client_version: VERSION,
105
+ region: project_config.region || 'US'
105
106
  }
106
107
  end
107
108
  end
108
109
 
109
110
  class EventBuilder < BaseEventBuilder
110
- ENDPOINT = 'https://logx.optimizely.com/v1/events'
111
+ ENDPOINTS = {
112
+ US: 'https://logx.optimizely.com/v1/events',
113
+ EU: 'https://eu.logx.optimizely.com/v1/events'
114
+ }.freeze
111
115
  POST_HEADERS = {'Content-Type' => 'application/json'}.freeze
112
116
  ACTIVATE_EVENT_KEY = 'campaign_activated'
113
117
 
@@ -122,11 +126,14 @@ module Optimizely
122
126
  #
123
127
  # Returns +Event+ encapsulating the impression event.
124
128
 
129
+ region = project_config.region || 'US'
125
130
  event_params = get_common_params(project_config, user_id, attributes)
126
131
  impression_params = get_impression_params(project_config, experiment, variation_id)
127
132
  event_params[:visitors][0][:snapshots].push(impression_params)
128
133
 
129
- Event.new(:post, ENDPOINT, event_params, POST_HEADERS)
134
+ endpoint = ENDPOINTS[region.to_s.upcase.to_sym]
135
+
136
+ Event.new(:post, endpoint, event_params, POST_HEADERS)
130
137
  end
131
138
 
132
139
  def create_conversion_event(project_config, event, user_id, attributes, event_tags)
@@ -140,11 +147,14 @@ module Optimizely
140
147
  #
141
148
  # Returns +Event+ encapsulating the conversion event.
142
149
 
150
+ region = project_config.region || 'US'
143
151
  event_params = get_common_params(project_config, user_id, attributes)
144
152
  conversion_params = get_conversion_params(event, event_tags)
145
153
  event_params[:visitors][0][:snapshots] = [conversion_params]
146
154
 
147
- Event.new(:post, ENDPOINT, event_params, POST_HEADERS)
155
+ endpoint = ENDPOINTS[region.to_s.upcase.to_sym]
156
+
157
+ Event.new(:post, endpoint, event_params, POST_HEADERS)
148
158
  end
149
159
 
150
160
  private