Skip to content

Commit c5133b2

Browse files
oakbanimikeproeng37
authored andcommitted
Forced Bucketing - Implementation (#63)
1 parent 746ac3a commit c5133b2

File tree

6 files changed

+452
-36
lines changed

6 files changed

+452
-36
lines changed

lib/optimizely.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ def get_variation(experiment_key, user_id, attributes = nil)
131131
return nil
132132
end
133133

134+
unless user_id.is_a? String
135+
@logger.log(Logger::ERROR, "User id: #{user_id} is not a string")
136+
return nil
137+
end
138+
134139
unless user_inputs_valid?(attributes)
135140
@logger.log(Logger::INFO, "Not activating user '#{user_id}.")
136141
return nil
@@ -147,6 +152,36 @@ def get_variation(experiment_key, user_id, attributes = nil)
147152
nil
148153
end
149154

155+
def set_forced_variation(experiment_key, user_id, variation_key)
156+
# Force a user into a variation for a given experiment.
157+
#
158+
# experiment_key - String - key identifying the experiment.
159+
# user_id - String - The user ID to be used for bucketing.
160+
# variation_key - The variation key specifies the variation which the user will
161+
# be forced into. If nil, then clear the existing experiment-to-variation mapping.
162+
#
163+
# Returns - Boolean - indicates if the set completed successfully.
164+
165+
@config.set_forced_variation(experiment_key, user_id, variation_key);
166+
end
167+
168+
def get_forced_variation(experiment_key, user_id)
169+
# Gets the forced variation for a given user and experiment.
170+
#
171+
# experiment_key - String - Key identifying the experiment.
172+
# user_id - String - The user ID to be used for bucketing.
173+
#
174+
# Returns String|nil The forced variation key.
175+
176+
forced_variation_key = nil
177+
forced_variation = @config.get_forced_variation(experiment_key, user_id);
178+
if forced_variation
179+
forced_variation_key = forced_variation['key']
180+
end
181+
182+
forced_variation_key
183+
end
184+
150185
def track(event_key, user_id, attributes = nil, event_tags = nil)
151186
# Send conversion event to Optimizely.
152187
#

lib/optimizely/decision_service.rb

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ class DecisionService
2222
# The decision service contains all logic relating to how a user bucketing decisions is made.
2323
# This includes all of the following (in order):
2424
#
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
25+
# 1. Check experiment status
26+
# 2. Check forced bucketing
27+
# 3. Check whitelisting
28+
# 4. Check user profile service for past bucketing decisions (sticky bucketing)
29+
# 5. Check audience targeting
30+
# 6. Use Murmurhash3 to bucket the user
3031

3132
attr_reader :bucketer
3233
attr_reader :config
@@ -58,9 +59,13 @@ def get_variation(experiment_key, user_id, attributes = nil)
5859
return nil
5960
end
6061

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
62+
# Check if a forced variation is set for the user
63+
forced_variation = @config.get_forced_variation(experiment_key, user_id)
64+
return forced_variation['id'] if forced_variation
65+
66+
# Check if user is in a white-listed variation
67+
whitelisted_variation_id = get_whitelisted_variation_id(experiment_key, user_id)
68+
return whitelisted_variation_id if whitelisted_variation_id
6469

6570
# Check for saved bucketing decisions
6671
user_profile = get_user_profile(user_id)
@@ -278,37 +283,37 @@ def get_variation_for_feature_rollout(feature_flag, user_id, attributes = nil)
278283

279284
private
280285

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
286+
def get_whitelisted_variation_id(experiment_key, user_id)
287+
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
283288
#
284289
# experiment_key - Key representing the experiment for which user is to be bucketed
285290
# user_id - ID for the user
286291
#
287-
# Returns variation ID into which user_id is forced (nil if no variation)
292+
# Returns variation ID into which user_id is whitelisted (nil if no variation)
288293

289-
forced_variations = @config.get_forced_variations(experiment_key)
294+
whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
290295

291-
return nil unless forced_variations
296+
return nil unless whitelisted_variations
292297

293-
forced_variation_key = forced_variations[user_id]
298+
whitelisted_variation_key = whitelisted_variations[user_id]
294299

295-
return nil unless forced_variation_key
300+
return nil unless whitelisted_variation_key
296301

297-
forced_variation_id = @config.get_variation_id_from_key(experiment_key, forced_variation_key)
302+
whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
298303

299-
unless forced_variation_id
304+
unless whitelisted_variation_id
300305
@config.logger.log(
301306
Logger::INFO,
302-
"User '#{user_id}' is whitelisted into variation '#{forced_variation_key}', which is not in the datafile."
307+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
303308
)
304309
return nil
305310
end
306311

307312
@config.logger.log(
308313
Logger::INFO,
309-
"User '#{user_id}' is whitelisted into variation '#{forced_variation_key}' of experiment '#{experiment_key}'."
314+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
310315
)
311-
forced_variation_id
316+
whitelisted_variation_id
312317
end
313318

314319
def get_saved_variation_id(experiment_id, user_profile)

lib/optimizely/project_config.rb

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ class ProjectConfig
5656
attr_reader :variation_id_to_variable_usage_map
5757
attr_reader :variation_key_map
5858

59+
# Hash of user IDs to a Hash
60+
# of experiments to variations. This contains all the forced variations
61+
# set by the user by calling setForcedVariation (it is not the same as the
62+
# whitelisting forcedVariations data structure in the Experiments class).
63+
attr_reader :forced_variation_map
64+
5965
def initialize(datafile, logger, error_handler)
6066
# ProjectConfig init method to fetch and set project config data
6167
#
@@ -98,6 +104,7 @@ def initialize(datafile, logger, error_handler)
98104
@audience_id_map = generate_key_map(@audiences, 'id')
99105
@variation_id_map = {}
100106
@variation_key_map = {}
107+
@forced_variation_map = {}
101108
@variation_id_to_variable_usage_map = {}
102109
@variation_id_to_experiment_map = {}
103110
@experiment_key_map.each do |key, exp|
@@ -246,19 +253,113 @@ def get_variation_id_from_key(experiment_key, variation_key)
246253
nil
247254
end
248255

249-
def get_forced_variations(experiment_key)
250-
# Retrieves forced variations for a given experiment Key
256+
def get_whitelisted_variations(experiment_key)
257+
# Retrieves whitelisted variations for a given experiment Key
251258
#
252259
# experiment_key - String Key representing the experiment
253260
#
254-
# Returns forced variations for the experiment or nil
261+
# Returns whitelisted variations for the experiment or nil
255262

256263
experiment = @experiment_key_map[experiment_key]
257264
return experiment['forcedVariations'] if experiment
258265
@logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
259266
@error_handler.handle_error InvalidExperimentError
260267
end
261268

269+
def get_forced_variation(experiment_key, user_id)
270+
# Gets the forced variation for the given user and experiment.
271+
#
272+
# experiment_key - String Key for experiment.
273+
# user_id - String ID for user
274+
#
275+
# Returns Variation The variation which the given user and experiment should be forced into.
276+
277+
# check for nil and empty string user ID
278+
if user_id.nil? or user_id.empty?
279+
@logger.log(Logger::DEBUG, "User ID is invalid")
280+
return nil
281+
end
282+
283+
unless @forced_variation_map.has_key? (user_id)
284+
@logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.")
285+
return nil
286+
end
287+
288+
experimentToVariationMap = @forced_variation_map[user_id]
289+
experiment = get_experiment_from_key(experiment_key)
290+
experiment_id = experiment["id"] if experiment
291+
# check for nil and empty string experiment ID
292+
if experiment_id.nil? or experiment_id.empty?
293+
# this case is logged in get_experiment_from_key
294+
return nil
295+
end
296+
297+
unless experimentToVariationMap.has_key? (experiment_id)
298+
@logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map.")
299+
return nil
300+
end
301+
302+
variation_id = experimentToVariationMap[experiment_id]
303+
variation_key = ""
304+
variation = get_variation_from_id(experiment_key,variation_id)
305+
variation_key = variation["key"] if variation
306+
307+
# check if the variation exists in the datafile
308+
if variation_key.empty?
309+
# this case is logged in get_variation_from_id
310+
return nil
311+
end
312+
313+
@logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map")
314+
315+
variation
316+
end
317+
318+
def set_forced_variation(experiment_key, user_id, variation_key)
319+
# Sets a Hash of user IDs to a Hash of experiments to forced variations.
320+
#
321+
# experiment_key - String Key for experiment.
322+
# user_id - String ID for user.
323+
# variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping.
324+
#
325+
# Returns a boolean value that indicates if the set completed successfully.
326+
327+
# check for null and empty string user ID
328+
if user_id.nil? or user_id.empty?
329+
@logger.log(Logger::DEBUG, "User ID is invalid")
330+
return false
331+
end
332+
333+
experiment = get_experiment_from_key(experiment_key)
334+
experiment_id = experiment["id"] if experiment
335+
# check if the experiment exists in the datafile
336+
if experiment_id.nil? or experiment_id.empty?
337+
return false
338+
end
339+
340+
# clear the forced variation if the variation key is null
341+
if variation_key.nil? or variation_key.empty?
342+
@forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.has_key? (user_id)
343+
@logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user '#{user_id}'.")
344+
return true
345+
end
346+
347+
variation_id = get_variation_id_from_key(experiment_key, variation_key)
348+
349+
# check if the variation exists in the datafile
350+
unless variation_id
351+
# this case is logged in get_variation_id_from_key
352+
return false
353+
end
354+
355+
unless @forced_variation_map.has_key? user_id
356+
@forced_variation_map[user_id] = {}
357+
end
358+
@forced_variation_map[user_id][experiment_id] = variation_id
359+
@logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map.")
360+
return true
361+
end
362+
262363
def get_attribute_id(attribute_key)
263364
attribute = @attribute_key_map[attribute_key]
264365
return attribute['id'] if attribute
@@ -290,7 +391,6 @@ def variation_id_exists?(experiment_id, variation_id)
290391
return true if variation
291392
@logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
292393
@error_handler.handle_error InvalidVariationError
293-
return false
294394
end
295395

296396
false

spec/decision_service_spec.rb

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,32 @@
3030
before(:example) do
3131
# stub out bucketer and audience evaluator so we can make sure they are / aren't called
3232
allow(decision_service.bucketer).to receive(:bucket).and_call_original
33-
allow(decision_service).to receive(:get_forced_variation_id).and_call_original
33+
allow(decision_service).to receive(:get_whitelisted_variation_id).and_call_original
3434
allow(Optimizely::Audience).to receive(:user_in_experiment?).and_call_original
3535

3636
# by default, spy user profile service should no-op. we override this behavior in specific tests
3737
allow(spy_user_profile_service).to receive(:lookup).and_return(nil)
3838
end
3939

40+
it 'should return the correct variation ID for a given user for whom a variation has been forced' do
41+
config.set_forced_variation('test_experiment','test_user', 'variation')
42+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111129')
43+
# Setting forced variation should short circuit whitelist check, bucketing and audience evaluation
44+
expect(decision_service).not_to have_received(:get_whitelisted_variation_id)
45+
expect(decision_service.bucketer).not_to have_received(:bucket)
46+
expect(Optimizely::Audience).not_to have_received(:user_in_experiment?)
47+
end
48+
4049
it 'should return the correct variation ID for a given user ID and key of a running experiment' do
4150
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
4251

4352
expect(spy_logger).to have_received(:log)
4453
.once.with(Logger::INFO,"User 'test_user' is in variation 'control' of experiment 'test_experiment'.")
45-
expect(decision_service).to have_received(:get_forced_variation_id).once
54+
expect(decision_service).to have_received(:get_whitelisted_variation_id).once
4655
expect(decision_service.bucketer).to have_received(:bucket).once
4756
end
4857

49-
it 'should return correct variation ID if user ID is in forcedVariations and variation is valid' do
58+
it 'should return correct variation ID if user ID is in whitelisted Variations and variation is valid' do
5059
expect(decision_service.get_variation('test_experiment', 'forced_user1')).to eq('111128')
5160
expect(spy_logger).to have_received(:log)
5261
.once.with(Logger::INFO, "User 'forced_user1' is whitelisted into variation 'control' of experiment 'test_experiment'.")
@@ -55,13 +64,13 @@
5564
expect(spy_logger).to have_received(:log)
5665
.once.with(Logger::INFO, "User 'forced_user2' is whitelisted into variation 'variation' of experiment 'test_experiment'.")
5766

58-
# forced variations should short circuit bucketing
67+
# whitelisted variations should short circuit bucketing
5968
expect(decision_service.bucketer).not_to have_received(:bucket)
60-
# forced variations should short circuit audience evaluation
69+
# whitelisted variations should short circuit audience evaluation
6170
expect(Optimizely::Audience).not_to have_received(:user_in_experiment?)
6271
end
6372

64-
it 'should return the correct variation ID for a user in a forced variation (even when audience conditions do not match)' do
73+
it 'should return the correct variation ID for a user in a whitelisted variation (even when audience conditions do not match)' do
6574
user_attributes = {'browser_type' => 'wrong_browser'}
6675
expect(decision_service.get_variation('test_experiment_with_audience', 'forced_audience_user', user_attributes)).to eq('122229')
6776
expect(spy_logger).to have_received(:log)
@@ -90,7 +99,7 @@
9099
.once.with(Logger::INFO,"User 'test_user' does not meet the conditions to be in experiment 'test_experiment_with_audience'.")
91100

92101
# should have checked forced variations
93-
expect(decision_service).to have_received(:get_forced_variation_id).once
102+
expect(decision_service).to have_received(:get_whitelisted_variation_id).once
94103
# wrong audience conditions should short circuit bucketing
95104
expect(decision_service.bucketer).not_to have_received(:bucket)
96105
end
@@ -101,7 +110,7 @@
101110
.once.with(Logger::INFO,"Experiment 'test_experiment_not_started' is not running.")
102111

103112
# non-running experiments should short circuit whitelisting
104-
expect(decision_service).not_to have_received(:get_forced_variation_id)
113+
expect(decision_service).not_to have_received(:get_whitelisted_variation_id)
105114
# non-running experiments should short circuit audience evaluation
106115
expect(Optimizely::Audience).not_to have_received(:user_in_experiment?)
107116
# non-running experiments should short circuit bucketing

0 commit comments

Comments
 (0)