UNPKG

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
/* 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 })) ); } }