@featurevisor/sdk
Version:
Featurevisor SDK for Node.js and the browser
782 lines (650 loc) • 20.3 kB
text/typescript
import {
Feature,
FeatureKey,
Context,
BucketKey,
BucketValue,
RuleKey,
Traffic,
Force,
Required,
OverrideFeature,
Variation,
VariationValue,
VariableKey,
VariableValue,
VariableSchema,
StickyFeatures,
InitialFeatures,
Allocation,
} from "@featurevisor/types";
import { Logger } from "./logger";
import { DatafileReader } from "./datafileReader";
import { getBucket, ConfigureBucketKey, ConfigureBucketValue } from "./bucket";
import {
getMatchedTraffic,
getMatchedTrafficAndAllocation,
findForceFromFeature,
parseFromStringifiedSegments,
} from "./feature";
import { allConditionsAreMatched } from "./conditions";
import { allGroupSegmentsAreMatched } from "./segments";
import type { Statuses, InterceptContext } from "./instance";
export enum EvaluationReason {
NOT_FOUND = "not_found",
NO_VARIATIONS = "no_variations",
NO_MATCH = "no_match",
DISABLED = "disabled",
REQUIRED = "required",
OUT_OF_RANGE = "out_of_range",
FORCED = "forced",
INITIAL = "initial",
STICKY = "sticky",
RULE = "rule",
ALLOCATED = "allocated",
DEFAULTED = "defaulted",
OVERRIDE = "override",
ERROR = "error",
}
type EvaluationType = "flag" | "variation" | "variable";
export interface Evaluation {
// required
// type: EvaluationType; // @TODO: bring in later
featureKey: FeatureKey;
reason: EvaluationReason;
// common
bucketKey?: BucketKey;
bucketValue?: BucketValue;
ruleKey?: RuleKey;
error?: Error;
enabled?: boolean;
traffic?: Traffic;
forceIndex?: number;
force?: Force;
required?: Required[];
sticky?: OverrideFeature;
initial?: OverrideFeature;
// variation
variation?: Variation;
variationValue?: VariationValue;
// variable
variableKey?: VariableKey;
variableValue?: VariableValue;
variableSchema?: VariableSchema;
}
export interface EvaluateOptions {
type: EvaluationType;
featureKey: FeatureKey | Feature;
variableKey?: VariableKey;
context: Context;
logger: Logger;
datafileReader: DatafileReader;
statuses?: Statuses;
interceptContext?: InterceptContext;
stickyFeatures?: StickyFeatures;
initialFeatures?: InitialFeatures;
bucketKeySeparator?: string;
configureBucketKey?: ConfigureBucketKey;
configureBucketValue?: ConfigureBucketValue;
}
export function evaluate(options: EvaluateOptions): Evaluation {
let evaluation: Evaluation;
const {
type,
featureKey,
variableKey,
context,
logger,
datafileReader,
statuses,
stickyFeatures,
initialFeatures,
interceptContext,
bucketKeySeparator,
configureBucketKey,
configureBucketValue,
} = options;
try {
const key = typeof featureKey === "string" ? featureKey : featureKey.key;
/**
* Root flag evaluation
*/
let flag: Evaluation;
if (type !== "flag") {
// needed by variation and variable evaluations
flag = evaluate({
type: "flag",
featureKey: key,
context,
logger,
datafileReader,
statuses,
stickyFeatures,
initialFeatures,
bucketKeySeparator,
configureBucketKey,
configureBucketValue,
});
if (flag.enabled === false) {
evaluation = {
featureKey: key,
reason: EvaluationReason.DISABLED,
};
logger.debug("feature is disabled", evaluation);
return evaluation;
}
}
/**
* Sticky
*/
if (stickyFeatures && stickyFeatures[key]) {
// flag
if (type === "flag" && typeof stickyFeatures[key].enabled !== "undefined") {
evaluation = {
featureKey: key,
reason: EvaluationReason.STICKY,
sticky: stickyFeatures[key],
enabled: stickyFeatures[key].enabled,
};
logger.debug("using sticky enabled", evaluation);
return evaluation;
}
// variation
if (type === "variation") {
const variationValue = stickyFeatures[key].variation;
if (typeof variationValue !== "undefined") {
evaluation = {
featureKey: key,
reason: EvaluationReason.STICKY,
variationValue,
};
logger.debug("using sticky variation", evaluation);
return evaluation;
}
}
// variable
if (variableKey) {
const variables = stickyFeatures[key].variables;
if (variables) {
const result = variables[variableKey];
if (typeof result !== "undefined") {
evaluation = {
featureKey: key,
reason: EvaluationReason.STICKY,
variableKey,
variableValue: result,
};
logger.debug("using sticky variable", evaluation);
return evaluation;
}
}
}
}
/**
* Initial
*/
if (statuses && !statuses.ready && initialFeatures && initialFeatures[key]) {
// flag
if (type === "flag" && typeof initialFeatures[key].enabled !== "undefined") {
evaluation = {
featureKey: key,
reason: EvaluationReason.INITIAL,
initial: initialFeatures[key],
enabled: initialFeatures[key].enabled,
};
logger.debug("using initial enabled", evaluation);
return evaluation;
}
// variation
if (type === "variation" && typeof initialFeatures[key].variation !== "undefined") {
const variationValue = initialFeatures[key].variation;
evaluation = {
featureKey: key,
reason: EvaluationReason.INITIAL,
variationValue,
};
logger.debug("using initial variation", evaluation);
return evaluation;
}
// variable
if (variableKey) {
const variables = initialFeatures[key].variables;
if (variables) {
if (typeof variables[variableKey] !== "undefined") {
evaluation = {
featureKey: key,
reason: EvaluationReason.INITIAL,
variableKey,
variableValue: variables[variableKey],
};
logger.debug("using initial variable", evaluation);
return evaluation;
}
}
}
}
/**
* Feature
*/
const feature =
typeof featureKey === "string" ? datafileReader.getFeature(featureKey) : featureKey;
// feature: not found
if (!feature) {
evaluation = {
featureKey: key,
reason: EvaluationReason.NOT_FOUND, // @TODO: make it type-specific
};
logger.warn("feature not found", evaluation);
return evaluation;
}
// feature: deprecated
if (type === "flag" && feature.deprecated) {
logger.warn("feature is deprecated", { featureKey: feature.key });
}
// variableSchema
let variableSchema: VariableSchema | undefined;
if (variableKey) {
if (feature.variablesSchema && feature.variablesSchema[variableKey]) {
variableSchema = feature.variablesSchema[variableKey];
}
// variable schema not found
if (!variableSchema) {
evaluation = {
featureKey: key,
reason: EvaluationReason.NOT_FOUND,
variableKey,
};
logger.warn("variable schema not found", evaluation);
return evaluation;
}
if (variableSchema.deprecated) {
logger.warn("variable is deprecated", {
featureKey: feature.key,
variableKey,
});
}
}
// variation: no variations
if (type === "variation" && (!feature.variations || feature.variations.length === 0)) {
evaluation = {
featureKey: key,
reason: EvaluationReason.NO_VARIATIONS,
};
logger.warn("no variations", evaluation);
return evaluation;
}
const finalContext = interceptContext ? interceptContext(context) : context;
/**
* Forced
*/
const { force, forceIndex } = findForceFromFeature(feature, context, datafileReader, logger);
if (force) {
// flag
if (type === "flag" && typeof force.enabled !== "undefined") {
evaluation = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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({
type: "flag",
featureKey: requiredKey,
context: finalContext,
logger,
datafileReader,
statuses,
stickyFeatures,
initialFeatures,
bucketKeySeparator,
configureBucketKey,
configureBucketValue,
});
const requiredIsEnabled = requiredEvaluation.enabled;
if (!requiredIsEnabled) {
return false;
}
if (typeof requiredVariation !== "undefined") {
const requiredVariationEvaluation = evaluate({
type: "variation",
featureKey: requiredKey,
context: finalContext,
logger,
datafileReader,
statuses,
stickyFeatures,
initialFeatures,
bucketKeySeparator,
configureBucketKey,
configureBucketValue,
});
let requiredVariationValue;
if (requiredVariationEvaluation.variationValue) {
requiredVariationValue = requiredVariationEvaluation.variationValue;
} else if (requiredVariationEvaluation.variation) {
requiredVariationValue = requiredVariationEvaluation.variation.value;
}
return requiredVariationValue === requiredVariation;
}
return true;
});
if (!requiredFeaturesAreEnabled) {
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.REQUIRED,
required: feature.required,
enabled: requiredFeaturesAreEnabled,
};
logger.debug("required features not enabled", evaluation);
return evaluation;
}
}
/**
* Bucketing
*/
const { bucketKey, bucketValue } = getBucket({
feature,
context: finalContext,
logger,
bucketKeySeparator,
configureBucketKey,
configureBucketValue,
});
let matchedTraffic: Traffic | undefined;
let matchedAllocation: Allocation | undefined;
if (type !== "flag") {
const matched = getMatchedTrafficAndAllocation(
feature.traffic,
finalContext,
bucketValue,
datafileReader,
logger,
);
matchedTraffic = matched.matchedTraffic;
matchedAllocation = matched.matchedAllocation;
} else {
matchedTraffic = getMatchedTraffic(feature.traffic, finalContext, datafileReader, logger);
}
if (matchedTraffic) {
// 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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
reason: EvaluationReason.OVERRIDE,
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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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 = {
featureKey: feature.key,
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.variables) {
const variableFromVariation = variation.variables.find((v) => v.key === variableKey);
if (variableFromVariation) {
if (variableFromVariation.overrides) {
const override = variableFromVariation.overrides.find((o) => {
if (o.conditions) {
return allConditionsAreMatched(
typeof o.conditions === "string" ? JSON.parse(o.conditions) : o.conditions,
finalContext,
logger,
);
}
if (o.segments) {
return allGroupSegmentsAreMatched(
parseFromStringifiedSegments(o.segments),
finalContext,
datafileReader,
logger,
);
}
return false;
});
if (override) {
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.OVERRIDE,
bucketKey,
bucketValue,
ruleKey: matchedTraffic?.key,
traffic: matchedTraffic,
variableKey,
variableSchema,
variableValue: override.value,
};
logger.debug("variable override", evaluation);
return evaluation;
}
}
if (typeof variableFromVariation.value !== "undefined") {
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.ALLOCATED,
bucketKey,
bucketValue,
ruleKey: matchedTraffic?.key,
traffic: matchedTraffic,
variableKey,
variableSchema,
variableValue: variableFromVariation.value,
};
logger.debug("allocated variable", evaluation);
return evaluation;
}
}
}
}
}
/**
* Nothing matched
*/
if (type === "variation") {
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.NO_MATCH,
bucketKey,
bucketValue,
};
logger.debug("no matched variation", evaluation);
return evaluation;
}
if (type === "variable" && variableSchema) {
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.DEFAULTED,
bucketKey,
bucketValue,
variableKey,
variableSchema,
variableValue: variableSchema.defaultValue,
};
logger.debug("using default value", evaluation);
return evaluation;
}
evaluation = {
featureKey: feature.key,
reason: EvaluationReason.NO_MATCH,
bucketKey,
bucketValue,
enabled: false,
};
logger.debug("nothing matched", evaluation);
return evaluation;
} catch (e) {
evaluation = {
featureKey: typeof featureKey === "string" ? featureKey : featureKey.key,
reason: EvaluationReason.ERROR,
error: e,
};
logger.error("error", evaluation);
return evaluation;
}
}