Skip to content

Commit 9048b60

Browse files
oakbaniMatt Auerbach
authored andcommitted
Forced Bucketing - Implementation (#63)
1 parent 647e72f commit 9048b60

File tree

6 files changed

+448
-37
lines changed

6 files changed

+448
-37
lines changed

lib/optimizely.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ def get_variation(experiment_key, user_id, attributes = nil)
138138
return nil
139139
end
140140

141+
unless user_id.is_a? String
142+
@logger.log(Logger::ERROR, "User id: #{user_id} is not a string")
143+
return nil
144+
end
145+
141146
unless user_inputs_valid?(attributes)
142147
@logger.log(Logger::INFO, "Not activating user '#{user_id}.")
143148
return nil
@@ -151,6 +156,36 @@ def get_variation(experiment_key, user_id, attributes = nil)
151156
nil
152157
end
153158

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

lib/optimizely/decision_service.rb

Lines changed: 26 additions & 21 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)
@@ -91,38 +96,38 @@ def get_variation(experiment_key, user_id, attributes = nil)
9196
end
9297

9398
private
94-
95-
def get_forced_variation_id(experiment_key, user_id)
96-
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
99+
100+
def get_whitelisted_variation_id(experiment_key, user_id)
101+
# Determine if a user is whitelisted into a variation for the given experiment and return the ID of that variation
97102
#
98103
# experiment_key - Key representing the experiment for which user is to be bucketed
99104
# user_id - ID for the user
100105
#
101-
# Returns variation ID into which user_id is forced (nil if no variation)
106+
# Returns variation ID into which user_id is whitelisted (nil if no variation)
102107

103-
forced_variations = @config.get_forced_variations(experiment_key)
108+
whitelisted_variations = @config.get_whitelisted_variations(experiment_key)
104109

105-
return nil unless forced_variations
110+
return nil unless whitelisted_variations
106111

107-
forced_variation_key = forced_variations[user_id]
112+
whitelisted_variation_key = whitelisted_variations[user_id]
108113

109-
return nil unless forced_variation_key
114+
return nil unless whitelisted_variation_key
110115

111-
forced_variation_id = @config.get_variation_id_from_key(experiment_key, forced_variation_key)
116+
whitelisted_variation_id = @config.get_variation_id_from_key(experiment_key, whitelisted_variation_key)
112117

113-
unless forced_variation_id
118+
unless whitelisted_variation_id
114119
@config.logger.log(
115120
Logger::INFO,
116-
"User '#{user_id}' is whitelisted into variation '#{forced_variation_key}', which is not in the datafile."
121+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}', which is not in the datafile."
117122
)
118123
return nil
119124
end
120125

121126
@config.logger.log(
122127
Logger::INFO,
123-
"User '#{user_id}' is whitelisted into variation '#{forced_variation_key}' of experiment '#{experiment_key}'."
128+
"User '#{user_id}' is whitelisted into variation '#{whitelisted_variation_key}' of experiment '#{experiment_key}'."
124129
)
125-
forced_variation_id
130+
whitelisted_variation_id
126131
end
127132

128133
def get_saved_variation_id(experiment_id, user_profile)

lib/optimizely/project_config.rb

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ class ProjectConfig
5252
attr_reader :variation_id_map
5353
attr_reader :variation_key_map
5454

55+
# Hash of user IDs to a Hash
56+
# of experiments to variations. This contains all the forced variations
57+
# set by the user by calling setForcedVariation (it is not the same as the
58+
# whitelisting forcedVariations data structure in the Experiments class).
59+
attr_reader :forced_variation_map
60+
5561
def initialize(datafile, logger, error_handler)
5662
# ProjectConfig init method to fetch and set project config data
5763
#
@@ -92,6 +98,9 @@ def initialize(datafile, logger, error_handler)
9298
@audience_id_map = generate_key_map(@audiences, 'id')
9399
@variation_id_map = {}
94100
@variation_key_map = {}
101+
@forced_variation_map = {}
102+
@variation_id_to_variable_usage_map = {}
103+
@variation_id_to_experiment_map = {}
95104
@experiment_key_map.each do |key, exp|
96105
variations = exp.fetch('variations')
97106
@variation_id_map[key] = generate_key_map(variations, 'id')
@@ -209,19 +218,113 @@ def get_variation_id_from_key(experiment_key, variation_key)
209218
nil
210219
end
211220

212-
def get_forced_variations(experiment_key)
213-
# Retrieves forced variations for a given experiment Key
221+
def get_whitelisted_variations(experiment_key)
222+
# Retrieves whitelisted variations for a given experiment Key
214223
#
215224
# experiment_key - String Key representing the experiment
216225
#
217-
# Returns forced variations for the experiment or nil
226+
# Returns whitelisted variations for the experiment or nil
218227

