optimizely-sdk 1.4.0 → 1.5.0
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 +223 -11
- data/lib/optimizely/bucketer.rb +19 -17
- data/lib/optimizely/decision_service.rb +245 -50
- data/lib/optimizely/event_builder.rb +33 -18
- data/lib/optimizely/exceptions.rb +9 -0
- data/lib/optimizely/helpers/constants.rb +7 -0
- data/lib/optimizely/helpers/variable_type.rb +56 -0
- data/lib/optimizely/notification_center.rb +148 -0
- data/lib/optimizely/project_config.rb +91 -37
- data/lib/optimizely/version.rb +2 -2
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3fae777d6ce539eb2e0809dbf38d4784130c8ade
|
4
|
+
data.tar.gz: f65bee6796830490f4ee8f1a86da20ed7b4f01fb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b8e6f2732d692b1844d3f0c990010aba9858c93b82244803962810d33c78b945f925936874097272705a1a7e724f9d3abad1c4c66ba358573ba32c8bd10d8515
|
7
|
+
data.tar.gz: a8804feff56093f399f87425869a733e9b91b74f502b075a1d790364f47da5e524c36ecde23a626d2b8c977c344e5bd2fb5c93e742ceb5a2cfcf4a50710b9444
|
data/lib/optimizely.rb
CHANGED
@@ -19,9 +19,12 @@ require_relative 'optimizely/error_handler'
|
|
19
19
|
require_relative 'optimizely/event_builder'
|
20
20
|
require_relative 'optimizely/event_dispatcher'
|
21
21
|
require_relative 'optimizely/exceptions'
|
22
|
+
require_relative 'optimizely/helpers/constants'
|
22
23
|
require_relative 'optimizely/helpers/group'
|
23
24
|
require_relative 'optimizely/helpers/validator'
|
25
|
+
require_relative 'optimizely/helpers/variable_type'
|
24
26
|
require_relative 'optimizely/logger'
|
27
|
+
require_relative 'optimizely/notification_center'
|
25
28
|
require_relative 'optimizely/project_config'
|
26
29
|
|
27
30
|
module Optimizely
|
@@ -36,6 +39,7 @@ module Optimizely
|
|
36
39
|
attr_reader :event_builder
|
37
40
|
attr_reader :event_dispatcher
|
38
41
|
attr_reader :logger
|
42
|
+
attr_reader :notification_center
|
39
43
|
|
40
44
|
def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = nil, skip_json_validation = false, user_profile_service = nil)
|
41
45
|
# Constructor for Projects.
|
@@ -81,6 +85,7 @@ module Optimizely
|
|
81
85
|
|
82
86
|
@decision_service = DecisionService.new(@config, @user_profile_service)
|
83
87
|
@event_builder = EventBuilder.new(@config)
|
88
|
+
@notification_center = NotificationCenter.new(@logger, @error_handler)
|
84
89
|
end
|
85
90
|
|
86
91
|
def activate(experiment_key, user_id, attributes = nil)
|
@@ -107,17 +112,8 @@ module Optimizely
|
|
107
112
|
end
|
108
113
|
|
109
114
|
# Create and dispatch impression event
|
110
|
-
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
|
111
115
|
experiment = @config.get_experiment_from_key(experiment_key)
|
112
|
-
|
113
|
-
@logger.log(Logger::INFO,
|
114
|
-
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
|
115
|
-
impression_event.params])
|
116
|
-
begin
|
117
|
-
@event_dispatcher.dispatch_event(impression_event)
|
118
|
-
rescue => e
|
119
|
-
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
|
120
|
-
end
|
116
|
+
send_impression(experiment, variation_key, user_id, attributes)
|
121
117
|
|
122
118
|
variation_key
|
123
119
|
end
|
@@ -151,7 +147,10 @@ module Optimizely
|
|
151
147
|
variation_id = @decision_service.get_variation(experiment_key, user_id, attributes)
|
152
148
|
|
153
149
|
unless variation_id.nil?
|
154
|
-
|
150
|
+
variation = @config.get_variation_from_id(experiment_key, variation_id)
|
151
|
+
if variation
|
152
|
+
return variation['key']
|
153
|
+
end
|
155
154
|
end
|
156
155
|
nil
|
157
156
|
end
|
@@ -235,10 +234,204 @@ module Optimizely
|
|
235
234
|
rescue => e
|
236
235
|
@logger.log(Logger::ERROR, "Unable to dispatch conversion event. Error: #{e}")
|
237
236
|
end
|
237
|
+
@notification_center.send_notifications(
|
238
|
+
NotificationCenter::NOTIFICATION_TYPES[:TRACK],
|
239
|
+
event_key, user_id, attributes, event_tags, conversion_event
|
240
|
+
)
|
241
|
+
end
|
242
|
+
|
243
|
+
def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
|
244
|
+
# Determine whether a feature is enabled.
|
245
|
+
# Sends an impression event if the user is bucketed into an experiment using the feature.
|
246
|
+
#
|
247
|
+
# feature_flag_key - String unique key of the feature.
|
248
|
+
# userId - String ID of the user.
|
249
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
250
|
+
#
|
251
|
+
# Returns True if the feature is enabled.
|
252
|
+
# False if the feature is disabled.
|
253
|
+
# False if the feature is not found.
|
254
|
+
|
255
|
+
unless @is_valid
|
256
|
+
logger = SimpleLogger.new
|
257
|
+
logger.log(Logger::ERROR, InvalidDatafileError.new('is_feature_enabled').message)
|
258
|
+
return nil
|
259
|
+
end
|
260
|
+
|
261
|
+
feature_flag = @config.get_feature_flag_from_key(feature_flag_key)
|
262
|
+
unless feature_flag
|
263
|
+
@logger.log(Logger::ERROR, "No feature flag was found for key '#{feature_flag_key}'.")
|
264
|
+
return false
|
265
|
+
end
|
266
|
+
|
267
|
+
decision = @decision_service.get_variation_for_feature(feature_flag, user_id, attributes)
|
268
|
+
unless decision.nil?
|
269
|
+
variation = decision['variation']
|
270
|
+
experiment = decision['experiment']
|
271
|
+
unless experiment.nil?
|
272
|
+
send_impression(experiment, variation['key'], user_id, attributes)
|
273
|
+
else
|
274
|
+
@logger.log(Logger::DEBUG,
|
275
|
+
"The user '#{user_id}' is not being experimented on in feature '#{feature_flag_key}'.")
|
276
|
+
end
|
277
|
+
|
278
|
+
@logger.log(Logger::INFO, "Feature '#{feature_flag_key}' is enabled for user '#{user_id}'.")
|
279
|
+
return true
|
280
|
+
end
|
281
|
+
|
282
|
+
@logger.log(Logger::INFO,
|
283
|
+
"Feature '#{feature_flag_key}' is not enabled for user '#{user_id}'.")
|
284
|
+
false
|
285
|
+
end
|
286
|
+
|
287
|
+
def get_feature_variable_string(feature_flag_key, variable_key, user_id, attributes = nil)
|
288
|
+
# Get the String value of the specified variable in the feature flag.
|
289
|
+
#
|
290
|
+
# feature_flag_key - String key of feature flag the variable belongs to
|
291
|
+
# variable_key - String key of variable for which we are getting the string value
|
292
|
+
# user_id - String user ID
|
293
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
294
|
+
#
|
295
|
+
# Returns the string variable value.
|
296
|
+
# Returns nil if the feature flag or variable are not found.
|
297
|
+
|
298
|
+
variable_value = get_feature_variable_for_type(
|
299
|
+
feature_flag_key,
|
300
|
+
variable_key,
|
301
|
+
Optimizely::Helpers::Constants::VARIABLE_TYPES["STRING"],
|
302
|
+
user_id,
|
303
|
+
attributes
|
304
|
+
)
|
305
|
+
|
306
|
+
return variable_value
|
307
|
+
end
|
308
|
+
|
309
|
+
def get_feature_variable_boolean(feature_flag_key, variable_key, user_id, attributes = nil)
|
310
|
+
# Get the Boolean value of the specified variable in the feature flag.
|
311
|
+
#
|
312
|
+
# feature_flag_key - String key of feature flag the variable belongs to
|
313
|
+
# variable_key - String key of variable for which we are getting the string value
|
314
|
+
# user_id - String user ID
|
315
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
316
|
+
#
|
317
|
+
# Returns the boolean variable value.
|
318
|
+
# Returns nil if the feature flag or variable are not found.
|
319
|
+
|
320
|
+
variable_value = get_feature_variable_for_type(
|
321
|
+
feature_flag_key,
|
322
|
+
variable_key,
|
323
|
+
Optimizely::Helpers::Constants::VARIABLE_TYPES["BOOLEAN"],
|
324
|
+
user_id,
|
325
|
+
attributes
|
326
|
+
)
|
327
|
+
|
328
|
+
return variable_value
|
329
|
+
end
|
330
|
+
|
331
|
+
def get_feature_variable_double(feature_flag_key, variable_key, user_id, attributes = nil)
|
332
|
+
# Get the Double value of the specified variable in the feature flag.
|
333
|
+
#
|
334
|
+
# feature_flag_key - String key of feature flag the variable belongs to
|
335
|
+
# variable_key - String key of variable for which we are getting the string value
|
336
|
+
# user_id - String user ID
|
337
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
338
|
+
#
|
339
|
+
# Returns the double variable value.
|
340
|
+
# Returns nil if the feature flag or variable are not found.
|
341
|
+
|
342
|
+
variable_value = get_feature_variable_for_type(
|
343
|
+
feature_flag_key,
|
344
|
+
variable_key,
|
345
|
+
Optimizely::Helpers::Constants::VARIABLE_TYPES["DOUBLE"],
|
346
|
+
user_id,
|
347
|
+
attributes
|
348
|
+
)
|
349
|
+
|
350
|
+
return variable_value
|
351
|
+
end
|
352
|
+
|
353
|
+
def get_feature_variable_integer(feature_flag_key, variable_key, user_id, attributes = nil)
|
354
|
+
# Get the Integer value of the specified variable in the feature flag.
|
355
|
+
#
|
356
|
+
# feature_flag_key - String key of feature flag the variable belongs to
|
357
|
+
# variable_key - String key of variable for which we are getting the string value
|
358
|
+
# user_id - String user ID
|
359
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
360
|
+
#
|
361
|
+
# Returns the integer variable value.
|
362
|
+
# Returns nil if the feature flag or variable are not found.
|
363
|
+
|
364
|
+
variable_value = get_feature_variable_for_type(
|
365
|
+
feature_flag_key,
|
366
|
+
variable_key,
|
367
|
+
Optimizely::Helpers::Constants::VARIABLE_TYPES["INTEGER"],
|
368
|
+
user_id,
|
369
|
+
attributes
|
370
|
+
)
|
371
|
+
|
372
|
+
return variable_value
|
238
373
|
end
|
239
374
|
|
240
375
|
private
|
241
376
|
|
377
|
+
def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type, user_id, attributes = nil)
|
378
|
+
# Get the variable value for the given feature variable and cast it to the specified type
|
379
|
+
# The default value is returned if the feature flag is not enabled for the user.
|
380
|
+
#
|
381
|
+
# feature_flag_key - String key of feature flag the variable belongs to
|
382
|
+
# variable_key - String key of variable for which we are getting the string value
|
383
|
+
# variable_type - String requested type for feature variable
|
384
|
+
# user_id - String user ID
|
385
|
+
# attributes - Hash representing visitor attributes and values which need to be recorded.
|
386
|
+
#
|
387
|
+
# Returns the type-casted variable value.
|
388
|
+
# Returns nil if the feature flag or variable are not found.
|
389
|
+
|
390
|
+
feature_flag = @config.get_feature_flag_from_key(feature_flag_key)
|
391
|
+
unless feature_flag
|
392
|
+
@logger.log(Logger::INFO, "No feature flag was found for key '#{feature_flag_key}'.")
|
393
|
+
return nil
|
394
|
+
end
|
395
|
+
|
396
|
+
variable_value = nil
|
397
|
+
variable = @config.get_feature_variable(feature_flag, variable_key)
|
398
|
+
unless variable.nil?
|
399
|
+
variable_value = variable['defaultValue']
|
400
|
+
|
401
|
+
decision = @decision_service.get_variation_for_feature(feature_flag, user_id, attributes)
|
402
|
+
unless decision
|
403
|
+
@logger.log(Logger::INFO,
|
404
|
+
"User '#{user_id}' was not bucketed into any variation for feature flag '#{feature_flag_key}'. Returning the default variable value '#{variable_value}'.")
|
405
|
+
else
|
406
|
+
variation = decision['variation']
|
407
|
+
variation_variable_usages = @config.variation_id_to_variable_usage_map[variation['id']]
|
408
|
+
variable_id = variable['id']
|
409
|
+
unless variation_variable_usages and variation_variable_usages.key?(variable_id)
|
410
|
+
variation_key = variation['key']
|
411
|
+
@logger.log(Logger::DEBUG,
|
412
|
+
"Variable '#{variable_key}' is not used in variation '#{variation_key}'. Returning the default variable value '#{variable_value}'."
|
413
|
+
)
|
414
|
+
else
|
415
|
+
variable_value = variation_variable_usages[variable_id]['value']
|
416
|
+
@logger.log(Logger::INFO,
|
417
|
+
"Got variable value '#{variable_value}' for variable '#{variable_key}' of feature flag '#{feature_flag_key}'.")
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
unless variable_value.nil?
|
423
|
+
actual_variable_type = variable['type']
|
424
|
+
unless variable_type == actual_variable_type
|
425
|
+
@logger.log(Logger::WARN,
|
426
|
+
"Requested variable type '#{variable_type}' but variable '#{variable_key}' is of type '#{actual_variable_type}'.")
|
427
|
+
end
|
428
|
+
|
429
|
+
variable_value = Helpers::VariableType.cast_value_to_type(variable_value, variable_type, @logger)
|
430
|
+
end
|
431
|
+
|
432
|
+
return variable_value
|
433
|
+
end
|
434
|
+
|
242
435
|
def get_valid_experiments_for_event(event_key, user_id, attributes)
|
243
436
|
# Get the experiments that we should be tracking for the given event.
|
244
437
|
#
|
@@ -313,5 +506,24 @@ module Optimizely
|
|
313
506
|
raise InvalidInputError.new('error_handler') unless Helpers::Validator.error_handler_valid?(@error_handler)
|
314
507
|
raise InvalidInputError.new('event_dispatcher') unless Helpers::Validator.event_dispatcher_valid?(@event_dispatcher)
|
315
508
|
end
|
509
|
+
|
510
|
+
def send_impression(experiment, variation_key, user_id, attributes = nil)
|
511
|
+
experiment_key = experiment['key']
|
512
|
+
variation_id = @config.get_variation_id_from_key(experiment_key, variation_key)
|
513
|
+
impression_event = @event_builder.create_impression_event(experiment, variation_id, user_id, attributes)
|
514
|
+
@logger.log(Logger::INFO,
|
515
|
+
'Dispatching impression event to URL %s with params %s.' % [impression_event.url,
|
516
|
+
impression_event.params])
|
517
|
+
begin
|
518
|
+
@event_dispatcher.dispatch_event(impression_event)
|
519
|
+
rescue => e
|
520
|
+
@logger.log(Logger::ERROR, "Unable to dispatch impression event. Error: #{e}")
|
521
|
+
end
|
522
|
+
variation = @config.get_variation_from_id(experiment_key, variation_id)
|
523
|
+
@notification_center.send_notifications(
|
524
|
+
NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE],
|
525
|
+
experiment,user_id, attributes, variation, impression_event
|
526
|
+
)
|
527
|
+
end
|
316
528
|
end
|
317
529
|
end
|
data/lib/optimizely/bucketer.rb
CHANGED
@@ -20,7 +20,7 @@ module Optimizely
|
|
20
20
|
class Bucketer
|
21
21
|
# Optimizely bucketing algorithm that evenly distributes visitors.
|
22
22
|
|
23
|
-
BUCKETING_ID_TEMPLATE = '%{
|
23
|
+
BUCKETING_ID_TEMPLATE = '%{bucketing_id}%{entity_id}'
|
24
24
|
HASH_SEED = 1
|
25
25
|
MAX_HASH_VALUE = 2**32
|
26
26
|
MAX_TRAFFIC_VALUE = 10_000
|
@@ -35,13 +35,15 @@ module Optimizely
|
|
35
35
|
@config = config
|
36
36
|
end
|
37
37
|
|
38
|
-
def bucket(experiment, user_id)
|
38
|
+
def bucket(experiment, bucketing_id, user_id)
|
39
39
|
# Determines ID of variation to be shown for a given experiment key and user ID.
|
40
40
|
#
|
41
41
|
# experiment - Experiment for which visitor is to be bucketed.
|
42
|
+
# bucketing_id - String A customer-assigned value used to generate the bucketing key
|
42
43
|
# user_id - String ID for user.
|
43
44
|
#
|
44
45
|
# Returns variation in which visitor with ID user_id has been placed. Nil if no variation.
|
46
|
+
return nil if experiment.nil?
|
45
47
|
|
46
48
|
# check if experiment is in a group; if so, check if user is bucketed into specified experiment
|
47
49
|
experiment_id = experiment['id']
|
@@ -51,7 +53,7 @@ module Optimizely
|
|
51
53
|
group = @config.group_key_map.fetch(group_id)
|
52
54
|
if Helpers::Group.random_policy?(group)
|
53
55
|
traffic_allocations = group.fetch('trafficAllocation')
|
54
|
-
bucketed_experiment_id = find_bucket(user_id, group_id, traffic_allocations)
|
56
|
+
bucketed_experiment_id = find_bucket(bucketing_id, user_id, group_id, traffic_allocations)
|
55
57
|
# return if the user is not bucketed into any experiment
|
56
58
|
unless bucketed_experiment_id
|
57
59
|
@config.logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
|
@@ -76,7 +78,7 @@ module Optimizely
|
|
76
78
|
end
|
77
79
|
|
78
80
|
traffic_allocations = experiment['trafficAllocation']
|
79
|
-
variation_id = find_bucket(user_id, experiment_id, traffic_allocations)
|
81
|
+
variation_id = find_bucket(bucketing_id, user_id, experiment_id, traffic_allocations)
|
80
82
|
if variation_id && variation_id != ''
|
81
83
|
variation = @config.get_variation_from_id(experiment_key, variation_id)
|
82
84
|
variation_key = variation ? variation['key'] : nil
|
@@ -96,18 +98,18 @@ module Optimizely
|
|
96
98
|
nil
|
97
99
|
end
|
98
100
|
|
99
|
-
def find_bucket(user_id, parent_id, traffic_allocations)
|
101
|
+
def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations)
|
100
102
|
# Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
|
101
103
|
#
|
104
|
+
# bucketing_id - String A customer-assigned value user to generate bucketing key
|
102
105
|
# user_id - String ID for user
|
103
106
|
# parent_id - String entity ID to use for bucketing ID
|
104
107
|
# traffic_allocations - Array of traffic allocations
|
105
108
|
#
|
106
109
|
# Returns entity ID corresponding to the provided bucket value or nil if no match is found.
|
107
|
-
|
108
|
-
|
109
|
-
bucket_value
|
110
|
-
@config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}'.")
|
110
|
+
bucketing_key = sprintf(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id)
|
111
|
+
bucket_value = generate_bucket_value(bucketing_key)
|
112
|
+
@config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}' with bucketing ID: '#{bucketing_id}'.")
|
111
113
|
|
112
114
|
traffic_allocations.each do |traffic_allocation|
|
113
115
|
current_end_of_range = traffic_allocation['endOfRange']
|
@@ -122,25 +124,25 @@ module Optimizely
|
|
122
124
|
|
123
125
|
private
|
124
126
|
|
125
|
-
def generate_bucket_value(
|
127
|
+
def generate_bucket_value(bucketing_key)
|
126
128
|
# Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE).
|
127
129
|
#
|
128
|
-
#
|
130
|
+
# bucketing_key - String - Value used to generate bucket value
|
129
131
|
#
|
130
|
-
# Returns bucket value corresponding to the provided bucketing
|
132
|
+
# Returns bucket value corresponding to the provided bucketing key.
|
131
133
|
|
132
|
-
ratio = (generate_unsigned_hash_code_32_bit(
|
134
|
+
ratio = (generate_unsigned_hash_code_32_bit(bucketing_key)).to_f / MAX_HASH_VALUE
|
133
135
|
(ratio * MAX_TRAFFIC_VALUE).to_i
|
134
136
|
end
|
135
137
|
|
136
|
-
def generate_unsigned_hash_code_32_bit(
|
138
|
+
def generate_unsigned_hash_code_32_bit(bucketing_key)
|
137
139
|
# Helper function to retreive hash code
|
138
140
|
#
|
139
|
-
#
|
141
|
+
# bucketing_key - String - Value used for the key of the murmur hash
|
140
142
|
#
|
141
143
|
# Returns hash code which is a 32 bit unsigned integer.
|
142
144
|
|
143
|
-
MurmurHash3::V32.str_hash(
|
145
|
+
MurmurHash3::V32.str_hash(bucketing_key, @bucket_seed) & UNSIGNED_MAX_32_BIT_VALUE
|
144
146
|
end
|
145
147
|
end
|
146
|
-
end
|
148
|
+
end
|
@@ -16,6 +16,9 @@
|
|
16
16
|
require_relative './bucketer'
|
17
17
|
|
18
18
|
module Optimizely
|
19
|
+
|
20
|
+
RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = "\$opt_bucketing_id".freeze
|
21
|
+
|
19
22
|
class DecisionService
|
20
23
|
# Optimizely's decision service that determines into which variation of an experiment a user will be allocated.
|
21
24
|
#
|
@@ -28,16 +31,16 @@ module Optimizely
|
|
28
31
|
# 4. Check user profile service for past bucketing decisions (sticky bucketing)
|
29
32
|
# 5. Check audience targeting
|
30
33
|
# 6. Use Murmurhash3 to bucket the user
|
31
|
-
|
34
|
+
|
32
35
|
attr_reader :bucketer
|
33
36
|
attr_reader :config
|
34
|
-
|
37
|
+
|
35
38
|
def initialize(config, user_profile_service = nil)
|
36
39
|
@config = config
|
37
40
|
@user_profile_service = user_profile_service
|
38
41
|
@bucketer = Bucketer.new(@config)
|
39
42
|
end
|
40
|
-
|
43
|
+
|
41
44
|
def get_variation(experiment_key, user_id, attributes = nil)
|
42
45
|
# Determines variation into which user will be bucketed.
|
43
46
|
#
|
@@ -46,58 +49,250 @@ module Optimizely
|
|
46
49
|
# attributes - Hash representing user attributes
|
47
50
|
#
|
48
51
|
# Returns variation ID where visitor will be bucketed (nil if experiment is inactive or user does not meet audience conditions)
|
49
|
-
|
52
|
+
|
53
|
+
# By default, the bucketing ID should be the user ID
|
54
|
+
bucketing_id = user_id;
|
55
|
+
|
56
|
+
# If the bucketing ID key is defined in attributes, then use that in place of the userID
|
57
|
+
if attributes and attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].is_a? String
|
58
|
+
unless attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID].empty?
|
59
|
+
bucketing_id = attributes[RESERVED_ATTRIBUTE_KEY_BUCKETING_ID]
|
60
|
+
@config.logger.log(Logger::DEBUG, "Setting the bucketing ID '#{bucketing_id}'")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
50
64
|
# Check to make sure experiment is active
|
51
65
|
experiment = @config.get_experiment_from_key(experiment_key)
|
52
|
-
if experiment.nil?
|
53
|
-
|
54
|
-
end
|
55
|
-
|
66
|
+
return nil if experiment.nil?
|
67
|
+
|
56
68
|
experiment_id = experiment['id']
|
57
69
|
unless @config.experiment_running?(experiment)
|
58
70
|
@config.logger.log(Logger::INFO, "Experiment '#{experiment_key}' is not running.")
|
59
71
|
return nil
|
60
72
|
end
|
61
|
-
|
73
|
+
|
62
74
|
# Check if a forced variation is set for the user
|
63
75
|
forced_variation = @config.get_forced_variation(experiment_key, user_id)
|
64
76
|
return forced_variation['id'] if forced_variation
|
65
|
-
|
77
|
+
|
66
78
|
# Check if user is in a white-listed variation
|
67
79
|
whitelisted_variation_id = get_whitelisted_variation_id(experiment_key, user_id)
|
68
80
|
return whitelisted_variation_id if whitelisted_variation_id
|
69
|
-
|
81
|
+
|
70
82
|
# Check for saved bucketing decisions
|
71
83
|
user_profile = get_user_profile(user_id)
|
72
84
|
saved_variation_id = get_saved_variation_id(experiment_id, user_profile)
|
73
85
|
if saved_variation_id
|
74
86
|
@config.logger.log(
|
75
|
-
|
76
|
-
|
87
|
+
Logger::INFO,
|
88
|
+
"Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
|
77
89
|
)
|
78
90
|
return saved_variation_id
|
79
91
|
end
|
80
|
-
|
92
|
+
|
81
93
|
# Check audience conditions
|
82
94
|
unless Audience.user_in_experiment?(@config, experiment, attributes)
|
83
95
|
@config.logger.log(
|
84
|
-
|
85
|
-
|
96
|
+
Logger::INFO,
|
97
|
+
"User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
|
86
98
|
)
|
87
99
|
return nil
|
88
100
|
end
|
89
|
-
|
101
|
+
|
90
102
|
# Bucket normally
|
91
|
-
variation = @bucketer.bucket(experiment, user_id)
|
103
|
+
variation = @bucketer.bucket(experiment, bucketing_id, user_id)
|
92
104
|
variation_id = variation ? variation['id'] : nil
|
93
|
-
|
105
|
+
|
94
106
|
# Persist bucketing decision
|
95
107
|
save_user_profile(user_profile, experiment_id, variation_id)
|
96
108
|
variation_id
|
97
109
|
end
|
98
|
-
|
110
|
+
|
111
|
+
def get_variation_for_feature(feature_flag, user_id, attributes = nil)
|
112
|
+
# Get the variation the user is bucketed into for the given FeatureFlag.
|
113
|
+
#
|
114
|
+
# feature_flag - The feature flag the user wants to access
|
115
|
+
# user_id - String ID for the user
|
116
|
+
# attributes - Hash representing user attributes
|
117
|
+
#
|
118
|
+
# 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)
|
119
|
+
|
120
|
+
# check if the feature is being experiment on and whether the user is bucketed into the experiment
|
121
|
+
decision = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
|
122
|
+
return decision unless decision.nil?
|
123
|
+
|
124
|
+
feature_flag_key = feature_flag['key']
|
125
|
+
variation = get_variation_for_feature_rollout(feature_flag, user_id, attributes)
|
126
|
+
if variation
|
127
|
+
@config.logger.log(
|
128
|
+
Logger::INFO,
|
129
|
+
"User '#{user_id}' is in the rollout for feature flag '#{feature_flag_key}'."
|
130
|
+
)
|
131
|
+
# return decision with nil experiment so we don't track impressions for it
|
132
|
+
return {
|
133
|
+
'experiment' => nil,
|
134
|
+
'variation' => variation
|
135
|
+
}
|
136
|
+
else
|
137
|
+
@config.logger.log(
|
138
|
+
Logger::INFO,
|
139
|
+
"User '#{user_id}' is not in the rollout for feature flag '#{feature_flag_key}'."
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
return nil
|
144
|
+
end
|
145
|
+
|
146
|
+
def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil)
|
147
|
+
# Gets the variation the user is bucketed into for the feature flag's experiment.
|
148
|
+
#
|
149
|
+
# feature_flag - The feature flag the user wants to access
|
150
|
+
# user_id - String ID for the user
|
151
|
+
# attributes - Hash representing user attributes
|
152
|
+
#
|
153
|
+
# Returns a hash with the experiment and variation where visitor will be bucketed
|
154
|
+
# or nil if the user is not bucketed into any of the experiments on the feature
|
155
|
+
|
156
|
+
feature_flag_key = feature_flag['key']
|
157
|
+
unless feature_flag['experimentIds'].empty?
|
158
|
+
# check if experiment is part of mutex group
|
159
|
+
experiment_id = feature_flag['experimentIds'][0]
|
160
|
+
experiment = @config.experiment_id_map[experiment_id]
|
161
|
+
unless experiment
|
162
|
+
@config.logger.log(
|
163
|
+
Logger::DEBUG,
|
164
|
+
"Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
|
165
|
+
)
|
166
|
+
return nil
|
167
|
+
end
|
168
|
+
|
169
|
+
group_id = experiment['groupId']
|
170
|
+
# if experiment is part of mutex group we first determine which experiment (if any) in the group the user is part of
|
171
|
+
if group_id and @config.group_key_map.has_key?(group_id)
|
172
|
+
group = @config.group_key_map[group_id]
|
173
|
+
bucketed_experiment_id = @bucketer.find_bucket(user_id, group_id, group['trafficAllocation'])
|
174
|
+
if bucketed_experiment_id.nil?
|
175
|
+
@config.logger.log(
|
176
|
+
Logger::INFO,
|
177
|
+
"The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
|
178
|
+
)
|
179
|
+
return nil
|
180
|
+
end
|
181
|
+
else
|
182
|
+
bucketed_experiment_id = experiment_id
|
183
|
+
end
|
184
|
+
|
185
|
+
if feature_flag['experimentIds'].include?(bucketed_experiment_id)
|
186
|
+
experiment = @config.experiment_id_map[bucketed_experiment_id]
|
187
|
+
experiment_key = experiment['key']
|
188
|
+
variation_id = get_variation(experiment_key, user_id, attributes)
|
189
|
+
unless variation_id.nil?
|
190
|
+
variation = @config.variation_id_map[experiment_key][variation_id]
|
191
|
+
@config.logger.log(
|
192
|
+
Logger::INFO,
|
193
|
+
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
|
194
|
+
)
|
195
|
+
return {
|
196
|
+
'variation' => variation,
|
197
|
+
'experiment' => experiment
|
198
|
+
}
|
199
|
+
else
|
200
|
+
@config.logger.log(
|
201
|
+
Logger::INFO,
|
202
|
+
"The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
|
203
|
+
)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
else
|
207
|
+
@config.logger.log(
|
208
|
+
Logger::DEBUG,
|
209
|
+
"The feature flag '#{feature_flag_key}' is not used in any experiments."
|
210
|
+
)
|
211
|
+
end
|
212
|
+
|
213
|
+
return nil
|
214
|
+
end
|
215
|
+
|
216
|
+
def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil)
|
217
|
+
# Determine which variation the user is in for a given rollout.
|
218
|
+
# Returns the variation of the first experiment the user qualifies for.
|
219
|
+
#
|
220
|
+
# feature_flag - The feature flag the user wants to access
|
221
|
+
# user_id - String ID for the user
|
222
|
+
# attributes - Hash representing user attributes
|
223
|
+
#
|
224
|
+
# Returns the variation the user is bucketed into or nil if not bucketed into any of the targeting rules
|
225
|
+
|
226
|
+
rollout_id = feature_flag['rolloutId']
|
227
|
+
if rollout_id.nil? or rollout_id.empty?
|
228
|
+
feature_flag_key = feature_flag['key']
|
229
|
+
@config.logger.log(
|
230
|
+
Logger::DEBUG,
|
231
|
+
"Feature flag '#{feature_flag_key}' is not part of a rollout."
|
232
|
+
)
|
233
|
+
return nil
|
234
|
+
end
|
235
|
+
|
236
|
+
rollout = @config.get_rollout_from_id(rollout_id)
|
237
|
+
unless rollout.nil? or rollout['experiments'].empty?
|
238
|
+
rollout_experiments = rollout['experiments']
|
239
|
+
number_of_rules = rollout_experiments.length - 1
|
240
|
+
|
241
|
+
# Go through each experiment in order and try to get the variation for the user
|
242
|
+
for index in (0...number_of_rules)
|
243
|
+
experiment = rollout_experiments[index]
|
244
|
+
experiment_key = experiment['key']
|
245
|
+
|
246
|
+
# Check that user meets audience conditions for targeting rule
|
247
|
+
unless Audience.user_in_experiment?(@config, experiment, attributes)
|
248
|
+
@config.logger.log(
|
249
|
+
Logger::DEBUG,
|
250
|
+
"User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
|
251
|
+
)
|
252
|
+
# move onto the next targeting rule
|
253
|
+
next
|
254
|
+
end
|
255
|
+
|
256
|
+
@config.logger.log(
|
257
|
+
Logger::DEBUG,
|
258
|
+
"User '#{user_id}' meets conditions for targeting rule '#{index + 1}'."
|
259
|
+
)
|
260
|
+
variation = @bucketer.bucket(experiment, user_id)
|
261
|
+
unless variation.nil?
|
262
|
+
variation_key = variation['key']
|
263
|
+
return variation
|
264
|
+
end
|
265
|
+
|
266
|
+
# User failed traffic allocation, jump to Everyone Else rule
|
267
|
+
@config.logger.log(
|
268
|
+
Logger::DEBUG,
|
269
|
+
"User '#{user_id}' is not in the traffic group for the targeting rule. Checking 'Eveyrone Else' rule now."
|
270
|
+
)
|
271
|
+
break
|
272
|
+
end
|
273
|
+
|
274
|
+
# Evalute the "Everyone Else" rule, which is the last rule.
|
275
|
+
everyone_else_experiment = rollout_experiments[number_of_rules]
|
276
|
+
variation = @bucketer.bucket(everyone_else_experiment, user_id)
|
277
|
+
unless variation.nil?
|
278
|
+
@config.logger.log(
|
279
|
+
Logger::DEBUG,
|
280
|
+
"User '#{user_id}' meets conditions for targeting rule 'Everyone Else'."
|
281
|
+
)
|
282
|
+
return variation
|
283
|
+
end
|
284
|
+
|
285
|
+
@config.logger.log(
|
286
|
+
Logger::DEBUG,
|
287
|
+
"User '#{user_id}' does not meet conditions for targeting rule 'Everyone Else'."
|
288
|
+
)
|
289
|
+
end
|
290
|
+
|
291
|
+
return nil
|
292
|
+
end
|
293
|
+
|
99
294
|
private
|
100
|
-
|
295
|
+
|
101
296
|
def get_whitelisted_variation_id(experiment_key, user_id)
|
102
297
|
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
|
103
298
|
#
|
@@ -105,32 +300,32 @@ module Optimizely
|
|
105
300
|
# user_id - ID for the user
|
106
301
|
#
|
107
302
|
# Returns variation ID into which user_id is whitelisted (nil if no variation)
|
108
|
-
|
303
|
+
|
109
304
|
whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
|
110
|
-
|
305
|
+
|
111
306
|
return nil unless whitelisted_variations
|
112
|
-
|
307
|
+
|
113
308
|
whitelisted_variation_key = whitelisted_variations[user_id]
|
114
|
-
|
309
|
+
|
115
310
|
return nil unless whitelisted_variation_key
|
116
|
-
|
311
|
+
|
117
312
|
whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
|
118
|
-
|
313
|
+
|
119
314
|
unless whitelisted_variation_id
|
120
315
|
@config.logger.log(
|
121
|
-
|
122
|
-
|
316
|
+
Logger::INFO,
|
317
|
+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
|
123
318
|
)
|
124
319
|
return nil
|
125
320
|
end
|
126
|
-
|
321
|
+
|
127
322
|
@config.logger.log(
|
128
|
-
|
129
|
-
|
323
|
+
Logger::INFO,
|
324
|
+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
|
130
325
|
)
|
131
326
|
whitelisted_variation_id
|
132
327
|
end
|
133
|
-
|
328
|
+
|
134
329
|
def get_saved_variation_id(experiment_id, user_profile)
|
135
330
|
# Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
|
136
331
|
#
|
@@ -139,56 +334,56 @@ module Optimizely
|
|
139
334
|
#
|
140
335
|
# Returns string variation ID (nil if no decision is found)
|
141
336
|
return nil unless user_profile[:experiment_bucket_map]
|
142
|
-
|
337
|
+
|
143
338
|
decision = user_profile[:experiment_bucket_map][experiment_id]
|
144
339
|
return nil unless decision
|
145
340
|
variation_id = decision[:variation_id]
|
146
341
|
return variation_id if @config.variation_id_exists?(experiment_id, variation_id)
|
147
|
-
|
342
|
+
|
148
343
|
@config.logger.log(
|
149
|
-
|
150
|
-
|
344
|
+
Logger::INFO,
|
345
|
+
"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."
|
151
346
|
)
|
152
347
|
nil
|
153
348
|
end
|
154
|
-
|
349
|
+
|
155
350
|
def get_user_profile(user_id)
|
156
351
|
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
|
157
352
|
#
|
158
353
|
# user_id - String ID for the user
|
159
354
|
#
|
160
355
|
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
|
161
|
-
|
356
|
+
|
162
357
|
user_profile = {
|
163
|
-
|
164
|
-
|
358
|
+
:user_id => user_id,
|
359
|
+
:experiment_bucket_map => {}
|
165
360
|
}
|
166
|
-
|
361
|
+
|
167
362
|
return user_profile unless @user_profile_service
|
168
|
-
|
363
|
+
|
169
364
|
begin
|
170
365
|
user_profile = @user_profile_service.lookup(user_id) || user_profile
|
171
366
|
rescue => e
|
172
367
|
@config.logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
|
173
368
|
end
|
174
|
-
|
369
|
+
|
175
370
|
user_profile
|
176
371
|
end
|
177
|
-
|
178
|
-
|
372
|
+
|
373
|
+
|
179
374
|
def save_user_profile(user_profile, experiment_id, variation_id)
|
180
375
|
# Save a given bucketing decision to a given user profile
|
181
376
|
#
|
182
377
|
# user_profile - Hash user profile
|
183
378
|
# experiment_id - String experiment ID
|
184
379
|
# variation_id - String variation ID
|
185
|
-
|
380
|
+
|
186
381
|
return unless @user_profile_service
|
187
|
-
|
382
|
+
|
188
383
|
user_id = user_profile[:user_id]
|
189
384
|
begin
|
190
385
|
user_profile[:experiment_bucket_map][experiment_id] = {
|
191
|
-
|
386
|
+
:variation_id => variation_id
|
192
387
|
}
|
193
388
|
@user_profile_service.save(user_profile)
|
194
389
|
@config.logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
|
@@ -197,4 +392,4 @@ module Optimizely
|
|
197
392
|
end
|
198
393
|
end
|
199
394
|
end
|
200
|
-
end
|
395
|
+
end
|