optimizely-sdk 5.1.0 → 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,19 +38,24 @@ 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
61
  def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = [])
@@ -61,8 +67,7 @@ module Optimizely
61
67
  # user_profile_tracker: Tracker for reading and updating user profile of the user.
62
68
  # reasons: Decision reasons.
63
69
  #
64
- # Returns variation ID where visitor will be bucketed
65
- # (nil if experiment is inactive or user does not meet audience conditions)
70
+ # Returns VariationResult struct
66
71
  user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker)
67
72
  decide_reasons = []
68
73
  decide_reasons.push(*reasons)
@@ -73,25 +78,25 @@ module Optimizely
73
78
  decide_reasons.push(*bucketing_id_reasons)
74
79
  # Check to make sure experiment is active
75
80
  experiment = project_config.get_experiment_from_id(experiment_id)
76
- return nil, decide_reasons if experiment.nil?
81
+ return VariationResult.new(nil, false, decide_reasons, nil) if experiment.nil?
77
82
 
78
83
  experiment_key = experiment['key']
79
84
  unless project_config.experiment_running?(experiment)
80
85
  message = "Experiment '#{experiment_key}' is not running."
81
86
  @logger.log(Logger::INFO, message)
82
87
  decide_reasons.push(message)
83
- return nil, decide_reasons
88
+ return VariationResult.new(nil, false, decide_reasons, nil)
84
89
  end
85
90
 
86
91
  # Check if a forced variation is set for the user
87
92
  forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
88
93
  decide_reasons.push(*reasons_received)
89
- return forced_variation['id'], decide_reasons if forced_variation
94
+ return VariationResult.new(nil, false, decide_reasons, forced_variation['id']) if forced_variation
90
95
 
91
96
  # Check if user is in a white-listed variation
92
97
  whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
93
98
  decide_reasons.push(*reasons_received)
94
- 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
95
100
 
96
101
  should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
97
102
  # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
@@ -102,7 +107,7 @@ module Optimizely
102
107
  message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
103
108
  @logger.log(Logger::INFO, message)
104
109
  decide_reasons.push(message)
105
- return saved_variation_id, decide_reasons
110
+ return VariationResult.new(nil, false, decide_reasons, saved_variation_id)
106
111
  end
107
112
  end
108
113
 
@@ -113,27 +118,45 @@ module Optimizely
113
118
  message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
114
119
  @logger.log(Logger::INFO, message)
115
120
  decide_reasons.push(message)
116
- return nil, decide_reasons
121
+ return VariationResult.new(nil, false, decide_reasons, nil)
117
122
  end
118
123
 
119
- # Bucket normally
120
- variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
121
- decide_reasons.push(*bucket_reasons)
122
- 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
123
134
 
124
- message = ''
125
- if variation_id
126
- variation_key = variation['key']
127
- 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
128
139
  else
129
- 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
130
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
+
131
154
  @logger.log(Logger::INFO, message)
132
- decide_reasons.push(message)
155
+ decide_reasons.push(message) if message
133
156
 
134
157
  # Persist bucketing decision
135
158
  user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
136
- [variation_id, decide_reasons]
159
+ VariationResult.new(cmab_uuid, false, decide_reasons, variation_id)
137
160
  end
138
161
 
139
162
  def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
@@ -143,8 +166,129 @@ module Optimizely
143
166
  # feature_flag - The feature flag the user wants to access
144
167
  # user_context - Optimizely user context instance
145
168
  #
146
- # 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
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
248
+
249
+ decide_reasons = []
250
+ user_id = user_context.user_id
251
+ attributes = user_context.user_attributes
252
+
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
260
+
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)
266
+ decide_reasons.push(*reasons_received)
267
+
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
148
292
  end
149
293
 
150
294
  def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
@@ -157,27 +301,26 @@ module Optimizely
157
301
  # decide_options: Decide options.
158
302
  #
159
303
  # Returns:
160
- # Array of Decision struct.
304
+ # Array of DecisionResult struct.
161
305
  ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
162
306
  user_profile_tracker = nil
163
307
  unless ignore_ups && @user_profile_service
164
- user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
308
+ user_id = user_context.user_id
309
+ user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
165
310
  user_profile_tracker.load_user_profile
166
311
  end
312
+
167
313
  decisions = []
168
314
  feature_flags.each do |feature_flag|
169
- decide_reasons = []
170
315
  # 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]
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)
180
322
  end
323
+ decisions << decision_result
181
324
  end
182
325
  user_profile_tracker&.save_user_profile
183
326
  decisions
@@ -190,8 +333,8 @@ module Optimizely
190
333
  # feature_flag - The feature flag the user wants to access
191
334
  # user_context - Optimizely user context instance
192
335
  #
