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