Skip to content

Commit fde1cbb

Browse files
authored
Add support for user profiles (#48)
* Stub out user profile service * Implement user profile service interface and add tests * Docstrings * Stub user profile service * Check that variation ID in user profile exists * Add test case for error in user profile lookup, tighten assertions * Logging assertions * Symbolize UserProfile hash keys * Add log message for variation IDs not in datafile
1 parent 5b38416 commit fde1cbb

File tree

5 files changed

+297
-18
lines changed

5 files changed

+297
-18
lines changed

lib/optimizely.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,22 @@ class Project
3737
attr_reader :event_dispatcher
3838
attr_reader :logger
3939

40-
def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = nil, skip_json_validation = false)
40+
def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = nil, skip_json_validation = false, user_profile_service = nil)
4141
# Constructor for Projects.
4242
#
4343
# datafile - JSON string representing the project.
4444
# event_dispatcher - Provides a dispatch_event method which if given a URL and params sends a request to it.
45-
# logger - Optional param which provides a log method to log messages. By default nothing would be logged.
46-
# error_handler - Optional param which provides a handle_error method to handle exceptions.
45+
# logger - Optional component which provides a log method to log messages. By default nothing would be logged.
46+
# error_handler - Optional component which provides a handle_error method to handle exceptions.
4747
# By default all exceptions will be suppressed.
48+
# user_profile_service - Optional component which provides methods to store and retreive user profiles.
4849
# skip_json_validation - Optional boolean param to skip JSON schema validation of the provided datafile.
4950

5051
@is_valid = true
5152
@logger = logger || NoOpLogger.new
5253
@error_handler = error_handler || NoOpErrorHandler.new
5354
@event_dispatcher = event_dispatcher || EventDispatcher.new
55+
@user_profile_service = user_profile_service
5456

5557
begin
5658
validate_instantiation_options(datafile, skip_json_validation)
@@ -77,7 +79,7 @@ def initialize(datafile, event_dispatcher = nil, logger = nil, error_handler = n
7779
return
7880
end
7981

80-
@decision_service = DecisionService.new(@config)
82+
@decision_service = DecisionService.new(@config, @user_profile_service)
8183
@event_builder = EventBuilderV2.new(@config)
8284
end
8385

lib/optimizely/decision_service.rb

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ class DecisionService
2424
#
2525
# 1. Checking experiment status
2626
# 2. Checking whitelisting
27+
# 3. Checking user profile service for past bucketing decisions (sticky bucketing)
2728
# 3. Checking audience targeting
2829
# 4. Using Murmurhash3 to bucket the user
2930

3031
attr_reader :bucketer
3132
attr_reader :config
3233

33-
def initialize(config)
34+
def initialize(config, user_profile_service = nil)
3435
@config = config
36+
@user_profile_service = user_profile_service
3537
@bucketer = Bucketer.new(@config)
3638
end
3739

@@ -50,21 +52,37 @@ def get_variation(experiment_key, user_id, attributes = nil)
5052
return nil
5153
end
5254

55+
experiment_id = @config.get_experiment_id(experiment_key)
56+
5357
# Check if user is in a forced variation
54-
variation_id = get_forced_variation_id(experiment_key, user_id)
55-
56-
if variation_id.nil?
57-
unless Audience.user_in_experiment?(@config, experiment_key, attributes)
58-
@config.logger.log(
59-
Logger::INFO,
60-
"User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
61-
)
62-
return nil
63-
end
64-
65-
variation_id = @bucketer.bucket(experiment_key, user_id)
58+
forced_variation_id = get_forced_variation_id(experiment_key, user_id)
59+
return forced_variation_id if forced_variation_id
60+
61+
# Check for saved bucketing decisions
62+
user_profile = get_user_profile(user_id)
63+
saved_variation_id = get_saved_variation_id(experiment_id, user_profile)
64+
if saved_variation_id
65+
@config.logger.log(
66+
Logger::INFO,
67+
"Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile."
68+
)
69+
return saved_variation_id
70+
end
71+
72+
# Check audience conditions
73+
unless Audience.user_in_experiment?(@config, experiment_key, attributes)
74+
@config.logger.log(
75+
Logger::INFO,
76+
"User '#{user_id}' does not meet the conditions to be in experiment '#{experiment_key}'."
77+
)
78+
return nil
6679
end
6780

81+
# Bucket normally
82+
variation_id = @bucketer.bucket(experiment_key, user_id)
83+
84+
# Persist bucketing decision
85+
save_user_profile(user_profile, experiment_id, variation_id)
6886
variation_id
6987
end
7088

@@ -102,5 +120,71 @@ def get_forced_variation_id(experiment_key, user_id)
102120
)
103121
forced_variation_id
104122
end
123+
124+
def get_saved_variation_id(experiment_id, user_profile)
125+
# Retrieve variation ID of stored bucketing decision for a given experiment from a given user profile
126+
#
127+
# experiment_id - String experiment ID
128+
# user_profile - Hash user profile
129+
#
130+
# Returns string variation ID (nil if no decision is found)
131+
return nil unless user_profile[:experiment_bucket_map]
132+
133+
decision = user_profile[:experiment_bucket_map][experiment_id]
134+
return nil unless decision
135+
variation_id = decision[:variation_id]
136+
return variation_id if @config.variation_id_exists?(experiment_id, variation_id)
137+
138+
@config.logger.log(
139+
Logger::INFO,
140+
"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."
141+
)
142+
nil
143+
end
144+
145+
def get_user_profile(user_id)
146+
# Determine if a user is forced into a variation for the given experiment and return the ID of that variation
147+
#
148+
# user_id - String ID for the user
149+
#
150+
# Returns Hash stored user profile (or a default one if lookup fails or user profile service not provided)
151+
152+
user_profile = {
153+
:user_id => user_id,
154+
:experiment_bucket_map => {}
155+
}
156+
157+
return user_profile unless @user_profile_service
158+
159+
begin
160+
user_profile = @user_profile_service.lookup(user_id) || user_profile
161+
rescue => e
162+
@config.logger.log(Logger::ERROR, "Error while looking up user profile for user ID '#{user_id}': #{e}.")
163+
end
164+
165+
user_profile
166+
end
167+
168+
169+
def save_user_profile(user_profile, experiment_id, variation_id)
170+
# Save a given bucketing decision to a given user profile
171+
#
172+
# user_profile - Hash user profile
173+
# experiment_id - String experiment ID
174+
# variation_id - String variation ID
175+
176+
return unless @user_profile_service
177+
178+
user_id = user_profile[:user_id]
179+
begin
180+
user_profile[:experiment_bucket_map][experiment_id] = {
181+
:variation_id => variation_id
182+
}
183+
@user_profile_service.save(user_profile)
184+
@config.logger.log(Logger::INFO, "Saved variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.")
185+
rescue => e
186+
@config.logger.log(Logger::ERROR, "Error while saving user profile for user ID '#{user_id}': #{e}.")
187+
end
188+
end
105189
end
106190
end