193
- # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
194
- # 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.
195
338
  decide_reasons = []
196
339
  user_id = user_context.user_id
197
340
  feature_flag_key = feature_flag['key']
@@ -199,7 +342,7 @@ module Optimizely
199
342
  message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
200
343
  @logger.log(Logger::DEBUG, message)
201
344
  decide_reasons.push(message)
202
- return nil, decide_reasons
345
+ return DecisionResult.new(nil, false, decide_reasons)
203
346
  end
204
347
 
205
348
  # Evaluate each experiment and return the first bucketed experiment variation
@@ -209,26 +352,34 @@ module Optimizely
209
352
  message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
210
353
  @logger.log(Logger::DEBUG, message)
211
354
  decide_reasons.push(message)
212
- return nil, decide_reasons
355
+ return DecisionResult.new(nil, false, decide_reasons)
213
356
  end
214
357
 
215
358
  experiment_id = experiment['id']
216
- variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, 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
217
364
  decide_reasons.push(*reasons_received)
218
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
+
219
369
  next unless variation_id
220
370
 
221
371
  variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
222
372
  variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
223
373
 
224
- 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)
225
376
  end
226
377
 
227
378
  message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
228
379
  @logger.log(Logger::INFO, message)
229
380
  decide_reasons.push(message)
230
381
 
231
- [nil, decide_reasons]
382
+ DecisionResult.new(nil, false, decide_reasons)
232
383
  end
233
384
 
234
385
  def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
@@ -239,7 +390,8 @@ module Optimizely
239
390
  # feature_flag - The feature flag the user wants to access
240
391
  # user_context - Optimizely user context instance
241
392
  #
242
- # 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.
243
395
  decide_reasons = []
244
396
 
245
397
  rollout_id = feature_flag['rolloutId']
@@ -248,7 +400,7 @@ module Optimizely
248
400
  message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
249
401
  @logger.log(Logger::DEBUG, message)
250
402
  decide_reasons.push(message)
251
- return nil, decide_reasons
403
+ return DecisionResult.new(nil, false, decide_reasons)
252
404
  end
253
405
 
254
406
  rollout = project_config.get_rollout_from_id(rollout_id)
@@ -256,10 +408,10 @@ module Optimizely
256
408
  message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
257
409
  @logger.log(Logger::DEBUG, message)
258
410
  decide_reasons.push(message)
259
- return nil, decide_reasons
411
+ return DecisionResult.new(nil, false, decide_reasons)
260
412
  end
261
413
 
262
- return nil, decide_reasons if rollout['experiments'].empty?
414
+ return DecisionResult.new(nil, false, decide_reasons) if rollout['experiments'].empty?
263
415
 
264
416
  index = 0
265
417
  rollout_rules = rollout['experiments']
@@ -268,14 +420,14 @@ module Optimizely
268
420
  decide_reasons.push(*reasons_received)
269
421
  if variation
270
422
  rule = rollout_rules[index]
271
- feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
272
- 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)
273
425
  end
274
426
 
275
427
  index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
276
428
  end
277
429
 
278
- [nil, decide_reasons]
430
+ DecisionResult.new(nil, false, decide_reasons)
279
431
  end
280
432
 
281
433
  def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
@@ -293,13 +445,11 @@ module Optimizely
293
445
  context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
294
446
  variation, forced_reasons = validated_forced_decision(project_config, context, user)
295
447
  reasons.push(*forced_reasons)
448
+ return VariationResult.new(nil, false, reasons, variation['id']) if variation
296
449
 
297
- return [variation['id'], reasons] if variation
298
-
299
- variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
300
- reasons.push(*response_reasons)
301
-
302
- [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
303
453
  end
304
454
 
305
455
  def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
@@ -467,6 +617,51 @@ module Optimizely
467
617
 
468
618
  private
469
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
+
470
665
  def get_whitelisted_variation_id(project_config, experiment_id, user_id)
471
666
  # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
472
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
@@ -190,4 +190,28 @@ module Optimizely
190
190
  super
191
191
  end
192
192
  end
193
+
194
+ class CmabError < Error
195
+ # Base exception for CMAB errors
196
+
197
+ def initialize(msg = 'CMAB error occurred.')
198
+ super
199
+ end
200
+ end
201
+
202
+ class CmabFetchError < CmabError
203
+ # Exception raised when CMAB fetch fails
204
+
205
+ def initialize(msg = 'CMAB decision fetch failed with status:')
206
+ super
207
+ end
208
+ end
209
+
210
+ class CmabInvalidResponseError < CmabError
211
+ # Exception raised when CMAB fetch returns an invalid response
212
+
213
+ def initialize(msg = 'Invalid CMAB fetch response')
214
+ super
215
+ end
216
+ end
193
217
  end