Skip to content

Commit 03cd0b3

Browse files
jordangarciajordangarcia
authored and
jordangarcia
committed
feat: Use attributes for sticky bucketing (#179)
1 parent 3335e13 commit 03cd0b3

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

+38-22
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
@@ -183,23 +197,20 @@ DecisionService.prototype.__buildBucketerParams = function(experimentKey, bucket
183197
};
184198

185199
/**
186-
* Get the stored variation from the user profile for the given experiment
200+
* Pull the stored variation out of the experimentBucketMap for an experiment/userId
187201
* @param {Object} experiment
188-
* @param {Object} userProfile
202+
* @param {String} userId
203+
* @param {Object} experimentBucketMap mapping experiment => { variation_id: <variationId> }
189204
* @return {Object} the stored variation or null if the user profile does not have one for the given experiment
190205
*/
191-
DecisionService.prototype.__getStoredVariation = function(experiment, userProfile) {
192-
if (!userProfile || !userProfile.experiment_bucket_map) {
193-
return null;
194-
}
195-
196-
if (userProfile.experiment_bucket_map.hasOwnProperty(experiment.id)) {
197-
var decision = userProfile.experiment_bucket_map[experiment.id];
206+
DecisionService.prototype.__getStoredVariation = function(experiment, userId, experimentBucketMap) {
207+
if (experimentBucketMap.hasOwnProperty(experiment.id)) {
208+
var decision = experimentBucketMap[experiment.id];
198209
var variationId = decision.variation_id;
199210
if (this.configObj.variationIdMap.hasOwnProperty(variationId)) {
200211
return this.configObj.variationIdMap[decision.variation_id];
201212
} else {
202-
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION_NOT_FOUND, MODULE_NAME, userProfile.user_id, variationId, experiment.key));
213+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION_NOT_FOUND, MODULE_NAME, userId, variationId, experiment.key));
203214
}
204215
}
205216

@@ -209,7 +220,7 @@ DecisionService.prototype.__getStoredVariation = function(experiment, userProfil
209220
/**
210221
* Get the user profile with the given user ID
211222
* @param {string} userId
212-
* @return {Object} the stored user profile or an empty one if not found
223+
* @return {Object|undefined} the stored user profile or undefined if one isn't found
213224
*/
214225
DecisionService.prototype.__getUserProfile = function(userId) {
215226
var userProfile = {
@@ -222,33 +233,38 @@ DecisionService.prototype.__getUserProfile = function(userId) {
222233
}
223234

224235
try {
225-
userProfile = this.userProfileService.lookup(userId) || userProfile; // only assign if the lookup is successful
236+
return this.userProfileService.lookup(userId);
226237
} catch (ex) {
227238
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_LOOKUP_ERROR, MODULE_NAME, userId, ex.message));
228239
}
229-
return userProfile;
230240
};
231241

232242
/**
233243
* Saves the bucketing decision to the user profile
234244
* @param {Object} userProfile
235245
* @param {Object} experiment
236246
* @param {Object} variation
247+
* @param {Object} experimentBucketMap
237248
*/
238-
DecisionService.prototype.__saveUserProfile = function(userProfile, experiment, variation) {
249+
DecisionService.prototype.__saveUserProfile = function(experiment, variation, userId, experimentBucketMap) {
239250
if (!this.userProfileService) {
240251
return;
241252
}
242253

243254
try {
244-
userProfile.experiment_bucket_map[experiment.id] = {
245-
variation_id: variation.id,
255+
var newBucketMap = fns.cloneDeep(experimentBucketMap);
256+
newBucketMap[experiment.id] = {
257+
variation_id: variation.id
246258
};
247259

248-
this.userProfileService.save(userProfile);
249-
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION, MODULE_NAME, variation.key, experiment.key, userProfile.user_id));
260+
this.userProfileService.save({
261+
user_id: userId,
262+
experiment_bucket_map: newBucketMap,
263+
});
264+
265+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SAVED_VARIATION, MODULE_NAME, variation.key, experiment.key, userId));
250266
} catch (ex) {
251-
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_SAVE_ERROR, MODULE_NAME, userProfile.user_id, ex.message));
267+
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.USER_PROFILE_SAVE_ERROR, MODULE_NAME, userId, ex.message));
252268
}
253269
};
254270

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

+114-1
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

@@ -458,6 +570,7 @@ describe('lib/core/decision_service', function() {
458570
'test_user',
459571
userAttributesWithBucketingId
460572
));
573+
sinon.assert.calledWithExactly(userProfileLookupStub, 'test_user');
461574
});
462575
});
463576

@@ -474,7 +587,7 @@ describe('lib/core/decision_service', function() {
474587
'browser_type': 'safari',
475588
'$opt_bucketing_id': 50
476589
};
477-
590+
478591
beforeEach(function() {
479592
sinon.stub(mockLogger, 'log');
480593
configObj = projectConfig.createProjectConfig(testData);

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

+16
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,22 @@ describe('lib/optimizely', function() {
635635
JSON.stringify(expectedObj.params)));
636636
});
637637

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

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

+1
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)