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