optimizely-sdk 5.0.0 → 5.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) 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 +589 -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 +183 -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/user_profile_tracker.rb +64 -0
  63. data/lib/optimizely/version.rb +21 -21
  64. data/lib/optimizely.rb +1326 -1262
  65. metadata +8 -5
@@ -1,563 +1,589 @@
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, user_profile_tracker = nil, decide_options = [], reasons = [])
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
+ # user_profile_tracker: Tracker for reading and updating user profile of the user.
62
+ # reasons: Decision reasons.
63
+ #
64
+ # Returns variation ID where visitor will be bucketed
65
+ # (nil if experiment is inactive or user does not meet audience conditions)
66
+ user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker)
67
+ decide_reasons = []
68
+ decide_reasons.push(*reasons)
69
+ user_id = user_context.user_id
70
+ attributes = user_context.user_attributes
71
+ # By default, the bucketing ID should be the user ID
72
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
73
+ decide_reasons.push(*bucketing_id_reasons)
74
+ # Check to make sure experiment is active
75
+ experiment = project_config.get_experiment_from_id(experiment_id)
76
+ return nil, decide_reasons if experiment.nil?
77
+
78
+ experiment_key = experiment['key']
79
+ unless project_config.experiment_running?(experiment)
80
+ message = "Experiment '#{experiment_key}' is not running."
81
+ @logger.log(Logger::INFO, message)
82
+ decide_reasons.push(message)
83
+ return nil, decide_reasons
84
+ end
85
+
86
+ # Check if a forced variation is set for the user
87
+ forced_variation, reasons_received = get_forced_variation(project_config, experiment['key'], user_id)
88
+ decide_reasons.push(*reasons_received)
89
+ return forced_variation['id'], decide_reasons if forced_variation
90
+
91
+ # Check if user is in a white-listed variation
92
+ whitelisted_variation_id, reasons_received = get_whitelisted_variation_id(project_config, experiment_id, user_id)
93
+ decide_reasons.push(*reasons_received)
94
+ return whitelisted_variation_id, decide_reasons if whitelisted_variation_id
95
+
96
+ should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
97
+ # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService
98
+ unless should_ignore_user_profile_service && user_profile_tracker
99
+ saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile)
100
+ decide_reasons.push(*reasons_received)
101
+ if saved_variation_id
102
+ message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
103
+ @logger.log(Logger::INFO, message)
104
+ decide_reasons.push(message)
105
+ return saved_variation_id, decide_reasons
106
+ end
107
+ end
108
+
109
+ # Check audience conditions
110
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, experiment, user_context, @logger)
111
+ decide_reasons.push(*reasons_received)
112
+ unless user_meets_audience_conditions
113
+ message = "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
114
+ @logger.log(Logger::INFO, message)
115
+ decide_reasons.push(message)
116
+ return nil, decide_reasons
117
+ end
118
+
119
+ # Bucket normally
120
+ variation, bucket_reasons = @bucketer.bucket(project_config, experiment, bucketing_id, user_id)
121
+ decide_reasons.push(*bucket_reasons)
122
+ variation_id = variation ? variation['id'] : nil
123
+
124
+ message = ''
125
+ if variation_id
126
+ variation_key = variation['key']
127
+ message = "User '#{user_id}' is in variation '#{variation_key}' of experiment '#{experiment_id}'."
128
+ else
129
+ message = "User '#{user_id}' is in no variation."
130
+ end
131
+ @logger.log(Logger::INFO, message)
132
+ decide_reasons.push(message)
133
+
134
+ # Persist bucketing decision
135
+ user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker
136
+ [variation_id, decide_reasons]
137
+ end
138
+
139
+ def get_variation_for_feature(project_config, feature_flag, user_context, decide_options = [])
140
+ # Get the variation the user is bucketed into for the given FeatureFlag.
141
+ #
142
+ # project_config - project_config - Instance of ProjectConfig
143
+ # feature_flag - The feature flag the user wants to access
144
+ # user_context - Optimizely user context instance
145
+ #
146
+ # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
147
+ get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first
148
+ end
149
+
150
+ def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = [])
151
+ # Returns the list of experiment/variation the user is bucketed in for the given list of features.
152
+ #
153
+ # Args:
154
+ # project_config: Instance of ProjectConfig.
155
+ # feature_flags: Array of features for which we are determining if it is enabled or not for the given user.
156
+ # user_context: User context for user.
157
+ # decide_options: Decide options.
158
+ #
159
+ # Returns:
160
+ # Array of Decision struct.
161
+ ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE
162
+ user_profile_tracker = nil
163
+ unless ignore_ups && @user_profile_service
164
+ user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger)
165
+ user_profile_tracker.load_user_profile
166
+ end
167
+ decisions = []
168
+ feature_flags.each do |feature_flag|
169
+ decide_reasons = []
170
+ # check if the feature is being experiment on and whether the user is bucketed into the experiment
171
+ decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options)
172
+ decide_reasons.push(*reasons_received)
173
+ if decision
174
+ decisions << [decision, decide_reasons]
175
+ else
176
+ # Proceed to rollout if the decision is nil
177
+ rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context)
178
+ decide_reasons.push(*reasons_received)
179
+ decisions << [rollout_decision, decide_reasons]
180
+ end
181
+ end
182
+ user_profile_tracker&.save_user_profile
183
+ decisions
184
+ end
185
+
186
+ def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = [])
187
+ # Gets the variation the user is bucketed into for the feature flag's experiment.
188
+ #
189
+ # project_config - project_config - Instance of ProjectConfig
190
+ # feature_flag - The feature flag the user wants to access
191
+ # user_context - Optimizely user context instance
192
+ #
193
+ # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
194
+ # or nil if the user is not bucketed into any of the experiments on the feature
195
+ decide_reasons = []
196
+ user_id = user_context.user_id
197
+ feature_flag_key = feature_flag['key']
198
+ if feature_flag['experimentIds'].empty?
199
+ message = "The feature flag '#{feature_flag_key}' is not used in any experiments."
200
+ @logger.log(Logger::DEBUG, message)
201
+ decide_reasons.push(message)
202
+ return nil, decide_reasons
203
+ end
204
+
205
+ # Evaluate each experiment and return the first bucketed experiment variation
206
+ feature_flag['experimentIds'].each do |experiment_id|
207
+ experiment = project_config.experiment_id_map[experiment_id]
208
+ unless experiment
209
+ message = "Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
210
+ @logger.log(Logger::DEBUG, message)
211
+ decide_reasons.push(message)
212
+ return nil, decide_reasons
213
+ end
214
+
215
+ experiment_id = experiment['id']
216
+ variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options)
217
+ decide_reasons.push(*reasons_received)
218
+
219
+ next unless variation_id
220
+
221
+ variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
222
+ variation = project_config.get_variation_from_flag(feature_flag['key'], variation_id, 'id') if variation.nil?
223
+
224
+ return Decision.new(experiment, variation, DECISION_SOURCES['FEATURE_TEST']), decide_reasons
225
+ end
226
+
227
+ message = "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
228
+ @logger.log(Logger::INFO, message)
229
+ decide_reasons.push(message)
230
+
231
+ [nil, decide_reasons]
232
+ end
233
+
234
+ def get_variation_for_feature_rollout(project_config, feature_flag, user_context)
235
+ # Determine which variation the user is in for a given rollout.
236
+ # Returns the variation of the first experiment the user qualifies for.
237
+ #
238
+ # project_config - project_config - Instance of ProjectConfig
239
+ # feature_flag - The feature flag the user wants to access
240
+ # user_context - Optimizely user context instance
241
+ #
242
+ # Returns the Decision struct or nil if not bucketed into any of the targeting rules
243
+ decide_reasons = []
244
+
245
+ rollout_id = feature_flag['rolloutId']
246
+ feature_flag_key = feature_flag['key']
247
+ if rollout_id.nil? || rollout_id.empty?
248
+ message = "Feature flag '#{feature_flag_key}' is not used in a rollout."
249
+ @logger.log(Logger::DEBUG, message)
250
+ decide_reasons.push(message)
251
+ return nil, decide_reasons
252
+ end
253
+
254
+ rollout = project_config.get_rollout_from_id(rollout_id)
255
+ if rollout.nil?
256
+ message = "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
257
+ @logger.log(Logger::DEBUG, message)
258
+ decide_reasons.push(message)
259
+ return nil, decide_reasons
260
+ end
261
+
262
+ return nil, decide_reasons if rollout['experiments'].empty?
263
+
264
+ index = 0
265
+ rollout_rules = rollout['experiments']
266
+ while index < rollout_rules.length
267
+ variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
268
+ decide_reasons.push(*reasons_received)
269
+ if variation
270
+ rule = rollout_rules[index]
271
+ feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'])
272
+ return [feature_decision, decide_reasons]
273
+ end
274
+
275
+ index = skip_to_everyone_else ? (rollout_rules.length - 1) : (index + 1)
276
+ end
277
+
278
+ [nil, decide_reasons]
279
+ end
280
+
281
+ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = [])
282
+ # Determine which variation the user is in for a given rollout.
283
+ # Returns the variation from experiment rules.
284
+ #
285
+ # project_config - project_config - Instance of ProjectConfig
286
+ # flag_key - The feature flag the user wants to access
287
+ # rule - An experiment rule key
288
+ # user - Optimizely user context instance
289
+ #
290
+ # Returns variation_id and reasons
291
+ reasons = []
292
+
293
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
294
+ variation, forced_reasons = validated_forced_decision(project_config, context, user)
295
+ reasons.push(*forced_reasons)
296
+
297
+ return [variation['id'], reasons] if variation
298
+
299
+ variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
300
+ reasons.push(*response_reasons)
301
+
302
+ [variation_id, reasons]
303
+ end
304
+
305
+ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index, user_context)
306
+ # Determine which variation the user is in for a given rollout.
307
+ # Returns the variation from delivery rules.
308
+ #
309
+ # project_config - project_config - Instance of ProjectConfig
310
+ # flag_key - The feature flag the user wants to access
311
+ # rule - An experiment rule key
312
+ # user_context - Optimizely user context instance
313
+ #
314
+ # Returns variation, boolean to skip for eveyone else rule and reasons
315
+ reasons = []
316
+ skip_to_everyone_else = false
317
+ rule = rules[rule_index]
318
+ context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
319
+ variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
320
+ reasons.push(*forced_reasons)
321
+
322
+ return [variation, skip_to_everyone_else, reasons] if variation
323
+
324
+ user_id = user_context.user_id
325
+ attributes = user_context.user_attributes
326
+ bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
327
+ reasons.push(*bucketing_id_reasons)
328
+
329
+ everyone_else = (rule_index == rules.length - 1)
330
+
331
+ logging_key = everyone_else ? 'Everyone Else' : (rule_index + 1).to_s
332
+
333
+ user_meets_audience_conditions, reasons_received = Audience.user_meets_audience_conditions?(project_config, rule, user_context, @logger, 'ROLLOUT_AUDIENCE_EVALUATION_LOGS', logging_key)
334
+ reasons.push(*reasons_received)
335
+ unless user_meets_audience_conditions
336
+ message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
337
+ @logger.log(Logger::DEBUG, message)
338
+ reasons.push(message)
339
+ return [nil, skip_to_everyone_else, reasons]
340
+ end
341
+
342
+ message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
343
+ @logger.log(Logger::DEBUG, message)
344
+ reasons.push(message)
345
+ bucket_variation, bucket_reasons = @bucketer.bucket(project_config, rule, bucketing_id, user_id)
346
+
347
+ reasons.push(*bucket_reasons)
348
+
349
+ if bucket_variation
350
+ message = "User '#{user_id}' is in the traffic group of targeting rule '#{logging_key}'."
351
+ @logger.log(Logger::DEBUG, message)
352
+ reasons.push(message)
353
+ elsif !everyone_else
354
+ message = "User '#{user_id}' is not in the traffic group for targeting rule '#{logging_key}'."
355
+ @logger.log(Logger::DEBUG, message)
356
+ reasons.push(message)
357
+ skip_to_everyone_else = true
358
+ end
359
+ [bucket_variation, skip_to_everyone_else, reasons]
360
+ end
361
+
362
+ def set_forced_variation(project_config, experiment_key, user_id, variation_key)
363
+ # Sets a Hash of user IDs to a Hash of experiments to forced variations.
364
+ #
365
+ # project_config - Instance of ProjectConfig
366
+ # experiment_key - String Key for experiment
367
+ # user_id - String ID for user.
368
+ # variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping
369
+ #
370
+ # Returns a boolean value that indicates if the set completed successfully
371
+
372
+ experiment = project_config.get_experiment_from_key(experiment_key)
373
+ experiment_id = experiment['id'] if experiment
374
+ # check if the experiment exists in the datafile
375
+ return false if experiment_id.nil? || experiment_id.empty?
376
+
377
+ # clear the forced variation if the variation key is null
378
+ if variation_key.nil?
379
+ @forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.key? user_id
380
+ @logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user "\
381
+ "'#{user_id}'.")
382
+ return true
383
+ end
384
+
385
+ variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, variation_key)
386
+
387
+ # check if the variation exists in the datafile
388
+ unless variation_id
389
+ # this case is logged in get_variation_id_from_key
390
+ return false
391
+ end
392
+
393
+ @forced_variation_map[user_id] = {} unless @forced_variation_map.key? user_id
394
+ @forced_variation_map[user_id][experiment_id] = variation_id
395
+ @logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and "\
396
+ "user '#{user_id}' in the forced variation map.")
397
+ true
398
+ end
399
+
400
+ def get_forced_variation(project_config, experiment_key, user_id)
401
+ # Gets the forced variation for the given user and experiment.
402
+ #
403
+ # project_config - Instance of ProjectConfig
404
+ # experiment_key - String key for experiment
405
+ # user_id - String ID for user
406
+ #
407
+ # Returns Variation The variation which the given user and experiment should be forced into
408
+
409
+ decide_reasons = []
410
+ unless @forced_variation_map.key? user_id
411
+ message = "User '#{user_id}' is not in the forced variation map."
412
+ @logger.log(Logger::DEBUG, message)
413
+ return nil, decide_reasons
414
+ end
415
+
416
+ experiment_to_variation_map = @forced_variation_map[user_id]
417
+ experiment = project_config.get_experiment_from_key(experiment_key)
418
+ experiment_id = experiment['id'] if experiment
419
+ # check for nil and empty string experiment ID
420
+ # this case is logged in get_experiment_from_key
421
+ return nil, decide_reasons if experiment_id.nil? || experiment_id.empty?
422
+
423
+ unless experiment_to_variation_map.key? experiment_id
424
+ message = "No experiment '#{experiment_id}' mapped to user '#{user_id}' in the forced variation map."
425
+ @logger.log(Logger::DEBUG, message)
426
+ decide_reasons.push(message)
427
+ return nil, decide_reasons
428
+ end
429
+
430
+ variation_id = experiment_to_variation_map[experiment_id]
431
+ variation_key = ''
432
+ variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
433
+ variation_key = variation['key'] if variation
434
+
435
+ # check if the variation exists in the datafile
436
+ # this case is logged in get_variation_from_id
437
+ return nil, decide_reasons if variation_key.empty?
438
+
439
+ message = "Variation '#{variation_key}' is mapped to experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map"
440
+ @logger.log(Logger::DEBUG, message)
441
+ decide_reasons.push(message)
442
+
443
+ [variation, decide_reasons]
444
+ end
445
+
446
+ def validated_forced_decision(project_config, context, user_context)
447
+ decision = user_context.get_forced_decision(context)
448
+ flag_key = context[:flag_key]
449
+ rule_key = context[:rule_key]
450
+ variation_key = decision ? decision[:variation_key] : decision
451
+ reasons = []
452
+ target = rule_key ? "flag (#{flag_key}), rule (#{rule_key})" : "flag (#{flag_key})"
453
+ if variation_key
454
+ variation = project_config.get_variation_from_flag(flag_key, variation_key, 'key')
455
+ if variation
456
+ reason = "Variation (#{variation_key}) is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
457
+ reasons.push(reason)
458
+ return variation, reasons
459
+ else
460
+ reason = "Invalid variation is mapped to #{target} and user (#{user_context.user_id}) in the forced decision map."
461
+ reasons.push(reason)
462
+ end
463
+ end
464
+
465
+ [nil, reasons]
466
+ end
467
+
468
+ private
469
+
470
+ def get_whitelisted_variation_id(project_config, experiment_id, user_id)
471
+ # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
472
+ #
473
+ # project_config - project_config - Instance of ProjectConfig
474
+ # experiment_key - Key representing the experiment for which user is to be bucketed
475
+ # user_id - ID for the user
476
+ #
477
+ # Returns variation ID into which user_id is whitelisted (nil if no variation)
478
+
479
+ whitelisted_variations = project_config.get_whitelisted_variations(experiment_id)
480
+
481
+ return nil, nil unless whitelisted_variations
482
+
483
+ whitelisted_variation_key = whitelisted_variations[user_id]
484
+
485
+ return nil, nil unless whitelisted_variation_key
486
+
487
+ whitelisted_variation_id = project_config.get_variation_id_from_key_by_experiment_id(experiment_id, whitelisted_variation_key)
488
+
489
+ unless whitelisted_variation_id
490
+ message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
491
+ @logger.log(Logger::INFO, message)
492
+ return nil, message
493
+ end
494
+
495
+ message = "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_id}'."
496
+ @logger.log(Logger::INFO, message)
497
+
498
+ [whitelisted_variation_id, message]
499
+ end
500
+
501
+ def get_saved_variation_id(project_config, experiment_id, user_profile)
502
+ # Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
503
+ #
504
+ # project_config - project_config - Instance of ProjectConfig
505
+ # experiment_id - String experiment ID
506
+ # user_profile - Hash user profile
507
+ #
508
+ # Returns string variation ID (nil if no decision is found)
509
+ return nil, nil unless user_profile[:experiment_bucket_map]
510
+
511
+ decision = user_profile[:experiment_bucket_map][experiment_id]
512
+ return nil, nil unless decision
513
+
514
+ variation_id = decision[:variation_id]
515
+ return variation_id, nil if project_config.variation_id_exists?(experiment_id, variation_id)
516
+
517
+ 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."
518
+ @logger.log(Logger::INFO, message)
519
+
520
+ [nil, message]
521
+ end
522
+
523
+ def get_user_profile(user_id)
524
+ # Determine if a user is forced into a variation for the given experiment and return the ID of that variation
525
+ #
526
+ # user_id - String ID for the user
527
+ #
528
+ # Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
529
+
530
+ user_profile = {
531
+ user_id: user_id,
532
+ experiment_bucket_map: {}
533
+ }
534
+
535
+ return user_profile, nil unless @user_profile_service
536
+
537
+ message = nil
538
+ begin
539
+ user_profile = @user_profile_service.lookup(user_id) || user_profile
540
+ rescue => e
541
+ message = "Error while looking up user profile for user ID '#{user_id}': #{e}."
542
+ @logger.log(Logger::ERROR, message)
543
+ end
544
+
545
+ [user_profile, message]
546
+ end
547
+
548
+ def save_user_profile(user_profile, experiment_id, variation_id)
549
+ # Save a given bucketing decision to a given user profile
550
+ #
551
+ # user_profile - Hash user profile
552
+ # experiment_id - String experiment ID
553
+ # variation_id - String variation ID
554
+
555
+ return unless @user_profile_service
556
+
557
+ user_id = user_profile[:user_id]
558
+ begin
559
+ user_profile[:experiment_bucket_map][experiment_id] = {
560
+ variation_id: variation_id
561
+ }
562
+ @user_profile_service.save(user_profile)
563
+ @logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
564
+ rescue => e
565
+ @logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
566
+ end
567
+ end
568
+
569
+ def get_bucketing_id(user_id, attributes)
570
+ # Gets the Bucketing Id for Bucketing
571
+ #
572
+ # user_id - String user ID
573
+ # attributes - Hash user attributes
574
+ # Returns String representing bucketing ID if it is a String type in attributes else return user ID
575
+
576
+ return user_id, nil unless attributes
577
+
578
+ bucketing_id = attributes[Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID']]
579
+
580
+ if bucketing_id
581
+ return bucketing_id, nil if bucketing_id.is_a?(String)
582
+
583
+ message = 'Bucketing ID attribute is not a string. Defaulted to user ID.'
584
+ @logger.log(Logger::WARN, message)
585
+ end
586
+ [user_id, message]
587
+ end
588
+ end
589
+ end