optimizely-sdk 2.0.0.beta → 2.0.0.beta1

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.
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  #
2
- # Copyright 2016, Optimizely and contributors
4
+ # Copyright 2016-2017, Optimizely and contributors
3
5
  #
4
6
  # Licensed under the Apache License, Version 2.0 (the "License");
5
7
  # you may not use this file except in compliance with the License.
@@ -27,7 +29,7 @@ module Optimizely
27
29
  ConditionalOperatorTypes::AND,
28
30
  ConditionalOperatorTypes::OR,
29
31
  ConditionalOperatorTypes::NOT
30
- ]
32
+ ].freeze
31
33
 
32
34
  attr_reader :user_attributes
33
35
 
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  #
2
- # Copyright 2017, Optimizely and contributors
4
+ # Copyright 2017-2018, Optimizely and contributors
3
5
  #
4
6
  # Licensed under the Apache License, Version 2.0 (the "License");
5
7
  # you may not use this file except in compliance with the License.
@@ -16,21 +18,28 @@
16
18
  require_relative './bucketer'
17
19
 
18
20
  module Optimizely
21
+ RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = "\$opt_bucketing_id"
22
+
19
23
  class DecisionService
20
24
  # Optimizely's decision service that determines into which variation of an experiment a user will be allocated.
21
25
  #
22
26
  # The decision service contains all logic relating to how a user bucketing decisions is made.
23
27
  # This includes all of the following (in order):
24
28
  #
25
- # 1. Checking experiment status
26
- # 2. Checking whitelisting
27
- # 3. Checking user profile service for past bucketing decisions (sticky bucketing)
28
- # 3. Checking audience targeting
29
- # 4. Using Murmurhash3 to bucket the user
29
+ # 1. Check experiment status
30
+ # 2. Check forced bucketing
31
+ # 3. Check whitelisting
32
+ # 4. Check user profile service for past bucketing decisions (sticky bucketing)
33
+ # 5. Check audience targeting
34
+ # 6. Use Murmurhash3 to bucket the user
30
35
 
31
36
  attr_reader :bucketer
32
37
  attr_reader :config
33
38
 
39
+ Decision = Struct.new(:experiment, :variation, :source)
40
+ DECISION_SOURCE_EXPERIMENT = 'experiment'
41
+ DECISION_SOURCE_ROLLOUT = 'rollout'
42
+
34
43
  def initialize(config, user_profile_service = nil)
35
44
  @config = config
36
45
  @user_profile_service = user_profile_service
@@ -44,13 +53,14 @@ module Optimizely
44
53
  # user_id - String ID for user
45
54
  # attributes - Hash representing user attributes
46
55
  #
47
- # Returns variation ID where visitor will be bucketed (nil if experiment is inactive or user does not meet audience conditions)
56
+ # Returns variation ID where visitor will be bucketed
57
+ # (nil if experiment is inactive or user does not meet audience conditions)
48
58
 
59
+ # By default, the bucketing ID should be the user ID
60
+ bucketing_id = get_bucketing_id(user_id, attributes)
49
61
  # Check to make sure experiment is active
50
62
  experiment = @config.get_experiment_from_key(experiment_key)
51
- if experiment.nil?
52
- return nil
53
- end
63
+ return nil if experiment.nil?
54
64
 
55
65
  experiment_id = experiment['id']
56
66
  unless @config.experiment_running?(experiment)
@@ -58,9 +68,13 @@ module Optimizely
58
68
  return nil
59
69
  end
60
70
 
61
- # Check if user is in a forced variation
62
- forced_variation_id = get_forced_variation_id(experiment_key, user_id)
63
- return forced_variation_id if forced_variation_id
71
+ # Check if a forced variation is set for the user
72
+ forced_variation = @config.get_forced_variation(experiment_key, user_id)
73
+ return forced_variation['id'] if forced_variation
74
+
75
+ # Check if user is in a white-listed variation
76
+ whitelisted_variation_id = get_whitelisted_variation_id(experiment_key, user_id)
77
+ return whitelisted_variation_id if whitelisted_variation_id
64
78
 
65
79
  # Check for saved bucketing decisions
66
80
  user_profile = get_user_profile(user_id)
@@ -83,7 +97,7 @@ module Optimizely
83
97
  end
84
98
 
85
99
  # Bucket normally
86
- variation = @bucketer.bucket(experiment, user_id)
100
+ variation = @bucketer.bucket(experiment, bucketing_id, user_id)
87
101
  variation_id = variation ? variation['id'] : nil
88
102
 
89
103
  # Persist bucketing decision
@@ -98,34 +112,27 @@ module Optimizely
98
112
  # user_id - String ID for the user
