optimizely-sdk 5.1.0 → 5.2.1

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,132 @@ 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
+ # If there's an error (e.g., CMAB error), return immediately without falling back to rollout
218
+ return DecisionResult.new(nil, experiment_decision.error, reasons) if experiment_decision.error
219
+
220
+ # Check if the feature flag has a rollout and the user is bucketed into that rollout
221
+ rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
222
+ reasons.push(*rollout_decision.reasons)
223
+
224
+ if rollout_decision.decision
225
+ # Check if this was a forced decision (last reason contains "forced decision map")
226
+ is_forced_decision = reasons.last&.include?('forced decision map')
227
+
228
+ unless is_forced_decision
229
+ # Only add the "bucketed into rollout" message for normal bucketing
230
+ message = "The user '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag['key']}'."
231
+ @logger.log(Logger::INFO, message)
232
+ reasons.push(message)
233
+ end
234
+
235
+ DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
236
+ else
237
+ message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
238
+ @logger.log(Logger::INFO, message)
239
+ DecisionResult.new(nil, false, reasons)
240
+ end
241
+ end
242
+
243
+ def get_variation_for_holdout(holdout, user_context, project_config)
244
+ # Get the variation for holdout
245
+ #
246
+ # holdout - The holdout configuration
247
+ # user_context - The user context
248
+ # project_config - The project config
249
+ #
250
+ # Returns a DecisionResult for the holdout
251
+
252
+ decide_reasons = []
253
+ user_id = user_context.user_id
254
+ attributes = user_context.user_attributes
255
+
256
+ if holdout.nil? || holdout['status'].nil? || holdout['status'] != 'Running'
257
+ key = holdout && holdout['key'] ? holdout['key'] : 'unknown'
258
+ message = "Holdout '#{key}' is not running."
259
+ @logger.log(Logger::INFO, message)
260
+ decide_reasons.push(message)
261
+ return DecisionResult.new(nil, false, decide_reasons)
262
+ end
263
+
264
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
265
+ decide_reasons.push(*bucketing_id_reasons)
266
+
267
+ # Check audience conditions
268
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, holdout, user_context, @logger)
269
+ decide_reasons.push(*reasons_received)
270
+
271
+ unless user_meets_audience_conditions
272
+ message = "User '#{user_id}' does not meet the conditions for holdout '#{holdout['key']}'."
273
+ @logger.log(Logger::DEBUG, message)
274
+ decide_reasons.push(message)
275
+ return DecisionResult.new(nil, false, decide_reasons)
276
+ end
277
+
278
+ # Bucket user into holdout variation
279
+ variation, bucket_reasons = @bucketer.bucket(project_config, holdout, bucketing_id, user_id)
280
+ decide_reasons.push(*bucket_reasons)
281
+
282
+ if variation && !variation['key'].nil? && !variation['key'].empty?
283
+ message = "The user '#{user_id}' is bucketed into variation '#{variation['key']}' of holdout '#{holdout['key']}'."
284
+ @logger.log(Logger::INFO, message)
285
+ decide_reasons.push(message)
286
+
287
+ holdout_decision = Decision.new(holdout, variation, DECISION_SOURCES['HOLDOUT'], nil)
288
+ DecisionResult.new(holdout_decision, false, decide_reasons)
289
+ else
290
+ message = "The user '#{user_id}' is not bucketed into holdout '#{holdout['key']}'."
291
+ @logger.log(Logger::DEBUG, message)
292
+ decide_reasons.push(message)
293
+ DecisionResult.new(nil, false, decide_reasons)
294
+ end
148
295
  end
149
296
 
150
297
  def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
@@ -157,27 +304,19 @@ module Optimizely
157
304
  # decide_options: Decide options.
158
305
  #
159
306
  # Returns:
160
- # Array of Decision struct.
307
+ # Array of DecisionResult struct.
161
308
  ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
162
309
  user_profile_tracker = nil
163
310
  unless ignore_ups && @user_profile_service
