UNPKG

@featurevisor/sdk

Version:

Featurevisor SDK for Node.js and the browser

871 lines (723 loc) 22.5 kB
import type { FeatureKey, Context, RuleKey, Traffic, Force, Required, Variation, VariationValue, VariableKey, VariableValue, VariableSchema, EvaluatedFeature, StickyFeatures, Allocation, } from "@featurevisor/types"; import { Logger } from "./logger"; import { HooksManager } from "./hooks"; import { DatafileReader } from "./datafileReader"; import { BucketKey, BucketValue, getBucketKey, getBucketedNumber } from "./bucketer"; export enum EvaluationReason { // feature specific FEATURE_NOT_FOUND = "feature_not_found", // feature is not found in datafile DISABLED = "disabled", // feature is disabled REQUIRED = "required", // required features are not enabled OUT_OF_RANGE = "out_of_range", // out of range when mutually exclusive experiments are involved via Groups // variations specific NO_VARIATIONS = "no_variations", // feature has no variations VARIATION_DISABLED = "variation_disabled", // feature is disabled, and variation's disabledVariationValue is used // variable specific VARIABLE_NOT_FOUND = "variable_not_found", // variable's schema is not defined in the feature VARIABLE_DEFAULT = "variable_default", // default variable value used VARIABLE_DISABLED = "variable_disabled", // feature is disabled, and variable's disabledValue is used VARIABLE_OVERRIDE = "variable_override", // variable overridden from inside a variation // common NO_MATCH = "no_match", // no rules matched FORCED = "forced", // against a forced rule STICKY = "sticky", // against a sticky feature RULE = "rule", // against a regular rule ALLOCATED = "allocated", // regular allocation based on bucketing ERROR = "error", // error } type EvaluationType = "flag" | "variation" | "variable"; export interface Evaluation { // required type: EvaluationType; featureKey: FeatureKey; reason: EvaluationReason; // common bucketKey?: BucketKey; bucketValue?: BucketValue; ruleKey?: RuleKey; error?: Error; enabled?: boolean; traffic?: Traffic; forceIndex?: number; force?: Force; required?: Required[]; sticky?: EvaluatedFeature; // variation variation?: Variation; variationValue?: VariationValue; // variable variableKey?: VariableKey; variableValue?: VariableValue; variableSchema?: VariableSchema; } export interface EvaluateDependencies { context: Context; logger: Logger; hooksManager: HooksManager; datafileReader: DatafileReader; // OverrideOptions sticky?: StickyFeatures; defaultVariationValue?: VariationValue; defaultVariableValue?: VariableValue; } export interface EvaluateParams { type: EvaluationType; featureKey: FeatureKey; variableKey?: VariableKey; } export type EvaluateOptions = EvaluateParams & EvaluateDependencies; export function evaluateWithHooks(opts: EvaluateOptions): Evaluation { try { const { hooksManager } = opts; const hooks = hooksManager.getAll(); // run before hooks let options = opts; for (const hook of hooksManager.getAll()) { if (hook.before) { options = hook.before(options); } } // evaluate let evaluation = evaluate(options); // default: variation if ( typeof options.defaultVariationValue !== "undefined" && evaluation.type === "variation" && typeof evaluation.variationValue === "undefined" ) { evaluation.variationValue = options.defaultVariationValue; } // default: variable if ( typeof options.defaultVariableValue !== "undefined" && evaluation.type === "variable" && typeof evaluation.variableValue === "undefined" ) { evaluation.variableValue = options.defaultVariableValue; } // run after hooks for (const hook of hooks) { if (hook.after) { evaluation = hook.after(evaluation, options); } } return evaluation; } catch (e) { const { type, featureKey, variableKey, logger } = opts; const evaluation: Evaluation = { type, featureKey, variableKey, reason: EvaluationReason.ERROR, error: e, }; logger.error("error during evaluation", evaluation); return evaluation; } } export function evaluate(options: EvaluateOptions): Evaluation { const { type, featureKey, variableKey, context, logger, datafileReader, sticky, hooksManager } = options; const hooks = hooksManager.getAll(); let evaluation: Evaluation; try { /** * Root flag evaluation */ let flag: Evaluation; if (type !== "flag") { // needed by variation and variable evaluations flag = evaluate({ ...options, type: "flag", }); if (flag.enabled === false) { evaluation = { type, featureKey, reason: EvaluationReason.DISABLED, }; const feature = datafileReader.getFeature(featureKey); // serve variable default value if feature is disabled (if explicitly specified) if (type === "variable") { if ( feature && variableKey && feature.variablesSchema && feature.variablesSchema[variableKey] ) { const variableSchema = feature.variablesSchema[variableKey]; if (typeof variableSchema.disabledValue !== "undefined") { // disabledValue: <value> evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_DISABLED, variableKey, variableValue: variableSchema.disabledValue, variableSchema, enabled: false, }; } else if (variableSchema.useDefaultWhenDisabled) { // useDefaultWhenDisabled: true evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_DEFAULT, variableKey, variableValue: variableSchema.defaultValue, variableSchema, enabled: false, }; } } } // serve disabled variation value if feature is disabled (if explicitly specified) if (type === "variation" && feature && feature.disabledVariationValue) { evaluation = { type, featureKey, reason: EvaluationReason.VARIATION_DISABLED, variationValue: feature.disabledVariationValue, enabled: false, }; } logger.debug("feature is disabled", evaluation); return evaluation; } } /** * Sticky */ if (sticky && sticky[featureKey]) { // flag if (type === "flag" && typeof sticky[featureKey].enabled !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.STICKY, sticky: sticky[featureKey], enabled: sticky[featureKey].enabled, }; logger.debug("using sticky enabled", evaluation); return evaluation; } // variation if (type === "variation") { const variationValue = sticky[featureKey].variation; if (typeof variationValue !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.STICKY, variationValue, }; logger.debug("using sticky variation", evaluation); return evaluation; } } // variable if (variableKey) { const variables = sticky[featureKey].variables; if (variables) { const result = variables[variableKey]; if (typeof result !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.STICKY, variableKey, variableValue: result, }; logger.debug("using sticky variable", evaluation); return evaluation; } } } } /** * Feature */ const feature = typeof featureKey === "string" ? datafileReader.getFeature(featureKey) : featureKey; // feature: not found if (!feature) { evaluation = { type, featureKey, reason: EvaluationReason.FEATURE_NOT_FOUND, }; logger.warn("feature not found", evaluation); return evaluation; } // feature: deprecated if (type === "flag" && feature.deprecated) { logger.warn("feature is deprecated", { featureKey }); } // variableSchema let variableSchema: VariableSchema | undefined; if (variableKey) { if (feature.variablesSchema && feature.variablesSchema[variableKey]) { variableSchema = feature.variablesSchema[variableKey]; } // variable schema not found if (!variableSchema) { evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_NOT_FOUND, variableKey, }; logger.warn("variable schema not found", evaluation); return evaluation; } if (variableSchema.deprecated) { logger.warn("variable is deprecated", { featureKey, variableKey, }); } } // variation: no variations if (type === "variation" && (!feature.variations || feature.variations.length === 0)) { evaluation = { type, featureKey, reason: EvaluationReason.NO_VARIATIONS, }; logger.warn("no variations", evaluation); return evaluation; } /** * Forced */ const { force, forceIndex } = datafileReader.getMatchedForce(feature, context); if (force) { // flag if (type === "flag" && typeof force.enabled !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.FORCED, forceIndex, force, enabled: force.enabled, }; logger.debug("forced enabled found", evaluation); return evaluation; } // variation if (type === "variation" && force.variation && feature.variations) { const variation = feature.variations.find((v) => v.value === force.variation); if (variation) { evaluation = { type, featureKey, reason: EvaluationReason.FORCED, forceIndex, force, variation, }; logger.debug("forced variation found", evaluation); return evaluation; } } // variable if (variableKey && force.variables && typeof force.variables[variableKey] !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.FORCED, forceIndex, force, variableKey, variableSchema, variableValue: force.variables[variableKey], }; logger.debug("forced variable", evaluation); return evaluation; } } /** * Required */ if (type === "flag" && feature.required && feature.required.length > 0) { const requiredFeaturesAreEnabled = feature.required.every((required) => { let requiredKey; let requiredVariation; if (typeof required === "string") { requiredKey = required; } else { requiredKey = required.key; requiredVariation = required.variation; } const requiredEvaluation = evaluate({ ...options, type: "flag", featureKey: requiredKey, }); const requiredIsEnabled = requiredEvaluation.enabled; if (!requiredIsEnabled) { return false; } if (typeof requiredVariation !== "undefined") { const requiredVariationEvaluation = evaluate({ ...options, type: "variation", featureKey: requiredKey, }); let requiredVariationValue; if (requiredVariationEvaluation.variationValue) { requiredVariationValue = requiredVariationEvaluation.variationValue; } else if (requiredVariationEvaluation.variation) { requiredVariationValue = requiredVariationEvaluation.variation.value; } return requiredVariationValue === requiredVariation; } return true; }); if (!requiredFeaturesAreEnabled) { evaluation = { type, featureKey, reason: EvaluationReason.REQUIRED, required: feature.required, enabled: requiredFeaturesAreEnabled, }; logger.debug("required features not enabled", evaluation); return evaluation; } } /** * Bucketing */ // bucketKey let bucketKey = getBucketKey({ featureKey, bucketBy: feature.bucketBy, context, logger, }); for (const hook of hooks) { if (hook.bucketKey) { bucketKey = hook.bucketKey({ featureKey, context, bucketBy: feature.bucketBy, bucketKey, }); } } // bucketValue let bucketValue = getBucketedNumber(bucketKey); for (const hook of hooks) { if (hook.bucketValue) { bucketValue = hook.bucketValue({ featureKey, bucketKey, context, bucketValue, }); } } let matchedTraffic: Traffic | undefined; let matchedAllocation: Allocation | undefined; if (type !== "flag") { matchedTraffic = datafileReader.getMatchedTraffic(feature.traffic, context); if (matchedTraffic) { matchedAllocation = datafileReader.getMatchedAllocation(matchedTraffic, bucketValue); } } else { matchedTraffic = datafileReader.getMatchedTraffic(feature.traffic, context); } if (matchedTraffic) { // percentage: 0 if (matchedTraffic.percentage === 0) { evaluation = { type, featureKey, reason: EvaluationReason.RULE, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, enabled: false, }; logger.debug("matched rule with 0 percentage", evaluation); return evaluation; } // flag if (type === "flag") { // flag: check if mutually exclusive if (feature.ranges && feature.ranges.length > 0) { const matchedRange = feature.ranges.find((range) => { return bucketValue >= range[0] && bucketValue < range[1]; }); // matched if (matchedRange) { evaluation = { type, featureKey, reason: EvaluationReason.ALLOCATED, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, enabled: typeof matchedTraffic.enabled === "undefined" ? true : matchedTraffic.enabled, }; logger.debug("matched", evaluation); return evaluation; } // no match evaluation = { type, featureKey, reason: EvaluationReason.OUT_OF_RANGE, bucketKey, bucketValue, enabled: false, }; logger.debug("not matched", evaluation); return evaluation; } // flag: override from rule if (typeof matchedTraffic.enabled !== "undefined") { evaluation = { type, featureKey, reason: EvaluationReason.RULE, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, enabled: matchedTraffic.enabled, }; logger.debug("override from rule", evaluation); return evaluation; } // treated as enabled because of matched traffic if (bucketValue <= matchedTraffic.percentage) { evaluation = { type, featureKey, reason: EvaluationReason.RULE, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, enabled: true, }; logger.debug("matched traffic", evaluation); return evaluation; } } // variation if (type === "variation" && feature.variations) { // override from rule if (matchedTraffic.variation) { const variation = feature.variations.find((v) => v.value === matchedTraffic.variation); if (variation) { evaluation = { type, featureKey, reason: EvaluationReason.RULE, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, variation, }; logger.debug("override from rule", evaluation); return evaluation; } } // regular allocation if (matchedAllocation && matchedAllocation.variation) { const variation = feature.variations.find((v) => v.value === matchedAllocation.variation); if (variation) { evaluation = { type, featureKey, reason: EvaluationReason.ALLOCATED, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, variation, }; logger.debug("allocated variation", evaluation); return evaluation; } } } } // variable if (type === "variable" && variableKey) { // override from rule if ( matchedTraffic && matchedTraffic.variables && typeof matchedTraffic.variables[variableKey] !== "undefined" ) { evaluation = { type, featureKey, reason: EvaluationReason.RULE, bucketKey, bucketValue, ruleKey: matchedTraffic.key, traffic: matchedTraffic, variableKey, variableSchema, variableValue: matchedTraffic.variables[variableKey], }; logger.debug("override from rule", evaluation); return evaluation; } // check variations let variationValue; if (force && force.variation) { variationValue = force.variation; } else if (matchedTraffic && matchedTraffic.variation) { variationValue = matchedTraffic.variation; } else if (matchedAllocation && matchedAllocation.variation) { variationValue = matchedAllocation.variation; } if (variationValue && Array.isArray(feature.variations)) { const variation = feature.variations.find((v) => v.value === variationValue); if (variation && variation.variableOverrides && variation.variableOverrides[variableKey]) { const overrides = variation.variableOverrides[variableKey]; const override = overrides.find((o) => { if (o.conditions) { return datafileReader.allConditionsAreMatched( typeof o.conditions === "string" && o.conditions !== "*" ? JSON.parse(o.conditions) : o.conditions, context, ); } if (o.segments) { return datafileReader.allSegmentsAreMatched( datafileReader.parseSegmentsIfStringified(o.segments), context, ); } return false; }); if (override) { evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_OVERRIDE, bucketKey, bucketValue, ruleKey: matchedTraffic?.key, traffic: matchedTraffic, variableKey, variableSchema, variableValue: override.value, }; logger.debug("variable override", evaluation); return evaluation; } } if ( variation && variation.variables && typeof variation.variables[variableKey] !== "undefined" ) { evaluation = { type, featureKey, reason: EvaluationReason.ALLOCATED, bucketKey, bucketValue, ruleKey: matchedTraffic?.key, traffic: matchedTraffic, variableKey, variableSchema, variableValue: variation.variables[variableKey], }; logger.debug("allocated variable", evaluation); return evaluation; } } } /** * Nothing matched */ if (type === "variation") { evaluation = { type, featureKey, reason: EvaluationReason.NO_MATCH, bucketKey, bucketValue, }; logger.debug("no matched variation", evaluation); return evaluation; } if (type === "variable") { if (variableSchema) { evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_DEFAULT, bucketKey, bucketValue, variableKey, variableSchema, variableValue: variableSchema.defaultValue, }; logger.debug("using default value", evaluation); return evaluation; } evaluation = { type, featureKey, reason: EvaluationReason.VARIABLE_NOT_FOUND, variableKey, bucketKey, bucketValue, }; logger.debug("variable not found", evaluation); return evaluation; } evaluation = { type, featureKey, reason: EvaluationReason.NO_MATCH, bucketKey, bucketValue, enabled: false, }; logger.debug("nothing matched", evaluation); return evaluation; } catch (e) { evaluation = { type, featureKey, variableKey, reason: EvaluationReason.ERROR, error: e, }; logger.error("error", evaluation); return evaluation; } }