optimizely-sdk 5.0.0.pre.beta → 5.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +202 -202
  3. data/lib/optimizely/audience.rb +127 -127
  4. data/lib/optimizely/bucketer.rb +156 -156
  5. data/lib/optimizely/condition_tree_evaluator.rb +123 -123
  6. data/lib/optimizely/config/datafile_project_config.rb +48 -32
  7. data/lib/optimizely/config/proxy_config.rb +34 -34
  8. data/lib/optimizely/config_manager/async_scheduler.rb +95 -95
  9. data/lib/optimizely/config_manager/http_project_config_manager.rb +340 -338
  10. data/lib/optimizely/config_manager/project_config_manager.rb +25 -25
  11. data/lib/optimizely/config_manager/static_project_config_manager.rb +55 -54
  12. data/lib/optimizely/decide/optimizely_decide_option.rb +28 -28
  13. data/lib/optimizely/decide/optimizely_decision.rb +60 -60
  14. data/lib/optimizely/decide/optimizely_decision_message.rb +26 -26
  15. data/lib/optimizely/decision_service.rb +563 -563
  16. data/lib/optimizely/error_handler.rb +39 -39
  17. data/lib/optimizely/event/batch_event_processor.rb +235 -235
  18. data/lib/optimizely/event/entity/conversion_event.rb +44 -44
  19. data/lib/optimizely/event/entity/decision.rb +38 -38
  20. data/lib/optimizely/event/entity/event_batch.rb +86 -86
  21. data/lib/optimizely/event/entity/event_context.rb +50 -50
  22. data/lib/optimizely/event/entity/impression_event.rb +48 -48
  23. data/lib/optimizely/event/entity/snapshot.rb +33 -33
  24. data/lib/optimizely/event/entity/snapshot_event.rb +48 -48
  25. data/lib/optimizely/event/entity/user_event.rb +22 -22
  26. data/lib/optimizely/event/entity/visitor.rb +36 -36
  27. data/lib/optimizely/event/entity/visitor_attribute.rb +38 -38
  28. data/lib/optimizely/event/event_factory.rb +156 -156
  29. data/lib/optimizely/event/event_processor.rb +25 -25
  30. data/lib/optimizely/event/forwarding_event_processor.rb +44 -44
  31. data/lib/optimizely/event/user_event_factory.rb +88 -88
  32. data/lib/optimizely/event_builder.rb +221 -221
  33. data/lib/optimizely/event_dispatcher.rb +69 -69
  34. data/lib/optimizely/exceptions.rb +193 -149
  35. data/lib/optimizely/helpers/constants.rb +459 -459
  36. data/lib/optimizely/helpers/date_time_utils.rb +30 -30
  37. data/lib/optimizely/helpers/event_tag_utils.rb +132 -132
  38. data/lib/optimizely/helpers/group.rb +31 -31
  39. data/lib/optimizely/helpers/http_utils.rb +68 -68
  40. data/lib/optimizely/helpers/sdk_settings.rb +61 -61
  41. data/lib/optimizely/helpers/validator.rb +236 -238
  42. data/lib/optimizely/helpers/variable_type.rb +67 -67
  43. data/lib/optimizely/logger.rb +46 -46
  44. data/lib/optimizely/notification_center.rb +174 -174
  45. data/lib/optimizely/notification_center_registry.rb +71 -71
  46. data/lib/optimizely/odp/lru_cache.rb +114 -114
  47. data/lib/optimizely/odp/odp_config.rb +102 -102
  48. data/lib/optimizely/odp/odp_event.rb +75 -75
  49. data/lib/optimizely/odp/odp_event_api_manager.rb +70 -70
  50. data/lib/optimizely/odp/odp_event_manager.rb +286 -286
  51. data/lib/optimizely/odp/odp_manager.rb +159 -159
  52. data/lib/optimizely/odp/odp_segment_api_manager.rb +122 -122
  53. data/lib/optimizely/odp/odp_segment_manager.rb +97 -97
  54. data/lib/optimizely/optimizely_config.rb +273 -271
  55. data/lib/optimizely/optimizely_factory.rb +184 -186
  56. data/lib/optimizely/optimizely_user_context.rb +238 -238
  57. data/lib/optimizely/params.rb +31 -31
  58. data/lib/optimizely/project_config.rb +99 -99
  59. data/lib/optimizely/semantic_version.rb +166 -166
  60. data/lib/optimizely/user_condition_evaluator.rb +391 -391
  61. data/lib/optimizely/user_profile_service.rb +35 -35
  62. data/lib/optimizely/version.rb +21 -21
  63. data/lib/optimizely.rb +1262 -1262
  64. metadata +12 -10
@@ -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