99
113
  # attributes - Hash representing user attributes
100
114
  #
101
- # Returns hash with the experiment and variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature)
115
+ # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
102
116
 
103
117
  # check if the feature is being experiment on and whether the user is bucketed into the experiment
104
118
  decision = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
105
- unless decision.nil?
106
- return decision
107
- end
119
+ return decision unless decision.nil?
108
120
 
109
121
  feature_flag_key = feature_flag['key']
110
- variation = get_variation_for_feature_rollout(feature_flag, user_id, attributes)
111
- if variation
122
+ decision = get_variation_for_feature_rollout(feature_flag, user_id, attributes)
123
+ if decision
112
124
  @config.logger.log(
113
125
  Logger::INFO,
114
- "User '#{user_id}' is in the rollout for feature flag '#{feature_flag_key}'."
115
- )
116
- # return decision with nil experiment so we don't track impressions for it
117
- return {
118
- 'experiment' => nil,
119
- 'variation' => variation
120
- }
121
- else
122
- @config.logger.log(
123
- Logger::INFO,
124
- "User '#{user_id}' is not in the rollout for feature flag '#{feature_flag_key}'."
126
+ "User '#{user_id}' is bucketed into a rollout for feature flag '#{feature_flag_key}'."
125
127
  )
128
+ return decision
126
129
  end
130
+ @config.logger.log(
131
+ Logger::INFO,
132
+ "User '#{user_id}' is not bucketed into a rollout for feature flag '#{feature_flag_key}'."
133
+ )
127
134
 
128
- return nil
135
+ nil
129
136
  end
130
137
 
131
138
  def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil)
@@ -135,13 +142,19 @@ module Optimizely
135
142
  # user_id - String ID for the user
136
143
  # attributes - Hash representing user attributes
137
144
  #
138
- # Returns a hash with the experiment and variation where visitor will be bucketed
145
+ # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature)
139
146
  # or nil if the user is not bucketed into any of the experiments on the feature
140
-
141
147
  feature_flag_key = feature_flag['key']
142
- unless feature_flag['experimentIds'].empty?
143
- # check if experiment is part of mutex group
144
- experiment_id = feature_flag['experimentIds'][0]
148
+ if feature_flag['experimentIds'].empty?
149
+ @config.logger.log(
150
+ Logger::DEBUG,
151
+ "The feature flag '#{feature_flag_key}' is not used in any experiments."
152
+ )
153
+ return nil
154
+ end
155
+
156
+ # Evaluate each experiment and return the first bucketed experiment variation
157
+ feature_flag['experimentIds'].each do |experiment_id|
145
158
  experiment = @config.experiment_id_map[experiment_id]
146
159
  unless experiment
147
160
  @config.logger.log(
@@ -151,51 +164,24 @@ module Optimizely
151
164
  return nil
152
165
  end
153
166
 
154
- group_id = experiment['groupId']
155
- # if experiment is part of mutex group we first determine which experiment (if any) in the group the user is part of
156
- if group_id and @config.group_key_map.has_key?(group_id)
157
- group = @config.group_key_map[group_id]
158
- bucketed_experiment_id = @bucketer.find_bucket(user_id, group_id, group['trafficAllocation'])
159
- if bucketed_experiment_id.nil?
160
- @config.logger.log(
161
- Logger::INFO,
162
- "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
163
- )
164
- return nil
165
- end
166
- else
167
- bucketed_experiment_id = experiment_id
168
- end
167
+ experiment_key = experiment['key']
168
+ variation_id = get_variation(experiment_key, user_id, attributes)
169
169
 
170
- if feature_flag['experimentIds'].include?(bucketed_experiment_id)
171
- experiment = @config.experiment_id_map[bucketed_experiment_id]
172
- experiment_key = experiment['key']
173
- variation_id = get_variation(experiment_key, user_id, attributes)
174
- unless variation_id.nil?
175
- variation = @config.variation_id_map[experiment_key][variation_id]
176
- @config.logger.log(
177
- Logger::INFO,
178
- "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
179
- )
180
- return {
181
- 'variation' => variation,
182
- 'experiment' => experiment
183
- }
184
- else
185
- @config.logger.log(
186
- Logger::INFO,
187
- "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
188
- )
189
- end
190
- end
191
- else
170
+ next unless variation_id
171
+ variation = @config.variation_id_map[experiment_key][variation_id]
192
172
  @config.logger.log(
193
- Logger::DEBUG,
194
- "The feature flag '#{feature_flag_key}' is not used in any experiments."
173
+ Logger::INFO,
174
+ "The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
195
175
  )
176
+ return Decision.new(experiment, variation, DECISION_SOURCE_EXPERIMENT)
196
177
  end
197
178
 
198
- return nil
179
+ @config.logger.log(
180
+ Logger::INFO,
181
+ "The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
182
+ )
183
+
184
+ nil
199
185
  end
200
186
 
201
187
  def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil)
