Skip to content

Commit 42eb08e

Browse files
jordangarciamikeproeng37
authored andcommitted
feat: Use attributes for sticky bucketing (#179)
1 parent 8af7eb2 commit 42eb08e

File tree

4 files changed

+169
-23
lines changed

4 files changed

+169
-23
lines changed

packages/optimizely-sdk/lib/core/decision_service/index.js

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ var LOG_MESSAGES = enums.LOG_MESSAGES;
2929
var DECISION_SOURCES = enums.DECISION_SOURCES;
3030

3131

32+
3233
/**
3334
* Optimizely's decision service that determines which variation of an experiment the user will be allocated to.
3435
*
@@ -79,8 +80,8 @@ DecisionService.prototype.getVariation = function(experimentKey, userId, attribu
7980
}
8081

8182
// check for sticky bucketing
82-
var userProfile = this.__getUserProfile(userId);
83-
variation = this.__getStoredVariation(experiment, userProfile);
83+
var experimentBucketMap = this.__resolveExperimentBucketMap(userId, attributes);
84+
variation = this.__getStoredVariation(experiment, userId, experimentBucketMap);
8485
if (!!variation) {
8586
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.RETURNING_STORED_VARIATION, MODULE_NAME, variation.key, experimentKey, userId));
8687
return variation.key;
@@ -99,11 +100,24 @@ DecisionService.prototype.getVariation = function(experimentKey, userId, attribu
99100
}
100101

101102
// persist bucketing
102-
this.__saveUserProfile(userProfile, experiment, variation);
103+
this.__saveUserProfile(experiment, variation, userId, experimentBucketMap);
103104

104105
return variation.key;
105106
};
106107

108+
/**
109+
* Merges attributes from attributes[STICKY_BUCKETING_KEY] and userProfileService
110+
* @param {Object} attributes
111+
* @return {Object} finalized copy of experiment_bucket_map
112+
*/
113+
DecisionService.prototype.__resolveExperimentBucketMap = function(userId, attributes) {
114+
attributes = attributes || {}
115+
var userProfile = this.__getUserProfile(userId) || {};
116+
var attributeExperimentBucketMap = attributes[enums.CONTROL_ATTRIBUTES.STICKY_BUCKETING_KEY];
117+
return fns.assignIn({}, userProfile.experiment_bucket_map, attributeExperimentBucketMap);
118+
};
119+
120+
107121
/**
108122
* Checks whether the experiment is running or launched
109123
* @param {string} experimentKey Key of experiment being validated
@@ -184,23 +198,20 @@ DecisionService.prototype.__buildBucketerParams = function(experimentKey, bucket
184198
};
185199

186200
/**
187-
* Get the stored variation from the user profile for the given experiment
201+
* Pull the stored variation out of the experimentBucketMap for an experiment/userId
188202
* @param {Object} experiment
189-
* @param {Object} userProfile
203+
* @param {String} userId
204+
* @param {Object} experimentBucketMap mapping experiment => { variation_id: <variationId> }
190205
* @return {Object} the stored variation or null if the user profile does not have one for the given experiment
191206
*/
192-
DecisionService.prototype.__getStoredVariation = function(experiment, userProfile) {
193-
if (!userProfile || !userProfile.experiment_bucket_map) {
194-
return null;
195-
}
196-
197-
if (userProfile.experiment_bucket_map.hasOwnProperty(experiment.id)) {
198-
var decision = userProfile.experiment_bucket_map[experiment.id];
207+
DecisionService.prototype.__getStoredVariation = function(experiment, userId, experimentBucketMap) {
208+
if (experimentBucketMap.hasOwnProperty(experiment.id)) {
209+
var decision = experimentBucketMap[experiment.id];
199210
var variationId = decision.variation_id;
200211
if (this.configObj.variationIdMap.hasOwnProperty(variationId)) {
201212
return this.configObj.variationIdMap[decision.variation_id];
202213
} else {
203-
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION_NOT_FOUND, MODULE_NAME, userProfile.user_id, variationId, experiment.key));
214+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION_NOT_FOUND, MODULE_NAME, userId, variationId, experiment.key));
204215
}
205216
}
206217

