optimizely-sdk 3.9.0 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
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