lib/optimizely/project_config.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,29 @@ def parsing_succeeded?
294294
@parsing_succeeded
295295
end
296296

297+
def variation_id_exists?(experiment_id, variation_id)
298+
# Determines if a given experiment ID / variation ID pair exists in the datafile
299+
#
300+
# experiment_id - String experiment ID
301+
# variation_id - String variation ID
302+
#
303+
# Returns true if variation is in datafile
304+
305+
experiment_key = get_experiment_key(experiment_id)
306+
variation_id_map = @variation_id_map[experiment_key]
307+
if variation_id_map
308+
variation = variation_id_map[variation_id]
309+
return true if variation
310+
@logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile."
311+
@error_handler.handle_error InvalidVariationError
312+
return false
313+
end
314+
315+
@logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile."
316+
@error_handler.handle_error InvalidExperimentError
317+
false
318+
end
319+
297320
private
298321

299322
def generate_key_map(array, key)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#
2+
# Copyright 2017, Optimizely and contributors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
module Optimizely
18+
class BaseUserProfileService
19+
# Class encapsulating user profile service functionality.
20+
# Override with your own implementation for storing and retrieving user profiles.
21+
22+
def lookup(user_id)
23+
# Retrieve the Hash user profile associated with a given user ID.
24+
#
25+
# user_id - String user ID
26+
#
27+
# Returns Hash user profile.
28+
end
29+
30+
def save(user_profile)
31+
# Saves a given user profile.
32+
#
33+
# user_profile - Hash user profile.
34+
end
35+
end
36+
end

spec/decision_service_spec.rb

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,19 @@
2222
let(:config_body_JSON) { OptimizelySpec::V2_CONFIG_BODY_JSON }
2323
let(:error_handler) { Optimizely::NoOpErrorHandler.new }
2424
let(:spy_logger) { spy('logger') }
25+
let(:spy_user_profile_service) { spy('user_profile_service') }
2526
let(:config) { Optimizely::ProjectConfig.new(config_body_JSON, spy_logger, error_handler) }
26-
let(:decision_service) { Optimizely::DecisionService.new(config) }
27+
let(:decision_service) { Optimizely::DecisionService.new(config, spy_user_profile_service) }
2728

2829
describe '#get_variation' do
2930
before(:example) do
3031
# stub out bucketer and audience evaluator so we can make sure they are / aren't called
3132
allow(decision_service.bucketer).to receive(:bucket).and_call_original
3233
allow(decision_service).to receive(:get_forced_variation_id).and_call_original
3334
allow(Optimizely::Audience).to receive(:user_in_experiment?).and_call_original
35+
36+
# by default, spy user profile service should no-op. we override this behavior in specific tests
37+
allow(spy_user_profile_service).to receive(:lookup).and_return(nil)
3438
end
3539