@@ -210,7 +221,7 @@ DecisionService.prototype.__getStoredVariation = function(experiment, userProfil
210221
/**
211222
* Get the user profile with the given user ID
212223
* @param {string} userId
213-
* @return {Object} the stored user profile or an empty one if not found
224+
* @return {Object|undefined} the stored user profile or undefined if one isn't found
214225
*/
215226
DecisionService.prototype.__getUserProfile = function(userId) {
216227
var userProfile = {
@@ -223,33 +234,38 @@ DecisionService.prototype.__getUserProfile = function(userId) {
223234
}
224235

225236
try {
226-
userProfile = this.userProfileService.lookup(userId) || userProfile; // only assign if the lookup is successful
237+
return this.userProfileService.lookup(userId);
227238
} catch (ex) {
228239
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_LOOKUP_ERROR, MODULE_NAME, userId, ex.message));
229240
}
230-
return userProfile;
231241
};
232242

233243
/**
234244
* Saves the bucketing decision to the user profile
235245
* @param {Object} userProfile
236246
* @param {Object} experiment
237247
* @param {Object} variation
248+
* @param {Object} experimentBucketMap
238249
*/
239-
DecisionService.prototype.__saveUserProfile = function(userProfile, experiment, variation) {
250+
DecisionService.prototype.__saveUserProfile = function(experiment, variation, userId, experimentBucketMap) {
240251
if (!this.userProfileService) {
241252
return;
242253
}
243254

244255
try {
245-
userProfile.experiment_bucket_map[experiment.id] = {
246-
variation_id: variation.id,
256+
var newBucketMap = fns.cloneDeep(experimentBucketMap);
257+
newBucketMap[experiment.id] = {
258+
variation_id: variation.id
247259
};
248260

249-
this.userProfileService.save(userProfile);
250-
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION, MODULE_NAME, variation.key, experiment.key, userProfile.user_id));
261+
this.userProfileService.save({
262+
user_id: userId,
263+
experiment_bucket_map: newBucketMap,
264+
});
265+
266+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION, MODULE_NAME, variation.key, experiment.key, userId));
251267
} catch (ex) {
252-
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_SAVE_ERROR, MODULE_NAME, userProfile.user_id, ex.message));
268+
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_SAVE_ERROR, MODULE_NAME, userId, ex.message));
253269
}
254270
};
255271

packages/optimizely-sdk/lib/core/decision_service/index.tests.js

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ describe('lib/core/decision_service', function() {
8787
assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: Experiment testExperimentNotRunning is not running.');
8888
});
8989

