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.
- 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
|