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.
- checksums.yaml +4 -4
- data/lib/optimizely/audience.rb +15 -1
- data/lib/optimizely/bucketer.rb +34 -13
- data/lib/optimizely/cmab/cmab_client.rb +230 -0
- data/lib/optimizely/cmab/cmab_service.rb +218 -0
- data/lib/optimizely/config/datafile_project_config.rb +166 -2
- data/lib/optimizely/config_manager/http_project_config_manager.rb +1 -1
- data/lib/optimizely/decide/optimizely_decide_option.rb +3 -0
- data/lib/optimizely/decide/optimizely_decision.rb +19 -0
- data/lib/optimizely/decision_service.rb +250 -59
- data/lib/optimizely/event/entity/event_context.rb +5 -2
- data/lib/optimizely/event/event_factory.rb +8 -2
- data/lib/optimizely/event/user_event_factory.rb +2 -0
- data/lib/optimizely/event_builder.rb +15 -5
- data/lib/optimizely/exceptions.rb +24 -0
- data/lib/optimizely/helpers/constants.rb +46 -0
- data/lib/optimizely/helpers/sdk_settings.rb +5 -2
- data/lib/optimizely/helpers/validator.rb +2 -2
- data/lib/optimizely/odp/lru_cache.rb +14 -1
- data/lib/optimizely/optimizely_factory.rb +56 -1
- data/lib/optimizely/project_config.rb +6 -0
- data/lib/optimizely/version.rb +1 -1
- data/lib/optimizely.rb +60 -21
- metadata +5 -3
|
@@ -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.
|
|
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
|
|
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']
|
|
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
|
|
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
|
|
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
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
194
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|