164
- user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
311
+ user_id = user_context.user_id
312
+ user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
165
313
  user_profile_tracker.load_user_profile
166
314
  end
315
+
167
316
  decisions = []
168
317
  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
318
+ decision = get_decision_for_flag(feature_flag, user_context, project_config, decide_options, user_profile_tracker)
319
+ decisions << decision
181
320
  end
182
321
  user_profile_tracker&.save_user_profile
183
322
  decisions
@@ -190,8 +329,8 @@ module Optimizely
190
329
  # feature_flag - The feature flag the user wants to access
191
330
  # user_context - Optimizely user context instance
192
331
  #
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
332
+ # Returns a DecisionResult containing the decision (or nil if not bucketed),
333
+ # an error flag, and an array of decision reasons.
195
334
  decide_reasons = []
196
335
  user_id = user_context.user_id
197
336
  feature_flag_key = feature_flag['key']
@@ -199,7 +338,7 @@ module Optimizely
199
338
  message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
200
339
  @logger.log(Logger::DEBUG, message)
201
340
  decide_reasons.push(message)
202
- return nil, decide_reasons
341
+ return DecisionResult.new(nil, false, decide_reasons)
203
342
  end
204
343
 
205
344
  # Evaluate each experiment and return the first bucketed experiment variation
@@ -209,26 +348,34 @@ module Optimizely
209
348
  message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
210
349
  @logger.log(Logger::DEBUG, message)
211
350
  decide_reasons.push(message)
212
- return nil, decide_reasons
351
+ return DecisionResult.new(nil, false, decide_reasons)
213
352
  end
214
353
 
215
354
  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)
355
+ variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
356
+ error = variation_result.error
357
+ reasons_received = variation_result.reasons
358
+ variation_id = variation_result.variation_id
359
+ cmab_uuid = variation_result.cmab_uuid
217
360
  decide_reasons.push(*reasons_received)
218
361
 
362
+ # If there's an error, return immediately instead of falling back to next experiment
363
+ return DecisionResult.new(nil, error, decide_reasons) if error
364
+
219
365
  next unless variation_id
220
366
 
221
367
  variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
222
368
  variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
223
369
 
224
- return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
370
+ decision = Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'], cmab_uuid)
371
+ return DecisionResult.new(decision, error, decide_reasons)
225
372
  end
226
373
 
227
374
  message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
228
375
  @logger.log(Logger::INFO, message)
229
376
  decide_reasons.push(message)
230
377
 
231
- [nil, decide_reasons]
378
+ DecisionResult.new(nil, false, decide_reasons)
232
379
  end
233
380
 
234
381
  def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
@@ -239,7 +386,8 @@ module Optimizely
239
386
  # feature_flag - The feature flag the user wants to access
240
387
  # user_context - Optimizely user context instance
241
388
  #
242
- # Returns the Decision struct or nil if not bucketed into any of the targeting rules
389
+ # Returns a DecisionResult containing the decision (or nil if not bucketed),
390
+ # an error flag, and an array of decision reasons.
243
391
  decide_reasons = []
244
392
 
245
393
  rollout_id = feature_flag['rolloutId']
@@ -248,7 +396,7 @@ module Optimizely
248
396
  message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
249
397
  @logger.log(Logger::DEBUG, message)
250
398
  decide_reasons.push(message)
251
- return nil, decide_reasons
399
+ return DecisionResult.new(nil, false, decide_reasons)
252
400
  end
253
401
 
254
402
  rollout = project_config.get_rollout_from_id(rollout_id)
@@ -256,10 +404,10 @@ module Optimizely
256
404
  message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
257
405
  @logger.log(Logger::DEBUG, message)
258
406
  decide_reasons.push(message)
259
- return nil, decide_reasons
407
+ return DecisionResult.new(nil, false, decide_reasons)
260
408
  end
261
409
 
262
- return nil, decide_reasons if rollout['experiments'].empty?
410
+ return DecisionResult.new(nil, false, decide_reasons) if rollout['experiments'].empty?
263
411
 
