optimizely-sdk 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +252 -57
- 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 +59 -20
- metadata +4 -2
|
@@ -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,129 @@ module Optimizely
|
|
|
143
166
|
# feature_flag - The feature flag the user wants to access
|
|
144
167
|
# user_context - Optimizely user context instance
|
|
145
168
|
#
|
|
146
|
-
# Returns
|
|
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
|
+
# Check if the feature flag has a rollout and the user is bucketed into that rollout
|
|
218
|
+
rollout_decision = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
|
219
|
+
reasons.push(*rollout_decision.reasons)
|
|
220
|
+
|
|
221
|
+
if rollout_decision.decision
|
|
222
|
+
# Check if this was a forced decision (last reason contains "forced decision map")
|
|
223
|
+
is_forced_decision = reasons.last&.include?('forced decision map')
|
|
224
|
+
|
|
225
|
+
unless is_forced_decision
|
|
226
|
+
# Only add the "bucketed into rollout" message for normal bucketing
|
|
227
|
+
message = "The user '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag['key']}'."
|
|
228
|
+
@logger.log(Logger::INFO, message)
|
|
229
|
+
reasons.push(message)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
DecisionResult.new(rollout_decision.decision, rollout_decision.error, reasons)
|
|
233
|
+
else
|
|
234
|
+
message = "The user '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag['key']}'."
|
|
235
|
+
@logger.log(Logger::INFO, message)
|
|
236
|
+
DecisionResult.new(nil, false, reasons)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def get_variation_for_holdout(holdout, user_context, project_config)
|
|
241
|
+
# Get the variation for holdout
|
|
242
|
+
#
|
|
243
|
+
# holdout - The holdout configuration
|
|
244
|
+
# user_context - The user context
|
|
245
|
+
# project_config - The project config
|
|
246
|
+
#
|
|
247
|
+
# Returns a DecisionResult for the holdout
|
|
248
|
+
|
|
249
|
+
decide_reasons = []
|
|
250
|
+
user_id = user_context.user_id
|
|
251
|
+
attributes = user_context.user_attributes
|
|
252
|
+
|
|
253
|
+
if holdout.nil? || holdout['status'].nil? || holdout['status'] != 'Running'
|
|
254
|
+
key = holdout && holdout['key'] ? holdout['key'] : 'unknown'
|
|
255
|
+
message = "Holdout '#{key}' is not running."
|
|
256
|
+
@logger.log(Logger::INFO, message)
|
|
257
|
+
decide_reasons.push(message)
|
|
258
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
|
262
|
+
decide_reasons.push(*bucketing_id_reasons)
|
|
263
|
+
|
|
264
|
+
# Check audience conditions
|
|
265
|
+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, holdout, user_context, @logger)
|
|
266
|
+
decide_reasons.push(*reasons_received)
|
|
267
|
+
|
|
268
|
+
unless user_meets_audience_conditions
|
|
269
|
+
message = "User '#{user_id}' does not meet the conditions for holdout '#{holdout['key']}'."
|
|
270
|
+
@logger.log(Logger::DEBUG, message)
|
|
271
|
+
decide_reasons.push(message)
|
|
272
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Bucket user into holdout variation
|
|
276
|
+
variation, bucket_reasons = @bucketer.bucket(project_config, holdout, bucketing_id, user_id)
|
|
277
|
+
decide_reasons.push(*bucket_reasons)
|
|
278
|
+
|
|
279
|
+
if variation && !variation['key'].nil? && !variation['key'].empty?
|
|
280
|
+
message = "The user '#{user_id}' is bucketed into variation '#{variation['key']}' of holdout '#{holdout['key']}'."
|
|
281
|
+
@logger.log(Logger::INFO, message)
|
|
282
|
+
decide_reasons.push(message)
|
|
283
|
+
|
|
284
|
+
holdout_decision = Decision.new(holdout, variation, DECISION_SOURCES['HOLDOUT'], nil)
|
|
285
|
+
DecisionResult.new(holdout_decision, false, decide_reasons)
|
|
286
|
+
else
|
|
287
|
+
message = "The user '#{user_id}' is not bucketed into holdout '#{holdout['key']}'."
|
|
288
|
+
@logger.log(Logger::DEBUG, message)
|
|
289
|
+
decide_reasons.push(message)
|
|
290
|
+
DecisionResult.new(nil, false, decide_reasons)
|
|
291
|
+
end
|
|
148
292
|
end
|
|
149
293
|
|
|
150
294
|
def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
|
|
@@ -157,27 +301,26 @@ module Optimizely
|
|
|
157
301
|
# decide_options: Decide options.
|
|
158
302
|
#
|
|
159
303
|
# Returns:
|
|
160
|
-
# Array of
|
|
304
|
+
# Array of DecisionResult struct.
|
|
161
305
|
ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
|
|
162
306
|
user_profile_tracker = nil
|
|
163
307
|
unless ignore_ups && @user_profile_service
|
|
164
|
-
|
|
308
|
+
user_id = user_context.user_id
|
|
309
|
+
user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger)
|
|
165
310
|
user_profile_tracker.load_user_profile
|
|
166
311
|
end
|
|
312
|
+
|
|
167
313
|
decisions = []
|
|
168
314
|
feature_flags.each do |feature_flag|
|
|
169
|
-
decide_reasons = []
|
|
170
315
|
# check if the feature is being experiment on and whether the user is bucketed into the experiment
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if decision
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
|
178
|
-
decide_reasons.push(*reasons_received)
|
|
179
|
-
decisions << [rollout_decision, decide_reasons]
|
|
316
|
+
decision_result = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
|
|
317
|
+
# Only process rollout if no experiment decision was found and no error
|
|
318
|
+
if decision_result.decision.nil? && !decision_result.error
|
|
319
|
+
decision_result_rollout = get_variation_for_feature_rollout(project_config, feature_flag, user_context) unless decision_result.decision
|
|
320
|
+
decision_result.decision = decision_result_rollout.decision
|
|
321
|
+
decision_result.reasons.push(*decision_result_rollout.reasons)
|
|
180
322
|
end
|
|
323
|
+
decisions << decision_result
|
|
181
324
|
end
|
|
182
325
|
user_profile_tracker&.save_user_profile
|
|
183
326
|
decisions
|
|
@@ -190,8 +333,8 @@ module Optimizely
|
|
|
190
333
|
# feature_flag - The feature flag the user wants to access
|
|
191
334
|
# user_context - Optimizely user context instance
|
|
192
335
|
#
|
|
193
|
-
# Returns
|
|
194
|
-
#
|
|
336
|
+
# Returns a DecisionResult containing the decision (or nil if not bucketed),
|
|
337
|
+
# an error flag, and an array of decision reasons.
|
|
195
338
|
decide_reasons = []
|
|
196
339
|
user_id = user_context.user_id
|
|
197
340
|
feature_flag_key = feature_flag['key']
|
|
@@ -199,7 +342,7 @@ module Optimizely
|
|
|
199
342
|
message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
|
|
200
343
|
@logger.log(Logger::DEBUG, message)
|
|
201
344
|
decide_reasons.push(message)
|
|
202
|
-
return nil, decide_reasons
|
|
345
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
203
346
|
end
|
|
204
347
|
|
|
205
348
|
# Evaluate each experiment and return the first bucketed experiment variation
|
|
@@ -209,26 +352,34 @@ module Optimizely
|
|
|
209
352
|
message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
|
|
210
353
|
@logger.log(Logger::DEBUG, message)
|
|
211
354
|
decide_reasons.push(message)
|
|
212
|
-
return nil, decide_reasons
|
|
355
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
213
356
|
end
|
|
214
357
|
|
|
215
358
|
experiment_id = experiment['id']
|
|
216
|
-
|
|
359
|
+
variation_result = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
|
|
360
|
+
error = variation_result.error
|
|
361
|
+
reasons_received = variation_result.reasons
|
|
362
|
+
variation_id = variation_result.variation_id
|
|
363
|
+
cmab_uuid = variation_result.cmab_uuid
|
|
217
364
|
decide_reasons.push(*reasons_received)
|
|
218
365
|
|
|
366
|
+
# If there's an error, return immediately instead of falling back to next experiment
|
|
367
|
+
return DecisionResult.new(nil, error, decide_reasons) if error
|
|
368
|
+
|
|
219
369
|
next unless variation_id
|
|
220
370
|
|
|
221
371
|
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
|
|
222
372
|
variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
|
|
223
373
|
|
|
224
|
-
|
|
374
|
+
decision = Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST'], cmab_uuid)
|
|
375
|
+
return DecisionResult.new(decision, error, decide_reasons)
|
|
225
376
|
end
|
|
226
377
|
|
|
227
378
|
message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
|
|
228
379
|
@logger.log(Logger::INFO, message)
|
|
229
380
|
decide_reasons.push(message)
|
|
230
381
|
|
|
231
|
-
|
|
382
|
+
DecisionResult.new(nil, false, decide_reasons)
|
|
232
383
|
end
|
|
233
384
|
|
|
234
385
|
def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
|
@@ -239,7 +390,8 @@ module Optimizely
|
|
|
239
390
|
# feature_flag - The feature flag the user wants to access
|
|
240
391
|
# user_context - Optimizely user context instance
|
|
241
392
|
#
|
|
242
|
-
# Returns the
|
|
393
|
+
# Returns a DecisionResult containing the decision (or nil if not bucketed),
|
|
394
|
+
# an error flag, and an array of decision reasons.
|
|
243
395
|
decide_reasons = []
|
|
244
396
|
|
|
245
397
|
rollout_id = feature_flag['rolloutId']
|
|
@@ -248,7 +400,7 @@ module Optimizely
|
|
|
248
400
|
message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
|
|
249
401
|
@logger.log(Logger::DEBUG, message)
|
|
250
402
|
decide_reasons.push(message)
|
|
251
|
-
return nil, decide_reasons
|
|
403
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
252
404
|
end
|
|
253
405
|
|
|
254
406
|
rollout = project_config.get_rollout_from_id(rollout_id)
|
|
@@ -256,10 +408,10 @@ module Optimizely
|
|
|
256
408
|
message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
|
|
257
409
|
@logger.log(Logger::DEBUG, message)
|
|
258
410
|
decide_reasons.push(message)
|
|
259
|
-
return nil, decide_reasons
|
|
411
|
+
return DecisionResult.new(nil, false, decide_reasons)
|
|
260
412
|
end
|
|
261
413
|
|
|
262
|
-
return nil, decide_reasons if rollout['experiments'].empty?
|
|
414
|
+
return DecisionResult.new(nil, false, decide_reasons) if rollout['experiments'].empty?
|
|
263
415
|
|
|
264
416
|
index = 0
|
|
265
417
|
rollout_rules = rollout['experiments']
|
|
@@ -268,14 +420,14 @@ module Optimizely
|
|
|
268
420
|
decide_reasons.push(*reasons_received)
|
|
269
421
|
if variation
|
|
270
422
|
rule = rollout_rules[index]
|
|
271
|
-
feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
|
|
272
|
-
return
|
|
423
|
+
feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'], nil)
|
|
424
|
+
return DecisionResult.new(feature_decision, false, decide_reasons)
|
|
273
425
|
end
|
|
274
426
|
|
|
275
427
|
index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
|
|
276
428
|
end
|
|
277
429
|
|
|
278
|
-
|
|
430
|
+
DecisionResult.new(nil, false, decide_reasons)
|
|
279
431
|
end
|
|
280
432
|
|
|
281
433
|
def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
|
|
@@ -293,13 +445,11 @@ module Optimizely
|
|
|
293
445
|
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
|
|
294
446
|
variation, forced_reasons = validated_forced_decision(project_config, context, user)
|
|
295
447
|
reasons.push(*forced_reasons)
|
|
448
|
+
return VariationResult.new(nil, false, reasons, variation['id']) if variation
|
|
296
449
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
reasons.push(*response_reasons)
|
|
301
|
-
|
|
302
|
-
[variation_id, reasons]
|
|
450
|
+
variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
|
|
451
|
+
variation_result.reasons = reasons + variation_result.reasons
|
|
452
|
+
variation_result
|
|
303
453
|
end
|
|
304
454
|
|
|
305
455
|
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
|
|
@@ -467,6 +617,51 @@ module Optimizely
|
|
|
467
617
|
|
|
468
618
|
private
|
|
469
619
|
|
|
620
|
+
def get_decision_for_cmab_experiment(project_config, experiment, user_context, bucketing_id, decide_options = [])
|
|
621
|
+
# Determines the CMAB (Contextual Multi-Armed Bandit) decision for a given experiment and user context.
|
|
622
|
+
#
|
|
623
|
+
# This method first checks if the user is bucketed into the CMAB experiment based on traffic allocation.
|
|
624
|
+
# If the user is not bucketed, it returns a CmabDecisionResult indicating exclusion.
|
|
625
|
+
# If the user is bucketed, it attempts to fetch a CMAB decision using the CMAB service.
|
|
626
|
+
# In case of errors during CMAB decision retrieval, it logs the error and returns a result indicating failure.
|
|
627
|
+
#
|
|
628
|
+
# @param project_config [ProjectConfig] The current project configuration.
|
|
629
|
+
# @param experiment [Hash] The experiment configuration hash.
|
|
630
|
+
# @param user_context [OptimizelyUserContext] The user context object containing user information.
|
|
631
|
+
# @param bucketing_id [String] The bucketing ID used for traffic allocation.
|
|
632
|
+
# @param decide_options [Array] Optional array of decision options.
|
|
633
|
+
#
|
|
634
|
+
# @return [CmabDecisionResult] The result of the CMAB decision process, including decision error status, decision data, and reasons.
|
|
635
|
+
decide_reasons = []
|
|
636
|
+
user_id = user_context.user_id
|
|
637
|
+
|
|
638
|
+
# Check if user is in CMAB traffic allocation
|
|
639
|
+
bucketed_entity_id, bucket_reasons = @bucketer.bucket_to_entity_id(
|
|
640
|
+
project_config, experiment, bucketing_id, user_id
|
|
641
|
+
)
|
|
642
|
+
decide_reasons.push(*bucket_reasons)
|
|
643
|
+
unless bucketed_entity_id
|
|
644
|
+
message = "User \"#{user_context.user_id}\" not in CMAB experiment \"#{experiment['key']}\" due to traffic allocation."
|
|
645
|
+
@logger.log(Logger::INFO, message)
|
|
646
|
+
decide_reasons.push(message)
|
|
647
|
+
return CmabDecisionResult.new(false, nil, decide_reasons)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# User is in CMAB allocation, proceed to CMAB decision
|
|
651
|
+
begin
|
|
652
|
+
cmab_decision, reasons = @cmab_service.get_decision(
|
|
653
|
+
project_config, user_context, experiment['id'], decide_options
|
|
654
|
+
)
|
|
655
|
+
decide_reasons.push(*reasons)
|
|
656
|
+
CmabDecisionResult.new(false, cmab_decision, decide_reasons)
|
|
657
|
+
rescue StandardError => e
|
|
658
|
+
error_message = "Failed to fetch CMAB data for experiment #{experiment['key']}."
|
|
659
|
+
decide_reasons.push(error_message)
|
|
660
|
+
@logger&.log(Logger::ERROR, "#{error_message} #{e}")
|
|
661
|
+
CmabDecisionResult.new(true, nil, decide_reasons)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
470
665
|
def get_whitelisted_variation_id(project_config, experiment_id, user_id)
|
|
471
666
|
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
|
|
472
667
|
#
|
|
@@ -26,7 +26,8 @@ module Optimizely
|
|
|
26
26
|
anonymize_ip:,
|
|
27
27
|
revision:,
|
|
28
28
|
client_name:,
|
|
29
|
-
client_version
|
|
29
|
+
client_version:,
|
|
30
|
+
region:
|
|
30
31
|
)
|
|
31
32
|
@account_id = account_id
|
|
32
33
|
@project_id = project_id
|
|
@@ -34,6 +35,7 @@ module Optimizely
|
|
|
34
35
|
@revision = revision
|
|
35
36
|
@client_name = client_name
|
|
36
37
|
@client_version = client_version
|
|
38
|
+
@region = region
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def as_json
|
|
@@ -43,7 +45,8 @@ module Optimizely
|
|
|
43
45
|
anonymize_ip: @anonymize_ip,
|
|
44
46
|
revision: @revision,
|
|
45
47
|
client_name: @client_name,
|
|
46
|
-
client_version: @client_version
|
|
48
|
+
client_version: @client_version,
|
|
49
|
+
region: @region
|
|
47
50
|
}
|
|
48
51
|
end
|
|
49
52
|
end
|
|
@@ -28,7 +28,10 @@ module Optimizely
|
|
|
28
28
|
# EventFactory builds LogEvent objects from a given user_event.
|
|
29
29
|
class << self
|
|
30
30
|
CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom'
|
|
31
|
-
|
|
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
|