hypertune
Version:
[Hypertune](https://www.hypertune.com/) is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git-style version control and local, synchronous, in-memory flag evaluation. Opt
795 lines (699 loc) • 21.2 kB
text/typescript
/* eslint-disable no-underscore-dangle */
import {
evaluate,
nullThrows,
prefixError,
getExpressionEvaluationCountLogs,
mergeLogs,
Expression,
Fragment,
ObjectValue,
Query,
Selection,
Step,
Value,
breakingSchemaChangesError,
UpdateListener,
asError,
DehydratedState,
DeepPartial,
LogLevel,
uniqueId,
ObjectValueWithVariables,
FieldQuery,
Logs,
formatHashData,
InitData,
} from "../shared";
import getMetadata from "../shared/helpers/getMetadata";
import merge from "./merge";
import throwIfObjectValueIsInvalid from "../shared/helpers/throwIfObjectValueIsInvalid";
import Context from "./Context";
import Logger from "./Logger";
import getLocalLogArguments from "../shared/helpers/getLocalLogArguments";
import getNodeCacheKey from "./getNodeCacheKey";
import getNodePath, { getJsonNodePathAndArgs } from "./getNodePath";
export type NodeProps = {
readonly logger: Logger | null;
readonly context: Context | null;
readonly parent: Node | null;
readonly step: Step | null;
readonly initDataHash: string | null;
readonly expression: Expression | null;
};
export default class Node {
// @internal
public props: NodeProps;
// This should be overridden by subclasses as the constructor name may be
// minified and mangled by bundlers
public typeName: string = this.constructor.name;
constructor(props: NodeProps) {
this.props = props;
}
protected updateIfNeeded(): void {
const { context, parent, step } = this.props;
if (!context || !context.initData) {
return;
}
// If we're on the latest commit hash, we don't need to update
if (this.props.initDataHash === context.initData.hash) {
return;
}
// If we don't have a parent, we're the source node
if (!parent) {
this.props = {
...this.props,
initDataHash: context.initData.hash,
expression: context.initData.reducedExpression,
};
return;
}
// Update from the parent
if (!step) {
throw new Error("Node update error: Missing step.");
}
switch (step.type) {
case "GetFieldStep": {
this.props = parent.getFieldNodeProps(step.fieldName, {
fieldArguments: step.fieldArguments,
});
break;
}
case "GetItemStep": {
const newProps: NodeProps | undefined = parent.getItemNodeProps({
fallbackLength: step.fallbackLength,
})[step.index];
if (!newProps) {
throw new Error("Node update error: No item props.");
}
this.props = newProps;
break;
}
default: {
const neverStep: never = step;
throw new Error(`Unexpected step: ${JSON.stringify(neverStep)}`);
}
}
}
getFieldNode(
fieldName: string,
{ fieldArguments = {} }: { fieldArguments?: ObjectValue } = {}
): Node {
return new Node(this.getFieldNodeProps(fieldName, { fieldArguments }));
}
/**
* @deprecated This method will be removed in the next major SDK version.
*/
protected getField(
fieldName: string,
fieldArguments: ObjectValue
): NodeProps {
return this.getFieldNodeProps(fieldName, { fieldArguments });
}
protected getFieldNodeProps(
fieldName: string,
{ fieldArguments = {} }: { fieldArguments?: ObjectValue } = {}
): NodeProps {
const step: Step = { type: "GetFieldStep", fieldName, fieldArguments };
const { context } = this.props;
const initDataHash = context?.initData?.hash;
if (!initDataHash || !context.getFieldCache) {
// No caching if the sdk hasn't been initialized or there is no cache.
return this.createProps(
step,
this.getReducedFieldExpression(fieldName, fieldArguments)
);
}
const cacheKey = getNodeCacheKey(
initDataHash,
getNodePath(/* parent */ this, step),
/* suffix */ ""
);
const cachedReducedFieldExpression = context.getFieldCache.get(cacheKey);
if (cachedReducedFieldExpression) {
return this.createProps(step, cachedReducedFieldExpression);
}
const reducedFieldExpression = this.getReducedFieldExpression(
fieldName,
fieldArguments
);
if (reducedFieldExpression) {
context.getFieldCache.set(cacheKey, reducedFieldExpression);
}
return this.createProps(step, reducedFieldExpression);
}
// @internal
private getReducedFieldExpression(
fieldName: string,
fieldArguments: ObjectValue
): Expression | null {
try {
prefixError(
() => throwIfObjectValueIsInvalid(fieldArguments),
"Invalid field arguments: "
);
this.updateIfNeeded();
const { expression } = this.props;
if (!expression) {
this.log(
LogLevel.Debug,
`Using fallback for field "${fieldName}" as expression is null. This is expected before initialization.`
);
return null;
}
if (expression.type !== "ObjectExpression") {
throw new Error(
`Cannot get field "${fieldName}" as expression type is "${expression.type}".`
);
}
const context = nullThrows(
this.props.context,
`Cannot get field "${fieldName}" as context is null.`
);
const selection: Selection<ObjectValue> = {
[fieldName]: { fieldArguments, fieldQuery: null },
};
const { objectTypeName } = expression;
const fragment: Fragment<ObjectValue> = {
type: "InlineFragment",
objectTypeName,
selection,
};
const fieldQuery: FieldQuery<ObjectValue> = {
[objectTypeName]: fragment,
};
const reducedObjectExpression = context.reduce(fieldQuery, expression);
if (reducedObjectExpression.type !== "ObjectExpression") {
throw new Error(
`Cannot get field "${fieldName}" as reduced expression type is "${expression.type}".`
);
}
const reducedFieldExpression = nullThrows(
reducedObjectExpression.fields[fieldName],
`Object expression does not contain field "${fieldName}".`
);
reducedFieldExpression.logs = mergeLogs(
reducedObjectExpression.logs,
getExpressionEvaluationCountLogs(reducedObjectExpression),
reducedFieldExpression.logs
);
return reducedFieldExpression;
} catch (error) {
this.log(
LogLevel.Error,
`Error getting field "${fieldName}" with arguments ${JSON.stringify(fieldArguments)}: ${asError(error).message}`,
getMetadata(error)
);
return null;
}
}
getItemNodes({
fallbackLength = 0,
}: { fallbackLength?: number } = {}): Node[] {
return this.getItemNodeProps({ fallbackLength }).map(
(props) => new Node(props)
);
}
/**
* @deprecated This method will be removed in the next major SDK version.
*/
_getItems(fallbackLength: number): NodeProps[] {
return this.getItemNodeProps({ fallbackLength });
}
getItemNodeProps({
fallbackLength = 0,
}: { fallbackLength?: number } = {}): NodeProps[] {
const { context, parent, step } = this.props;
const initDataHash = context?.initData?.hash;
if (!initDataHash || !context.getItemsCache) {
// No caching if the sdk hasn't been initialized or there is no cache.
return this.createPropsArray(this._getItemExpressions(), fallbackLength);
}
const cacheKey = getNodeCacheKey(
initDataHash,
getNodePath(parent, step),
/* suffix */ ""
);
const cachedItemExpressions = context.getItemsCache.get(cacheKey);
if (cachedItemExpressions) {
return this.createPropsArray(cachedItemExpressions, fallbackLength);
}
const itemExpressions = this._getItemExpressions();
if (itemExpressions) {
context.getItemsCache.set(cacheKey, itemExpressions);
}
return this.createPropsArray(itemExpressions, fallbackLength);
}
// @internal
private _getItemExpressions(): Expression[] | null {
try {
this.updateIfNeeded();
const { expression } = this.props;
if (!expression) {
this.log(
LogLevel.Debug,
"Using fallback for array items as expression is null. This is expected before initialization."
);
return null;
}
if (expression.type !== "ListExpression") {
throw new Error(
`Cannot get items as expression type is "${expression.type}". ${breakingSchemaChangesError}`
);
}
const listLogs = mergeLogs(
expression.logs,
getExpressionEvaluationCountLogs(expression)
);
const result: Expression[] = expression.items.map((item, index) => {
const itemExpression = nullThrows(
item,
`List expression has null item at index ${index}.`
);
itemExpression.logs = mergeLogs(listLogs, itemExpression.logs);
return itemExpression;
});
return result;
} catch (error) {
this.log(
LogLevel.Error,
`Error getting items: ${asError(error).message}`,
getMetadata(error)
);
return null;
}
}
getFieldValue(
fieldName: string,
{
fallback,
query = null,
fieldArguments = {},
}: {
fallback: Value;
query?: FieldQuery<ObjectValue> | null;
fieldArguments?: ObjectValue;
}
): Value {
return this.getFieldNode(fieldName, { fieldArguments }).getValue({
fallback,
query,
});
}
/**
* @deprecated This method will be removed in the next major SDK version.
*/
protected evaluate(
query: FieldQuery<ObjectValue> | null,
fallback: Value
): Value {
return this.getValue({ query, fallback });
}
getValue({
query = null,
fallback,
}: {
query?: FieldQuery<ObjectValue> | null;
fallback: Value;
}): Value {
const valueAndLogs = this.getValueAndLogsWithCache(query);
const {
value: valueWithoutOverride,
logs: reductionLogs,
path,
args,
shouldLogEvaluation,
} = valueAndLogs;
const isFallback = valueWithoutOverride === null;
const value = merge(
isFallback ? fallback : valueWithoutOverride,
this.getNodeOverride()
);
this.logReductionLogs({
...reductionLogs,
evaluationList: shouldLogEvaluation
? [
...(reductionLogs.evaluationList ?? []),
{ path, value, args, isFallback },
]
: reductionLogs.evaluationList,
});
return value;
}
private getNodeOverride(): DeepPartial<Value> {
const { context, parent, step } = this.props;
if (!context) {
return undefined;
}
if (context.override === null || context.override === undefined) {
// Short-circuit if no override in context
return undefined;
}
if (!parent) {
// We're the Query node
return context.override ?? undefined;
}
if (!step) {
return undefined;
}
const parentOverride = parent.getNodeOverride();
if (parentOverride === null || parentOverride === undefined) {
return undefined;
}
if (step.type === "GetFieldStep") {
return (parentOverride as ObjectValue)[step.fieldName] ?? undefined;
}
return (parentOverride as Value[])[step.index] ?? undefined;
}
// @internal
protected getValueAndLogsWithCache(query: FieldQuery<ObjectValue> | null): {
value: Value | null;
logs: Logs;
path: string;
args: { [path: string]: ObjectValue };
shouldLogEvaluation: boolean;
} {
const { context, parent, step } = this.props;
const initDataHash = context?.initData?.hash;
if (!initDataHash || !context.evaluateCache) {
// No caching if the sdk hasn't been initialized or there is no cache.
return this.getValueAndLogs(query);
}
const cacheKey = getNodeCacheKey(
initDataHash,
getNodePath(parent, step),
/* suffix */ JSON.stringify(query)
);
const cachedValueAndLogs = context.evaluateCache.get(cacheKey);
if (cachedValueAndLogs) {
return cachedValueAndLogs;
}
const valueAndLogs = this.getValueAndLogs(query);
if (valueAndLogs.value !== null) {
context.evaluateCache.set(cacheKey, valueAndLogs as any);
}
return valueAndLogs;
}
// @internal
private getValueAndLogs(query: FieldQuery<ObjectValue> | null): {
value: Value | null;
logs: Logs;
path: string;
args: { [path: string]: ObjectValue };
shouldLogEvaluation: boolean;
} {
const { path, args } = getJsonNodePathAndArgs(
this.props.parent,
this.props.step
);
try {
this.updateIfNeeded();
const { expression } = this.props;
if (!expression) {
return {
path,
args,
value: null,
logs: {
messageList: [
{
level: LogLevel.Debug,
message: `Using fallback while evaluating as expression is null. This is expected before initialization.`,
metadata: {},
},
],
},
shouldLogEvaluation: true,
};
}
const context = nullThrows(
this.props.context,
"Cannot evaluate as context is null."
);
const reducedExpression = context.reduce(query, expression);
const { value, logs, shouldLogEvaluation } = prefixError(
() => evaluate(reducedExpression),
"Evaluation error: "
);
return { value, logs, path, args, shouldLogEvaluation };
} catch (error) {
return {
path,
args,
value: null,
logs: {
messageList: [
{
level: LogLevel.Error,
message: `Error getting value and logs: ${asError(error).message}`,
metadata: getMetadata(error),
},
],
},
shouldLogEvaluation: true,
};
}
}
// @internal
private createProps(step: Step, expression: Expression | null): NodeProps {
const { context, logger, initDataHash } = this.props;
return { step, parent: this, context, expression, logger, initDataHash };
}
// @internal
private createPropsArray(
itemExpressions: Expression[] | null,
fallbackLength: number
): NodeProps[] {
return (itemExpressions || Array(fallbackLength).fill(null)).map(
(expression, index) =>
this.createProps(
{ type: "GetItemStep", index, fallbackLength },
expression
)
);
}
_logUnexpectedTypeError(): void {
if (!this.props.expression) {
this.log(
LogLevel.Debug,
`Unexpected expression type as expression is null but this is expected before initialization.`
);
return;
}
this.log(LogLevel.Error, "Unexpected expression type.");
}
protected logUnexpectedValueError(value: Value): void {
this.log(
LogLevel.Error,
`Evaluated to unexpected value: ${JSON.stringify(value)}`
);
}
private log(level: LogLevel, message: string, metadata: object = {}): void {
this.logReductionLogs({
messageList: [{ level, message, metadata }],
});
}
private logReductionLogs(reductionLogs: Logs): void {
const { typeName } = this;
const { parent, step, logger, expression, initDataHash } = this.props;
const commitId = this.props.context?.initData?.commitId.toString() ?? null;
const nodePath = getNodePath(parent, step);
if (!logger) {
// eslint-disable-next-line no-console
console.error(
...getLocalLogArguments(
`No logger for ${typeName}Node at ${nodePath} to log reduction logs`,
{ reductionLogs }
)
);
return;
}
logger.nodeLog({
commitId,
initDataHash,
nodeTypeName: typeName,
nodePath,
nodeExpression: expression,
reductionLogs,
});
}
getStateHash(): string | null {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot get state hash.");
return null;
}
return context.getStateHash();
}
getInitResponse(): InitData | null {
const { context } = this.props;
if (!context || !context.initData) {
this.log(LogLevel.Error, "No context so cannot get init data.");
return null;
}
return context.initData;
}
getHashResponse(): string | null {
const { context } = this.props;
if (!context || !context.initData) {
this.log(LogLevel.Error, "No context so cannot get hash data.");
return null;
}
return formatHashData(context.initData);
}
addUpdateListener(listener: UpdateListener): void {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot add update listener.");
return;
}
context.addUpdateListener(listener);
}
removeUpdateListener(listener: UpdateListener): void {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot remove update listener.");
return;
}
context.removeUpdateListener(listener);
}
/**
* Initialize from the init data provider if needed
*/
initIfNeeded(traceId = uniqueId(), retries = 0): Promise<void> {
const { context } = this.props;
if (!context) {
this.log(
LogLevel.Error,
"No context so cannot initialize from the data provider."
);
return Promise.resolve();
}
return context.initIfNeeded(traceId, retries);
}
/**
* Returns the timestamp of the last time the SDK was initialized from
* the init data provider
*/
getLastInitDataRefreshTime(): number | null {
const { context } = this.props;
if (!context) {
this.log(
LogLevel.Error,
"No context so cannot get the last data provider init time."
);
return null;
}
return context.lastInitDataRefreshTime;
}
/**
* @returns @deprecated use `getLastInitDataRefreshTime` instead
*/
getLastDataProviderInitTime(): number | null {
return this.getLastInitDataRefreshTime();
}
/**
* Indicates whether the SDK is ready to evaluate flags and log events.
*/
isReady(): boolean {
const { context } = this.props;
if (!context) {
return false;
}
return context.isReady();
}
flushLogs(traceId = uniqueId()): Promise<void> {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot flush logs.");
return Promise.resolve();
}
return context.logger.flush(traceId);
}
setOverride<T extends ObjectValue>(
override: DeepPartial<T> | null,
traceId = uniqueId()
): void {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot set override.");
return;
}
context.setOverride<T>(traceId, override);
}
dehydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
query?: Query<ObjectValueWithVariables>,
variableValues?: TVariableValues
): DehydratedState<TOverride, TVariableValues> | null {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot dehydrate.");
return null;
}
return context.dehydrate(query, variableValues);
}
hydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>(
dehydratedState: DehydratedState<TOverride, TVariableValues>,
traceId = uniqueId()
): void {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot hydrate.");
return;
}
context.hydrate(traceId, dehydratedState);
}
/**
* Close flushes any remaining logs and stops all background processes
* ensuring clean shutdown.
*/
close(traceId = uniqueId()): Promise<void> {
const { context } = this.props;
if (!context) {
this.log(LogLevel.Error, "No context so cannot close.");
return Promise.resolve();
}
return context.close(traceId);
}
getFlagValues<
FlagValues extends ObjectValue,
FlagPath extends keyof FlagValues & string,
>({
flagFallbacks,
flagPaths,
}: {
flagFallbacks: FlagValues;
flagPaths: FlagPath[];
}): Pick<FlagValues, FlagPath> {
return flagPaths.reduce<Pick<FlagValues, FlagPath>>(
(current, flag) => {
current[flag] = this.getFlagValue(
flag,
flagFallbacks[flag]
) as FlagValues[FlagPath];
return current;
},
{} as Pick<FlagValues, FlagPath>
);
}
private getFlagValue(flagPath: string, fallback: Value): Value {
return flagPath
.split(".")
.reduce<Node>((node, step) => node.getFieldNode(step, {}), this)
.getValue({ fallback });
}
getEncodedFlagValues<
FlagValues extends ObjectValue,
Flag extends keyof FlagValues & string,
>({
flagFallbacks,
flagPaths,
}: {
flagFallbacks: FlagValues;
flagPaths: Flag[];
}): string {
return btoa(
JSON.stringify(this.getFlagValues({ flagFallbacks, flagPaths }))
);
}
}