@@ -206,109 +192,106 @@ module Optimizely
206
192
  # user_id - String ID for the user
207
193
  # attributes - Hash representing user attributes
208
194
  #
209
- # Returns the variation the user is bucketed into or nil if not bucketed into any of the targeting rules
210
-
195
+ # Returns the Decision struct or nil if not bucketed into any of the targeting rules
196
+ bucketing_id = get_bucketing_id(user_id, attributes)
211
197
  rollout_id = feature_flag['rolloutId']
212
- feature_flag_key = feature_flag['key']
213
- if rollout_id.nil? or rollout_id.empty?
198
+ if rollout_id.nil? || rollout_id.empty?
199
+ feature_flag_key = feature_flag['key']
214
200
  @config.logger.log(
215
201
  Logger::DEBUG,
216
- "Feature flag '#{feature_flag_key}' is not part of a rollout."
202
+ "Feature flag '#{feature_flag_key}' is not used in a rollout."
217
203
  )
218
204
  return nil
219
205
  end
220
206
 
221
207
  rollout = @config.get_rollout_from_id(rollout_id)
222
- unless rollout.nil? or rollout['experiments'].empty?
223
- rollout_experiments = rollout['experiments']
224
- number_of_rules = rollout_experiments.length - 1
225
-
226
- # Go through each experiment in order and try to get the variation for the user
227
- for index in (0...number_of_rules)
228
- experiment = rollout_experiments[index]
229
- experiment_key = experiment['key']
230
-
231
- # Check that user meets audience conditions for targeting rule
232
- unless Audience.user_in_experiment?(@config, experiment, attributes)
233
- @config.logger.log(
234
- Logger::DEBUG,
235
- "User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}' of rollout with feature flag '#{feature_flag_key}'."
236
- )
237
- # move onto the next targeting rule
238
- next
239
- end
208
+ if rollout.nil?
209
+ @config.logger.log(
210
+ Logger::DEBUG,
211
+ "Rollout with ID '#{rollout_id}' is not in the datafile '#{feature_flag['key']}'"
212
+ )
213
+ return nil
214
+ end
240
215
 
241
- @config.logger.log(
242
- Logger::DEBUG,
243
- "User '#{user_id}' meets conditions for targeting rule '#{index + 1}'."
244
- )
245
- variation = @bucketer.bucket(experiment, user_id)
246
- unless variation.nil?
247
- variation_key = variation['key']
248
- return variation
249
- end
216
+ return nil if rollout['experiments'].empty?
250
217
 
251
- # User failed traffic allocation, jump to Everyone Else rule
252
- @config.logger.log(
253
- Logger::DEBUG,
254
- "User '#{user_id}' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now."
255
- )
256
- break
257
- end
218
+ rollout_rules = rollout['experiments']
219
+ number_of_rules = rollout_rules.length - 1
258
220
 
259
- # Evalute the "Everyone Else" rule, which is the last rule.
260
- everyone_else_experiment = rollout_experiments[number_of_rules]
261
- variation = @bucketer.bucket(everyone_else_experiment, user_id)
262
- unless variation.nil?
221
+ # Go through each experiment in order and try to get the variation for the user
222
+ number_of_rules.times do |index|
223
+ rollout_rule = rollout_rules[index]
224
+ audience_id = rollout_rule['audienceIds'][0]
225
+ audience = @config.get_audience_from_id(audience_id)
226
+ audience_name = audience['name']
227
+
228
+ # Check that user meets audience conditions for targeting rule
229
+ unless Audience.user_in_experiment?(@config, rollout_rule, attributes)
263
230
  @config.logger.log(
264
231
  Logger::DEBUG,
265
- "User '#{user_id}' meets conditions for targeting rule 'Everyone Else' of rollout with feature flag '#{feature_flag_key}'."
232
+ "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
266
233
  )
267
- return variation
234
+ # move onto the next targeting rule
235
+ next
268
236
  end
269
237
 