90+
describe('when attributes.$opt_experiment_bucket_map is supplied', function() {
91+
it('should respect the sticky bucketing information for attributes', function() {
92+
bucketerStub.returns('111128'); // ID of the 'control' variation from `test_data`
93+
var attributes = {
94+
$opt_experiment_bucket_map: {
95+
'111127': {
96+
'variation_id': '111129' // ID of the 'variation' variation
97+
},
98+
},
99+
};
100+
101+
assert.strictEqual('variation', decisionServiceInstance.getVariation('testExperiment', 'decision_service_user', attributes));
102+
sinon.assert.notCalled(bucketerStub);
103+
});
104+
});
105+
90106
describe('when a user profile service is provided', function () {
91107
var userProfileServiceInstance = null;
92108
var userProfileLookupStub;
@@ -252,6 +268,102 @@ describe('lib/core/decision_service', function() {
252268
},
253269
});
254270
});
271+
272+
describe('when passing `attributes.$opt_experiment_bucket_map`', function() {
273+
it('should respect attributes over the userProfileService for the matching experiment id', function () {
274+
userProfileLookupStub.returns({
275+
user_id: 'decision_service_user',
276+
experiment_bucket_map: {
277+
'111127': {
278+
'variation_id': '111128' // ID of the 'control' variation
279+
},
280+
},
281+
});
282+
283+
var attributes = {
284+
$opt_experiment_bucket_map: {
285+
'111127': {
286+
'variation_id': '111129' // ID of the 'variation' variation
287+
},
288+
},
289+
};
290+
291+
292+
assert.strictEqual('variation', decisionServiceInstance.getVariation('testExperiment', 'decision_service_user', attributes));
293+
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
294+
sinon.assert.notCalled(bucketerStub);
295+
assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.');
296+
assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
297+
});
298+
299+
it('should ignore attributes for a different experiment id', function () {
300+
userProfileLookupStub.returns({
301+
user_id: 'decision_service_user',
302+
experiment_bucket_map: {
303+
'111127': { // 'testExperiment' ID
304+
'variation_id': '111128' // ID of the 'control' variation
305+
},
306+
},
307+
});
308+
309+
var attributes = {
310+
$opt_experiment_bucket_map: {
311+
'122227': { // other experiment ID
312+
'variation_id': '122229' // ID of the 'variationWithAudience' variation
313+
},
314+
},
315+
};
316+
317+
assert.strictEqual('control', decisionServiceInstance.getVariation('testExperiment', 'decision_service_user', attributes));
318+
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
319+
sinon.assert.notCalled(bucketerStub);
320+
assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.');
321+
assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"control\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
322+
});
323+
324+
it('should use attributes when the userProfileLookup variations for other experiments', function () {
325+
userProfileLookupStub.returns({
326+
user_id: 'decision_service_user',
327+
experiment_bucket_map: {
328+
'122227': { // other experiment ID
329+
'variation_id': '122229' // ID of the 'variationWithAudience' variation
330+
},
331+
}
332+
});
333+
334+
var attributes = {
335+
$opt_experiment_bucket_map: {
336+
'111127': { // 'testExperiment' ID
337+
'variation_id': '111129' // ID of the 'variation' variation
338+
},
339+
},
340+
};
341+
342+
assert.strictEqual('variation', decisionServiceInstance.getVariation('testExperiment', 'decision_service_user', attributes));
343+
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
344+
sinon.assert.notCalled(bucketerStub);
345+
assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.');
346+
assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
347+
});
348+
349+
it('should use attributes when the userProfileLookup returns null', function () {
350+
userProfileLookupStub.returns(null);
351+
352+
var attributes = {
353+
$opt_experiment_bucket_map: {
354+
'111127': {
355+
'variation_id': '111129' // ID of the 'variation' variation
356+
},
357+
},
358+
};
359+
360+
assert.strictEqual('variation', decisionServiceInstance.getVariation('testExperiment', 'decision_service_user', attributes));
361+
sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user');
362+
sinon.assert.notCalled(bucketerStub);
363+
assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.');
364+
assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.');
365+
});
366+
});
255367
});
256368
});
257369

@@ -459,6 +571,7 @@ describe('lib/core/decision_service', function() {
459571
'test_user',
460572
userAttributesWithBucketingId
461573
));
574+
sinon.assert.calledWithExactly(userProfileLookupStub, 'test_user');
462575
});
463576
});
464577

@@ -475,7 +588,7 @@ describe('lib/core/decision_service', function() {
475588
'browser_type': 'safari',
476589
'$opt_bucketing_id': 50
477590
};
478-
591+
479592
beforeEach(function() {
480593
sinon.stub(mockLogger, 'log');
481594
configObj = projectConfig.createProjectConfig(testData);

packages/optimizely-sdk/lib/optimizely/index.tests.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,22 @@ describe('lib/optimizely', function() {
639639
JSON.stringify(expectedObj.params)));
640640
});
641641

642+
describe('when experiment_bucket_map attribute is present', function() {
643+
it('should call activate and respect attribute experiment_bucket_map', function() {
644+
bucketStub.returns('111128'); // id of "control" variation
645+
var activate = optlyInstance.activate('testExperiment', 'testUser', {
646+
$opt_experiment_bucket_map: {
647+
'111127': {
648+
variation_id: '111129', // id of "variation" variation
649+
},
650+
},
651+
});
652+
653+
assert.strictEqual(activate, 'variation');
654+
sinon.assert.notCalled(bucketer.bucket);
655+
});
656+
});
657+
642658
it('should call bucketer and dispatchEvent with proper args and return variation key if user is in grouped experiment', function() {
643659
bucketStub.returns('662');
644660
var activate = optlyInstance.activate('groupExperiment2', 'testUser');

packages/optimizely-sdk/lib/utils/enums/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ exports.RESERVED_EVENT_KEYWORDS = {
135135
exports.CONTROL_ATTRIBUTES = {
136136
BOT_FILTERING: '$opt_bot_filtering',
137137
BUCKETING_ID: '$opt_bucketing_id',
138+
STICKY_BUCKETING_KEY: '$opt_experiment_bucket_map',
138139
USER_AGENT: '$opt_user_agent',
139140
};
140141

0 commit comments

Comments
 (0)