3640
it 'should return the correct variation ID for a given user ID and key of a running experiment' do
@@ -118,5 +122,135 @@
118122
# bucketing should have occured
119123
expect(decision_service.bucketer).to have_received(:bucket).once.with('test_experiment', 'forced_user_with_invalid_variation')
120124
end
125+
126+
describe 'when a UserProfile service is provided' do
127+
it 'should look up the UserProfile, bucket normally, and save the result if no saved profile is found' do
128+
expected_user_profile = {
129+
:user_id => 'test_user',
130+
:experiment_bucket_map => {
131+
'111127' => {
132+
:variation_id => '111128'
133+
}
134+
}
135+
}
136+
expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil)
137+
138+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
139+
140+
# bucketing should have occurred
141+
expect(decision_service.bucketer).to have_received(:bucket).once
142+
# bucketing decision should have been saved
143+
expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile)
144+
expect(spy_logger).to have_received(:log).once
145+
.with(Logger::INFO, "Saved variation ID 111128 of experiment ID 111127 for user 'test_user'.")
146+
end
147+
148+
it 'should look up the user profile and skip normal bucketing if a profile with a saved decision is found' do
149+
saved_user_profile = {
150+
:user_id => 'test_user',
151+
:experiment_bucket_map => {
152+
'111127' => {
153+
:variation_id => '111129'
154+
}
155+
}
156+
}
157+
expect(spy_user_profile_service).to receive(:lookup)
158+
.with('test_user').once.and_return(saved_user_profile)
159+
160+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111129')
161+
expect(spy_logger).to have_received(:log).once
162+
.with(Logger::INFO, "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile.")
163+
164+
# saved user profiles should short circuit bucketing
165+
expect(decision_service.bucketer).not_to have_received(:bucket)
166+
# saved user profiles should short circuit audience evaluation
167+
expect(Optimizely::Audience).not_to have_received(:user_in_experiment?)
168+
# the user profile should not be updated if bucketing did not take place
169+
expect(spy_user_profile_service).not_to have_received(:save)
170+
end
171+
172+
it 'should look up the user profile and bucket normally if a profile without a saved decision is found' do
173+
saved_user_profile = {
174+
:user_id => 'test_user',
175+
:experiment_bucket_map => {
176+
# saved decision, but not for this experiment
177+
'122227' => {
178+
:variation_id => '122228'
179+
}
180+
}
181+
}
182+
expect(spy_user_profile_service).to receive(:lookup)
183+
.once.with('test_user').and_return(saved_user_profile)
184+
185+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
186+
187+
# bucketing should have occurred
188+
expect(decision_service.bucketer).to have_received(:bucket).once
189+
190+
# user profile should have been updated with bucketing decision
191+
expected_user_profile = {
192+
:user_id => 'test_user',
193+
:experiment_bucket_map => {
194+
'111127' => {
195+
:variation_id => '111128'
196+
},
197+
'122227' => {
198+
:variation_id => '122228'
199+
}
200+
}
201+
}
202+
expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile)
203+
end
204+
205+
it 'should bucket normally if the user profile contains a variation ID not in the datafile' do
206+
saved_user_profile = {
207+
:user_id => 'test_user',
208+
:experiment_bucket_map => {
209+
# saved decision, but with invalid variation ID
210+
'111127' => {
211+
:variation_id => '111111'
212+
}
213+
}
214+
}
215+
expect(spy_user_profile_service).to receive(:lookup)
216+
.once.with('test_user').and_return(saved_user_profile)
217+
218+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
219+
220+
# bucketing should have occurred
221+
expect(decision_service.bucketer).to have_received(:bucket).once
222+
223+
# user profile should have been updated with bucketing decision
224+
expected_user_profile = {
225+
:user_id => 'test_user',
226+
:experiment_bucket_map => {
227+
'111127' => {
228+
:variation_id => '111128'
229+
}
230+
}
231+
}
232+
expect(spy_user_profile_service).to have_received(:save).with(expected_user_profile)
233+
end
234+
235+
it 'should bucket normally if the user profile service throws an error during lookup' do
236+
expect(spy_user_profile_service).to receive(:lookup).once.with('test_user').and_throw(:LookupError)
237+
238+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
239+
240+
expect(spy_logger).to have_received(:log).once
241+
.with(Logger::ERROR, "Error while looking up user profile for user ID 'test_user': uncaught throw :LookupError.")
242+
# bucketing should have occurred
243+
expect(decision_service.bucketer).to have_received(:bucket).once
244+
end
245+
246+
it 'should log an error if the user profile service throws an error during save' do
247+
expect(spy_user_profile_service).to receive(:save).once.and_throw(:SaveError)
248+
249+
expect(decision_service.get_variation('test_experiment', 'test_user')).to eq('111128')
250+
251+
expect(spy_logger).to have_received(:log).once
252+
.with(Logger::ERROR, "Error while saving user profile for user ID 'test_user': uncaught throw :SaveError.")
253+
end
254+
end
121255
end
122256
end

0 commit comments

Comments
 (0)