optimizely-sdk 2.0.0.beta → 2.0.0.beta1

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