238
+ # Evaluate if user satisfies the traffic allocation for this rollout rule
239
+ variation = @bucketer.bucket(rollout_rule, bucketing_id, user_id)
240
+ return Decision.new(rollout_rule, variation, DECISION_SOURCE_ROLLOUT) unless variation.nil?
241
+ break
242
+ end
243
+
244
+ # get last rule which is the everyone else rule
245
+ everyone_else_experiment = rollout_rules[number_of_rules]
246
+ # Check that user meets audience conditions for last rule
247
+ unless Audience.user_in_experiment?(@config, everyone_else_experiment, attributes)
248
+ audience_id = everyone_else_experiment['audienceIds'][0]
249
+ audience = @config.get_audience_from_id(audience_id)
250
+ audience_name = audience['name']
270
251
  @config.logger.log(
271
252
  Logger::DEBUG,
272
- "User '#{user_id}' does not meet conditions for targeting rule 'Everyone Else' of rollout with feature flag '#{feature_flag_key}'."
253
+ "User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
273
254
  )
255
+ return nil
274
256
  end
275
-
276
- return nil
257
+ variation = @bucketer.bucket(everyone_else_experiment, bucketing_id, user_id)
258
+ return Decision.new(everyone_else_experiment, variation, DECISION_SOURCE_ROLLOUT) unless variation.nil?
259
+ nil
277
260
  end
278
261
 
279
262
  private
280
263
 
281
- def get_forced_variation_id(experiment_key, user_id)
282
- # Determine if a user is forced into a variation for the given experiment and return the ID of that variation
264
+ def get_whitelisted_variation_id(experiment_key, user_id)
265
+ # Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
283
266
  #
284
267
  # experiment_key - Key representing the experiment for which user is to be bucketed
285
268
  # user_id - ID for the user
286
269
  #
287
- # Returns variation ID into which user_id is forced (nil if no variation)
270
+ # Returns variation ID into which user_id is whitelisted (nil if no variation)
288
271
 
289
- forced_variations = @config.get_forced_variations(experiment_key)
272
+ whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
290
273
 
291
- return nil unless forced_variations
274
+ return nil unless whitelisted_variations
292
275
 
293
- forced_variation_key = forced_variations[user_id]
276
+ whitelisted_variation_key = whitelisted_variations[user_id]
294
277
 
295
- return nil unless forced_variation_key
278
+ return nil unless whitelisted_variation_key
296
279
 
297
- forced_variation_id = @config.get_variation_id_from_key(experiment_key, forced_variation_key)
280
+ whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
298
281
 
299
- unless forced_variation_id
282
+ unless whitelisted_variation_id
300
283
  @config.logger.log(
301
284
  Logger::INFO,
302
- "User '#{user_id}' is whitelisted into variation '#{forced_variation_key}', which is not in the datafile."
285
+ "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
303
286
  )
304
287
  return nil
305
288
  end
306
289
 
307
290
  @config.logger.log(
308
291
  Logger::INFO,
309
- "User '#{user_id}' is whitelisted into variation '#{forced_variation_key}' of experiment '#{experiment_key}'."
292
+ "User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
310
293
  )
311
- forced_variation_id
294
+ whitelisted_variation_id
312
295
  end
313
296
 
314
297
  def get_saved_variation_id(experiment_id, user_profile)
@@ -340,8 +323,8 @@ module Optimizely
340
323
  # Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
341
324
 
342
325
  user_profile = {
343
- :user_id => user_id,
344
- :experiment_bucket_map => {}
326
+ user_id: user_id,
327
+ experiment_bucket_map: {}
345
328
  }
346
329
 
347
330
  return user_profile unless @user_profile_service
@@ -355,7 +338,6 @@ module Optimizely
355
338
  user_profile
356
339
  end
357
340
 
358
-
359
341
  def save_user_profile(user_profile, experiment_id, variation_id)
360
342
  # Save a given bucketing decision to a given user profile
361
343
  #
@@ -368,7 +350,7 @@ module Optimizely
368
350
  user_id = user_profile[:user_id]
369
351
  begin
370
352
  user_profile[:experiment_bucket_map][experiment_id] = {
371
- :variation_id => variation_id
353
+ variation_id: variation_id
372
354
  }
373
355
  @user_profile_service.save(user_profile)
374
356
  @config.logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
@@ -376,5 +358,23 @@ module Optimizely
376
358
  @config.logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
377
359
  end
378
360
  end
361
+
362
+ def get_bucketing_id(user_id, attributes)
363
+ # Gets the Bucketing Id for Bucketing
364
+ #
365
+ # user_id - String user ID
366
+ # attributes - Hash user attributes
367
+ # By default, the bucketing ID should be the user ID
368
+ bucketing_id = user_id
369
+
370
+ # If the bucketing ID key is defined in attributes, then use that in place of the userID
371
+ if attributes && attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].is_a?(String)
372
+ unless attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].empty?
373
+ bucketing_id = attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID]
374
+ @config.logger.log(Logger::DEBUG, "Setting the bucketing ID '#{bucketing_id}'")
375
+ end
376
+ end
377
+ bucketing_id
378
+ end
379
379
  end
380
380
  end