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.
- checksums.yaml +4 -4
- data/lib/optimizely.rb +166 -73
- data/lib/optimizely/audience.rb +6 -3
- data/lib/optimizely/bucketer.rb +21 -16
- data/lib/optimizely/condition.rb +4 -2
- data/lib/optimizely/decision_service.rb +141 -141
- data/lib/optimizely/error_handler.rb +5 -5
- data/lib/optimizely/event_builder.rb +158 -147
- data/lib/optimizely/event_dispatcher.rb +7 -6
- data/lib/optimizely/exceptions.rb +12 -1
- data/lib/optimizely/helpers/constants.rb +64 -63
- data/lib/optimizely/helpers/event_tag_utils.rb +86 -11
- data/lib/optimizely/helpers/group.rb +3 -1
- data/lib/optimizely/helpers/validator.rb +9 -1
- data/lib/optimizely/helpers/variable_type.rb +11 -7
- data/lib/optimizely/logger.rb +5 -5
- data/lib/optimizely/notification_center.rb +150 -0
- data/lib/optimizely/params.rb +3 -1
- data/lib/optimizely/project_config.rb +128 -24
- data/lib/optimizely/user_profile_service.rb +2 -0
- data/lib/optimizely/version.rb +4 -1
- metadata +15 -16
data/lib/optimizely/condition.rb
CHANGED
@@ -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.
|
26
|
-
# 2.
|
27
|
-
# 3.
|
28
|
-
#
|
29
|
-
#
|
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
|
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
|
62
|
-
|
63
|
-
return
|
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
|
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
|
-
|
111
|
-
if
|
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
|
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
|
-
|
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
|
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
|
-
|
143
|
-
|
144
|
-
|
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
|
-
|
155
|
-
|
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
|
-
|
171
|
-
|
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::
|
194
|
-
"The
|
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
|
-
|
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
|
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
|
-
|
213
|
-
|
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
|
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
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
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
|
-
|
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
|
-
|
252
|
-
|
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
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
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}'
|
232
|
+
"User '#{user_id}' does not meet the conditions to be in rollout rule for audience '#{audience_name}'."
|
266
233
|
)
|
267
|
-
|
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
|
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
|
282
|
-
# Determine if a user is
|
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
|
270
|
+
# Returns variation ID into which user_id is whitelisted (nil if no variation)
|
288
271
|
|
289
|
-
|
272
|
+
whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
|
290
273
|
|
291
|
-
return nil unless
|
274
|
+
return nil unless whitelisted_variations
|
292
275
|
|
293
|
-
|
276
|
+
whitelisted_variation_key = whitelisted_variations[user_id]
|
294
277
|
|
295
|
-
return nil unless
|
278
|
+
return nil unless whitelisted_variation_key
|
296
279
|
|
297
|
-
|
280
|
+
whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
|
298
281
|
|
299
|
-
unless
|
282
|
+
unless whitelisted_variation_id
|
300
283
|
@config.logger.log(
|
301
284
|
Logger::INFO,
|
302
|
-
"User '#{user_id}' is whitelisted into variation '#{
|
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 '#{
|
292
|
+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
|
310
293
|
)
|
311
|
-
|
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
|
-
:
|
344
|
-
:
|
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
|
-
:
|
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
|