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.
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 +558 -558
  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 -340
  10. data/lib/optimizely/config_manager/project_config_manager.rb +25 -25
  11. data/lib/optimizely/config_manager/static_project_config_manager.rb +55 -55
  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 -193
  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 -236
  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 -273
  55. data/lib/optimizely/optimizely_factory.rb +184 -184
  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 +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