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

791 lines (729 loc) 22.5 kB
import pRetry from "p-retry"; import { nullThrows, prefixError, reduce, InitData, Expression, Query, Value, ObjectValueWithVariables, UpdateListener, DehydratedState, DeepPartial, hash, stableStringify, LogLevel, uniqueId, InitDataProvider, ObjectValue, getSplitsAndCommitConfigForExpression, GetHashDataFunction, GetInitDataFunction, defaultRetries, UpdateTrigger, InitQuery, FieldQuery, Logs, HashData, } from "../shared"; import Logger from "./Logger"; import { isBrowser } from "./environment"; import LRUCache from "../shared/helpers/LRUCache"; import getMetadata from "../shared/helpers/getMetadata"; /** @internal: Not part of the Hypertune public API */ export default class Context { protected readonly initDataProvider: InitDataProvider | null; protected readonly initDataRefreshIntervalMs: number; protected readonly shouldSkipInitDataUpdateOnRefresh: boolean; protected readonly query: Query<ObjectValueWithVariables> | null; protected readonly initQuery: InitQuery; protected variableValues: ObjectValue; protected readonly updateListeners: Map<UpdateListener, boolean>; protected shouldClose = false; protected updateTimeout: NodeJS.Timeout | null = null; // @internal public readonly logger: Logger; // @internal public initData: InitData | null = null; // @internal public lastInitDataRefreshTime: number | null = null; // @internal public readonly getFieldCache: LRUCache<Expression> | null = null; // @internal public readonly getItemsCache: LRUCache<Expression[]> | null = null; // @internal public readonly evaluateCache: LRUCache<{ value: Value; logs: Logs; path: string; args: { [path: string]: ObjectValue }; shouldLogEvaluation: boolean; }> | null = null; // @internal public override: DeepPartial<ObjectValue> | null = null; // @internal // eslint-disable-next-line max-params constructor({ traceId, initData, lastInitDataRefreshTime, initDataProvider, initDataRefreshIntervalMs, shouldRefreshInitData, shouldRefreshInitDataOnCreate, shouldSkipInitDataUpdateOnRefresh, query, initQuery, variableValues, logger, cacheSize, override, }: { traceId: string; initData: InitData | null; lastInitDataRefreshTime: number | null; initDataProvider: InitDataProvider | null; initDataRefreshIntervalMs: number; shouldRefreshInitData: boolean; shouldRefreshInitDataOnCreate: boolean; shouldSkipInitDataUpdateOnRefresh: boolean; query: Query<ObjectValueWithVariables> | null; initQuery: InitQuery; variableValues: ObjectValue; logger: Logger; cacheSize: number; override: object | null; }) { this.initDataProvider = initDataProvider; this.initDataRefreshIntervalMs = initDataRefreshIntervalMs; this.shouldSkipInitDataUpdateOnRefresh = shouldSkipInitDataUpdateOnRefresh; this.query = query; this.initQuery = initQuery; this.variableValues = variableValues; this.updateListeners = new Map(); this.logger = logger; this.override = override; if (cacheSize > 0) { this.getFieldCache = new LRUCache(cacheSize); this.getItemsCache = new LRUCache(cacheSize); this.evaluateCache = new LRUCache(cacheSize); } if (initData) { this.log(traceId, LogLevel.Info, "Initializing from snapshot data..."); this.updateInitData( traceId, "snapshot", initData, lastInitDataRefreshTime ); } this.initAndStartIntervals( traceId, shouldRefreshInitDataOnCreate, shouldRefreshInitData ); if (isBrowser) { window.addEventListener("beforeunload", async () => { if (this.shouldClose) { return; } await this.close(/* traceId */ uniqueId()); }); } } // eslint-disable-next-line max-params private updateInitData( traceId: string, initSourceName: string, newInitData: InitData, newDataProviderInitTime: number | null ): boolean { try { const currentCommitId = this.initData?.commitId ?? -1; const currentCommitHash = this.initData?.hash ?? ""; if (newInitData.commitId < currentCommitId) { this.log( traceId, LogLevel.Info, `Skipped initialization from ${initSourceName} data as commit with id "${newInitData.commitId}" isn't newer than "${currentCommitId}".` ); return false; } if ( newInitData.commitId === currentCommitId && newInitData.hash === currentCommitHash ) { this.updateLastInitDataRefreshTime(newDataProviderInitTime); this.log( traceId, LogLevel.Info, `Skipped initialization from ${initSourceName} data as commit id "${newInitData.commitId}" with hash "${newInitData.hash}" is already active.` ); return false; } // If initializing from Hypertune Edge, the expression should already be // reduced given the query and variables. If initializing from the // fallback, it should already be reduced given the query but not the // variables. If initializing from Vercel Edge Config, it won't be reduced // at all. In all cases, we reduce the returned expression again anyway // given the query and variables. const reducedExpression = prefixError( () => reduce( newInitData.splits, newInitData.commitConfig, this.query, this.variableValues, newInitData.reducedExpression, /* allowMissingVariables */ false ), "Reduction Error: " ); this.initData = { ...newInitData, reducedExpression }; this.updateLastInitDataRefreshTime(newDataProviderInitTime); this.log( traceId, LogLevel.Info, `Initialized successfully from ${initSourceName} data.`, { commitId: newInitData.commitId, hash: newInitData.hash } ); this.getFieldCache?.purge(); this.getItemsCache?.purge(); this.evaluateCache?.purge(); return true; } catch (error) { this.log( traceId, LogLevel.Error, `Error initializing from ${initSourceName} data.`, getMetadata(error) ); return false; } } // @internal initIfNeeded(traceId: string, retries: number): Promise<void> { const { lastInitDataRefreshTime, initDataProvider, initDataRefreshIntervalMs, } = this; if (!initDataProvider) { this.log( traceId, LogLevel.Info, "Not initializing from data provider as it's null." ); return Promise.resolve(); } if (lastInitDataRefreshTime) { const msSinceLastInitDataRefresh = Date.now() - lastInitDataRefreshTime; if (msSinceLastInitDataRefresh < initDataRefreshIntervalMs) { this.log( traceId, LogLevel.Debug, `Not initializing from data provider as already did ${msSinceLastInitDataRefresh}ms ago which is less than the update interval of ${initDataRefreshIntervalMs}ms.` ); return Promise.resolve(); } } return this.initFromDataProvider(traceId, initDataProvider, retries); } private initFromDataProvider( traceId: string, initDataProvider: InitDataProvider, retries: number ): Promise<void> { return this.withUpdateNotificationAsync(async () => { let checkingHash = true; const initSourceName = initDataProvider.getName(); try { let newInitData: InitData | null = null; // If already initialized, first get the latest commit hash // to check if we need to update if (this.initData) { let hashData: HashData; if (initDataProvider.getHashData) { hashData = await this.getHashData( traceId, initDataProvider.getHashData.bind(initDataProvider), retries ); } else { newInitData = await this.getInitData( traceId, initSourceName, initDataProvider.getInitData.bind(initDataProvider), retries ); hashData = { hash: newInitData.hash, commitId: newInitData.commitId, }; } if (this.initData.hash === hashData.hash) { // Log this as latest init time as verifying that we already have // the latest commit is equivalent to initialization from server. this.log(traceId, LogLevel.Debug, "Commit hash is already latest."); this.updateLastInitDataRefreshTime(Date.now()); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } if (hashData.commitId < this.initData.commitId) { // This happens in the unlikely case when the hash data is for an // older commit than the init data. this.log( traceId, LogLevel.Info, `Skipped initialization from ${initSourceName} as hash data is for commit with ID "${hashData.commitId}" which isn't newer than "${this.initData.commitId}".` ); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } this.log( traceId, LogLevel.Info, `Commit hash (${this.initData.hash}) is not latest (${hashData.hash}).` ); if (this.shouldSkipInitDataUpdateOnRefresh) { return { updateTrigger: "initDataProvider", notify: true, hasUpdated: false, }; } } checkingHash = false; this.log( traceId, LogLevel.Info, `Initializing from ${initSourceName}...` ); if (!newInitData) { newInitData = await this.getInitData( traceId, initSourceName, initDataProvider.getInitData.bind(initDataProvider), retries ); } const hasUpdated = this.updateInitData( traceId, initSourceName, newInitData, Date.now() ); return { updateTrigger: "initDataProvider", notify: hasUpdated, hasUpdated, }; } catch (error) { this.log( traceId, LogLevel.Error, `All attempts to ${checkingHash ? "check for updates" : "initialize"} from ${initSourceName} failed.`, getMetadata(error) ); return { updateTrigger: "initDataProvider", notify: false, hasUpdated: false, }; } }); } private async getHashData( traceId: string, getInitDataHash: GetHashDataFunction, retries: number ): Promise<HashData> { this.log(traceId, LogLevel.Debug, "Getting latest commit hash..."); const latestInitDataHash = await pRetry( (attemptNumber) => { this.log( traceId, LogLevel.Debug, `Attempt ${attemptNumber} to get latest commit hash...` ); return getInitDataHash({ traceId, initQuery: this.initQuery, variableValues: this.variableValues, }); }, { retries, maxTimeout: 6000, onFailedAttempt: (error) => { this.log( traceId, LogLevel.Debug, `Attempt ${error.attemptNumber} to get latest commit hash failed. There are ${error.retriesLeft} retries left.`, getMetadata(error) ); if (this.shouldClose) { throw new pRetry.AbortError( `Stopped trying to get latest commit hash.` ); } }, } ); return latestInitDataHash; } private getInitData( traceId: string, initSourceName: string, getInitData: GetInitDataFunction, retries: number ): Promise<InitData> { return pRetry( (attemptNumber) => { this.log( traceId, LogLevel.Debug, `Attempt ${attemptNumber} to initialize from ${initSourceName}...` ); return getInitData({ traceId, initQuery: this.initQuery, variableValues: this.variableValues, }); }, { retries, maxTimeout: 6000, onFailedAttempt: (error) => { this.log( traceId, LogLevel.Debug, `Attempt ${error.attemptNumber} to initialize from ${initSourceName} failed. There are ${error.retriesLeft} retries left.`, getMetadata(error) ); if (this.shouldClose) { throw new pRetry.AbortError( `Stopped trying to initialize from ${initSourceName}.` ); } }, } ); } private initAndStartIntervals( initTraceId: string, shouldRefreshInitDataOnCreate: boolean, shouldRefreshInitData: boolean ): void { if (!this.initDataProvider) { this.log(initTraceId, LogLevel.Info, "Not checking for updates."); return; } const providerName = this.initDataProvider.getName(); // eslint-disable-next-line func-style const update = (traceId = uniqueId()): void => { if (this.shouldClose) { // No need to get updates when the SDK is shutting down. this.updateTimeout = null; this.log( traceId, LogLevel.Debug, `Stopped checking for updates from ${providerName}.` ); return; } this.initIfNeeded(traceId, defaultRetries) .catch((error) => this.log( traceId, LogLevel.Error, `Error updating from ${providerName}.`, getMetadata(error) ) ) .finally(() => { if (this.shouldClose) { this.updateTimeout = null; this.log( traceId, LogLevel.Debug, `Stopped checking for updates from ${providerName}.` ); return; } if (shouldRefreshInitData) { this.updateTimeout = setTimeout( update, this.initDataRefreshIntervalMs ); } }); }; if (shouldRefreshInitData) { this.log( initTraceId, LogLevel.Info, `Started checking for updates from ${providerName}.` ); } else { this.log( initTraceId, LogLevel.Info, `Not checking for updates from ${providerName}.` ); } if ( shouldRefreshInitDataOnCreate && // Only need to check for updates immediately when not skipping update // on refresh as otherwise it would trigger an unnecessary notification. !this.shouldSkipInitDataUpdateOnRefresh ) { update(initTraceId); // Refresh immediately return; } if (shouldRefreshInitData) { // Refresh after interval this.updateTimeout = setTimeout(update, this.initDataRefreshIntervalMs); } } // @internal isReady(): boolean { return this.initDataProvider ? !!this.lastInitDataRefreshTime : !!this.initData; } // @internal async close(traceId: string): Promise<void> { this.log(traceId, LogLevel.Info, "Closing..."); this.shouldClose = true; if (this.updateTimeout) { clearTimeout(this.updateTimeout); this.updateTimeout = null; } await this.logger.close(traceId); this.log(traceId, LogLevel.Info, "Closed."); } // @internal getStateHash(): string { const initDataHash = this.initData?.hash ?? null; const ssOverride = stableStringify(this.override); const ssVariableValues = stableStringify(this.variableValues); return hash(`${ssVariableValues}/${initDataHash}/${ssOverride}`).toString(); } // @internal addUpdateListener(listener: UpdateListener): void { this.updateListeners.set(listener, true); } // @internal removeUpdateListener(listener: UpdateListener): void { this.updateListeners.delete(listener); } // @internal setOverride<TOverride extends ObjectValue>( traceId: string, override: DeepPartial<TOverride> | null ): void { this.withUpdateNotification(() => { const hasUpdated = this.updateOverride(traceId, override); return { updateTrigger: "override", hasUpdated }; }); } private updateOverride<TOverride extends ObjectValue>( traceId: string, override: DeepPartial<TOverride> | null ): boolean { if (stableStringify(override) === stableStringify(this.override)) { if (override) { this.log( traceId, LogLevel.Debug, "Skipped setting override as it's equal to the one already set." ); } return false; } this.override = override; this.log(traceId, LogLevel.Info, "Set override.", { override }); return true; } // @internal dehydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>( query?: Query<ObjectValueWithVariables>, variableValues?: TVariableValues ): DehydratedState<TOverride, TVariableValues> | null { // Create a copy of the init data as we are modifying it below. const initData = this.initData ? { ...this.initData } : null; if (!initData) { return null; } const { lastInitDataRefreshTime, override } = this; const dehydrateQuery = query ?? this.query; const dehydrateQueryVariableValues: TVariableValues = variableValues ?? (this.variableValues as TVariableValues); initData.reducedExpression = prefixError( () => reduce( initData.splits, initData.commitConfig, dehydrateQuery, dehydrateQueryVariableValues, initData.reducedExpression, /* allowMissingVariables */ false ), "Reduction Error: " ); const { splits, commitConfig } = getSplitsAndCommitConfigForExpression( initData.reducedExpression, initData.splits, initData.commitConfig ); initData.splits = splits; initData.commitConfig = commitConfig; return { initData, lastInitDataRefreshTime, override: override as DeepPartial<TOverride> | null, variableValues: dehydrateQueryVariableValues, }; } // @internal hydrate<TOverride extends ObjectValue, TVariableValues extends ObjectValue>( traceId: string, dehydratedState: DehydratedState<TOverride, TVariableValues> ): void { return this.withUpdateNotification(() => { this.log(traceId, LogLevel.Info, "Hydrating..."); const { initData, override, variableValues, lastInitDataRefreshTime } = dehydratedState; const variableValuesUpdated = stableStringify(this.variableValues) !== stableStringify(variableValues); if (variableValuesUpdated) { this.variableValues = variableValues; } const overrideUpdated = this.updateOverride(traceId, override); let initDataUpdated = initData && initData.hash !== this.initData?.hash; if (initDataUpdated) { initDataUpdated = this.updateInitData( traceId, "hydration", initData, lastInitDataRefreshTime ); } else { this.updateLastInitDataRefreshTime(lastInitDataRefreshTime); } this.log(traceId, LogLevel.Info, "Hydrated."); return { updateTrigger: "hydration", hasUpdated: variableValuesUpdated || overrideUpdated || initDataUpdated, }; }); } private updateLastInitDataRefreshTime(newTime: number | null): void { if (!this.initDataProvider || !newTime) { return; } // Don't set the time to a future value. const now = Date.now(); this.lastInitDataRefreshTime = newTime > now ? now : newTime; } private withUpdateNotification( performUpdate: () => { updateTrigger: UpdateTrigger; hasUpdated: boolean; } ): void { const wasReady = this.isReady(); const { updateTrigger, hasUpdated } = performUpdate(); this.notifyUpdateListenersIfNeeded({ notify: hasUpdated, wasReady, updateTrigger, hasUpdated, }); } private async withUpdateNotificationAsync( performUpdate: () => Promise<{ updateTrigger: UpdateTrigger; notify: boolean; hasUpdated: boolean; }> ): Promise<void> { const wasReady = this.isReady(); const { updateTrigger, notify, hasUpdated } = await performUpdate(); this.notifyUpdateListenersIfNeeded({ notify, wasReady, updateTrigger, hasUpdated, }); } private notifyUpdateListenersIfNeeded({ notify, wasReady, updateTrigger, hasUpdated, }: { notify: boolean; wasReady: boolean; updateTrigger: UpdateTrigger; hasUpdated: boolean; }): void { const stateHash = this.getStateHash(); const becameReady = !wasReady && this.isReady(); if (becameReady || notify) { this.updateListeners.forEach((_, listener) => { listener(stateHash, { becameReady, updateTrigger, hasUpdated }); }); } } // @internal reduce( fieldQuery: FieldQuery<ObjectValueWithVariables> | null, expression: Expression ): Expression { const { splits, commitConfig } = nullThrows( this.initData, "No init data so cannot reduce expression." ); return prefixError( () => reduce( splits, commitConfig, fieldQuery ? { variableDefinitions: {}, fragmentDefinitions: {}, fieldQuery, } : null, /* variableValues */ {}, expression, /* allowMissingVariables */ false ), "Reduction error: " ); } private log( traceId: string, level: LogLevel, message: string, metadata: object = {} ): void { this.logger.log( level, this.initData?.commitId.toString() ?? null, message, { traceId, ...metadata } ); } }