219228
experiment = @experiment_key_map[experiment_key]
220229
return experiment['forcedVariations'] if experiment
221230
@logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile."
222231
@error_handler.handle_error InvalidExperimentError
223232
end
224233

234+
def get_forced_variation(experiment_key, user_id)
235+
# Gets the forced variation for the given user and experiment.
236+
#
237+
# experiment_key - String Key for experiment.
238+
# user_id - String ID for user
239+
#
240+
# Returns Variation The variation which the given user and experiment should be forced into.
241+
242+
# check for nil and empty string user ID
243+
if user_id.nil? or user_id.empty?
244+
@logger.log(Logger::DEBUG, "User ID is invalid")
245+
return nil
246+
end
247+
248+
unless @forced_variation_map.has_key? (user_id)
249+
@logger.log(Logger::DEBUG, "User '#{user_id}' is not in the forced variation map.")
250+
return nil
251+
end
252+
253+
experimentToVariationMap = @forced_variation_map[user_id]
254+
experiment = get_experiment_from_key(experiment_key)
255+
experiment_id = experiment["id"] if experiment
256+
# check for nil and empty string experiment ID
257+
if experiment_id.nil? or experiment_id.empty?
258+
# this case is logged in get_experiment_from_key
259+
return nil
260+
end
261+
262+
unless experimentToVariationMap.has_key? (experiment_id)
263+
@logger.log(Logger::DEBUG, "No experiment '#{experiment_key}' mapped to user '#{user_id}' in the forced variation map.")
264+
return nil
265+
end
266+
267+
variation_id = experimentToVariationMap[experiment_id]
268+
variation_key = ""
269+
variation = get_variation_from_id(experiment_key,variation_id)
270+
variation_key = variation["key"] if variation
271+
272+
# check if the variation exists in the datafile
273+
if variation_key.empty?
274+
# this case is logged in get_variation_from_id
275+
return nil
276+
end
277+
278+
@logger.log(Logger::DEBUG, "Variation '#{variation_key}' is mapped to experiment '#{experiment_key}' and user '#{user_id}' in the forced variation map")
279+
280+
variation
281+
end
282+
283+
def set_forced_variation(experiment_key, user_id, variation_key)
284+
# Sets a Hash of user IDs to a Hash of experiments to forced variations.
285+
#
286+
# experiment_key - String Key for experiment.
287+
# user_id - String ID for user.
288+
# variation_key - String Key for variation. If null, then clear the existing experiment-to-variation mapping.
289+
#
290+
# Returns a boolean value that indicates if the set completed successfully.
291+
292+
# check for null and empty string user ID
293+
if user_id.nil? or user_id.empty?
294+
@logger.log(Logger::DEBUG, "User ID is invalid")
295+
return false
296+
end
297+
298+
experiment = get_experiment_from_key(experiment_key)
299+
experiment_id = experiment["id"] if experiment
300+
# check if the experiment exists in the datafile
301+
if experiment_id.nil? or experiment_id.empty?
302+
return false
303+
end
304+
305+
# clear the forced variation if the variation key is null
306+
if variation_key.nil? or variation_key.empty?
307+
@forced_variation_map[user_id].delete(experiment_id) if @forced_variation_map.has_key? (user_id)
308+
@logger.log(Logger::DEBUG, "Variation mapped to experiment '#{experiment_key}' has been removed for user '#{user_id}'.")
309+
return true
310+
end
311+
312+
variation_id = get_variation_id_from_key(experiment_key, variation_key)
313+
314+
# check if the variation exists in the datafile
315+
unless variation_id
316+
# this case is logged in get_variation_id_from_key
317+
return false
318+
end
319+
320+
unless @forced_variation_map.has_key? user_id
321+
@forced_variation_map[user_id] = {}
322+
end
323+
@forced_variation_map[user_id][experiment_id] = variation_id
324+
@logger.log(Logger::DEBUG, "Set variation '#{variation_id}' for experiment '#{experiment_id}' and user '#{user_id}' in the forced variation map.")
325+
return true
326+
end
327+
225328
def get_attribute_id(attribute_key)
226329
attribute = @attribute_key_map[attribute_key]
227330
return attribute['id'] if attribute
@@ -253,7 +356,6 @@ def variation_id_exists?(experiment_id, variation_id)
253356
return true if variation
254357
@logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
255358
@error_handler.handle_error InvalidVariationError
256-
return false
257359
end
258360

259361
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)