@optimizely/optimizely-sdk
Version:
JavaScript SDK for Optimizely X Full Stack
1,352 lines (1,248 loc) • 52.7 kB
text/typescript
/****************************************************************************
* Copyright 2020, Optimizely, Inc. and contributors *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); *
* you may not use this file except in compliance with the License. *
* You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
***************************************************************************/
import { sprintf, objectValues } from '@optimizely/js-sdk-utils';
import { LogHandler, ErrorHandler } from '@optimizely/js-sdk-logging';
import { FeatureFlag, FeatureVariable } from '../core/project_config/entities';
import {
UserAttributes,
EventTags,
OptimizelyConfig,
EventDispatcher,
OnReadyResult,
UserProfileService,
DatafileOptions
} from '../shared_types';
import { Variation } from '../core/project_config/entities';
import { createProjectConfigManager, ProjectConfigManager } from '../core/project_config/project_config_manager';
import { createNotificationCenter, NotificationCenter } from '../core/notification_center';
import { createDecisionService, DecisionService, DecisionObj } from '../core/decision_service';
import { getImpressionEvent, getConversionEvent } from '../core/event_builder';
import { buildImpressionEvent, buildConversionEvent } from '../core/event_builder/event_helpers';
import fns from '../utils/fns'
import { validate } from '../utils/attributes_validator';
import { EventProcessor, default as eventProcessor } from '../core/event_processor';
import * as enums from '../utils/enums';
import * as eventTagsValidator from '../utils/event_tags_validator';
import * as projectConfig from '../core/project_config';
import * as userProfileServiceValidator from '../utils/user_profile_service_validator';
import * as stringValidator from '../utils/string_value_validator';
import * as decision from '../core/decision';
import {
ERROR_MESSAGES,
LOG_LEVEL,
LOG_MESSAGES,
DECISION_SOURCES,
FEATURE_VARIABLE_TYPES,
DECISION_NOTIFICATION_TYPES,
NOTIFICATION_TYPES
} from '../utils/enums';
const MODULE_NAME = 'OPTIMIZELY';
const DEFAULT_ONREADY_TIMEOUT = 30000;
/**
* options required to create optimizely object
*/
export interface OptimizelyOptions {
UNSTABLE_conditionEvaluators?: unknown;
clientEngine: string;
clientVersion?: string;
datafile?: string;
datafileOptions?: DatafileOptions;
errorHandler: ErrorHandler;
eventBatchSize?: number;
eventDispatcher: EventDispatcher;
eventFlushInterval?: number;
eventMaxQueueSize?: number;
isValidInstance: boolean;
// TODO[OASIS-6649]: Don't use object type
// eslint-disable-next-line @typescript-eslint/ban-types
jsonSchemaValidator?: object;
logger: LogHandler;
sdkKey?: string;
userProfileService?: UserProfileService | null;
}
/**
* The Optimizely class
* @param {OptimizelyOptions} config
* @param {string} config.clientEngine
* @param {string} config.clientVersion
* @param {Object|string} config.datafile
* @param {Object} config.errorHandler
* @param {Object} config.eventDispatcher
* @param {Object} config.logger
* @param {Object} config.userProfileService
* @param {Object} config.eventBatchSize
* @param {Object} config.eventFlushInterval
* @param {string} config.sdkKey
*/
export default class Optimizely {
private isOptimizelyConfigValid: boolean;
private disposeOnUpdate: (() => void ) | null;
private readyPromise: Promise<{ success: boolean; reason?: string }>;
private readyTimeouts: { [key: string]: {readyTimeout: number; onClose:() => void} };
private nextReadyTimeoutId: number;
private clientEngine: string;
private clientVersion: string;
private errorHandler: ErrorHandler;
private eventDispatcher: EventDispatcher;
private logger: LogHandler;
private projectConfigManager: ProjectConfigManager;
private notificationCenter: NotificationCenter;
private decisionService: DecisionService;
private eventProcessor: EventProcessor;
constructor(config: OptimizelyOptions) {
let clientEngine = config.clientEngine;
if (enums.VALID_CLIENT_ENGINES.indexOf(clientEngine) === -1) {
config.logger.log(
LOG_LEVEL.INFO,
sprintf(LOG_MESSAGES.INVALID_CLIENT_ENGINE, MODULE_NAME, clientEngine)
);
clientEngine = enums.NODE_CLIENT_ENGINE;
}
this.clientEngine = clientEngine;
this.clientVersion = config.clientVersion || enums.NODE_CLIENT_VERSION;
this.errorHandler = config.errorHandler;
this.eventDispatcher = config.eventDispatcher;
this.isOptimizelyConfigValid = config.isValidInstance;
this.logger = config.logger;
this.projectConfigManager = createProjectConfigManager({
datafile: config.datafile,
datafileOptions: config.datafileOptions,
jsonSchemaValidator: config.jsonSchemaValidator,
sdkKey: config.sdkKey,
});
this.disposeOnUpdate = this.projectConfigManager.onUpdate(
(configObj: projectConfig.ProjectConfig) => {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(LOG_MESSAGES.UPDATED_OPTIMIZELY_CONFIG, MODULE_NAME, configObj.revision, configObj.projectId)
);
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE);
}
);
const projectConfigManagerReadyPromise = this.projectConfigManager.onReady();
let userProfileService = null;
if (config.userProfileService) {
try {
if (userProfileServiceValidator.validate(config.userProfileService)) {
userProfileService = config.userProfileService;
this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.VALID_USER_PROFILE_SERVICE, MODULE_NAME));
}
} catch (ex) {
this.logger.log(LOG_LEVEL.WARNING, ex.message);
}
}
this.decisionService = createDecisionService({
userProfileService: userProfileService,
logger: this.logger,
UNSTABLE_conditionEvaluators: config.UNSTABLE_conditionEvaluators,
});
this.notificationCenter = createNotificationCenter({
logger: this.logger,
errorHandler: this.errorHandler,
});
const eventProcessorConfig = {
dispatcher: this.eventDispatcher,
flushInterval: config.eventFlushInterval,
batchSize: config.eventBatchSize,
maxQueueSize: config.eventMaxQueueSize,
notificationCenter: this.notificationCenter,
}
this.eventProcessor = eventProcessor.createEventProcessor(eventProcessorConfig);
const eventProcessorStartedPromise = this.eventProcessor.start();
this.readyPromise = Promise.all([projectConfigManagerReadyPromise, eventProcessorStartedPromise]).then(function(promiseResults) {
// Only return status from project config promise because event processor promise does not return any status.
return promiseResults[0];
})
this.readyTimeouts = {};
this.nextReadyTimeoutId = 0;
}
/**
* Returns a truthy value if this instance currently has a valid project config
* object, and the initial configuration object that was passed into the
* constructor was also valid.
* @return {boolean}
*/
private isValidInstance(): boolean {
return this.isOptimizelyConfigValid && !!this.projectConfigManager.getConfig();
}
/**
* Buckets visitor and sends impression event to Optimizely.
* @param {string} experimentKey
* @param {string} userId
* @param {UserAttributes} attributes
* @return {string|null} variation key
*/
activate(experimentKey: string, userId: string, attributes?: UserAttributes): string | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'activate'));
return null;
}
if (!this.validateInputs({ experiment_key: experimentKey, user_id: userId }, attributes)) {
return this.notActivatingExperiment(experimentKey, userId);
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
try {
const variationKey = this.getVariation(experimentKey, userId, attributes);
if (variationKey === null) {
return this.notActivatingExperiment(experimentKey, userId);
}
// If experiment is not set to 'Running' status, log accordingly and return variation key
if (!projectConfig.isRunning(configObj, experimentKey)) {
const shouldNotDispatchActivateLogMessage = sprintf(
LOG_MESSAGES.SHOULD_NOT_DISPATCH_ACTIVATE,
MODULE_NAME,
experimentKey
);
this.logger.log(LOG_LEVEL.DEBUG, shouldNotDispatchActivateLogMessage);
return variationKey;
}
const experiment = projectConfig.getExperimentFromKey(configObj, experimentKey);
const variation = experiment.variationKeyMap[variationKey];
const decisionObj = {
experiment: experiment,
variation: variation,
decisionSource: enums.DECISION_SOURCES.EXPERIMENT
}
this.sendImpressionEvent(
decisionObj,
'',
userId,
attributes
);
return variationKey;
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
const failedActivationLogMessage = sprintf(
LOG_MESSAGES.NOT_ACTIVATING_USER,
MODULE_NAME,
userId,
experimentKey
);
this.logger.log(LOG_LEVEL.INFO, failedActivationLogMessage);
this.errorHandler.handleError(ex);
return null;
}
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Create an impression event and call the event dispatcher's dispatch method to
* send this event to Optimizely. Then use the notification center to trigger
* any notification listeners for the ACTIVATE notification type.
* @param {DecisionObj} decisionObj Decision Object
* @param {string} flagKey Key for a feature flag
* @param {string} userId ID of user to whom the variation was shown
* @param {UserAttributes} attributes Optional user attributes
*/
private sendImpressionEvent(
decisionObj: DecisionObj,
flagKey: string,
userId: string,
attributes?: UserAttributes
): void {
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return;
}
const impressionEvent = buildImpressionEvent({
decisionObj: decisionObj,
flagKey: flagKey,
userId: userId,
userAttributes: attributes,
clientEngine: this.clientEngine,
clientVersion: this.clientVersion,
configObj: configObj,
});
// TODO is it okay to not pass a projectConfig as second argument
this.eventProcessor.process(impressionEvent);
this.emitNotificationCenterActivate(decisionObj, flagKey, userId, attributes);
}
/**
* Emit the ACTIVATE notification on the notificationCenter
* @param {DecisionObj} decisionObj Decision object
* @param {string} flagKey Key for a feature flag
* @param {string} userId ID of user to whom the variation was shown
* @param {UserAttributes} attributes Optional user attributes
*/
private emitNotificationCenterActivate(
decisionObj: DecisionObj,
flagKey: string,
userId: string,
attributes?: UserAttributes
): void {
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return;
}
const ruleType = decisionObj.decisionSource;
const experimentKey = decision.getExperimentKey(decisionObj);
const variationKey = decision.getVariationKey(decisionObj);
let experimentId = null;
let variationId = null;
if (experimentKey !=='' && variationKey !== '') {
variationId = projectConfig.getVariationIdFromExperimentAndVariationKey(configObj, experimentKey, variationKey);
experimentId = projectConfig.getExperimentId(configObj, experimentKey);
}
const impressionEventOptions = {
attributes: attributes,
clientEngine: this.clientEngine,
clientVersion: this.clientVersion,
configObj: configObj,
experimentId: experimentId,
ruleKey: experimentKey,
flagKey: flagKey,
ruleType: ruleType,
userId: userId,
variationId: variationId,
logger: this.logger,
};
const impressionEvent = getImpressionEvent(impressionEventOptions);
const experiment = configObj.experimentKeyMap[experimentKey];
let variation;
if (experiment && experiment.variationKeyMap && variationKey !== '') {
variation = experiment.variationKeyMap[variationKey];
}
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.ACTIVATE, {
experiment: experiment,
userId: userId,
attributes: attributes,
variation: variation,
logEvent: impressionEvent,
});
}
/**
* Sends conversion event to Optimizely.
* @param {string} eventKey
* @param {string} userId
* @param {UserAttributes} attributes
* @param {EventTags} eventTags Values associated with the event.
*/
track(eventKey: string, userId: string, attributes?: UserAttributes, eventTags?: EventTags): void {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'track'));
return;
}
if (!this.validateInputs({ user_id: userId, event_key: eventKey }, attributes, eventTags)) {
return;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return;
}
if (!projectConfig.eventWithKeyExists(configObj, eventKey)) {
this.logger.log(
LOG_LEVEL.WARNING,
sprintf(enums.LOG_MESSAGES.EVENT_KEY_NOT_FOUND, MODULE_NAME, eventKey)
);
this.logger.log(LOG_LEVEL.WARNING, sprintf(LOG_MESSAGES.NOT_TRACKING_USER, MODULE_NAME, userId));
return;
}
// remove null values from eventTags
eventTags = this.filterEmptyValues(eventTags);
const conversionEvent = buildConversionEvent({
eventKey: eventKey,
eventTags: eventTags,
userId: userId,
userAttributes: attributes,
clientEngine: this.clientEngine,
clientVersion: this.clientVersion,
configObj: configObj,
});
this.logger.log(LOG_LEVEL.INFO, sprintf(enums.LOG_MESSAGES.TRACK_EVENT, MODULE_NAME, eventKey, userId));
// TODO is it okay to not pass a projectConfig as second argument
this.eventProcessor.process(conversionEvent);
this.emitNotificationCenterTrack(eventKey, userId, attributes, eventTags);
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
const failedTrackLogMessage = sprintf(LOG_MESSAGES.NOT_TRACKING_USER, MODULE_NAME, userId);
this.logger.log(LOG_LEVEL.ERROR, failedTrackLogMessage);
}
}
/**
* Send TRACK event to notificationCenter
* @param {string} eventKey
* @param {string} userId
* @param {UserAttributes} attributes
* @param {EventTags} eventTags Values associated with the event.
*/
private emitNotificationCenterTrack(eventKey: string, userId: string, attributes?: UserAttributes, eventTags?: EventTags): void {
try {
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return;
}
const conversionEventOptions = {
attributes: attributes,
clientEngine: this.clientEngine,
clientVersion: this.clientVersion,
configObj: configObj,
eventKey: eventKey,
eventTags: eventTags,
logger: this.logger,
userId: userId,
};
const conversionEvent = getConversionEvent(conversionEventOptions);
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.TRACK, {
eventKey: eventKey,
userId: userId,
attributes: attributes,
eventTags: eventTags,
logEvent: conversionEvent,
});
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
this.errorHandler.handleError(ex);
}
}
/**
* Gets variation where visitor will be bucketed.
* @param {string} experimentKey
* @param {string} userId
* @param {UserAttributes} attributes
* @return {string|null} variation key
*/
getVariation(experimentKey: string, userId: string, attributes?: UserAttributes): string | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getVariation'));
return null;
}
try {
if (!this.validateInputs({ experiment_key: experimentKey, user_id: userId }, attributes)) {
return null;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
const experiment = configObj.experimentKeyMap[experimentKey];
if (!experiment) {
this.logger.log(
LOG_LEVEL.DEBUG,
sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, MODULE_NAME, experimentKey)
);
return null;
}
const variationKey = this.decisionService.getVariation(configObj, experimentKey, userId, attributes);
const decisionNotificationType = projectConfig.isFeatureExperiment(configObj, experiment.id)
? DECISION_NOTIFICATION_TYPES.FEATURE_TEST
: DECISION_NOTIFICATION_TYPES.AB_TEST;
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, {
type: decisionNotificationType,
userId: userId,
attributes: attributes || {},
decisionInfo: {
experimentKey: experimentKey,
variationKey: variationKey,
},
});
return variationKey;
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
this.errorHandler.handleError(ex);
return null;
}
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Force a user into a variation for a given experiment.
* @param {string} experimentKey
* @param {string} userId
* @param {string|null} variationKey user will be forced into. If null,
* then clear the existing experiment-to-variation mapping.
* @return {boolean} A boolean value that indicates if the set completed successfully.
*/
setForcedVariation(experimentKey: string, userId: string, variationKey: string | null): boolean {
if (!this.validateInputs({ experiment_key: experimentKey, user_id: userId })) {
return false;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return false;
}
try {
return this.decisionService.setForcedVariation(configObj, experimentKey, userId, variationKey);
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
this.errorHandler.handleError(ex);
return false;
}
}
/**
* Gets the forced variation for a given user and experiment.
* @param {string} experimentKey
* @param {string} userId
* @return {string|null} The forced variation key.
*/
getForcedVariation(experimentKey: string, userId: string): string | null {
if (!this.validateInputs({ experiment_key: experimentKey, user_id: userId })) {
return null;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
try {
return this.decisionService.getForcedVariation(configObj, experimentKey, userId);
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
this.errorHandler.handleError(ex);
return null;
}
}
/**
* Validate string inputs, user attributes and event tags.
* @param {unknown} stringInputs Map of string keys and associated values
* @param {unknown} userAttributes Optional parameter for user's attributes
* @param {unknown} eventTags Optional parameter for event tags
* @return {boolean} True if inputs are valid
*
*/
private validateInputs(
// TODO: Make feature_key, user_id, variable_key, experiment_key camelCase
stringInputs: Partial<Record<'feature_key' | 'user_id' | 'variable_key' | 'experiment_key' | 'event_key', unknown>>,
userAttributes?: unknown,
eventTags?: unknown
): boolean {
try {
if (stringInputs.hasOwnProperty('user_id')) {
const userId = stringInputs['user_id'];
if (typeof userId !== 'string' || userId === null || userId === 'undefined') {
throw new Error(sprintf(ERROR_MESSAGES.INVALID_INPUT_FORMAT, MODULE_NAME, 'user_id'));
}
delete stringInputs['user_id'];
}
Object.keys(stringInputs).forEach(key => {
if (!stringValidator.validate(stringInputs[key])) {
throw new Error(sprintf(ERROR_MESSAGES.INVALID_INPUT_FORMAT, MODULE_NAME, key));
}
})
if (userAttributes) {
validate(userAttributes);
}
if (eventTags) {
eventTagsValidator.validate(eventTags);
}
return true;
} catch (ex) {
this.logger.log(LOG_LEVEL.ERROR, ex.message);
this.errorHandler.handleError(ex);
return false;
}
}
/**
* Shows failed activation log message and returns null when user is not activated in experiment
* @param {string} experimentKey
* @param {string} userId
* @return {null}
*/
private notActivatingExperiment(experimentKey: string, userId: string): null {
const failedActivationLogMessage = sprintf(
LOG_MESSAGES.NOT_ACTIVATING_USER,
MODULE_NAME,
userId,
experimentKey
);
this.logger.log(LOG_LEVEL.INFO, failedActivationLogMessage);
return null;
}
/**
* Filters out attributes/eventTags with null or undefined values
* @param {EventTags | undefined} map
* @returns {EventTags | undefined}
*/
private filterEmptyValues(map: EventTags | undefined): EventTags | undefined {
for (const key in map) {
if (map.hasOwnProperty(key) && (map[key] === null || map[key] === undefined)) {
delete map[key];
}
}
return map;
}
/**
* Returns true if the feature is enabled for the given user.
* @param {string} featureKey Key of feature which will be checked
* @param {string} userId ID of user which will be checked
* @param {UserAttributes} attributes Optional user attributes
* @return {boolean} true if the feature is enabled for the user, false otherwise
*/
isFeatureEnabled(featureKey: string, userId: string, attributes?: UserAttributes): boolean {
try {
if (!this.isValidInstance()) {
this.logger.log(
LOG_LEVEL.ERROR,
sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'isFeatureEnabled')
);
return false;
}
if (!this.validateInputs({ feature_key: featureKey, user_id: userId }, attributes)) {
return false;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return false;
}
const feature = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger);
if (!feature) {
return false;
}
let sourceInfo = {};
const decisionObj = this.decisionService.getVariationForFeature(configObj, feature, userId, attributes);
const decisionSource = decisionObj.decisionSource;
const experimentKey = decision.getExperimentKey(decisionObj);
const variationKey = decision.getVariationKey(decisionObj);
let featureEnabled = decision.getFeatureEnabledFromVariation(decisionObj);
if (decisionSource === DECISION_SOURCES.FEATURE_TEST) {
sourceInfo = {
experimentKey: experimentKey,
variationKey: variationKey,
};
}
if (
decisionSource === DECISION_SOURCES.FEATURE_TEST ||
decisionSource === DECISION_SOURCES.ROLLOUT && projectConfig.getSendFlagDecisionsValue(configObj)
) {
this.sendImpressionEvent(
decisionObj,
feature.key,
userId,
attributes
);
}
if (featureEnabled === true) {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId)
);
} else {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId)
);
featureEnabled = false;
}
const featureInfo = {
featureKey: featureKey,
featureEnabled: featureEnabled,
source: decisionObj.decisionSource,
sourceInfo: sourceInfo,
};
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, {
type: DECISION_NOTIFICATION_TYPES.FEATURE,
userId: userId,
attributes: attributes || {},
decisionInfo: featureInfo,
});
return featureEnabled;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return false;
}
}
/**
* Returns an Array containing the keys of all features in the project that are
* enabled for the given user.
* @param {string} userId
* @param {UserAttributes} attributes
* @return {string[]} Array of feature keys (strings)
*/
getEnabledFeatures(userId: string, attributes?: UserAttributes): string[] {
try {
const enabledFeatures: string[] = [];
if (!this.isValidInstance()) {
this.logger.log(
LOG_LEVEL.ERROR,
sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getEnabledFeatures')
);
return enabledFeatures;
}
if (!this.validateInputs({ user_id: userId })) {
return enabledFeatures;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return enabledFeatures;
}
objectValues(configObj.featureKeyMap).forEach(
(feature: FeatureFlag) => {
if (this.isFeatureEnabled(feature.key, userId, attributes)) {
enabledFeatures.push(feature.key);
}
}
);
return enabledFeatures;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return [];
}
}
/**
* Returns dynamically-typed value of the variable attached to the given
* feature flag. Returns null if the feature key or variable key is invalid.
*
* @param {string} featureKey Key of the feature whose variable's
* value is being accessed
* @param {string} variableKey Key of the variable whose value is
* being accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {unknown} Value of the variable cast to the appropriate
* type, or null if the feature key is invalid or
* the variable key is invalid
*/
getFeatureVariable(
featureKey: string,
variableKey: string,
userId: string,
attributes?: UserAttributes
): unknown {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariable'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, null, userId, attributes);
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Helper method to get the value for a variable of a certain type attached to a
* feature flag. Returns null if the feature key is invalid, the variable key is
* invalid, the given variable type does not match the variable's actual type,
* or the variable value cannot be cast to the required type. If the given variable
* type is null, the value of the variable cast to the appropriate type is returned.
*
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string|null} variableType Type of the variable whose value is being
* accessed (must be one of FEATURE_VARIABLE_TYPES
* in lib/utils/enums/index.js), or null to return the
* value of the variable cast to the appropriate type
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {unknown} Value of the variable cast to the appropriate
* type, or null if the feature key is invalid, thevariable
* key is invalid, or there is a mismatch with the type of
* the variable
*/
private getFeatureVariableForType(
featureKey: string,
variableKey: string,
variableType: string | null,
userId: string,
attributes?: UserAttributes): unknown {
if (!this.validateInputs({ feature_key: featureKey, variable_key: variableKey, user_id: userId }, attributes)) {
return null;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
const featureFlag = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger);
if (!featureFlag) {
return null;
}
const variable = projectConfig.getVariableForFeature(configObj, featureKey, variableKey, this.logger);
if (!variable) {
return null;
}
if (variableType && variable.type !== variableType) {
this.logger.log(
LOG_LEVEL.WARNING,
sprintf(LOG_MESSAGES.VARIABLE_REQUESTED_WITH_WRONG_TYPE, MODULE_NAME, variableType, variable.type)
);
return null;
}
const decisionObj = this.decisionService.getVariationForFeature(configObj, featureFlag, userId, attributes);
const featureEnabled = decision.getFeatureEnabledFromVariation(decisionObj);
const variableValue = this.getFeatureVariableValueFromVariation(featureKey, featureEnabled, decisionObj.variation, variable, userId);
let sourceInfo = {};
if (
decisionObj.decisionSource === DECISION_SOURCES.FEATURE_TEST &&
decisionObj.experiment !== null &&
decisionObj.variation !== null
) {
sourceInfo = {
experimentKey: decisionObj.experiment.key,
variationKey: decisionObj.variation.key,
};
}
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, {
type: DECISION_NOTIFICATION_TYPES.FEATURE_VARIABLE,
userId: userId,
attributes: attributes || {},
decisionInfo: {
featureKey: featureKey,
featureEnabled: featureEnabled,
source: decisionObj.decisionSource,
variableKey: variableKey,
variableValue: variableValue,
variableType: variable.type,
sourceInfo: sourceInfo,
},
});
return variableValue;
}
/**
* Helper method to get the non type-casted value for a variable attached to a
* feature flag. Returns appropriate variable value depending on whether there
* was a matching variation, feature was enabled or not or varible was part of the
* available variation or not. Also logs the appropriate message explaining how it
* evaluated the value of the variable.
*
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {boolean} featureEnabled Boolean indicating if feature is enabled or not
* @param {Variation} variation variation returned by decision service
* @param {FeatureVariable} variable varible whose value is being evaluated
* @param {string} userId ID for the user
* @return {unknown} Value of the variable or null if the
* config Obj is null
*/
private getFeatureVariableValueFromVariation(
featureKey: string,
featureEnabled: boolean,
variation: Variation | null,
variable: FeatureVariable,
userId: string
): unknown {
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
let variableValue = variable.defaultValue;
if (variation !== null) {
const value = projectConfig.getVariableValueForVariation(configObj, variable, variation, this.logger);
if (value !== null) {
if (featureEnabled) {
variableValue = value;
this.logger.log(
LOG_LEVEL.INFO,
sprintf(
LOG_MESSAGES.USER_RECEIVED_VARIABLE_VALUE,
MODULE_NAME,
variableValue,
variable.key,
featureKey
)
);
} else {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(
LOG_MESSAGES.FEATURE_NOT_ENABLED_RETURN_DEFAULT_VARIABLE_VALUE,
MODULE_NAME,
featureKey,
userId,
variableValue
)
);
}
} else {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(
LOG_MESSAGES.VARIABLE_NOT_USED_RETURN_DEFAULT_VARIABLE_VALUE,
MODULE_NAME,
variable.key,
variation.key
)
);
}
} else {
this.logger.log(
LOG_LEVEL.INFO,
sprintf(
LOG_MESSAGES.USER_RECEIVED_DEFAULT_VARIABLE_VALUE,
MODULE_NAME,
userId,
variable.key,
featureKey
)
);
}
return projectConfig.getTypeCastValue(variableValue, variable.type, this.logger);
}
/**
* Returns value for the given boolean variable attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {boolean|null} Boolean value of the variable, or null if the
* feature key is invalid, the variable key is invalid,
* or there is a mismatch with the type of the variable.
*/
getFeatureVariableBoolean(
featureKey: string,
variableKey: string,
userId: string,
attributes?: UserAttributes
): boolean | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariableBoolean'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, FEATURE_VARIABLE_TYPES.BOOLEAN, userId, attributes) as boolean | null;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns value for the given double variable attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {number|null} Number value of the variable, or null if the
* feature key is invalid, the variable key is
* invalid, or there is a mismatch with the type
* of the variable
*/
getFeatureVariableDouble(
featureKey:string,
variableKey: string,
userId: string,
attributes?: UserAttributes
): number | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariableDouble'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, FEATURE_VARIABLE_TYPES.DOUBLE, userId, attributes) as number | null;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns value for the given integer variable attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {number|null} Number value of the variable, or null if the
* feature key is invalid, the variable key is
* invalid, or there is a mismatch with the type
* of the variable
*/
getFeatureVariableInteger(
featureKey: string,
variableKey: string,
userId: string,
attributes?: UserAttributes
): number | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariableInteger'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, FEATURE_VARIABLE_TYPES.INTEGER, userId, attributes) as number | null;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns value for the given string variable attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {string|null} String value of the variable, or null if the
* feature key is invalid, the variable key is
* invalid, or there is a mismatch with the type
* of the variable
*/
getFeatureVariableString(
featureKey: string,
variableKey: string,
userId: string,
attributes?: UserAttributes
): string | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariableString'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, FEATURE_VARIABLE_TYPES.STRING, userId, attributes) as string | null;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns value for the given json variable attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variable's value is
* being accessed
* @param {string} variableKey Key of the variable whose value is being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {unknown} Object value of the variable, or null if the
* feature key is invalid, the variable key is
* invalid, or there is a mismatch with the type
* of the variable
*/
getFeatureVariableJSON(
featureKey: string,
variableKey: string,
userId: string,
attributes: UserAttributes
): unknown {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getFeatureVariableJSON'));
return null;
}
return this.getFeatureVariableForType(featureKey, variableKey, FEATURE_VARIABLE_TYPES.JSON, userId, attributes);
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns values for all the variables attached to the given feature
* flag.
* @param {string} featureKey Key of the feature whose variables are being
* accessed
* @param {string} userId ID for the user
* @param {UserAttributes} attributes Optional user attributes
* @return {object|null} Object containing all the variables, or null if the
* feature key is invalid
*/
getAllFeatureVariables(
featureKey: string,
userId: string,
attributes?: UserAttributes
): { [variableKey: string]: unknown } | null {
try {
if (!this.isValidInstance()) {
this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getAllFeatureVariables'));
return null;
}
if (!this.validateInputs({ feature_key: featureKey, user_id: userId }, attributes)) {
return null;
}
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
const featureFlag = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger);
if (!featureFlag) {
return null;
}
const decisionObj = this.decisionService.getVariationForFeature(configObj, featureFlag, userId, attributes);
const featureEnabled = decision.getFeatureEnabledFromVariation(decisionObj);
const allVariables = {};
featureFlag.variables.forEach((variable: FeatureVariable) => {
allVariables[variable.key] = this.getFeatureVariableValueFromVariation(featureKey, featureEnabled, decisionObj.variation, variable, userId);
});
let sourceInfo = {};
if (decisionObj.decisionSource === DECISION_SOURCES.FEATURE_TEST &&
decisionObj.experiment !== null &&
decisionObj.variation !== null
) {
sourceInfo = {
experimentKey: decisionObj.experiment.key,
variationKey: decisionObj.variation.key,
};
}
this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, {
type: DECISION_NOTIFICATION_TYPES.ALL_FEATURE_VARIABLES,
userId: userId,
attributes: attributes || {},
decisionInfo: {
featureKey: featureKey,
featureEnabled: featureEnabled,
source: decisionObj.decisionSource,
variableValues: allVariables,
sourceInfo: sourceInfo,
},
});
return allVariables;
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Returns OptimizelyConfig object containing experiments and features data
* @return {OptimizelyConfig|null}
*
* OptimizelyConfig Object Schema
* {
* 'experimentsMap': {
* 'my-fist-experiment': {
* 'id': '111111',
* 'key': 'my-fist-experiment'
* 'variationsMap': {
* 'variation_1': {
* 'id': '121212',
* 'key': 'variation_1',
* 'variablesMap': {
* 'age': {
* 'id': '222222',
* 'key': 'age',
* 'type': 'integer',
* 'value': '0',
* }
* }
* }
* }
* }
* },
* 'featuresMap': {
* 'awesome-feature': {
* 'id': '333333',
* 'key': 'awesome-feature',
* 'experimentsMap': Object,
* 'variationsMap': Object,
* }
* }
* }
*/
getOptimizelyConfig(): OptimizelyConfig | null {
try {
const configObj = this.projectConfigManager.getConfig();
if (!configObj) {
return null;
}
return this.projectConfigManager.getOptimizelyConfig();
} catch (e) {
this.logger.log(LOG_LEVEL.ERROR, e.message);
this.errorHandler.handleError(e);
return null;
}
}
/**
* Stop background processes belonging to this instance, including:
*
* - Active datafile requests
* - Pending datafile requests
* - Pending event queue flushes
*
* In-flight datafile requests will be aborted. Any events waiting to be sent
* as part of a batched event request will be immediately flushed to the event
* dispatcher.
*
* Returns a Promise that fulfills after all in-flight event dispatcher requests
* (including any final request resulting from flushing the queue as described
* above) are complete. If there are no in-flight event dispatcher requests and
* no queued events waiting to be sent, returns an immediately-fulfilled Promise.
*
* Returned Promises are fulfilled with result objects containing these
* properties:
* - success (boolean): true if the event dispatcher signaled completion of
* all in-flight and final requests, or if there were no
* queued events and no in-flight requests. false if an
* unexpected error was encountered during the close
* process.
* - reason (string=): If success is false, this is a string property with
* an explanatory message.
*
* NOTE: After close is called, this instance is no longer usable - any events
* generated will no longer be sent to the event dispatcher.
*
* @return {Promise}
*/
close(): Promise<{ success: boolean; reason?: string }> {
try {
const eventProcessorStoppedPromise = this.eventProcessor.stop();
if (this.disposeOnUpdate) {
this.disposeOnUpdate();
this.disposeOnUpdate = null;
}
if (this.projectConfigManager) {
this.projectConfigManager.stop();
}
Object.keys(this.readyTimeouts).forEach(
(readyTimeoutId: string) => {
const readyTimeoutRecord = this.readyTimeouts[readyTimeoutId];
clearTimeout(readyTimeoutRecord.readyTimeout);
readyTimeoutRecord.onClose();
}
);
this.readyTimeouts = {};
return eventProcessorStoppedPromise.then(
function() {
return {
success: true,
};
},
function(err) {
return {
success: false,
reason: String(err),
};
}
);
} catch (err) {
this.logger.log(LOG_LEVEL.ERROR, err.message);
this.errorHandler.handleError(err);
return Promise.resolve({
success: false,
reason: String(err),
});
}
}
/**
* Returns a Promise that fulfills when this instance is ready to use (meaning
* it has a valid datafile), or has failed to become ready within a period of
* time (configurable by the timeout property of the options argument), or when
* this instance is clos