vwo-fme-node-sdk
Version:
VWO Node/JavaScript SDK for Feature Management and Experimentation
379 lines (329 loc) • 13.2 kB
text/typescript
/**
* Copyright 2024-2025 Wingify Software Pvt. Ltd.
*
* 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 { SettingsModel } from '../models/settings/SettingsModel';
import { StorageDecorator } from '../decorators/StorageDecorator';
import { ApiEnum } from '../enums/ApiEnum';
import { CampaignTypeEnum } from '../enums/CampaignTypeEnum';
import { DebugLogMessagesEnum, ErrorLogMessagesEnum, InfoLogMessagesEnum } from '../enums/log-messages';
import { CampaignModel } from '../models/campaign/CampaignModel';
import { VariableModel } from '../models/campaign/VariableModel';
import { VariationModel } from '../models/campaign/VariationModel';
import { ContextModel } from '../models/user/ContextModel';
import { LogManager } from '../packages/logger';
import { SegmentationManager } from '../packages/segmentation-evaluator';
import IHooksService from '../services/HooksService';
import { StorageService } from '../services/StorageService';
import { getVariationFromCampaignKey } from '../utils/CampaignUtil';
import { isObject } from '../utils/DataTypeUtil';
import { evaluateTrafficAndGetVariation } from '../utils/DecisionUtil';
import { getAllExperimentRules, getFeatureFromKey, getSpecificRulesBasedOnType } from '../utils/FunctionUtil';
import { createAndSendImpressionForVariationShown } from '../utils/ImpressionUtil';
import { buildMessage } from '../utils/LogMessageUtil';
import { Deferred } from '../utils/PromiseUtil';
import { evaluateRule } from '../utils/RuleEvaluationUtil';
import { getShouldWaitForTrackingCalls } from '../utils/NetworkUtil';
export class Flag {
private readonly enabled: boolean;
private variation: VariationModel | Record<string, any> | undefined;
constructor(isEnabled: boolean, variation?: VariationModel | Record<string, any> | undefined) {
this.enabled = isEnabled;
this.variation = variation;
}
isEnabled(): boolean {
return this.enabled;
}
getVariables(): Record<string, unknown>[] {
return this.variation?.getVariables() || [];
}
// Overloads to give correct return types
getVariable<T = unknown>(key: string): T | undefined;
getVariable<T = unknown>(key: string, defaultValue: T): T;
getVariable<T = unknown>(key: string, defaultValue?: T): T | undefined {
const value = this.variation
?.getVariables()
.find((variable) => VariableModel.modelFromDictionary(variable).getKey() === key)
?.getValue();
return value !== undefined ? (value as T) : defaultValue;
}
}
export class FlagApi {
static async get(
featureKey: string,
settings: SettingsModel,
context: ContextModel,
hooksService: IHooksService,
): Promise<Flag> {
let isEnabled = false;
let rolloutVariationToReturn = null;
let experimentVariationToReturn = null;
let shouldCheckForExperimentsRules = false;
const passedRulesInformation = {}; // for storing and integration callback
const deferredObject = new Deferred();
const evaluatedFeatureMap: Map<string, any> = new Map();
// get feature object from feature key
const feature = getFeatureFromKey(settings, featureKey);
const decision = {
featureName: feature?.getName(),
featureId: feature?.getId(),
featureKey: feature?.getKey(),
userId: context?.getId(),
api: ApiEnum.GET_FLAG,
};
const storageService = new StorageService();
const storedData: Record<any, any> = await new StorageDecorator().getFeatureFromStorage(
featureKey,
context,
storageService,
);
if (storedData?.experimentVariationId) {
if (storedData.experimentKey) {
const variation: VariationModel = getVariationFromCampaignKey(
settings,
storedData.experimentKey,
storedData.experimentVariationId,
);
if (variation) {
LogManager.Instance.info(
buildMessage(InfoLogMessagesEnum.STORED_VARIATION_FOUND, {
variationKey: variation.getKey(),
userId: context.getId(),
experimentType: 'experiment',
experimentKey: storedData.experimentKey,
}),
);
deferredObject.resolve(new Flag(true, variation));
return deferredObject.promise;
}
}
} else if (storedData?.rolloutKey && storedData?.rolloutId) {
const variation: VariationModel = getVariationFromCampaignKey(
settings,
storedData.rolloutKey,
storedData.rolloutVariationId,
);
if (variation) {
LogManager.Instance.info(
buildMessage(InfoLogMessagesEnum.STORED_VARIATION_FOUND, {
variationKey: variation.getKey(),
userId: context.getId(),
experimentType: 'rollout',
experimentKey: storedData.rolloutKey,
}),
);
LogManager.Instance.debug(
buildMessage(DebugLogMessagesEnum.EXPERIMENTS_EVALUATION_WHEN_ROLLOUT_PASSED, {
userId: context.getId(),
}),
);
isEnabled = true;
shouldCheckForExperimentsRules = true;
rolloutVariationToReturn = variation;
const featureInfo = {
rolloutId: storedData.rolloutId,
rolloutKey: storedData.rolloutKey,
rolloutVariationId: storedData.rolloutVariationId,
};
evaluatedFeatureMap.set(featureKey, featureInfo);
Object.assign(passedRulesInformation, featureInfo);
}
}
if (!isObject(feature) || feature === undefined) {
LogManager.Instance.error(
buildMessage(ErrorLogMessagesEnum.FEATURE_NOT_FOUND, {
featureKey,
}),
);
deferredObject.reject({});
return deferredObject.promise;
}
// TODO: remove await from here, need not wait for gateway service at the time of calling getFlag
await SegmentationManager.Instance.setContextualData(settings, feature, context);
const rollOutRules = getSpecificRulesBasedOnType(feature, CampaignTypeEnum.ROLLOUT); // get all rollout rules
if (rollOutRules.length > 0 && !isEnabled) {
const rolloutRulesToEvaluate = [];
for (const rule of rollOutRules) {
const { preSegmentationResult, updatedDecision } = await evaluateRule(
settings,
feature,
rule,
context,
evaluatedFeatureMap,
null,
storageService,
decision,
);
Object.assign(decision, updatedDecision);
if (preSegmentationResult) {
// if pre segment passed, then break the loop and check the traffic allocation
rolloutRulesToEvaluate.push(rule);
evaluatedFeatureMap.set(featureKey, {
rolloutId: rule.getId(),
rolloutKey: rule.getKey(),
rolloutVariationId: rule.getVariations()[0]?.getId(),
});
break;
}
continue; // if rule does not satisfy, then check for other ROLLOUT rules
}
if (rolloutRulesToEvaluate.length > 0) {
const passedRolloutCampaign = new CampaignModel().modelFromDictionary(rolloutRulesToEvaluate[0]);
const variation = evaluateTrafficAndGetVariation(settings, passedRolloutCampaign, context.getId());
if (isObject(variation) && Object.keys(variation).length > 0) {
isEnabled = true;
shouldCheckForExperimentsRules = true;
rolloutVariationToReturn = variation;
_updateIntegrationsDecisionObject(passedRolloutCampaign, variation, passedRulesInformation, decision);
if (getShouldWaitForTrackingCalls()) {
await createAndSendImpressionForVariationShown(
settings,
passedRolloutCampaign.getId(),
variation.getId(),
context,
);
} else {
createAndSendImpressionForVariationShown(
settings,
passedRolloutCampaign.getId(),
variation.getId(),
context,
);
}
}
}
} else if (rollOutRules.length === 0) {
LogManager.Instance.debug(DebugLogMessagesEnum.EXPERIMENTS_EVALUATION_WHEN_NO_ROLLOUT_PRESENT);
shouldCheckForExperimentsRules = true;
}
if (shouldCheckForExperimentsRules) {
const experimentRulesToEvaluate = [];
// if rollout rule is passed, get all ab and Personalize rules
const experimentRules = getAllExperimentRules(feature);
const megGroupWinnerCampaigns: Map<number, any> = new Map();
for (const rule of experimentRules) {
const { preSegmentationResult, whitelistedObject, updatedDecision } = await evaluateRule(
settings,
feature,
rule,
context,
evaluatedFeatureMap,
megGroupWinnerCampaigns,
storageService,
decision,
);
Object.assign(decision, updatedDecision);
if (preSegmentationResult) {
if (whitelistedObject === null) {
// whitelistedObject will be null if pre segment passed but whitelisting failed
experimentRulesToEvaluate.push(rule);
} else {
isEnabled = true;
experimentVariationToReturn = whitelistedObject.variation;
Object.assign(passedRulesInformation, {
experimentId: rule.getId(),
experimentKey: rule.getKey(),
experimentVariationId: whitelistedObject.variationId,
});
}
break;
}
continue;
}
if (experimentRulesToEvaluate.length > 0) {
const campaign = new CampaignModel().modelFromDictionary(experimentRulesToEvaluate[0]);
const variation = evaluateTrafficAndGetVariation(settings, campaign, context.getId());
if (isObject(variation) && Object.keys(variation).length > 0) {
isEnabled = true;
experimentVariationToReturn = variation;
_updateIntegrationsDecisionObject(campaign, variation, passedRulesInformation, decision);
if (getShouldWaitForTrackingCalls()) {
await createAndSendImpressionForVariationShown(settings, campaign.getId(), variation.getId(), context);
} else {
createAndSendImpressionForVariationShown(settings, campaign.getId(), variation.getId(), context);
}
}
}
}
// If flag is enabled, store it in data
if (isEnabled) {
// set storage data
new StorageDecorator().setDataInStorage(
{
featureKey,
context,
...passedRulesInformation,
},
storageService,
);
}
// call integration callback, if defined
hooksService.set(decision);
hooksService.execute(hooksService.get());
// Send data for Impact Campaign, if defined
if (feature.getImpactCampaign()?.getCampaignId()) {
LogManager.Instance.info(
buildMessage(InfoLogMessagesEnum.IMPACT_ANALYSIS, {
userId: context.getId(),
featureKey,
status: isEnabled ? 'enabled' : 'disabled',
}),
);
if (getShouldWaitForTrackingCalls()) {
await createAndSendImpressionForVariationShown(
settings,
feature.getImpactCampaign()?.getCampaignId(),
isEnabled ? 2 : 1, // 2 is for Variation(flag enabled), 1 is for Control(flag disabled)
context,
);
} else {
createAndSendImpressionForVariationShown(
settings,
feature.getImpactCampaign()?.getCampaignId(),
isEnabled ? 2 : 1, // 2 is for Variation(flag enabled), 1 is for Control(flag disabled)
context,
);
}
}
deferredObject.resolve(
new Flag(
isEnabled,
new VariationModel().modelFromDictionary(experimentVariationToReturn ?? rolloutVariationToReturn),
),
);
return deferredObject.promise;
}
}
// Not PRIVATE methods but helper methods. If need be, move them to some util file to be reused by other API(s)
function _updateIntegrationsDecisionObject(
campaign: CampaignModel,
variation: VariationModel,
passedRulesInformation: any,
decision: any,
): void {
if (campaign.getType() === CampaignTypeEnum.ROLLOUT) {
Object.assign(passedRulesInformation, {
rolloutId: campaign.getId(),
rolloutKey: campaign.getKey(),
rolloutVariationId: variation.getId(),
});
} else {
Object.assign(passedRulesInformation, {
experimentId: campaign.getId(),
experimentKey: campaign.getKey(),
experimentVariationId: variation.getId(),
});
}
Object.assign(decision, passedRulesInformation);
}