Skip to content
This repository was archived by the owner on Jan 11, 2024. It is now read-only.

Commit fc965e7

Browse files
authored
Feature management decision (#103)
1 parent d2d99bb commit fc965e7

File tree

6 files changed

+1620
-193
lines changed

6 files changed

+1620
-193
lines changed

lib/core/bucketer/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ module.exports = {
5656
throw new Error(sprintf(ERROR_MESSAGES.INVALID_GROUP_ID, MODULE_NAME, groupId));
5757
}
5858
if (group.policy === RANDOM_POLICY) {
59-
var bucketedExperimentId = module.exports._bucketUserIntoExperiment(group,
59+
var bucketedExperimentId = module.exports.bucketUserIntoExperiment(group,
6060
bucketerParams.bucketingId,
6161
bucketerParams.userId,
6262
bucketerParams.logger);
@@ -111,7 +111,7 @@ module.exports = {
111111
* @param {Object} logger Logger implementation
112112
* @return {string} ID of experiment if user is bucketed into experiment within the group, null otherwise
113113
*/
114-
_bucketUserIntoExperiment: function(group, bucketingId, userId, logger) {
114+
bucketUserIntoExperiment: function(group, bucketingId, userId, logger) {
115115
var bucketingKey = sprintf('%s%s', bucketingId, group.id);
116116
var bucketValue = module.exports._generateBucketValue(bucketingKey);
117117
logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, MODULE_NAME, bucketValue, userId));

lib/core/decision_service/index.js

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var ERROR_MESSAGES = enums.ERROR_MESSAGES;
2727
var LOG_LEVEL = enums.LOG_LEVEL;
2828
var LOG_MESSAGES = enums.LOG_MESSAGES;
2929
var RESERVED_ATTRIBUTE_KEY_BUCKETING_ID = '$opt_bucketing_id';
30+
var DECISION_SOURCES = enums.DECISION_SOURCES;
3031

3132

3233
/**
@@ -260,11 +261,184 @@ DecisionService.prototype.__saveUserProfile = function(userProfile, experiment,
260261
}
261262
};
262263

264+
/**
265+
* Given a feature, user ID, and attributes, returns an object representing a
266+
* decision. If the user was bucketed into a variation for the given feature
267+
* and attributes, the returned decision object will have variation and
268+
* experiment properties (both objects), as well as a decisionSource property.
269+
* decisionSource indicates whether the decision was due to a rollout or an
270+
* experiment.
271+
* @param {Object} feature A feature flag object from project configuration
272+
* @param {String} userId A string identifying the user, for bucketing
273+
* @param {Object} attributes Optional user attributes
274+
* @return {Object} An object with experiment, variation, and decisionSource
275+
* properties. If the user was not bucketed into a variation, the variation
276+
* property is null.
277+
*/
278+
DecisionService.prototype.getVariationForFeature = function(feature, userId, attributes) {
279+
var experimentDecision = this._getVariationForFeatureExperiment(feature, userId, attributes);
280+
if (experimentDecision.variation !== null) {
281+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_IN_FEATURE_EXPERIMENT, MODULE_NAME, userId, experimentDecision.variation.key, experimentDecision.experiment.key, feature.key));
282+
return experimentDecision;
283+
}
284+
285+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_NOT_IN_FEATURE_EXPERIMENT, MODULE_NAME, userId, feature.key));
286+
287+
var rolloutDecision = this._getVariationForRollout(feature, userId, attributes);
288+
if (rolloutDecision.variation !== null) {
289+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key));
290+
return rolloutDecision;
291+
}
292+
293+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key));
294+
295+
return {
296+
experiment: null,
297+
variation: null,
298+
decisionSource: null,
299+
};
300+
};
301+
302+
DecisionService.prototype._getVariationForFeatureExperiment = function(feature, userId, attributes) {
303+
var experiment = null;
304+
var variationKey = null;
305+
306+
if (feature.hasOwnProperty('groupId')) {
307+
var group = this.configObj.groupIdMap[feature.groupId];
308+
if (group) {
309+
experiment = this._getExperimentInGroup(group, userId);
310+
if (experiment) {
311+
variationKey = this.getVariation(experiment.key, userId, attributes);
312+
}
313+
}
314+
} else if (feature.experimentIds.length > 0) {
315+
// If the feature does not have a group ID, then it can only be associated
316+
// with one experiment, so we look at the first experiment ID only
317+
experiment = projectConfig.getExperimentFromId(this.configObj, feature.experimentIds[0], this.logger);
318+
if (experiment) {
319+
variationKey = this.getVariation(experiment.key, userId, attributes);
320+
}
321+
} else {
322+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.FEATURE_HAS_NO_EXPERIMENTS, MODULE_NAME, feature.key));
323+
}
324+
325+
var variation = null;
326+
if (variationKey !== null && experiment !== null) {
327+
variation = experiment.variationKeyMap[variationKey];
328+
}
329+
return {
330+
experiment: experiment,
331+
variation: variation,
332+
decisionSource: DECISION_SOURCES.EXPERIMENT,
333+
};
334+
};
335+
336+
DecisionService.prototype._getExperimentInGroup = function(group, userId) {
337+
var experimentId = bucketer.bucketUserIntoExperiment(group, userId, userId, this.logger);
338+
if (experimentId !== null) {
339+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP, MODULE_NAME, userId, experimentId, group.id));
340+
var experiment = projectConfig.getExperimentFromId(this.configObj, experimentId, this.logger);
341+
if (experiment) {
342+
return experiment;
343+
}
344+
}
345+
346+
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.USER_NOT_BUCKETED_INTO_ANY_EXPERIMENT_IN_GROUP, MODULE_NAME, userId, group.id));
347+
return null;
348+
};
349+
350+
DecisionService.prototype._getVariationForRollout = function(feature, userId, attributes) {
351+
if (!feature.rolloutId) {
352+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.NO_ROLLOUT_EXISTS, MODULE_NAME, feature.key));
353+
return {
354+
experiment: null,
355+
variation: null,
356+
decisionSource: DECISION_SOURCES.ROLLOUT,
357+
};
358+
}
359+
360+
var rollout = this.configObj.rolloutIdMap[feature.rolloutId];
361+
if (!rollout) {
362+
this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.INVALID_ROLLOUT_ID, MODULE_NAME, feature.rolloutId, feature.key));
363+
return {
364+
experiment: null,
365+
variation: null,
366+
decisionSource: DECISION_SOURCES.ROLLOUT,
367+
};
368+
}
369+
370+
if (rollout.experiments.length === 0) {
371+
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.ROLLOUT_HAS_NO_EXPERIMENTS, MODULE_NAME, feature.rolloutId));
372+
return {
373+
experiment: null,
374+
variation: null,
375+
decisionSource: DECISION_SOURCES.ROLLOUT,
376+
};
377+
}
378+
379+
// The end index is length - 1 because the last experiment is assumed to be
380+
// "everyone else", which will be evaluated separately outside this loop
381+
var endIndex = rollout.experiments.length - 1;
382+
var index;
383+
var experiment;
384+
var bucketerParams;
385+
var variationId;
386+
var variation;
387+
for (index = 0; index < endIndex; index++) {
388+
experiment = this.configObj.experimentKeyMap[rollout.experiments[index].key];
389+
390+
if (!this.__checkIfUserIsInAudience(experiment.key, userId, attributes)) {
391+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_DOESNT_MEET_CONDITIONS_FOR_TARGETING_RULE, MODULE_NAME, userId, index + 1));
392+
continue;
393+
}
394+
395+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_MEETS_CONDITIONS_FOR_TARGETING_RULE, MODULE_NAME, userId, index + 1));
396+
bucketerParams = this.__buildBucketerParams(experiment.key, userId, userId);
397+
variationId = bucketer.bucket(bucketerParams);
398+
variation = this.configObj.variationIdMap[variationId];
399+
if (variation) {
400+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_BUCKETED_INTO_TARGETING_RULE, MODULE_NAME, userId, index + 1));
401+
return {
402+
experiment: experiment,
403+
variation: variation,
404+
decisionSource: DECISION_SOURCES.ROLLOUT,
405+
};
406+
} else {
407+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_NOT_BUCKETED_INTO_TARGETING_RULE, MODULE_NAME, userId, index + 1));
408+
break;
409+
}
410+
}
411+
412+
var everyoneElseExperiment = this.configObj.experimentKeyMap[rollout.experiments[endIndex].key];
413+
if (this.__checkIfUserIsInAudience(everyoneElseExperiment.key, userId, attributes)) {
414+
bucketerParams = this.__buildBucketerParams(everyoneElseExperiment.key, userId, userId);
415+
variationId = bucketer.bucket(bucketerParams);
416+
variation = this.configObj.variationIdMap[variationId];
417+
if (variation) {
418+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_BUCKETED_INTO_EVERYONE_TARGETING_RULE, MODULE_NAME, userId));
419+
return {
420+
experiment: everyoneElseExperiment,
421+
variation: variation,
422+
decisionSource: DECISION_SOURCES.ROLLOUT,
423+
};
424+
} else {
425+
this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_NOT_BUCKETED_INTO_EVERYONE_TARGETING_RULE, MODULE_NAME, userId));
426+
}
427+
}
428+
429+
return {
430+
experiment: null,
431+
variation: null,
432+
decisionSource: DECISION_SOURCES.ROLLOUT,
433+
};
434+
};
435+
263436
module.exports = {
264437
/**
265438
* Creates an instance of the DecisionService.
266439
* @param {Object} options Configuration options
267-
* @param {Object} options.projectConfig
440+
* @param {Object} options.configObj
441+
* @param {Object} options.userProfileService
268442
* @param {Object} options.logger
269443
* @return {Object} An instance of the DecisionService
270444
*/

0 commit comments

Comments
 (0)