optimizely-sdk 5.0.0 → 5.0.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/LICENSE +202 -202
- data/lib/optimizely/audience.rb +127 -127
- data/lib/optimizely/bucketer.rb +156 -156
- data/lib/optimizely/condition_tree_evaluator.rb +123 -123
- data/lib/optimizely/config/datafile_project_config.rb +558 -558
- data/lib/optimizely/config/proxy_config.rb +34 -34
- data/lib/optimizely/config_manager/async_scheduler.rb +95 -95
- data/lib/optimizely/config_manager/http_project_config_manager.rb +340 -340
- data/lib/optimizely/config_manager/project_config_manager.rb +25 -25
- data/lib/optimizely/config_manager/static_project_config_manager.rb +55 -55
- data/lib/optimizely/decide/optimizely_decide_option.rb +28 -28
- data/lib/optimizely/decide/optimizely_decision.rb +60 -60
- data/lib/optimizely/decide/optimizely_decision_message.rb +26 -26
- data/lib/optimizely/decision_service.rb +563 -563
- data/lib/optimizely/error_handler.rb +39 -39
- data/lib/optimizely/event/batch_event_processor.rb +235 -235
- data/lib/optimizely/event/entity/conversion_event.rb +44 -44
- data/lib/optimizely/event/entity/decision.rb +38 -38
- data/lib/optimizely/event/entity/event_batch.rb +86 -86
- data/lib/optimizely/event/entity/event_context.rb +50 -50
- data/lib/optimizely/event/entity/impression_event.rb +48 -48
- data/lib/optimizely/event/entity/snapshot.rb +33 -33
- data/lib/optimizely/event/entity/snapshot_event.rb +48 -48
- data/lib/optimizely/event/entity/user_event.rb +22 -22
- data/lib/optimizely/event/entity/visitor.rb +36 -36
- data/lib/optimizely/event/entity/visitor_attribute.rb +38 -38
- data/lib/optimizely/event/event_factory.rb +156 -156
- data/lib/optimizely/event/event_processor.rb +25 -25
- data/lib/optimizely/event/forwarding_event_processor.rb +44 -44
- data/lib/optimizely/event/user_event_factory.rb +88 -88
- data/lib/optimizely/event_builder.rb +221 -221
- data/lib/optimizely/event_dispatcher.rb +69 -69
- data/lib/optimizely/exceptions.rb +193 -193
- data/lib/optimizely/helpers/constants.rb +459 -459
- data/lib/optimizely/helpers/date_time_utils.rb +30 -30
- data/lib/optimizely/helpers/event_tag_utils.rb +132 -132
- data/lib/optimizely/helpers/group.rb +31 -31
- data/lib/optimizely/helpers/http_utils.rb +68 -68
- data/lib/optimizely/helpers/sdk_settings.rb +61 -61
- data/lib/optimizely/helpers/validator.rb +236 -236
- data/lib/optimizely/helpers/variable_type.rb +67 -67
- data/lib/optimizely/logger.rb +46 -46
- data/lib/optimizely/notification_center.rb +174 -174
- data/lib/optimizely/notification_center_registry.rb +71 -71
- data/lib/optimizely/odp/lru_cache.rb +114 -114
- data/lib/optimizely/odp/odp_config.rb +102 -102
- data/lib/optimizely/odp/odp_event.rb +75 -75
- data/lib/optimizely/odp/odp_event_api_manager.rb +70 -70
- data/lib/optimizely/odp/odp_event_manager.rb +286 -286
- data/lib/optimizely/odp/odp_manager.rb +159 -159
- data/lib/optimizely/odp/odp_segment_api_manager.rb +122 -122
- data/lib/optimizely/odp/odp_segment_manager.rb +97 -97
- data/lib/optimizely/optimizely_config.rb +273 -273
- data/lib/optimizely/optimizely_factory.rb +184 -184
- data/lib/optimizely/optimizely_user_context.rb +238 -238
- data/lib/optimizely/params.rb +31 -31
- data/lib/optimizely/project_config.rb +99 -99
- data/lib/optimizely/semantic_version.rb +166 -166
- data/lib/optimizely/user_condition_evaluator.rb +391 -391
- data/lib/optimizely/user_profile_service.rb +35 -35
- data/lib/optimizely/version.rb +21 -21
- data/lib/optimizely.rb +1262 -1262
- metadata +7 -5
@@ -1,563 +1,563 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
#
|
4
|
-
# Copyright 2017-2022, Optimizely and contributors
|
5
|
-
#
|
6
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
-
# you may not use this file except in compliance with the License.
|
8
|
-
# You may obtain a copy of the License at
|
9
|
-
#
|
10
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
-
#
|
12
|
-
# Unless required by applicable law or agreed to in writing, software
|
13
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
-
# See the License for the specific language governing permissions and
|
16
|
-
# limitations under the License.
|
17
|
-
#
|
18
|
-
require_relative './bucketer'
|
19
|
-
|
20
|
-
module Optimizely
|
21
|
-
class DecisionService
|
22
|
-
# Optimizely's decision service that determines into which variation of an experiment a user will be allocated.
|
23
|
-
#
|
24
|
-
# The decision service contains all logic relating to how a user bucketing decisions is made.
|
25
|
-
# This includes all of the following (in order):
|
26
|
-
#
|
27
|
-
# 1. Check experiment status
|
28
|
-
# 2. Check forced bucketing
|
29
|
-
# 3. Check whitelisting
|
30
|
-
# 4. Check user profile service for past bucketing decisions (sticky bucketing)
|
31
|
-
# 5. Check audience targeting
|
32
|
-
# 6. Use Murmurhash3 to bucket the user
|
33
|
-
|
34
|
-
attr_reader :bucketer
|
35
|
-
|
36
|
-
# Hash of user IDs to a Hash of experiments to variations.
|
37
|
-
# This contains all the forced variations set by the user by calling setForcedVariation.
|
38
|
-
attr_reader :forced_variation_map
|
39
|
-
|
40
|
-
Decision = Struct.new(:experiment, :variation, :source)
|
41
|
-
|
42
|
-
DECISION_SOURCES = {
|
43
|
-
'EXPERIMENT' => 'experiment',
|
44
|
-
'FEATURE_TEST' => 'feature-test',
|
45
|
-
'ROLLOUT' => 'rollout'
|
46
|
-
}.freeze
|
47
|
-
|
48
|
-
def initialize(logger, user_profile_service = nil)
|
49
|
-
@logger = logger
|
50
|
-
@user_profile_service = user_profile_service
|
51
|
-
@bucketer = Bucketer.new(logger)
|
52
|
-
@forced_variation_map = {}
|
53
|
-
end
|
54
|
-
|
55
|
-
def get_variation(project_config, experiment_id, user_context, decide_options = [])
|
56
|
-
# Determines variation into which user will be bucketed.
|
57
|
-
#
|
58
|
-
# project_config - project_config - Instance of ProjectConfig
|
59
|
-
# experiment_id - Experiment for which visitor variation needs to be determined
|
60
|
-
# user_context - Optimizely user context instance
|
61
|
-
#
|
62
|
-
# Returns variation ID where visitor will be bucketed
|
63
|
-
# (nil if experiment is inactive or user does not meet audience conditions)
|
64
|
-
|
65
|
-
decide_reasons = []
|
66
|
-
user_id = user_context.user_id
|
67
|
-
attributes = user_context.user_attributes
|
68
|
-
# By default, the bucketing ID should be the user ID
|
69
|
-
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
70
|
-
decide_reasons.push(*bucketing_id_reasons)
|
71
|
-
# Check to make sure experiment is active
|
72
|
-
experiment = project_config.get_experiment_from_id(experiment_id)
|
73
|
-
return nil, decide_reasons if experiment.nil?
|
74
|
-
|
75
|
-
experiment_key = experiment['key']
|
76
|
-
unless project_config.experiment_running?(experiment)
|
77
|
-
message = "Experiment '#{experiment_key}' is not running."
|
78
|
-
@logger.log(Logger::INFO, message)
|
79
|
-
decide_reasons.push(message)
|
80
|
-
return nil, decide_reasons
|
81
|
-
end
|
82
|
-
|
83
|
-
# Check if a forced variation is set for the user
|
84
|
-
forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
|
85
|
-
decide_reasons.push(*reasons_received)
|
86
|
-
return forced_variation['id'], decide_reasons if forced_variation
|
87
|
-
|
88
|
-
# Check if user is in a white-listed variation
|
89
|
-
whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
|
90
|
-
decide_reasons.push(*reasons_received)
|
91
|
-
return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
|
92
|
-
|
93
|
-
should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
|
94
|
-
# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
|
95
|
-
unless should_ignore_user_profile_service
|
96
|
-
user_profile, reasons_received = get_user_profile(user_id)
|
97
|
-
decide_reasons.push(*reasons_received)
|
98
|
-
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
|
99
|
-
decide_reasons.push(*reasons_received)
|
100
|
-
if saved_variation_id
|
101
|
-
message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
|
102
|
-
@logger.log(Logger::INFO, message)
|
103
|
-
decide_reasons.push(message)
|
104
|
-
return saved_variation_id, decide_reasons
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
# Check audience conditions
|
109
|
-
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, user_context, @logger)
|
110
|
-
decide_reasons.push(*reasons_received)
|
111
|
-
unless user_meets_audience_conditions
|
112
|
-
message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
|
113
|
-
@logger.log(Logger::INFO, message)
|
114
|
-
decide_reasons.push(message)
|
115
|
-
return nil, decide_reasons
|
116
|
-
end
|
117
|
-
|
118
|
-
# Bucket normally
|
119
|
-
variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
|
120
|
-
decide_reasons.push(*bucket_reasons)
|
121
|
-
variation_id = variation ? variation['id'] : nil
|
122
|
-
|
123
|
-
message = ''
|
124
|
-
if variation_id
|
125
|
-
variation_key = variation['key']
|
126
|
-
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
|
127
|
-
else
|
128
|
-
message = "User '#{user_id}' is in no variation."
|
129
|
-
end
|
130
|
-
@logger.log(Logger::INFO, message)
|
131
|
-
decide_reasons.push(message)
|
132
|
-
|
133
|
-
# Persist bucketing decision
|
134
|
-
save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
|
135
|
-
[variation_id, decide_reasons]
|
136
|
-
end
|
137
|
-
|
138
|
-
def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
|
139
|
-
# Get the variation the user is bucketed into for the given FeatureFlag.
|
140
|
-
#
|
141
|
-
# project_config - project_config - Instance of ProjectConfig
|
142
|
-
# feature_flag - The feature flag the user wants to access
|
143
|
-
# user_context - Optimizely user context instance
|
144
|
-
#
|
145
|
-
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
146
|
-
|
147
|
-
decide_reasons = []
|
148
|
-
|
149
|
-
# check if the feature is being experiment on and whether the user is bucketed into the experiment
|
150
|
-
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options)
|
151
|
-
decide_reasons.push(*reasons_received)
|
152
|
-
return decision, decide_reasons unless decision.nil?
|
153
|
-
|
154
|
-
decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
155
|
-
decide_reasons.push(*reasons_received)
|
156
|
-
|
157
|
-
[decision, decide_reasons]
|
158
|
-
end
|
159
|
-
|
160
|
-
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
|
161
|
-
# Gets the variation the user is bucketed into for the feature flag's experiment.
|
162
|
-
#
|
163
|
-
# project_config - project_config - Instance of ProjectConfig
|
164
|
-
# feature_flag - The feature flag the user wants to access
|
165
|
-
# user_context - Optimizely user context instance
|
166
|
-
#
|
167
|
-
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
168
|
-
# or nil if the user is not bucketed into any of the experiments on the feature
|
169
|
-
decide_reasons = []
|
170
|
-
user_id = user_context.user_id
|
171
|
-
feature_flag_key = feature_flag['key']
|
172
|
-
if feature_flag['experimentIds'].empty?
|
173
|
-
message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
|
174
|
-
@logger.log(Logger::DEBUG, message)
|
175
|
-
decide_reasons.push(message)
|
176
|
-
return nil, decide_reasons
|
177
|
-
end
|
178
|
-
|
179
|
-
# Evaluate each experiment and return the first bucketed experiment variation
|
180
|
-
feature_flag['experimentIds'].each do |experiment_id|
|
181
|
-
experiment = project_config.experiment_id_map[experiment_id]
|
182
|
-
unless experiment
|
183
|
-
message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
|
184
|
-
@logger.log(Logger::DEBUG, message)
|
185
|
-
decide_reasons.push(message)
|
186
|
-
return nil, decide_reasons
|
187
|
-
end
|
188
|
-
|
189
|
-
experiment_id = experiment['id']
|
190
|
-
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
|
191
|
-
decide_reasons.push(*reasons_received)
|
192
|
-
|
193
|
-
next unless variation_id
|
194
|
-
|
195
|
-
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
|
196
|
-
variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
|
197
|
-
|
198
|
-
return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
|
199
|
-
end
|
200
|
-
|
201
|
-
message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
|
202
|
-
@logger.log(Logger::INFO, message)
|
203
|
-
decide_reasons.push(message)
|
204
|
-
|
205
|
-
[nil, decide_reasons]
|
206
|
-
end
|
207
|
-
|
208
|
-
def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
209
|
-
# Determine which variation the user is in for a given rollout.
|
210
|
-
# Returns the variation of the first experiment the user qualifies for.
|
211
|
-
#
|
212
|
-
# project_config - project_config - Instance of ProjectConfig
|
213
|
-
# feature_flag - The feature flag the user wants to access
|
214
|
-
# user_context - Optimizely user context instance
|
215
|
-
#
|
216
|
-
# Returns the Decision struct or nil if not bucketed into any of the targeting rules
|
217
|
-
decide_reasons = []
|
218
|
-
|
219
|
-
rollout_id = feature_flag['rolloutId']
|
220
|
-
feature_flag_key = feature_flag['key']
|
221
|
-
if rollout_id.nil? || rollout_id.empty?
|
222
|
-
message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
|
223
|
-
@logger.log(Logger::DEBUG, message)
|
224
|
-
decide_reasons.push(message)
|
225
|
-
return nil, decide_reasons
|
226
|
-
end
|
227
|
-
|
228
|
-
rollout = project_config.get_rollout_from_id(rollout_id)
|
229
|
-
if rollout.nil?
|
230
|
-
message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
|
231
|
-
@logger.log(Logger::DEBUG, message)
|
232
|
-
decide_reasons.push(message)
|
233
|
-
return nil, decide_reasons
|
234
|
-
end
|
235
|
-
|
236
|
-
return nil, decide_reasons if rollout['experiments'].empty?
|
237
|
-
|
238
|
-
index = 0
|
239
|
-
rollout_rules = rollout['experiments']
|
240
|
-
while index < rollout_rules.length
|
241
|
-
variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
|
242
|
-
decide_reasons.push(*reasons_received)
|
243
|
-
if variation
|
244
|
-
rule = rollout_rules[index]
|
245
|
-
feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
|
246
|
-
return [feature_decision, decide_reasons]
|
247
|
-
end
|
248
|
-
|
249
|
-
index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
|
250
|
-
end
|
251
|
-
|
252
|
-
[nil, decide_reasons]
|
253
|
-
end
|
254
|
-
|
255
|
-
def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = [])
|
256
|
-
# Determine which variation the user is in for a given rollout.
|
257
|
-
# Returns the variation from experiment rules.
|
258
|
-
#
|
259
|
-
# project_config - project_config - Instance of ProjectConfig
|
260
|
-
# flag_key - The feature flag the user wants to access
|
261
|
-
# rule - An experiment rule key
|
262
|
-
# user - Optimizely user context instance
|
263
|
-
#
|
264
|
-
# Returns variation_id and reasons
|
265
|
-
reasons = []
|
266
|
-
|
267
|
-
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
|
268
|
-
variation, forced_reasons = validated_forced_decision(project_config, context, user)
|
269
|
-
reasons.push(*forced_reasons)
|
270
|
-
|
271
|
-
return [variation['id'], reasons] if variation
|
272
|
-
|
273
|
-
variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
|
274
|
-
reasons.push(*response_reasons)
|
275
|
-
|
276
|
-
[variation_id, reasons]
|
277
|
-
end
|
278
|
-
|
279
|
-
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
|
280
|
-
# Determine which variation the user is in for a given rollout.
|
281
|
-
# Returns the variation from delivery rules.
|
282
|
-
#
|
283
|
-
# project_config - project_config - Instance of ProjectConfig
|
284
|
-
# flag_key - The feature flag the user wants to access
|
285
|
-
# rule - An experiment rule key
|
286
|
-
# user_context - Optimizely user context instance
|
287
|
-
#
|
288
|
-
# Returns variation, boolean to skip for eveyone else rule and reasons
|
289
|
-
reasons = []
|
290
|
-
skip_to_everyone_else = false
|
291
|
-
rule = rules[rule_index]
|
292
|
-
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
|
293
|
-
variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
|
294
|
-
reasons.push(*forced_reasons)
|
295
|
-
|
296
|
-
return [variation, skip_to_everyone_else, reasons] if variation
|
297
|
-
|
298
|
-
user_id = user_context.user_id
|
299
|
-
attributes = user_context.user_attributes
|
300
|
-
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
301
|
-
reasons.push(*bucketing_id_reasons)
|
302
|
-
|
303
|
-
everyone_else = (rule_index == rules.length - 1)
|
304
|
-
|
305
|
-
logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s
|
306
|
-
|
307
|
-
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, user_context, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
|
308
|
-
reasons.push(*reasons_received)
|
309
|
-
unless user_meets_audience_conditions
|
310
|
-
message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
|
311
|
-
@logger.log(Logger::DEBUG, message)
|
312
|
-
reasons.push(message)
|
313
|
-
return [nil, skip_to_everyone_else, reasons]
|
314
|
-
end
|
315
|
-
|
316
|
-
message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
|
317
|
-
@logger.log(Logger::DEBUG, message)
|
318
|
-
reasons.push(message)
|
319
|
-
bucket_variation, bucket_reasons = @bucketer.bucket(project_config, rule, bucketing_id, user_id)
|
320
|
-
|
321
|
-
reasons.push(*bucket_reasons)
|
322
|
-
|
323
|
-
if bucket_variation
|
324
|
-
message = "User '#{user_id}' is in the traffic group of targeting rule '#{logging_key}'."
|
325
|
-
@logger.log(Logger::DEBUG, message)
|
326
|
-
reasons.push(message)
|
327
|
-
elsif !everyone_else
|
328
|
-
message = "User '#{user_id}' is not in the traffic group for targeting rule '#{logging_key}'."
|
329
|
-
@logger.log(Logger::DEBUG, message)
|
330
|
-
reasons.push(message)
|
331
|
-
skip_to_everyone_else = true
|
332
|
-
end
|
333
|
-
[bucket_variation, skip_to_everyone_else, reasons]
|
334
|
-
end
|
335
|
-
|
336
|
-
def set_forced_variation(project_config, experiment_key, user_id, variation_key)
|
337
|
-
# Sets a Hash of user IDs to a Hash of experiments to forced variations.
|
338
|
-
#
|
339
|
-
# project_config - Instance of ProjectConfig
|
340
|
-
# experiment_key - String Key for experiment
|
341
|
-
# user_id - String ID for user.
|
342
|
-
# variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping
|
343
|
-
#
|
344
|
-
# Returns a boolean value that indicates if the set completed successfully
|
345
|
-
|
346
|
-
experiment = project_config.get_experiment_from_key(experiment_key)
|
347
|
-
experiment_id = experiment['id'] if experiment
|
348
|
-
# check if the experiment exists in the datafile
|
349
|
-
return false if experiment_id.nil? || experiment_id.empty?
|
350
|
-
|
351
|
-
# clear the forced variation if the variation key is null
|
352
|
-
if variation_key.nil?
|
353
|
-
@forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.key? user_id
|
354
|
-
@logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user "\
|
355
|
-
"'#{user_id}'.")
|
356
|
-
return true
|
357
|
-
end
|
358
|
-
|
359
|
-
variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
|
360
|
-
|
361
|
-
# check if the variation exists in the datafile
|
362
|
-
unless variation_id
|
363
|
-
# this case is logged in get_variation_id_from_key
|
364
|
-
return false
|
365
|
-
end
|
366
|
-
|
367
|
-
@forced_variation_map[user_id] = {} unless @forced_variation_map.key? user_id
|
368
|
-
@forced_variation_map[user_id][experiment_id] = variation_id
|
369
|
-
@logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and "\
|
370
|
-
"user '#{user_id}' in the forced variation map.")
|
371
|
-
true
|
372
|
-
end
|
373
|
-
|
374
|
-
def get_forced_variation(project_config, experiment_key, user_id)
|
375
|
-
# Gets the forced variation for the given user and experiment.
|
376
|
-
#
|
377
|
-
# project_config - Instance of ProjectConfig
|
378
|
-
# experiment_key - String key for experiment
|
379
|
-
# user_id - String ID for user
|
380
|
-
#
|
381
|
-
# Returns Variation The variation which the given user and experiment should be forced into
|
382
|
-
|
383
|
-
decide_reasons = []
|
384
|
-
unless @forced_variation_map.key? user_id
|
385
|
-
message = "User '#{user_id}' is not in the forced variation map."
|
386
|
-
@logger.log(Logger::DEBUG, message)
|
387
|
-
return nil, decide_reasons
|
388
|
-
end
|
389
|
-
|
390
|
-
experiment_to_variation_map = @forced_variation_map[user_id]
|
391
|
-
experiment = project_config.get_experiment_from_key(experiment_key)
|
392
|
-
experiment_id = experiment['id'] if experiment
|
393
|
-
# check for nil and empty string experiment ID
|
394
|
-
# this case is logged in get_experiment_from_key
|
395
|
-
return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?
|
396
|
-
|
397
|
-
unless experiment_to_variation_map.key? experiment_id
|
398
|
-
message = "No experiment '#{experiment_id}' mapped to user '#{user_id}' in the forced variation map."
|
399
|
-
@logger.log(Logger::DEBUG, message)
|
400
|
-
decide_reasons.push(message)
|
401
|
-
return nil, decide_reasons
|
402
|
-
end
|
403
|
-
|
404
|
-
variation_id = experiment_to_variation_map[experiment_id]
|
405
|
-
variation_key = ''
|
406
|
-
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
|
407
|
-
variation_key = variation['key'] if variation
|
408
|
-
|
409
|
-
# check if the variation exists in the datafile
|
410
|
-
# this case is logged in get_variation_from_id
|
411
|
-
return nil, decide_reasons if variation_key.empty?
|
412
|
-
|
413
|
-
message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map"
|
414
|
-
@logger.log(Logger::DEBUG, message)
|
415
|
-
decide_reasons.push(message)
|
416
|
-
|
417
|
-
[variation, decide_reasons]
|
418
|
-
end
|
419
|
-
|
420
|
-
def validated_forced_decision(project_config, context, user_context)
|
421
|
-
decision = user_context.get_forced_decision(context)
|
422
|
-
flag_key = context[:flag_key]
|
423
|
-
rule_key = context[:rule_key]
|
424
|
-
variation_key = decision ? decision[:variation_key] : decision
|
425
|
-
reasons = []
|
426
|
-
target = rule_key ? "flag (#{flag_key}), rule (#{rule_key})" : "flag (#{flag_key})"
|
427
|
-
if variation_key
|
428
|
-
variation = project_config.get_variation_from_flag(flag_key, variation_key, 'key')
|
429
|
-
if variation
|
430
|
-
reason = "Variation (#{variation_key}) is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
|
431
|
-
reasons.push(reason)
|
432
|
-
return variation, reasons
|
433
|
-
else
|
434
|
-
reason = "Invalid variation is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
|
435
|
-
reasons.push(reason)
|
436
|
-
end
|
437
|
-
end
|
438
|
-
|
439
|
-
[nil, reasons]
|
440
|
-
end
|
441
|
-
|
442
|
-
private
|
443
|
-
|
444
|
-
def get_whitelisted_variation_id(project_config, experiment_id, user_id)
|
445
|
-
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
|
446
|
-
#
|
447
|
-
# project_config - project_config - Instance of ProjectConfig
|
448
|
-
# experiment_key - Key representing the experiment for which user is to be bucketed
|
449
|
-
# user_id - ID for the user
|
450
|
-
#
|
451
|
-
# Returns variation ID into which user_id is whitelisted (nil if no variation)
|
452
|
-
|
453
|
-
whitelisted_variations = project_config.get_whitelisted_variations(experiment_id)
|
454
|
-
|
455
|
-
return nil, nil unless whitelisted_variations
|
456
|
-
|
457
|
-
whitelisted_variation_key = whitelisted_variations[user_id]
|
458
|
-
|
459
|
-
return nil, nil unless whitelisted_variation_key
|
460
|
-
|
461
|
-
whitelisted_variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, whitelisted_variation_key)
|
462
|
-
|
463
|
-
unless whitelisted_variation_id
|
464
|
-
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
|
465
|
-
@logger.log(Logger::INFO, message)
|
466
|
-
return nil, message
|
467
|
-
end
|
468
|
-
|
469
|
-
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_id}'."
|
470
|
-
@logger.log(Logger::INFO, message)
|
471
|
-
|
472
|
-
[whitelisted_variation_id, message]
|
473
|
-
end
|
474
|
-
|
475
|
-
def get_saved_variation_id(project_config, experiment_id, user_profile)
|
476
|
-
# Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
|
477
|
-
#
|
478
|
-
# project_config - project_config - Instance of ProjectConfig
|
479
|
-
# experiment_id - String experiment ID
|
480
|
-
# user_profile - Hash user profile
|
481
|
-
#
|
482
|
-
# Returns string variation ID (nil if no decision is found)
|
483
|
-
return nil, nil unless user_profile[:experiment_bucket_map]
|
484
|
-
|
485
|
-
decision = user_profile[:experiment_bucket_map][experiment_id]
|
486
|
-
return nil, nil unless decision
|
487
|
-
|
488
|
-
variation_id = decision[:variation_id]
|
489
|
-
return variation_id, nil if project_config.variation_id_exists?(experiment_id, variation_id)
|
490
|
-
|
491
|
-
message = "User '#{user_profile[:user_id]}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user."
|
492
|
-
@logger.log(Logger::INFO, message)
|
493
|
-
|
494
|
-
[nil, message]
|
495
|
-
end
|
496
|
-
|
497
|
-
def get_user_profile(user_id)
|
498
|
-
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
|
499
|
-
#
|
500
|
-
# user_id - String ID for the user
|
501
|
-
#
|
502
|
-
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
|
503
|
-
|
504
|
-
user_profile = {
|
505
|
-
user_id: user_id,
|
506
|
-
experiment_bucket_map: {}
|
507
|
-
}
|
508
|
-
|
509
|
-
return user_profile, nil unless @user_profile_service
|
510
|
-
|
511
|
-
message = nil
|
512
|
-
begin
|
513
|
-
user_profile = @user_profile_service.lookup(user_id) || user_profile
|
514
|
-
rescue => e
|
515
|
-
message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
|
516
|
-
@logger.log(Logger::ERROR, message)
|
517
|
-
end
|
518
|
-
|
519
|
-
[user_profile, message]
|
520
|
-
end
|
521
|
-
|
522
|
-
def save_user_profile(user_profile, experiment_id, variation_id)
|
523
|
-
# Save a given bucketing decision to a given user profile
|
524
|
-
#
|
525
|
-
# user_profile - Hash user profile
|
526
|
-
# experiment_id - String experiment ID
|
527
|
-
# variation_id - String variation ID
|
528
|
-
|
529
|
-
return unless @user_profile_service
|
530
|
-
|
531
|
-
user_id = user_profile[:user_id]
|
532
|
-
begin
|
533
|
-
user_profile[:experiment_bucket_map][experiment_id] = {
|
534
|
-
variation_id: variation_id
|
535
|
-
}
|
536
|
-
@user_profile_service.save(user_profile)
|
537
|
-
@logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
|
538
|
-
rescue => e
|
539
|
-
@logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
|
540
|
-
end
|
541
|
-
end
|
542
|
-
|
543
|
-
def get_bucketing_id(user_id, attributes)
|
544
|
-
# Gets the Bucketing Id for Bucketing
|
545
|
-
#
|
546
|
-
# user_id - String user ID
|
547
|
-
# attributes - Hash user attributes
|
548
|
-
# Returns String representing bucketing ID if it is a String type in attributes else return user ID
|
549
|
-
|
550
|
-
return user_id, nil unless attributes
|
551
|
-
|
552
|
-
bucketing_id = attributes[Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID']]
|
553
|
-
|
554
|
-
if bucketing_id
|
555
|
-
return bucketing_id, nil if bucketing_id.is_a?(String)
|
556
|
-
|
557
|
-
message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
|
558
|
-
@logger.log(Logger::WARN, message)
|
559
|
-
end
|
560
|
-
[user_id, message]
|
561
|
-
end
|
562
|
-
end
|
563
|
-
end
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright 2017-2022, Optimizely and contributors
|
5
|
+
#
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
7
|
+
# you may not use this file except in compliance with the License.
|
8
|
+
# You may obtain a copy of the License at
|
9
|
+
#
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
11
|
+
#
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
15
|
+
# See the License for the specific language governing permissions and
|
16
|
+
# limitations under the License.
|
17
|
+
#
|
18
|
+
require_relative './bucketer'
|
19
|
+
|
20
|
+
module Optimizely
|
21
|
+
class DecisionService
|
22
|
+
# Optimizely's decision service that determines into which variation of an experiment a user will be allocated.
|
23
|
+
#
|
24
|
+
# The decision service contains all logic relating to how a user bucketing decisions is made.
|
25
|
+
# This includes all of the following (in order):
|
26
|
+
#
|
27
|
+
# 1. Check experiment status
|
28
|
+
# 2. Check forced bucketing
|
29
|
+
# 3. Check whitelisting
|
30
|
+
# 4. Check user profile service for past bucketing decisions (sticky bucketing)
|
31
|
+
# 5. Check audience targeting
|
32
|
+
# 6. Use Murmurhash3 to bucket the user
|
33
|
+
|
34
|
+
attr_reader :bucketer
|
35
|
+
|
36
|
+
# Hash of user IDs to a Hash of experiments to variations.
|
37
|
+
# This contains all the forced variations set by the user by calling setForcedVariation.
|
38
|
+
attr_reader :forced_variation_map
|
39
|
+
|
40
|
+
Decision = Struct.new(:experiment, :variation, :source)
|
41
|
+
|
42
|
+
DECISION_SOURCES = {
|
43
|
+
'EXPERIMENT' => 'experiment',
|
44
|
+
'FEATURE_TEST' => 'feature-test',
|
45
|
+
'ROLLOUT' => 'rollout'
|
46
|
+
}.freeze
|
47
|
+
|
48
|
+
def initialize(logger, user_profile_service = nil)
|
49
|
+
@logger = logger
|
50
|
+
@user_profile_service = user_profile_service
|
51
|
+
@bucketer = Bucketer.new(logger)
|
52
|
+
@forced_variation_map = {}
|
53
|
+
end
|
54
|
+
|
55
|
+
def get_variation(project_config, experiment_id, user_context, decide_options = [])
|
56
|
+
# Determines variation into which user will be bucketed.
|
57
|
+
#
|
58
|
+
# project_config - project_config - Instance of ProjectConfig
|
59
|
+
# experiment_id - Experiment for which visitor variation needs to be determined
|
60
|
+
# user_context - Optimizely user context instance
|
61
|
+
#
|
62
|
+
# Returns variation ID where visitor will be bucketed
|
63
|
+
# (nil if experiment is inactive or user does not meet audience conditions)
|
64
|
+
|
65
|
+
decide_reasons = []
|
66
|
+
user_id = user_context.user_id
|
67
|
+
attributes = user_context.user_attributes
|
68
|
+
# By default, the bucketing ID should be the user ID
|
69
|
+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
70
|
+
decide_reasons.push(*bucketing_id_reasons)
|
71
|
+
# Check to make sure experiment is active
|
72
|
+
experiment = project_config.get_experiment_from_id(experiment_id)
|
73
|
+
return nil, decide_reasons if experiment.nil?
|
74
|
+
|
75
|
+
experiment_key = experiment['key']
|
76
|
+
unless project_config.experiment_running?(experiment)
|
77
|
+
message = "Experiment '#{experiment_key}' is not running."
|
78
|
+
@logger.log(Logger::INFO, message)
|
79
|
+
decide_reasons.push(message)
|
80
|
+
return nil, decide_reasons
|
81
|
+
end
|
82
|
+
|
83
|
+
# Check if a forced variation is set for the user
|
84
|
+
forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
|
85
|
+
decide_reasons.push(*reasons_received)
|
86
|
+
return forced_variation['id'], decide_reasons if forced_variation
|
87
|
+
|
88
|
+
# Check if user is in a white-listed variation
|
89
|
+
whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
|
90
|
+
decide_reasons.push(*reasons_received)
|
91
|
+
return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
|
92
|
+
|
93
|
+
should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
|
94
|
+
# Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
|
95
|
+
unless should_ignore_user_profile_service
|
96
|
+
user_profile, reasons_received = get_user_profile(user_id)
|
97
|
+
decide_reasons.push(*reasons_received)
|
98
|
+
saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile)
|
99
|
+
decide_reasons.push(*reasons_received)
|
100
|
+
if saved_variation_id
|
101
|
+
message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
|
102
|
+
@logger.log(Logger::INFO, message)
|
103
|
+
decide_reasons.push(message)
|
104
|
+
return saved_variation_id, decide_reasons
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Check audience conditions
|
109
|
+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, user_context, @logger)
|
110
|
+
decide_reasons.push(*reasons_received)
|
111
|
+
unless user_meets_audience_conditions
|
112
|
+
message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
|
113
|
+
@logger.log(Logger::INFO, message)
|
114
|
+
decide_reasons.push(message)
|
115
|
+
return nil, decide_reasons
|
116
|
+
end
|
117
|
+
|
118
|
+
# Bucket normally
|
119
|
+
variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
|
120
|
+
decide_reasons.push(*bucket_reasons)
|
121
|
+
variation_id = variation ? variation['id'] : nil
|
122
|
+
|
123
|
+
message = ''
|
124
|
+
if variation_id
|
125
|
+
variation_key = variation['key']
|
126
|
+
message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
|
127
|
+
else
|
128
|
+
message = "User '#{user_id}' is in no variation."
|
129
|
+
end
|
130
|
+
@logger.log(Logger::INFO, message)
|
131
|
+
decide_reasons.push(message)
|
132
|
+
|
133
|
+
# Persist bucketing decision
|
134
|
+
save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service
|
135
|
+
[variation_id, decide_reasons]
|
136
|
+
end
|
137
|
+
|
138
|
+
def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
|
139
|
+
# Get the variation the user is bucketed into for the given FeatureFlag.
|
140
|
+
#
|
141
|
+
# project_config - project_config - Instance of ProjectConfig
|
142
|
+
# feature_flag - The feature flag the user wants to access
|
143
|
+
# user_context - Optimizely user context instance
|
144
|
+
#
|
145
|
+
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
146
|
+
|
147
|
+
decide_reasons = []
|
148
|
+
|
149
|
+
# check if the feature is being experiment on and whether the user is bucketed into the experiment
|
150
|
+
decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options)
|
151
|
+
decide_reasons.push(*reasons_received)
|
152
|
+
return decision, decide_reasons unless decision.nil?
|
153
|
+
|
154
|
+
decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
155
|
+
decide_reasons.push(*reasons_received)
|
156
|
+
|
157
|
+
[decision, decide_reasons]
|
158
|
+
end
|
159
|
+
|
160
|
+
def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = [])
|
161
|
+
# Gets the variation the user is bucketed into for the feature flag's experiment.
|
162
|
+
#
|
163
|
+
# project_config - project_config - Instance of ProjectConfig
|
164
|
+
# feature_flag - The feature flag the user wants to access
|
165
|
+
# user_context - Optimizely user context instance
|
166
|
+
#
|
167
|
+
# Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
|
168
|
+
# or nil if the user is not bucketed into any of the experiments on the feature
|
169
|
+
decide_reasons = []
|
170
|
+
user_id = user_context.user_id
|
171
|
+
feature_flag_key = feature_flag['key']
|
172
|
+
if feature_flag['experimentIds'].empty?
|
173
|
+
message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
|
174
|
+
@logger.log(Logger::DEBUG, message)
|
175
|
+
decide_reasons.push(message)
|
176
|
+
return nil, decide_reasons
|
177
|
+
end
|
178
|
+
|
179
|
+
# Evaluate each experiment and return the first bucketed experiment variation
|
180
|
+
feature_flag['experimentIds'].each do |experiment_id|
|
181
|
+
experiment = project_config.experiment_id_map[experiment_id]
|
182
|
+
unless experiment
|
183
|
+
message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
|
184
|
+
@logger.log(Logger::DEBUG, message)
|
185
|
+
decide_reasons.push(message)
|
186
|
+
return nil, decide_reasons
|
187
|
+
end
|
188
|
+
|
189
|
+
experiment_id = experiment['id']
|
190
|
+
variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options)
|
191
|
+
decide_reasons.push(*reasons_received)
|
192
|
+
|
193
|
+
next unless variation_id
|
194
|
+
|
195
|
+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
|
196
|
+
variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
|
197
|
+
|
198
|
+
return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
|
199
|
+
end
|
200
|
+
|
201
|
+
message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
|
202
|
+
@logger.log(Logger::INFO, message)
|
203
|
+
decide_reasons.push(message)
|
204
|
+
|
205
|
+
[nil, decide_reasons]
|
206
|
+
end
|
207
|
+
|
208
|
+
def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
|
209
|
+
# Determine which variation the user is in for a given rollout.
|
210
|
+
# Returns the variation of the first experiment the user qualifies for.
|
211
|
+
#
|
212
|
+
# project_config - project_config - Instance of ProjectConfig
|
213
|
+
# feature_flag - The feature flag the user wants to access
|
214
|
+
# user_context - Optimizely user context instance
|
215
|
+
#
|
216
|
+
# Returns the Decision struct or nil if not bucketed into any of the targeting rules
|
217
|
+
decide_reasons = []
|
218
|
+
|
219
|
+
rollout_id = feature_flag['rolloutId']
|
220
|
+
feature_flag_key = feature_flag['key']
|
221
|
+
if rollout_id.nil? || rollout_id.empty?
|
222
|
+
message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
|
223
|
+
@logger.log(Logger::DEBUG, message)
|
224
|
+
decide_reasons.push(message)
|
225
|
+
return nil, decide_reasons
|
226
|
+
end
|
227
|
+
|
228
|
+
rollout = project_config.get_rollout_from_id(rollout_id)
|
229
|
+
if rollout.nil?
|
230
|
+
message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
|
231
|
+
@logger.log(Logger::DEBUG, message)
|
232
|
+
decide_reasons.push(message)
|
233
|
+
return nil, decide_reasons
|
234
|
+
end
|
235
|
+
|
236
|
+
return nil, decide_reasons if rollout['experiments'].empty?
|
237
|
+
|
238
|
+
index = 0
|
239
|
+
rollout_rules = rollout['experiments']
|
240
|
+
while index < rollout_rules.length
|
241
|
+
variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
|
242
|
+
decide_reasons.push(*reasons_received)
|
243
|
+
if variation
|
244
|
+
rule = rollout_rules[index]
|
245
|
+
feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
|
246
|
+
return [feature_decision, decide_reasons]
|
247
|
+
end
|
248
|
+
|
249
|
+
index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
|
250
|
+
end
|
251
|
+
|
252
|
+
[nil, decide_reasons]
|
253
|
+
end
|
254
|
+
|
255
|
+
def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = [])
|
256
|
+
# Determine which variation the user is in for a given rollout.
|
257
|
+
# Returns the variation from experiment rules.
|
258
|
+
#
|
259
|
+
# project_config - project_config - Instance of ProjectConfig
|
260
|
+
# flag_key - The feature flag the user wants to access
|
261
|
+
# rule - An experiment rule key
|
262
|
+
# user - Optimizely user context instance
|
263
|
+
#
|
264
|
+
# Returns variation_id and reasons
|
265
|
+
reasons = []
|
266
|
+
|
267
|
+
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
|
268
|
+
variation, forced_reasons = validated_forced_decision(project_config, context, user)
|
269
|
+
reasons.push(*forced_reasons)
|
270
|
+
|
271
|
+
return [variation['id'], reasons] if variation
|
272
|
+
|
273
|
+
variation_id, response_reasons = get_variation(project_config, rule['id'], user, options)
|
274
|
+
reasons.push(*response_reasons)
|
275
|
+
|
276
|
+
[variation_id, reasons]
|
277
|
+
end
|
278
|
+
|
279
|
+
def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
|
280
|
+
# Determine which variation the user is in for a given rollout.
|
281
|
+
# Returns the variation from delivery rules.
|
282
|
+
#
|
283
|
+
# project_config - project_config - Instance of ProjectConfig
|
284
|
+
# flag_key - The feature flag the user wants to access
|
285
|
+
# rule - An experiment rule key
|
286
|
+
# user_context - Optimizely user context instance
|
287
|
+
#
|
288
|
+
# Returns variation, boolean to skip for eveyone else rule and reasons
|
289
|
+
reasons = []
|
290
|
+
skip_to_everyone_else = false
|
291
|
+
rule = rules[rule_index]
|
292
|
+
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
|
293
|
+
variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
|
294
|
+
reasons.push(*forced_reasons)
|
295
|
+
|
296
|
+
return [variation, skip_to_everyone_else, reasons] if variation
|
297
|
+
|
298
|
+
user_id = user_context.user_id
|
299
|
+
attributes = user_context.user_attributes
|
300
|
+
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
|
301
|
+
reasons.push(*bucketing_id_reasons)
|
302
|
+
|
303
|
+
everyone_else = (rule_index == rules.length - 1)
|
304
|
+
|
305
|
+
logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s
|
306
|
+
|
307
|
+
user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, user_context, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
|
308
|
+
reasons.push(*reasons_received)
|
309
|
+
unless user_meets_audience_conditions
|
310
|
+
message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
|
311
|
+
@logger.log(Logger::DEBUG, message)
|
312
|
+
reasons.push(message)
|
313
|
+
return [nil, skip_to_everyone_else, reasons]
|
314
|
+
end
|
315
|
+
|
316
|
+
message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
|
317
|
+
@logger.log(Logger::DEBUG, message)
|
318
|
+
reasons.push(message)
|
319
|
+
bucket_variation, bucket_reasons = @bucketer.bucket(project_config, rule, bucketing_id, user_id)
|
320
|
+
|
321
|
+
reasons.push(*bucket_reasons)
|
322
|
+
|
323
|
+
if bucket_variation
|
324
|
+
message = "User '#{user_id}' is in the traffic group of targeting rule '#{logging_key}'."
|
325
|
+
@logger.log(Logger::DEBUG, message)
|
326
|
+
reasons.push(message)
|
327
|
+
elsif !everyone_else
|
328
|
+
message = "User '#{user_id}' is not in the traffic group for targeting rule '#{logging_key}'."
|
329
|
+
@logger.log(Logger::DEBUG, message)
|
330
|
+
reasons.push(message)
|
331
|
+
skip_to_everyone_else = true
|
332
|
+
end
|
333
|
+
[bucket_variation, skip_to_everyone_else, reasons]
|
334
|
+
end
|
335
|
+
|
336
|
+
def set_forced_variation(project_config, experiment_key, user_id, variation_key)
|
337
|
+
# Sets a Hash of user IDs to a Hash of experiments to forced variations.
|
338
|
+
#
|
339
|
+
# project_config - Instance of ProjectConfig
|
340
|
+
# experiment_key - String Key for experiment
|
341
|
+
# user_id - String ID for user.
|
342
|
+
# variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping
|
343
|
+
#
|
344
|
+
# Returns a boolean value that indicates if the set completed successfully
|
345
|
+
|
346
|
+
experiment = project_config.get_experiment_from_key(experiment_key)
|
347
|
+
experiment_id = experiment['id'] if experiment
|
348
|
+
# check if the experiment exists in the datafile
|
349
|
+
return false if experiment_id.nil? || experiment_id.empty?
|
350
|
+
|
351
|
+
# clear the forced variation if the variation key is null
|
352
|
+
if variation_key.nil?
|
353
|
+
@forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.key? user_id
|
354
|
+
@logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user "\
|
355
|
+
"'#{user_id}'.")
|
356
|
+
return true
|
357
|
+
end
|
358
|
+
|
359
|
+
variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
|
360
|
+
|
361
|
+
# check if the variation exists in the datafile
|
362
|
+
unless variation_id
|
363
|
+
# this case is logged in get_variation_id_from_key
|
364
|
+
return false
|
365
|
+
end
|
366
|
+
|
367
|
+
@forced_variation_map[user_id] = {} unless @forced_variation_map.key? user_id
|
368
|
+
@forced_variation_map[user_id][experiment_id] = variation_id
|
369
|
+
@logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and "\
|
370
|
+
"user '#{user_id}' in the forced variation map.")
|
371
|
+
true
|
372
|
+
end
|
373
|
+
|
374
|
+
def get_forced_variation(project_config, experiment_key, user_id)
|
375
|
+
# Gets the forced variation for the given user and experiment.
|
376
|
+
#
|
377
|
+
# project_config - Instance of ProjectConfig
|
378
|
+
# experiment_key - String key for experiment
|
379
|
+
# user_id - String ID for user
|
380
|
+
#
|
381
|
+
# Returns Variation The variation which the given user and experiment should be forced into
|
382
|
+
|
383
|
+
decide_reasons = []
|
384
|
+
unless @forced_variation_map.key? user_id
|
385
|
+
message = "User '#{user_id}' is not in the forced variation map."
|
386
|
+
@logger.log(Logger::DEBUG, message)
|
387
|
+
return nil, decide_reasons
|
388
|
+
end
|
389
|
+
|
390
|
+
experiment_to_variation_map = @forced_variation_map[user_id]
|
391
|
+
experiment = project_config.get_experiment_from_key(experiment_key)
|
392
|
+
experiment_id = experiment['id'] if experiment
|
393
|
+
# check for nil and empty string experiment ID
|
394
|
+
# this case is logged in get_experiment_from_key
|
395
|
+
return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?
|
396
|
+
|
397
|
+
unless experiment_to_variation_map.key? experiment_id
|
398
|
+
message = "No experiment '#{experiment_id}' mapped to user '#{user_id}' in the forced variation map."
|
399
|
+
@logger.log(Logger::DEBUG, message)
|
400
|
+
decide_reasons.push(message)
|
401
|
+
return nil, decide_reasons
|
402
|
+
end
|
403
|
+
|
404
|
+
variation_id = experiment_to_variation_map[experiment_id]
|
405
|
+
variation_key = ''
|
406
|
+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
|
407
|
+
variation_key = variation['key'] if variation
|
408
|
+
|
409
|
+
# check if the variation exists in the datafile
|
410
|
+
# this case is logged in get_variation_from_id
|
411
|
+
return nil, decide_reasons if variation_key.empty?
|
412
|
+
|
413
|
+
message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map"
|
414
|
+
@logger.log(Logger::DEBUG, message)
|
415
|
+
decide_reasons.push(message)
|
416
|
+
|
417
|
+
[variation, decide_reasons]
|
418
|
+
end
|
419
|
+
|
420
|
+
def validated_forced_decision(project_config, context, user_context)
|
421
|
+
decision = user_context.get_forced_decision(context)
|
422
|
+
flag_key = context[:flag_key]
|
423
|
+
rule_key = context[:rule_key]
|
424
|
+
variation_key = decision ? decision[:variation_key] : decision
|
425
|
+
reasons = []
|
426
|
+
target = rule_key ? "flag (#{flag_key}), rule (#{rule_key})" : "flag (#{flag_key})"
|
427
|
+
if variation_key
|
428
|
+
variation = project_config.get_variation_from_flag(flag_key, variation_key, 'key')
|
429
|
+
if variation
|
430
|
+
reason = "Variation (#{variation_key}) is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
|
431
|
+
reasons.push(reason)
|
432
|
+
return variation, reasons
|
433
|
+
else
|
434
|
+
reason = "Invalid variation is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
|
435
|
+
reasons.push(reason)
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
[nil, reasons]
|
440
|
+
end
|
441
|
+
|
442
|
+
private
|
443
|
+
|
444
|
+
def get_whitelisted_variation_id(project_config, experiment_id, user_id)
|
445
|
+
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
|
446
|
+
#
|
447
|
+
# project_config - project_config - Instance of ProjectConfig
|
448
|
+
# experiment_key - Key representing the experiment for which user is to be bucketed
|
449
|
+
# user_id - ID for the user
|
450
|
+
#
|
451
|
+
# Returns variation ID into which user_id is whitelisted (nil if no variation)
|
452
|
+
|
453
|
+
whitelisted_variations = project_config.get_whitelisted_variations(experiment_id)
|
454
|
+
|
455
|
+
return nil, nil unless whitelisted_variations
|
456
|
+
|
457
|
+
whitelisted_variation_key = whitelisted_variations[user_id]
|
458
|
+
|
459
|
+
return nil, nil unless whitelisted_variation_key
|
460
|
+
|
461
|
+
whitelisted_variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, whitelisted_variation_key)
|
462
|
+
|
463
|
+
unless whitelisted_variation_id
|
464
|
+
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
|
465
|
+
@logger.log(Logger::INFO, message)
|
466
|
+
return nil, message
|
467
|
+
end
|
468
|
+
|
469
|
+
message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_id}'."
|
470
|
+
@logger.log(Logger::INFO, message)
|
471
|
+
|
472
|
+
[whitelisted_variation_id, message]
|
473
|
+
end
|
474
|
+
|
475
|
+
def get_saved_variation_id(project_config, experiment_id, user_profile)
|
476
|
+
# Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
|
477
|
+
#
|
478
|
+
# project_config - project_config - Instance of ProjectConfig
|
479
|
+
# experiment_id - String experiment ID
|
480
|
+
# user_profile - Hash user profile
|
481
|
+
#
|
482
|
+
# Returns string variation ID (nil if no decision is found)
|
483
|
+
return nil, nil unless user_profile[:experiment_bucket_map]
|
484
|
+
|
485
|
+
decision = user_profile[:experiment_bucket_map][experiment_id]
|
486
|
+
return nil, nil unless decision
|
487
|
+
|
488
|
+
variation_id = decision[:variation_id]
|
489
|
+
return variation_id, nil if project_config.variation_id_exists?(experiment_id, variation_id)
|
490
|
+
|
491
|
+
message = "User '#{user_profile[:user_id]}' was previously bucketed into variation ID '#{variation_id}' for experiment '#{experiment_id}', but no matching variation was found. Re-bucketing user."
|
492
|
+
@logger.log(Logger::INFO, message)
|
493
|
+
|
494
|
+
[nil, message]
|
495
|
+
end
|
496
|
+
|
497
|
+
def get_user_profile(user_id)
|
498
|
+
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
|
499
|
+
#
|
500
|
+
# user_id - String ID for the user
|
501
|
+
#
|
502
|
+
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
|
503
|
+
|
504
|
+
user_profile = {
|
505
|
+
user_id: user_id,
|
506
|
+
experiment_bucket_map: {}
|
507
|
+
}
|
508
|
+
|
509
|
+
return user_profile, nil unless @user_profile_service
|
510
|
+
|
511
|
+
message = nil
|
512
|
+
begin
|
513
|
+
user_profile = @user_profile_service.lookup(user_id) || user_profile
|
514
|
+
rescue => e
|
515
|
+
message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
|
516
|
+
@logger.log(Logger::ERROR, message)
|
517
|
+
end
|
518
|
+
|
519
|
+
[user_profile, message]
|
520
|
+
end
|
521
|
+
|
522
|
+
def save_user_profile(user_profile, experiment_id, variation_id)
|
523
|
+
# Save a given bucketing decision to a given user profile
|
524
|
+
#
|
525
|
+
# user_profile - Hash user profile
|
526
|
+
# experiment_id - String experiment ID
|
527
|
+
# variation_id - String variation ID
|
528
|
+
|
529
|
+
return unless @user_profile_service
|
530
|
+
|
531
|
+
user_id = user_profile[:user_id]
|
532
|
+
begin
|
533
|
+
user_profile[:experiment_bucket_map][experiment_id] = {
|
534
|
+
variation_id: variation_id
|
535
|
+
}
|
536
|
+
@user_profile_service.save(user_profile)
|
537
|
+
@logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
|
538
|
+
rescue => e
|
539
|
+
@logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
def get_bucketing_id(user_id, attributes)
|
544
|
+
# Gets the Bucketing Id for Bucketing
|
545
|
+
#
|
546
|
+
# user_id - String user ID
|
547
|
+
# attributes - Hash user attributes
|
548
|
+
# Returns String representing bucketing ID if it is a String type in attributes else return user ID
|
549
|
+
|
550
|
+
return user_id, nil unless attributes
|
551
|
+
|
552
|
+
bucketing_id = attributes[Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID']]
|
553
|
+
|
554
|
+
if bucketing_id
|
555
|
+
return bucketing_id, nil if bucketing_id.is_a?(String)
|
556
|
+
|
557
|
+
message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
|
558
|
+
@logger.log(Logger::WARN, message)
|
559
|
+
end
|
560
|
+
[user_id, message]
|
561
|
+
end
|
562
|
+
end
|
563
|
+
end
|