Skip to content

Commit 412eb9c

Browse files
authored
Features bucketing (#54)
1 parent 105a47c commit 412eb9c

File tree

6 files changed

+283
-303
lines changed

6 files changed

+283
-303
lines changed

lib/optimizely/bucketer.rb

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,8 @@ def bucket(experiment_key, user_id)
4949
if group_id
5050
group = @config.group_key_map.fetch(group_id)
5151
if Helpers::Group.random_policy?(group)
52-
bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: group_id)
5352
traffic_allocations = group.fetch('trafficAllocation')
54-
bucket_value = generate_bucket_value(bucketing_id)
55-
@config.logger.log(Logger::DEBUG, "Assigned experiment bucket #{bucket_value} to user '#{user_id}'.")
56-
bucketed_experiment_id = find_bucket(bucket_value, traffic_allocations)
57-
53+
bucketed_experiment_id = find_bucket(user_id, group_id, traffic_allocations)
5854
# return if the user is not bucketed into any experiment
5955
unless bucketed_experiment_id
6056
@config.logger.log(Logger::INFO, "User '#{user_id}' is in no experiment.")
@@ -78,11 +74,8 @@ def bucket(experiment_key, user_id)
7874
end
7975
end
8076

81-
bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: experiment_id)
82-
bucket_value = generate_bucket_value(bucketing_id)
83-
@config.logger.log(Logger::DEBUG, "Assigned variation bucket #{bucket_value} to user '#{user_id}'.")
8477
traffic_allocations = @config.get_traffic_allocation(experiment_key)
85-
variation_id = find_bucket(bucket_value, traffic_allocations)
78+
variation_id = find_bucket(user_id, experiment_id, traffic_allocations)
8679
if variation_id && variation_id != ''
8780
variation_key = @config.get_variation_key_from_id(experiment_key, variation_id)
8881
@config.logger.log(
@@ -101,16 +94,19 @@ def bucket(experiment_key, user_id)
10194
nil
10295
end
10396

104-
private
105-
106-
def find_bucket(bucket_value, traffic_allocations)
97+
def find_bucket(user_id, parent_id, traffic_allocations)
10798
# Helper function to find the matching entity ID for a given bucketing value in a list of traffic allocations.
10899
#
109-
# bucket_value - Integer bucket value
100+
# user_id - String ID for user
101+
# parent_id - String entity ID to use for bucketing ID
110102
# traffic_allocations - Array of traffic allocations
111103
#
112104
# Returns entity ID corresponding to the provided bucket value or nil if no match is found.
113105

106+
bucketing_id = sprintf(BUCKETING_ID_TEMPLATE, user_id: user_id, entity_id: parent_id)
107+
bucket_value = generate_bucket_value(bucketing_id)
108+
@config.logger.log(Logger::DEBUG, "Assigned bucket #{bucket_value} to user '#{user_id}'.")
109+
114110
traffic_allocations.each do |traffic_allocation|
115111
current_end_of_range = traffic_allocation['endOfRange']
116112
if bucket_value < current_end_of_range
@@ -122,6 +118,8 @@ def find_bucket(bucket_value, traffic_allocations)
122118
nil
123119
end
124120

121+
private
122+
125123
def generate_bucket_value(bucketing_id)
126124
# Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE).
127125
#

lib/optimizely/decision_service.rb

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,91 @@ def get_variation(experiment_key, user_id, attributes = nil)
8686
variation_id
8787
end
8888

89+
def get_variation_for_feature(feature_flag, user_id, attributes = nil)
90+
# Get the variation the user is bucketed into for the given FeatureFlag.
91+
#
92+
# feature_flag - The feature flag the user wants to access
93+
# user_id - String ID for the user
94+
# attributes - Hash representing user attributes
95+
#
96+
# Returns variation where visitor will be bucketed (nil if the user is not bucketed into any of the experiments on the feature)
97+
98+
# check if the feature is being experiment on and whether the user is bucketed into the experiment
99+
variation = get_variation_for_feature_experiment(feature_flag, user_id, attributes)
100+
return variation
101+
102+
# @TODO(mng) next check if the user feature being rolled out and whether the user is part of the rollout
103+
end
104+
89105
private
90-
106+
107+
def get_variation_for_feature_experiment(feature_flag, user_id, attributes = nil)
108+
# Gets the variation the user is bucketed into for the feature flag's experiment
109+
#
110+
# feature_flag - The feature flag the user wants to access
111+
# user_id - String ID for the user
112+
# attributes - Hash representing user attributes
113+
#
114+
# Returns variation where visitor will be bucketed
115+
# or nil if the user is not bucketed into any of the experiments on the feature
116+
117+
feature_flag_key = feature_flag['key']
118+
unless feature_flag['experimentIds'].empty?
119+
# check if experiment is part of mutex group
120+
experiment_id = feature_flag['experimentIds'][0]
121+
experiment = @config.experiment_id_map[experiment_id]
122+
unless experiment
123+
@config.logger.log(
124+
Logger::DEBUG,
125+
"Feature flag experiment with ID '#{experiment_id}' is not in the datafile."
126+
)
127+
return nil
128+
end
129+
130+
group_id = experiment['groupId']
131+
# if experiment is part of mutex group we first determine which experiment (if any) in the group the user is part of
132+
if group_id and @config.group_key_map.has_key?(group_id)
133+
group = @config.group_key_map[group_id]
134+
bucketed_experiment_id = @bucketer.find_bucket(user_id, group_id, group['trafficAllocation'])
135+
if bucketed_experiment_id.nil?
136+
@config.logger.log(
137+
Logger::INFO,
138+
"The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
139+
)
140+
return nil
141+
end
142+
else
143+
bucketed_experiment_id = experiment_id
144+
end
145+
146+
if feature_flag['experimentIds'].include?(bucketed_experiment_id)
147+
experiment = @config.experiment_id_map[bucketed_experiment_id]
148+
experiment_key = experiment['key']
149+
variation_id = get_variation(experiment_key, user_id, attributes)
150+
unless variation_id.nil?
151+
variation = @config.variation_id_map[experiment_key][variation_id]
152+
@config.logger.log(
153+
Logger::INFO,
154+
"The user '#{user_id}' is bucketed into experiment '#{experiment_key}' of feature '#{feature_flag_key}'."
155+
)
156+
return variation
157+
else
158+
@config.logger.log(
159+
Logger::INFO,
160+
"The user '#{user_id}' is not bucketed into any of the experiments on the feature '#{feature_flag_key}'."
161+
)
162+
end
163+
end
164+
else
165+
@config.logger.log(
166+
Logger::DEBUG,
167+
"The feature flag '#{feature_flag_key}' is not used in any experiments."
168+
)
169+
end
170+
171+
return nil
172+
end
173+
91174
def get_forced_variation_id(experiment_key, user_id)
92175
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
93176
#
@@ -147,7 +230,7 @@ def get_user_profile(user_id)
147230
#
148231
# user_id - String ID for the user
149232
#
150-
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
233+
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
151234

152235
user_profile = {
153236
:user_id => user_id,

spec/bucketing_spec.rb

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,10 @@ def get_bucketing_id(user_id, entity_id=nil)
5757

5858
expect(bucketer.bucket('group1_exp1', 'test_user')).to eq('130001')
5959
expect(spy_logger).to have_received(:log).exactly(4).times
60-
expect(spy_logger).to have_received(:log)
61-
.with(Logger::DEBUG, "Assigned experiment bucket 3000 to user 'test_user'.")
60+
expect(spy_logger).to have_received(:log).twice
61+
.with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.")
6262
expect(spy_logger).to have_received(:log)
6363
.with(Logger::INFO, "User 'test_user' is in experiment 'group1_exp1' of group 101.")
64-
expect(spy_logger).to have_received(:log)
65-
.with(Logger::DEBUG, "Assigned variation bucket 3000 to user 'test_user'.")
6664
expect(spy_logger).to have_received(:log)
6765
.with(Logger::INFO, "User 'test_user' is in variation 'g1_e1_v1' of experiment 'group1_exp1'.")
6866
end
@@ -72,13 +70,12 @@ def get_bucketing_id(user_id, entity_id=nil)
7270

7371
expect(bucketer.bucket('group1_exp2', 'test_user')).to be_nil
7472
expect(spy_logger).to have_received(:log)
75-
.with(Logger::DEBUG, "Assigned experiment bucket 3000 to user 'test_user'.")
73+
.with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.")
7674
expect(spy_logger).to have_received(:log)
7775
.with(Logger::INFO, "User 'test_user' is not in experiment 'group1_exp2' of group 101.")
7876
end
7977

8078
it 'should return nil when user is not bucketed into any bucket' do
81-
expect(bucketer).to receive(:generate_bucket_value).once.and_return(3000)
8279
expect(bucketer).to receive(:find_bucket).once.and_return(nil)
8380

8481
expect(bucketer.bucket('group1_exp2', 'test_user')).to be_nil
@@ -92,7 +89,7 @@ def get_bucketing_id(user_id, entity_id=nil)
9289
expect(bucketer.bucket('group2_exp1', 'test_user')).to eq('144443')
9390
expect(spy_logger).to have_received(:log).twice
9491
expect(spy_logger).to have_received(:log)
95-
.with(Logger::DEBUG, "Assigned variation bucket 3000 to user 'test_user'.")
92+
.with(Logger::DEBUG, "Assigned bucket 3000 to user 'test_user'.")
9693
expect(spy_logger).to have_received(:log)
9794
.with(Logger::INFO, "User 'test_user' is in variation 'g2_e1_v1' of experiment 'group2_exp1'.")
9895
end
@@ -103,7 +100,7 @@ def get_bucketing_id(user_id, entity_id=nil)
103100
expect(bucketer.bucket('group2_exp1', 'test_user')).to be_nil
104101
expect(spy_logger).to have_received(:log).twice
105102
expect(spy_logger).to have_received(:log)
106-
.with(Logger::DEBUG, "Assigned variation bucket 50000 to user 'test_user'.")
103+
.with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user'.")
107104
expect(spy_logger).to have_received(:log)
108105
.with(Logger::INFO, "User 'test_user' is in no variation.")
109106
end
@@ -135,7 +132,7 @@ def get_bucketing_id(user_id, entity_id=nil)
135132

136133
bucketer.bucket('test_experiment', 'test_user')
137134
expect(spy_logger).to have_received(:log).twice
138-
expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "Assigned variation bucket 50 to user 'test_user'.")
135+
expect(spy_logger).to have_received(:log).with(Logger::DEBUG, "Assigned bucket 50 to user 'test_user'.")
139136
expect(spy_logger).to have_received(:log).with(
140137
Logger::INFO,
141138
"User 'test_user' is in variation 'control' of experiment 'test_experiment'."
@@ -148,7 +145,7 @@ def get_bucketing_id(user_id, entity_id=nil)
148145
bucketer.bucket('test_experiment', 'test_user')
149146
expect(spy_logger).to have_received(:log).twice
150147
expect(spy_logger).to have_received(:log)
151-
.with(Logger::DEBUG, "Assigned variation bucket 5050 to user 'test_user'.")
148+
.with(Logger::DEBUG, "Assigned bucket 5050 to user 'test_user'.")
152149
expect(spy_logger).to have_received(:log).with(
153150
Logger::INFO,
154151
"User 'test_user' is in variation 'variation' of experiment 'test_experiment'."
@@ -161,7 +158,7 @@ def get_bucketing_id(user_id, entity_id=nil)
161158
bucketer.bucket('test_experiment', 'test_user')
162159
expect(spy_logger).to have_received(:log).twice
163160
expect(spy_logger).to have_received(:log)
164-
.with(Logger::DEBUG, "Assigned variation bucket 50000 to user 'test_user'.")
161+
.with(Logger::DEBUG, "Assigned bucket 50000 to user 'test_user'.")
165162
expect(spy_logger).to have_received(:log)
166163
.with(Logger::INFO, "User 'test_user' is in no variation.")
167164
end

spec/decision_service_spec.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,113 @@
253253
end
254254
end
255255
end
256+
257+
describe '#get_variation_for_feature' do
258+
user_attributes = {}
259+
user_id = 'user_1'
260+
261+
describe 'when the feature flag\'s experiment ids array is empty' do
262+
it 'should return nil and log a message' do
263+
user_attributes = {}
264+
feature_flag = config.feature_flag_key_map['double_single_variable_feature']
265+
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(nil)
266+
267+
expect(spy_logger).to have_received(:log).once
268+
.with(Logger::DEBUG, "The feature flag 'double_single_variable_feature' is not used in any experiments.")
269+
end
270+
end
271+
272+
describe 'when the feature flag is associated with a non-mutex experiment' do
273+
describe 'and the experiment is not in the datafile' do
274+
it 'should return nil and log a message' do
275+
feature_flag = config.feature_flag_key_map['boolean_feature'].dup
276+
feature_flag['experimentIds'] = ['1333333337'] # totally invalid exp id
277+
expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(nil)
278+
279+
expect(spy_logger).to have_received(:log).once
280+
.with(Logger::DEBUG, "Feature flag experiment with ID '1333333337' is not in the datafile.")
281+
end
282+
end
283+
284+
describe 'and the user is not bucketed into the feature flag\'s experiments' do
285+
before(:each) do
286+
multivariate_experiment = config.experiment_key_map['test_experiment_multivariate']
287+
288+
# make sure the user is not bucketed into the feature experiment
289+
allow(decision_service).to receive(:get_variation)
290+
.with(multivariate_experiment['key'], 'user_1', user_attributes)
291+
.and_return(nil)
292+
end
293+
it 'should return nil and log a message' do
294+
feature_flag = config.feature_flag_key_map['multi_variate_feature']
295+
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(nil)
296+
297+
expect(spy_logger).to have_received(:log).once
298+
.with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.")
299+
end
300+
end
301+
302+
describe 'and the user is bucketed into a variation for the experiment on the feature flag' do
303+
before(:each) do
304+
# mock and return the first variation of the `test_experiment_multivariate` experiment, which is attached to the `multi_variate_feature`
305+
allow(decision_service).to receive(:get_variation).and_return('122231')
306+
end
307+
308+
it 'should return the variation' do
309+
user_attributes = {}
310+
feature_flag = config.feature_flag_key_map['multi_variate_feature']
311+
expected_variation = config.variation_id_map['test_experiment_multivariate']['122231']
312+
expect(decision_service.get_variation_for_feature(feature_flag, 'user_1', user_attributes)).to eq(expected_variation)
313+
314+
expect(spy_logger).to have_received(:log).once
315+
.with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'test_experiment_multivariate' of feature 'multi_variate_feature'.")
316+
end
317+
end
318+
end
319+
320+
describe 'when the feature flag is associated with a mutex experiment' do
321+
mutex_exp = nil
322+
expected_variation = nil
323+
describe 'and the user is bucketed into one of the experiments' do
324+
before(:each) do
325+
group_1 = config.group_key_map['101']
326+
mutex_exp = config.experiment_key_map['group1_exp1']
327+
expected_variation = mutex_exp['variations'][0]
328+
allow(decision_service.bucketer).to receive(:find_bucket)
329+
.with(user_id, group_1['id'], group_1['trafficAllocation'])
330+
.and_return(mutex_exp['id'])
331+
332+
allow(decision_service).to receive(:get_variation)
333+
.and_return(expected_variation['id'])
334+
end
335+
336+
it 'should return the variation the user is bucketed into' do
337+
feature_flag = config.feature_flag_key_map['boolean_feature']
338+
expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(expected_variation)
339+
340+
expect(spy_logger).to have_received(:log).once
341+
.with(Logger::INFO, "The user 'user_1' is bucketed into experiment 'group1_exp1' of feature 'boolean_feature'.")
342+
end
343+
end
344+
345+
describe 'and the user is not bucketed into any of the mutex experiments' do
346+
before(:each) do
347+
group_1 = config.group_key_map['101']
348+
mutex_exp = config.experiment_key_map['group1_exp1']
349+
expected_variation = mutex_exp['variations'][0]
350+
allow(decision_service.bucketer).to receive(:find_bucket)
351+
.with(user_id, group_1['id'], group_1['trafficAllocation'])
352+
.and_return(nil)
353+
end
354+
355+
it 'should return nil and log a message' do
356+
feature_flag = config.feature_flag_key_map['boolean_feature']
357+
expect(decision_service.get_variation_for_feature(feature_flag, user_id, user_attributes)).to eq(nil)
358+
359+
expect(spy_logger).to have_received(:log).once
360+
.with(Logger::INFO, "The user 'user_1' is not bucketed into any of the experiments on the feature 'boolean_feature'.")
361+
end
362+
end
363+
end
364+
end
256365
end

0 commit comments

Comments
 (0)