optimizely-sdk 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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