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