264
412
  index = 0
265
413
  rollout_rules = rollout['experiments']
@@ -268,14 +416,14 @@ module Optimizely
268
416
  decide_reasons.push(*reasons_received)
269
417
  if variation
270
418
  rule = rollout_rules[index]
271
- feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
272
- return [feature_decision, decide_reasons]
419
+ feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'], nil)
420
+ return DecisionResult.new(feature_decision, false, decide_reasons)
273
421
  end
274
422
 
275
423
  index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
276
424
  end
277
425
 
278
- [nil, decide_reasons]
426
+ DecisionResult.new(nil, false, decide_reasons)
279
427
  end
280
428
 
281
429
  def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
@@ -293,13 +441,11 @@ module Optimizely
293
441
  context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
294
442
  variation, forced_reasons = validated_forced_decision(project_config, context, user)
295
443
  reasons.push(*forced_reasons)
444
+ return VariationResult.new(nil, false, reasons, variation['id']) if variation
296
445
 
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]
446
+ variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
447
+ variation_result.reasons = reasons + variation_result.reasons
448
+ variation_result
303
449
  end
304
450
 
305
451
  def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
@@ -467,6 +613,51 @@ module Optimizely
467
613
 
468
614
  private
469
615
 
616
+ def get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options = [])
617
+ # Determines the CMAB (Contextual Multi-Armed Bandit) decision for a given experiment and user context.
618
+ #
619
+ # This method first checks if the user is bucketed into the CMAB experiment based on traffic allocation.
620
+ # If the user is not bucketed, it returns a CmabDecisionResult indicating exclusion.
621
+ # If the user is bucketed, it attempts to fetch a CMAB decision using the CMAB service.
622
+ # In case of errors during CMAB decision retrieval, it logs the error and returns a result indicating failure.
623
+ #
624
+ # @param project_config [ProjectConfig] The current project configuration.
625
+ # @param experiment [Hash] The experiment configuration hash.
626
+ # @param user_context [OptimizelyUserContext] The user context object containing user information.
627
+ # @param bucketing_id [String] The bucketing ID used for traffic allocation.
628
+ # @param decide_options [Array] Optional array of decision options.
629
+ #
630
+ # @return [CmabDecisionResult] The result of the CMAB decision process, including decision error status, decision data, and reasons.
631
+ decide_reasons = []
632
+ user_id = user_context.user_id
633
+
634
+ # Check if user is in CMAB traffic allocation
635
+ bucketed_entity_id, bucket_reasons = @bucketer.bucket_to_entity_id(
636
+ project_config, experiment, bucketing_id, user_id
637
+ )
638
+ decide_reasons.push(*bucket_reasons)
639
+ unless bucketed_entity_id
640
+ message = "User \"#{user_context.user_id}\" not in CMAB experiment \"#{experiment['key']}\" due to traffic allocation."
641
+ @logger.log(Logger::INFO, message)
642
+ decide_reasons.push(message)
643
+ return CmabDecisionResult.new(false, nil, decide_reasons)
644
+ end
645
+
646
+ # User is in CMAB allocation, proceed to CMAB decision
647
+ begin
648
+ cmab_decision, reasons = @cmab_service.get_decision(
649
+ project_config, user_context, experiment['id'], decide_options
650
+ )
651
+ decide_reasons.push(*reasons)
652
+ CmabDecisionResult.new(false, cmab_decision, decide_reasons)
653
+ rescue StandardError => e
654
+ error_message = "Failed to fetch CMAB data for experiment #{experiment['key']}."
655
+ decide_reasons.push(error_message)
656
+ @logger&.log(Logger::ERROR, "#{error_message} #{e}")
657
+ CmabDecisionResult.new(true, nil, decide_reasons)
658
+ end
659
+ end
660
+
470
661
  def get_whitelisted_variation_id(project_config, experiment_id, user_id)
471
662
  # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
472
663
  #
@@ -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