From 60fafe16b37c6eab72784c9baa528df2cfd10d5c Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 12:54:36 +0200 Subject: [PATCH 01/25] refactor: add ConfigurationStore abstraction --- .../configuration-store.ts | 56 +++++++++++++++++++ src/configuration-store/index.ts | 1 + 2 files changed, 57 insertions(+) create mode 100644 src/configuration-store/index.ts diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index ff43a61..46f2a5d 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,5 +1,57 @@ +import { IConfiguration } from '../i-configuration'; import { Environment } from '../interfaces'; +/** + * `ConfigurationStore` is a central piece of Eppo SDK and answers a + * simple question: what configuration is currently active? + * + * @internal `ConfigurationStore` shall only be used inside Eppo SDKs. + */ +export class ConfigurationStore { + private readonly listeners: Array< + (configuration: IConfiguration | null) => void + > = []; + + // TODO: replace IConfiguration with a concrete `Configuration` type. + public constructor(private configuration: IConfiguration | null = null) {} + + public getConfiguration(): IConfiguration | null { + return this.configuration; + } + + public setConfiguration(configuration: IConfiguration | null): void { + this.configuration = configuration; + this.notifyListeners(); + } + + /** + * Subscribe to configuration changes. The callback will be called + * every time configuration is changed. + * + * Returns a function to unsubscribe from future updates. + */ + public onConfigurationChange( + listener: (configuration: IConfiguration | null) => void + ): () => void { + this.listeners.push(listener); + + return () => { + const idx = this.listeners.indexOf(listener); + if (idx !== -1) { + this.listeners.splice(idx, 1); + } + }; + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + try { + listener(this.configuration); + } catch {} + } + } +} + /** * ConfigurationStore interface * @@ -21,6 +73,8 @@ import { Environment } from '../interfaces'; * * The policy choices surrounding the use of one or more underlying storages are * implementation specific and handled upstream. + * + * @deprecated To be replaced with ConfigurationStore and PersistentStorage. */ export interface IConfigurationStore { init(): Promise; @@ -41,6 +95,7 @@ export interface IConfigurationStore { salt?: string; } +/** @deprecated To be replaced with ConfigurationStore and PersistentStorage. */ export interface ISyncStore { get(key: string): T | null; entries(): Record; @@ -49,6 +104,7 @@ export interface ISyncStore { setEntries(entries: Record): void; } +/** @deprecated To be replaced with ConfigurationStore and PersistentStorage. */ export interface IAsyncStore { isInitialized(): boolean; isExpired(): Promise; diff --git a/src/configuration-store/index.ts b/src/configuration-store/index.ts new file mode 100644 index 0000000..4ff7328 --- /dev/null +++ b/src/configuration-store/index.ts @@ -0,0 +1 @@ +export { ConfigurationStore } from "./configuration-store"; From 68c8b2fe2f908d92ab3d043a8ebb25fd2e0b564b Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 13:04:22 +0200 Subject: [PATCH 02/25] refactor: add PersistentConfigurationStorage interface --- src/persistent-configuration-storage.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/persistent-configuration-storage.ts diff --git a/src/persistent-configuration-storage.ts b/src/persistent-configuration-storage.ts new file mode 100644 index 0000000..931b7f8 --- /dev/null +++ b/src/persistent-configuration-storage.ts @@ -0,0 +1,24 @@ +import { IConfiguration } from './i-configuration'; + +/** + * Persistent configuration storages are responsible for persisting + * configuration between SDK reloads. + */ +// TODO: replace `IConfiguration` with a concrete `Configuration` type. +export interface PersistentConfigurationStorage { + /** + * Load configuration from the persistent storage. + * + * The method may fail to load a configuration or throw an + * exception (which is generally ignored). + */ + loadConfiguration(): PromiseLike; + + /** + * Store configuration to the persistent storage. + * + * The method is allowed to do async work (which is not awaited) or + * throw exceptions (which are ignored). + */ + storeConfiguration(configuration: IConfiguration | null): void; +} From b51b5760e0b9aefbb22bb7ee044ee8f4af229e68 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 13:12:18 +0200 Subject: [PATCH 03/25] refactor: add Configuration type (temp aliased to IConfiguration) --- src/configuration-store/configuration-store.ts | 13 ++++++------- src/i-configuration.ts | 6 ++++++ src/persistent-configuration-storage.ts | 7 +++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index 46f2a5d..63f5f93 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,4 +1,4 @@ -import { IConfiguration } from '../i-configuration'; +import { Configuration } from '../i-configuration'; import { Environment } from '../interfaces'; /** @@ -9,17 +9,16 @@ import { Environment } from '../interfaces'; */ export class ConfigurationStore { private readonly listeners: Array< - (configuration: IConfiguration | null) => void + (configuration: Configuration | null) => void > = []; - // TODO: replace IConfiguration with a concrete `Configuration` type. - public constructor(private configuration: IConfiguration | null = null) {} + public constructor(private configuration: Configuration | null = null) {} - public getConfiguration(): IConfiguration | null { + public getConfiguration(): Configuration | null { return this.configuration; } - public setConfiguration(configuration: IConfiguration | null): void { + public setConfiguration(configuration: Configuration | null): void { this.configuration = configuration; this.notifyListeners(); } @@ -31,7 +30,7 @@ export class ConfigurationStore { * Returns a function to unsubscribe from future updates. */ public onConfigurationChange( - listener: (configuration: IConfiguration | null) => void + listener: (configuration: Configuration | null) => void ): () => void { this.listeners.push(listener); diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0d..b210c85 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -28,6 +28,12 @@ export interface IConfiguration { isInitialized(): boolean; } +// TODO: replace more abstract `IConfiguration` with some concrete +// implementation, so we know what to expect from it (i.e., it's +// probably a bad idea to allow users implementing their own +// configurations). +export type Configuration = IConfiguration; + export type ConfigStoreHydrationPacket = { entries: Record; environment: Environment; diff --git a/src/persistent-configuration-storage.ts b/src/persistent-configuration-storage.ts index 931b7f8..185ad95 100644 --- a/src/persistent-configuration-storage.ts +++ b/src/persistent-configuration-storage.ts @@ -1,10 +1,9 @@ -import { IConfiguration } from './i-configuration'; +import { Configuration } from './i-configuration'; /** * Persistent configuration storages are responsible for persisting * configuration between SDK reloads. */ -// TODO: replace `IConfiguration` with a concrete `Configuration` type. export interface PersistentConfigurationStorage { /** * Load configuration from the persistent storage. @@ -12,7 +11,7 @@ export interface PersistentConfigurationStorage { * The method may fail to load a configuration or throw an * exception (which is generally ignored). */ - loadConfiguration(): PromiseLike; + loadConfiguration(): PromiseLike; /** * Store configuration to the persistent storage. @@ -20,5 +19,5 @@ export interface PersistentConfigurationStorage { * The method is allowed to do async work (which is not awaited) or * throw exceptions (which are ignored). */ - storeConfiguration(configuration: IConfiguration | null): void; + storeConfiguration(configuration: Configuration | null): void; } From a491d0bfcedb07c8accac98417ce907946265dea Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 13:16:58 +0200 Subject: [PATCH 04/25] chore: add top-level .prettierrc This integrates better with editor tools. --- .eslintrc.js | 9 +-------- .prettierrc | 5 +++++ 2 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 .prettierrc diff --git a/.eslintrc.js b/.eslintrc.js index 3f1fbde..f22f6d9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -78,14 +78,7 @@ module.exports = { message: "'setImmediate' unavailable in JavaScript. Use 'setTimeout(fn, 0)' instead", }, ], - 'prettier/prettier': [ - 'warn', - { - singleQuote: true, - trailingComma: 'all', - printWidth: 100, - }, - ], + 'prettier/prettier': ['warn'], 'unused-imports/no-unused-imports': 'error', }, overrides: [ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2fec1f2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} From 9325c8860b615c5bffd201c55f029b231a0fb04e Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 13:47:02 +0200 Subject: [PATCH 05/25] refactor: make evaluation use new ConfigurationStore --- src/client/eppo-client.ts | 121 +++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index fb58fa2..98a3c2a 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -15,6 +15,7 @@ import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-ca import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; import ConfigurationRequestor from '../configuration-requestor'; +import { ConfigurationStore } from '../configuration-store'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { @@ -41,7 +42,7 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; -import { IConfiguration, StoreBackedConfiguration } from '../i-configuration'; +import { Configuration, IConfiguration, StoreBackedConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, @@ -124,10 +125,7 @@ export default class EppoClient { private banditLogger?: IBanditLogger; private banditAssignmentCache?: AssignmentCache; private configurationRequestParameters?: FlagConfigurationRequestParameters; - private banditModelConfigurationStore?: IConfigurationStore; - private banditVariationConfigurationStore?: IConfigurationStore; private overrideStore?: ISyncStore; - private flagConfigurationStore: IConfigurationStore; private assignmentLogger?: IAssignmentLogger; private assignmentCache?: AssignmentCache; // whether to suppress any errors and return default values instead @@ -137,6 +135,14 @@ export default class EppoClient { private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); + private readonly configurationStore = new ConfigurationStore(null); + /** @deprecated use configurationStore instead. */ + private flagConfigurationStore: IConfigurationStore; + /** @deprecated use configurationStore instead. */ + private banditModelConfigurationStore?: IConfigurationStore; + /** @deprecated use configurationStore instead. */ + private banditVariationConfigurationStore?: IConfigurationStore; + constructor({ eventDispatcher = new NoOpEventDispatcher(), isObfuscated, @@ -161,14 +167,8 @@ export default class EppoClient { } } - private getConfiguration(): IConfiguration { - return this.configurationRequestor - ? this.configurationRequestor.getConfiguration() - : new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); + private getConfiguration(): Configuration | null { + return this.configurationStore.getConfiguration(); } /** @@ -206,18 +206,6 @@ export default class EppoClient { this.configurationRequestParameters = configurationRequestParameters; } - // noinspection JSUnusedGlobalSymbols - setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { - this.flagConfigurationStore = flagConfigurationStore; - } - - // noinspection JSUnusedGlobalSymbols - setBanditVariationConfigurationStore( - banditVariationConfigurationStore: IConfigurationStore, - ) { - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - } - /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ // noinspection JSUnusedGlobalSymbols setEventDispatcher(eventDispatcher: EventDispatcher) { @@ -239,29 +227,6 @@ export default class EppoClient { this.eventDispatcher?.attachContext(key, value); } - // noinspection JSUnusedGlobalSymbols - setBanditModelConfigurationStore( - banditModelConfigurationStore: IConfigurationStore, - ) { - this.banditModelConfigurationStore = banditModelConfigurationStore; - } - - // noinspection JSUnusedGlobalSymbols - /** - * Setting this value will have no side effects other than triggering a warning when the actual - * configuration's obfuscated does not match the value set here. - * - * @deprecated The client determines whether the configuration is obfuscated by inspection - * @param isObfuscated - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - setIsObfuscated(isObfuscated: boolean) { - logger.warn( - '[Eppo SDK] setIsObfuscated no longer has an effect and will be removed in the next major release; obfuscation ' + - 'is now inferred from the configuration, so you can safely remove the call to this method.', - ); - } - setOverrideStore(store: ISyncStore): void { this.overrideStore = store; } @@ -641,6 +606,9 @@ export default class EppoClient { defaultAction: string, ): string { const config = this.getConfiguration(); + if (!config) { + return defaultAction; + } let result: string | null = null; const flagBanditVariations = config.getFlagBanditVariations(flagKey); @@ -697,6 +665,10 @@ export default class EppoClient { variation = assignedVariation; evaluationDetails = assignmentEvaluationDetails; + if (!config) { + return { variation, action: null, evaluationDetails }; + } + // Check if the assigned variation is an active bandit // Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or // a rollout having been done. @@ -929,8 +901,11 @@ export default class EppoClient { subjectAttributes: Attributes = {}, ): Record { const config = this.getConfiguration(); + if (!config) { + return {}; + } const configDetails = config.getFlagConfigDetails(); - const flagKeys = this.getFlagKeys(); + const flagKeys = config.getFlagKeys(); const flags: Record = {}; // Evaluate all the enabled flags for the user @@ -985,11 +960,24 @@ export default class EppoClient { banditActions: Record = {}, salt?: string, ): string { + const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); + const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); + const config = this.getConfiguration(); + if (!config) { + const precomputedConfig = PrecomputedConfiguration.obfuscated( + subjectKey, + {}, + {}, + salt ?? '', + subjectContextualAttributes, + undefined, + ); + return JSON.stringify(ConfigurationWireV1.precomputed(precomputedConfig)); + } + const configDetails = config.getFlagConfigDetails(); - const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); - const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); const bandits = this.computeBanditsForFlags( @@ -1036,6 +1024,14 @@ export default class EppoClient { const config = this.getConfiguration(); const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(config, flagKey); + if (!config) { + const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( + 'FLAG_UNRECOGNIZED_OR_DISABLED', + "Configuration hasn't being fetched yet", + ); + return noneResult(flagKey, subjectKey, subjectAttributes, flagEvaluationDetails, ''); + } + const overrideVariation = this.overrideStore?.get(flagKey); if (overrideVariation) { return overrideResult( @@ -1138,9 +1134,13 @@ export default class EppoClient { } private newFlagEvaluationDetailsBuilder( - config: IConfiguration, + config: IConfiguration | null, flagKey: string, ): FlagEvaluationDetailsBuilder { + if (!config) { + return new FlagEvaluationDetailsBuilder('', [], '', ''); + } + const flag = this.getNormalizedFlag(config, flagKey); const configDetails = config.getFlagConfigDetails(); return new FlagEvaluationDetailsBuilder( @@ -1162,19 +1162,8 @@ export default class EppoClient { return flag ? decodeFlag(flag) : null; } - // noinspection JSUnusedGlobalSymbols - getFlagKeys() { - /** - * Returns a list of all flag keys that have been initialized. - * This can be useful to debug the initialization process. - * - * Note that it is generally not a good idea to preload all flag configurations. - */ - return this.getConfiguration().getFlagKeys(); - } - isInitialized() { - return this.getConfiguration().isInitialized(); + return this.getConfiguration()?.isInitialized() ?? false; } /** @deprecated Use `setAssignmentLogger` */ @@ -1239,10 +1228,6 @@ export default class EppoClient { this.isGracefulFailureMode = gracefulFailureMode; } - getFlagConfigurations(): Record { - return this.getConfiguration().getFlags(); - } - private flushQueuedEvents(eventQueue: BoundedEventQueue, logFunction?: (event: T) => void) { const eventsToFlush = eventQueue.flush(); if (!logFunction) { @@ -1319,7 +1304,7 @@ export default class EppoClient { private buildLoggerMetadata(): Record { return { - obfuscated: this.getConfiguration().isObfuscated(), + obfuscated: this.getConfiguration()?.isObfuscated(), sdkLanguage: 'javascript', sdkLibVersion: LIB_VERSION, }; From c2a07c124f37686e00c0a4bb79a24b6f1cbd80ae Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 13:53:08 +0200 Subject: [PATCH 06/25] refactor: fix linter errors --- src/client/eppo-client.ts | 2 +- src/configuration-store/configuration-store.ts | 15 +++++++++------ src/configuration-store/index.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 98a3c2a..b7912ac 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -42,7 +42,7 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; -import { Configuration, IConfiguration, StoreBackedConfiguration } from '../i-configuration'; +import { Configuration, IConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index 63f5f93..27a4d52 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -8,11 +8,12 @@ import { Environment } from '../interfaces'; * @internal `ConfigurationStore` shall only be used inside Eppo SDKs. */ export class ConfigurationStore { - private readonly listeners: Array< - (configuration: Configuration | null) => void - > = []; + private configuration: Configuration | null; + private readonly listeners: Array<(configuration: Configuration | null) => void> = []; - public constructor(private configuration: Configuration | null = null) {} + public constructor(configuration: Configuration | null) { + this.configuration = configuration; + } public getConfiguration(): Configuration | null { return this.configuration; @@ -30,7 +31,7 @@ export class ConfigurationStore { * Returns a function to unsubscribe from future updates. */ public onConfigurationChange( - listener: (configuration: Configuration | null) => void + listener: (configuration: Configuration | null) => void, ): () => void { this.listeners.push(listener); @@ -46,7 +47,9 @@ export class ConfigurationStore { for (const listener of this.listeners) { try { listener(this.configuration); - } catch {} + } catch { + // ignore + } } } } diff --git a/src/configuration-store/index.ts b/src/configuration-store/index.ts index 4ff7328..80c3180 100644 --- a/src/configuration-store/index.ts +++ b/src/configuration-store/index.ts @@ -1 +1 @@ -export { ConfigurationStore } from "./configuration-store"; +export { ConfigurationStore } from './configuration-store'; From 6df8767545b387b82cd5125762814e787d060a16 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 16:52:15 +0200 Subject: [PATCH 07/25] refactor: add Configuration type --- src/client/eppo-client.ts | 116 ++++++---------- .../configuration-store.ts | 16 +-- src/configuration.ts | 128 ++++++++++++++++++ src/evaluator.ts | 21 +-- src/i-configuration.ts | 1 + src/interfaces.ts | 1 + src/persistent-configuration-storage.ts | 2 +- 7 files changed, 189 insertions(+), 96 deletions(-) create mode 100644 src/configuration.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index b7912ac..c4e154e 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -14,6 +14,7 @@ import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; +import { Configuration } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; @@ -30,7 +31,6 @@ import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_REQUEST_TIMEOUT_MS, } from '../constants'; -import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; import { BoundedEventQueue } from '../events/bounded-event-queue'; @@ -42,19 +42,18 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; -import { Configuration, IConfiguration } from '../i-configuration'; import { BanditModelData, BanditParameters, BanditVariation, Flag, + FormatEnum, IPrecomputedBandit, ObfuscatedFlag, PrecomputedFlag, Variation, VariationType, } from '../interfaces'; -import { getMD5Hash } from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; import initPoller, { IPoller } from '../poller'; import { @@ -106,6 +105,7 @@ export type EppoClientParameters = { banditModelConfigurationStore?: IConfigurationStore; overrideStore?: ISyncStore; configurationRequestParameters?: FlagConfigurationRequestParameters; + initialConfiguration?: Configuration; /** * Setting this value will have no side effects other than triggering a warning when the actual * configuration's obfuscated does not match the value set here. @@ -135,7 +135,7 @@ export default class EppoClient { private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); - private readonly configurationStore = new ConfigurationStore(null); + private readonly configurationStore; /** @deprecated use configurationStore instead. */ private flagConfigurationStore: IConfigurationStore; /** @deprecated use configurationStore instead. */ @@ -151,7 +151,10 @@ export default class EppoClient { banditModelConfigurationStore, overrideStore, configurationRequestParameters, + initialConfiguration, }: EppoClientParameters) { + this.configurationStore = new ConfigurationStore(initialConfiguration); + this.eventDispatcher = eventDispatcher; this.flagConfigurationStore = flagConfigurationStore; this.banditVariationConfigurationStore = banditVariationConfigurationStore; @@ -167,10 +170,14 @@ export default class EppoClient { } } - private getConfiguration(): Configuration | null { + public getConfiguration(): Configuration { return this.configurationStore.getConfiguration(); } + public setConfiguration(configuration: Configuration) { + this.configurationStore.setConfiguration(configuration); + } + /** * Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension */ @@ -606,16 +613,13 @@ export default class EppoClient { defaultAction: string, ): string { const config = this.getConfiguration(); - if (!config) { - return defaultAction; - } let result: string | null = null; const flagBanditVariations = config.getFlagBanditVariations(flagKey); - const banditKey = flagBanditVariations?.at(0)?.key; + const banditKey = flagBanditVariations.at(0)?.key; if (banditKey) { - const banditParameters = config.getBandit(banditKey); + const banditParameters = config.getBanditConfiguration()?.response.bandits[banditKey]; if (banditParameters) { const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes); const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions); @@ -673,7 +677,6 @@ export default class EppoClient { // Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or // a rollout having been done. const bandit = config.getFlagVariationBandit(flagKey, variation); - if (!bandit) { return { variation, action: null, evaluationDetails }; } @@ -901,29 +904,19 @@ export default class EppoClient { subjectAttributes: Attributes = {}, ): Record { const config = this.getConfiguration(); - if (!config) { - return {}; - } - const configDetails = config.getFlagConfigDetails(); const flagKeys = config.getFlagKeys(); const flags: Record = {}; // Evaluate all the enabled flags for the user flagKeys.forEach((flagKey) => { - const flag = this.getNormalizedFlag(config, flagKey); + const flag = config.getFlag(flagKey); if (!flag) { logger.debug(`${loggerPrefix} No assigned variation. Flag does not exist.`); return; } // Evaluate the flag for this subject. - const evaluation = this.evaluator.evaluateFlag( - flag, - configDetails, - subjectKey, - subjectAttributes, - config.isObfuscated(), - ); + const evaluation = this.evaluator.evaluateFlag(config, flag, subjectKey, subjectAttributes); // allocationKey is set along with variation when there is a result. this check appeases typescript below if (!evaluation.variation || !evaluation.allocationKey) { @@ -960,24 +953,10 @@ export default class EppoClient { banditActions: Record = {}, salt?: string, ): string { - const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); - const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); - const config = this.getConfiguration(); - if (!config) { - const precomputedConfig = PrecomputedConfiguration.obfuscated( - subjectKey, - {}, - {}, - salt ?? '', - subjectContextualAttributes, - undefined, - ); - return JSON.stringify(ConfigurationWireV1.precomputed(precomputedConfig)); - } - - const configDetails = config.getFlagConfigDetails(); + const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); + const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); const bandits = this.computeBanditsForFlags( @@ -994,7 +973,7 @@ export default class EppoClient { bandits, salt ?? '', // no salt if not provided subjectContextualAttributes, - configDetails.configEnvironment, + config.getFlagsConfiguration()?.response.environment, ); const configWire: IConfigurationWire = ConfigurationWireV1.precomputed(precomputedConfig); @@ -1043,8 +1022,7 @@ export default class EppoClient { ); } - const configDetails = config.getFlagConfigDetails(); - const flag = this.getNormalizedFlag(config, flagKey); + const flag = config.getFlag(flagKey); if (flag === null) { logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); @@ -1058,7 +1036,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.environment.name ?? '', ); } @@ -1074,7 +1052,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.format ?? '', ); } throw new TypeError(errorMessage); @@ -1092,23 +1070,20 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.format ?? '', ); } - const isObfuscated = config.isObfuscated(); const result = this.evaluator.evaluateFlag( + config, flag, - configDetails, subjectKey, subjectAttributes, - isObfuscated, expectedVariationType, ); - if (isObfuscated) { - // flag.key is obfuscated, replace with requested flag key - result.flagKey = flagKey; - } + + // if flag.key is obfuscated, replace with requested flag key + result.flagKey = flagKey; try { if (result?.doLog) { @@ -1134,36 +1109,22 @@ export default class EppoClient { } private newFlagEvaluationDetailsBuilder( - config: IConfiguration | null, + config: Configuration, flagKey: string, ): FlagEvaluationDetailsBuilder { - if (!config) { - return new FlagEvaluationDetailsBuilder('', [], '', ''); - } - - const flag = this.getNormalizedFlag(config, flagKey); - const configDetails = config.getFlagConfigDetails(); + const flag = config.getFlag(flagKey); + const flagsConfiguration = config.getFlagsConfiguration(); return new FlagEvaluationDetailsBuilder( - configDetails.configEnvironment.name, + flagsConfiguration?.response.environment.name ?? '', flag?.allocations ?? [], - configDetails.configFetchedAt, - configDetails.configPublishedAt, + flagsConfiguration?.fetchedAt ?? '', + flagsConfiguration?.response.createdAt ?? '', ); } - private getNormalizedFlag(config: IConfiguration, flagKey: string): Flag | null { - return config.isObfuscated() - ? this.getObfuscatedFlag(config, flagKey) - : config.getFlag(flagKey); - } - - private getObfuscatedFlag(config: IConfiguration, flagKey: string): Flag | null { - const flag: ObfuscatedFlag | null = config.getFlag(getMD5Hash(flagKey)) as ObfuscatedFlag; - return flag ? decodeFlag(flag) : null; - } - isInitialized() { - return this.getConfiguration()?.isInitialized() ?? false; + // We treat configuration as initialized if we have flags config. + return !!this.configurationStore.getConfiguration()?.getFlagsConfiguration(); } /** @deprecated Use `setAssignmentLogger` */ @@ -1304,14 +1265,15 @@ export default class EppoClient { private buildLoggerMetadata(): Record { return { - obfuscated: this.getConfiguration()?.isObfuscated(), + obfuscated: + this.getConfiguration()?.getFlagsConfiguration()?.response.format === FormatEnum.CLIENT, sdkLanguage: 'javascript', sdkLibVersion: LIB_VERSION, }; } private computeBanditsForFlags( - config: IConfiguration, + config: Configuration, subjectKey: string, subjectAttributes: ContextAttributes, banditActions: Record, @@ -1341,7 +1303,7 @@ export default class EppoClient { } private getPrecomputedBandit( - config: IConfiguration, + config: Configuration, flagKey: string, variationValue: string, subjectKey: string, diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index 27a4d52..a63e2e0 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,4 +1,4 @@ -import { Configuration } from '../i-configuration'; +import { Configuration } from '../configuration'; import { Environment } from '../interfaces'; /** @@ -8,18 +8,18 @@ import { Environment } from '../interfaces'; * @internal `ConfigurationStore` shall only be used inside Eppo SDKs. */ export class ConfigurationStore { - private configuration: Configuration | null; - private readonly listeners: Array<(configuration: Configuration | null) => void> = []; + private configuration: Configuration; + private readonly listeners: Array<(configuration: Configuration) => void> = []; - public constructor(configuration: Configuration | null) { + public constructor(configuration: Configuration = Configuration.empty()) { this.configuration = configuration; } - public getConfiguration(): Configuration | null { + public getConfiguration(): Configuration { return this.configuration; } - public setConfiguration(configuration: Configuration | null): void { + public setConfiguration(configuration: Configuration): void { this.configuration = configuration; this.notifyListeners(); } @@ -30,9 +30,7 @@ export class ConfigurationStore { * * Returns a function to unsubscribe from future updates. */ - public onConfigurationChange( - listener: (configuration: Configuration | null) => void, - ): () => void { + public onConfigurationChange(listener: (configuration: Configuration) => void): () => void { this.listeners.push(listener); return () => { diff --git a/src/configuration.ts b/src/configuration.ts new file mode 100644 index 0000000..0d79858 --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,128 @@ +import { decodeFlag } from './decoding'; +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; +import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; +import { getMD5Hash } from './obfuscation'; +import { FlagKey, HashedFlagKey } from './types'; + +/** @internal for SDK use only */ +export type FlagsConfig = { + response: IUniversalFlagConfigResponse; + etag?: string; + /** ISO timestamp when configuration was fetched from the server. */ + fetchedAt?: string; +}; + +/** @internal for SDK use only */ +export type BanditsConfig = { + response: IBanditParametersResponse; + etag?: string; + /** ISO timestamp when configuration was fetched from the server. */ + fetchedAt?: string; +}; + +/** + * *The* Configuration. + * + * Note: configuration should be treated as immutable. Do not change + * any of the fields or returned data. Otherwise, bad things will + * happen. + */ +export class Configuration { + private flagBanditVariations: Record; + + private constructor( + private readonly flags?: FlagsConfig, + private readonly bandits?: BanditsConfig, + ) { + this.flagBanditVariations = flags ? indexBanditVariationsByFlagKey(flags.response) : {}; + } + + public static empty(): Configuration { + return new Configuration(); + } + + /** @internal For SDK usage only. */ + public static fromResponses({ + flags, + bandits, + }: { + flags?: FlagsConfig; + bandits?: BanditsConfig; + }): Configuration { + return new Configuration(flags, bandits); + } + + // TODO(v5) + // public static fromString(configurationWire: string): Configuration {} + // public toString(): string {} + + public getFlagKeys(): FlagKey[] | HashedFlagKey[] { + if (!this.flags) { + return []; + } + return Object.keys(this.flags.response.flags); + } + + /** @internal */ + public getFlagsConfiguration(): FlagsConfig | undefined { + return this.flags; + } + + /** @internal + * + * Returns flag configuration for the given flag key. Obfuscation is + * handled automatically. + */ + public getFlag(flagKey: string): Flag | null { + if (!this.flags) { + return null; + } + + if (this.flags.response.format === FormatEnum.SERVER) { + return this.flags.response.flags[flagKey] ?? null; + } else { + // Obfuscated configuration + const flag = this.flags.response.flags[getMD5Hash(flagKey)]; + return flag ? decodeFlag(flag as ObfuscatedFlag) : null; + } + } + + /** @internal */ + public getBanditConfiguration(): BanditsConfig | undefined { + return this.bandits; + } + + /** @internal */ + public getFlagBanditVariations(flagKey: FlagKey | HashedFlagKey): BanditVariation[] { + return this.flagBanditVariations[flagKey] ?? []; + } + + public getFlagVariationBandit(flagKey: string, variationValue: string): BanditParameters | null { + const banditVariations = this.getFlagBanditVariations(flagKey); + const banditKey = banditVariations?.find( + (banditVariation) => banditVariation.variationValue === variationValue, + )?.key; + + if (banditKey) { + return this.bandits?.response.bandits[banditKey] ?? null; + } + return null; + } +} + +function indexBanditVariationsByFlagKey( + flagsResponse: IUniversalFlagConfigResponse, +): Record { + const banditVariationsByFlagKey: Record = {}; + Object.values(flagsResponse.banditReferences).forEach((banditReference) => { + banditReference.flagVariations.forEach((banditVariation) => { + let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; + if (!banditVariations) { + banditVariations = []; + banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; + } + banditVariations.push(banditVariation); + }); + }); + return banditVariationsByFlagKey; +} diff --git a/src/evaluator.ts b/src/evaluator.ts index 98ab3fd..e65799a 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,4 +1,5 @@ import { checkValueTypeMatch } from './client/eppo-client'; +import { Configuration } from './configuration'; import { AllocationEvaluationCode, IFlagEvaluationDetails, @@ -14,7 +15,7 @@ import { Allocation, Split, VariationType, - ConfigDetails, + FormatEnum, } from './interfaces'; import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; @@ -45,19 +46,21 @@ export class Evaluator { } evaluateFlag( + configuration: Configuration, flag: Flag, - configDetails: ConfigDetails, subjectKey: string, subjectAttributes: Attributes, - obfuscated: boolean, expectedVariationType?: VariationType, ): FlagEvaluation { + const flagsConfig = configuration.getFlagsConfiguration(); const flagEvaluationDetailsBuilder = new FlagEvaluationDetailsBuilder( - configDetails.configEnvironment.name, + flagsConfig?.response.environment.name ?? '', flag.allocations, - configDetails.configFetchedAt, - configDetails.configPublishedAt, + flagsConfig?.fetchedAt ?? '', + flagsConfig?.response.createdAt ?? '', ); + const configFormat = flagsConfig?.response.format; + const obfuscated = configFormat !== FormatEnum.SERVER; try { if (!flag.enabled) { return noneResult( @@ -68,7 +71,7 @@ export class Evaluator { 'FLAG_UNRECOGNIZED_OR_DISABLED', `Unrecognized or disabled flag: ${flag.key}`, ), - configDetails.configFormat, + configFormat ?? '', ); } @@ -115,7 +118,7 @@ export class Evaluator { .build(flagEvaluationCode, flagEvaluationDescription); return { flagKey: flag.key, - format: configDetails.configFormat, + format: configFormat ?? '', subjectKey, subjectAttributes, allocationKey: allocation.key, @@ -141,7 +144,7 @@ export class Evaluator { 'DEFAULT_ALLOCATION_NULL', 'No allocations matched. Falling back to "Default Allocation", serving NULL', ), - configDetails.configFormat, + configFormat ?? '', ); } catch (err: any) { console.error('>>>>', err); diff --git a/src/i-configuration.ts b/src/i-configuration.ts index b210c85..5858356 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -11,6 +11,7 @@ import { } from './interfaces'; import { BanditKey, FlagKey, HashedFlagKey } from './types'; +// TODO(v5): remove IConfiguration once all users migrate to Configuration. export interface IConfiguration { getFlag(key: FlagKey | HashedFlagKey): Flag | ObfuscatedFlag | null; getFlags(): Record; diff --git a/src/interfaces.ts b/src/interfaces.ts index a84a848..031610c 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -44,6 +44,7 @@ export interface Environment { } export const UNKNOWN_ENVIRONMENT_NAME = 'UNKNOWN'; +/** @deprecated(v5) `ConfigDetails` is too naive about how configurations actually work. */ export interface ConfigDetails { configFetchedAt: string; configPublishedAt: string; diff --git a/src/persistent-configuration-storage.ts b/src/persistent-configuration-storage.ts index 185ad95..1dbe25b 100644 --- a/src/persistent-configuration-storage.ts +++ b/src/persistent-configuration-storage.ts @@ -1,4 +1,4 @@ -import { Configuration } from './i-configuration'; +import { Configuration } from './configuration'; /** * Persistent configuration storages are responsible for persisting From 4d3b534bbe0f8b1994dbef1972f17226576583b3 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 17:06:19 +0200 Subject: [PATCH 08/25] refactor: make requestor work with new ConfigurationStore --- src/client/eppo-client.ts | 11 +- src/configuration-requestor.ts | 142 ++++-------------- .../configuration-wire-helper.ts | 23 --- src/configuration.ts | 52 ++++++- src/http-client.ts | 27 +++- 5 files changed, 101 insertions(+), 154 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index c4e154e..b5b25e1 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -287,18 +287,11 @@ export default class EppoClient { queryParams: { apiKey, sdkName, sdkVersion }, }); const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); - const configurationRequestor = new ConfigurationRequestor( - httpClient, - this.flagConfigurationStore, - this.banditVariationConfigurationStore ?? null, - this.banditModelConfigurationStore ?? null, - ); + const configurationRequestor = new ConfigurationRequestor(httpClient, this.configurationStore); this.configurationRequestor = configurationRequestor; const pollingCallback = async () => { - if (await configurationRequestor.isFlagConfigExpired()) { - return configurationRequestor.fetchAndStoreConfigurations(); - } + return configurationRequestor.fetchAndStoreConfigurations(); }; this.requestPoller = initPoller(pollingIntervalMs, pollingCallback, { diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 1f48e09..e884f6a 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,132 +1,48 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; +import { Configuration } from './configuration'; +import { ConfigurationStore } from './configuration-store'; import { IHttpClient } from './http-client'; -import { - ConfigStoreHydrationPacket, - IConfiguration, - StoreBackedConfiguration, -} from './i-configuration'; -import { BanditVariation, BanditParameters, Flag, BanditReference } from './interfaces'; + +export type ConfigurationRequestorOptions = { + wantsBandits?: boolean; +}; // Requests AND stores flag configurations export default class ConfigurationRequestor { - private banditModelVersions: string[] = []; - private readonly configuration: StoreBackedConfiguration; + private readonly options: ConfigurationRequestorOptions; constructor( private readonly httpClient: IHttpClient, - private readonly flagConfigurationStore: IConfigurationStore, - private readonly banditVariationConfigurationStore: IConfigurationStore< - BanditVariation[] - > | null, - private readonly banditModelConfigurationStore: IConfigurationStore | null, + private readonly configurationStore: ConfigurationStore, + options: Partial = {}, ) { - this.configuration = new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); - } - - public isFlagConfigExpired(): Promise { - return this.flagConfigurationStore.isExpired(); - } - - public getConfiguration(): IConfiguration { - return this.configuration; + this.options = { + wantsBandits: true, + ...options, + }; } - async fetchAndStoreConfigurations(): Promise { + async fetchConfiguration(): Promise { const configResponse = await this.httpClient.getUniversalFlagConfiguration(); - if (!configResponse?.flags) { - return; - } - - const flagResponsePacket: ConfigStoreHydrationPacket = { - entries: configResponse.flags, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - let banditVariationPacket: ConfigStoreHydrationPacket | undefined; - let banditModelPacket: ConfigStoreHydrationPacket | undefined; - const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; - const banditStoresProvided = Boolean( - this.banditVariationConfigurationStore && this.banditModelConfigurationStore, - ); - if (flagsHaveBandits && banditStoresProvided) { - // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) - const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); - - banditVariationPacket = { - entries: banditVariations, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - if ( - this.requiresBanditModelConfigurationStoreUpdate( - this.banditModelVersions, - configResponse.banditReferences, - ) - ) { - const banditResponse = await this.httpClient.getBanditParameters(); - if (banditResponse?.bandits) { - banditModelPacket = { - entries: banditResponse.bandits, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - this.banditModelVersions = this.getLoadedBanditModelVersions(banditResponse.bandits); - } - } + if (!configResponse?.response.flags) { + return null; } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { - // TODO: Notify that config updated. - } - } + const needsBandits = + this.options.wantsBandits && + Object.keys(configResponse.response.banditReferences ?? {}).length > 0; - private getLoadedBanditModelVersions(entries: Record): string[] { - return Object.values(entries).map((banditParam: BanditParameters) => banditParam.modelVersion); - } + const banditsConfig = needsBandits ? await this.httpClient.getBanditParameters() : undefined; - private requiresBanditModelConfigurationStoreUpdate( - currentBanditModelVersions: string[], - banditReferences: Record, - ): boolean { - const referencedModelVersions = Object.values(banditReferences).map( - (banditReference: BanditReference) => banditReference.modelVersion, - ); - - return !referencedModelVersions.every((modelVersion) => - currentBanditModelVersions.includes(modelVersion), - ); + return Configuration.fromResponses({ + flags: configResponse, + bandits: banditsConfig, + }); } - private indexBanditVariationsByFlagKey( - banditVariationsByBanditKey: Record, - ): Record { - const banditVariationsByFlagKey: Record = {}; - Object.values(banditVariationsByBanditKey).forEach((banditReference) => { - banditReference.flagVariations.forEach((banditVariation) => { - let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; - if (!banditVariations) { - banditVariations = []; - banditVariationsByFlagKey[banditVariation.flagKey] = banditVariations; - } - banditVariations.push(banditVariation); - }); - }); - return banditVariationsByFlagKey; + async fetchAndStoreConfigurations(): Promise { + const configuration = await this.fetchConfiguration(); + if (configuration) { + this.configurationStore.setConfiguration(configuration); + } } } diff --git a/src/configuration-wire/configuration-wire-helper.ts b/src/configuration-wire/configuration-wire-helper.ts index 0a06511..b48bf58 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -49,27 +49,4 @@ export class ConfigurationWireHelper { this.httpClient = new FetchHttpClient(apiEndpoints, 5000); } - - /** - * Fetches configuration data from the API and build a Bootstrap Configuration (aka an `IConfigurationWire` object). - * The IConfigurationWire instance can be used to bootstrap some SDKs. - */ - public async fetchBootstrapConfiguration(): Promise { - // Get the configs - let banditResponse: IBanditParametersResponse | undefined; - const configResponse: IUniversalFlagConfigResponse | undefined = - await this.httpClient.getUniversalFlagConfiguration(); - - if (!configResponse?.flags) { - console.warn('Unable to fetch configuration, returning empty configuration'); - return Promise.resolve(ConfigurationWireV1.empty()); - } - - const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; - if (flagsHaveBandits) { - banditResponse = await this.httpClient.getBanditParameters(); - } - - return ConfigurationWireV1.fromResponses(configResponse, banditResponse); - } } diff --git a/src/configuration.ts b/src/configuration.ts index 0d79858..e0c6bcd 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -2,7 +2,7 @@ import { decodeFlag } from './decoding'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; import { getMD5Hash } from './obfuscation'; -import { FlagKey, HashedFlagKey } from './types'; +import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; /** @internal for SDK use only */ export type FlagsConfig = { @@ -52,9 +52,28 @@ export class Configuration { return new Configuration(flags, bandits); } - // TODO(v5) + // TODO: // public static fromString(configurationWire: string): Configuration {} - // public toString(): string {} + + /** Serializes configuration to "configuration wire" format. */ + public toString(): string { + const wire: ConfigurationWire = { + version: 1, + }; + if (this.flags) { + wire.config = { + ...this.flags, + response: JSON.stringify(this.flags.response), + }; + } + if (this.bandits) { + wire.bandits = { + ...this.bandits, + response: JSON.stringify(this.bandits.response), + }; + } + return JSON.stringify(wire); + } public getFlagKeys(): FlagKey[] | HashedFlagKey[] { if (!this.flags) { @@ -126,3 +145,30 @@ function indexBanditVariationsByFlagKey( }); return banditVariationsByFlagKey; } + +/** @internal */ +type ConfigurationWire = { + /** + * Version field should be incremented for breaking format changes. + * For example, removing required fields or changing field type/meaning. + */ + version: 1; + + config?: { + response: string; + etag?: string; + fetchedAt?: string; + }; + + bandits?: { + response: string; + etag?: string; + fetchedAt?: string; + }; + + precomputed?: { + response: string; + subjectKey: string; + subjectAttributes?: ContextAttributes; + }; +}; diff --git a/src/http-client.ts b/src/http-client.ts index 063ecb3..9df6b28 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,4 +1,5 @@ import ApiEndpoints from './api-endpoints'; +import { BanditsConfig, FlagsConfig } from './configuration'; import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; import { BanditParameters, @@ -47,8 +48,8 @@ export interface IBanditParametersResponse { } export interface IHttpClient { - getUniversalFlagConfiguration(): Promise; - getBanditParameters(): Promise; + getUniversalFlagConfiguration(): Promise; + getBanditParameters(): Promise; getPrecomputedFlags( payload: PrecomputedFlagsPayload, ): Promise; @@ -62,14 +63,28 @@ export default class FetchHttpClient implements IHttpClient { private readonly timeout: number, ) {} - async getUniversalFlagConfiguration(): Promise { + async getUniversalFlagConfiguration(): Promise { const url = this.apiEndpoints.ufcEndpoint(); - return await this.rawGet(url); + const response = await this.rawGet(url); + if (!response) { + return undefined; + } + return { + response, + fetchedAt: new Date().toISOString(), + }; } - async getBanditParameters(): Promise { + async getBanditParameters(): Promise { const url = this.apiEndpoints.banditParametersEndpoint(); - return await this.rawGet(url); + const response = await this.rawGet(url); + if (!response) { + return undefined; + } + return { + response, + fetchedAt: new Date().toISOString(), + }; } async getPrecomputedFlags( From 2f70a51519125726b96c91fa88dfa767828cab32 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 17:32:08 +0200 Subject: [PATCH 09/25] refactor: eppo client cleanup --- src/client/eppo-client.ts | 21 +--- src/client/test-utils.ts | 14 +-- .../configuration-wire-helper.spec.ts | 98 ------------------- .../configuration-wire-helper.ts | 52 ---------- src/index.ts | 2 - 5 files changed, 4 insertions(+), 183 deletions(-) delete mode 100644 src/configuration-wire/configuration-wire-helper.spec.ts delete mode 100644 src/configuration-wire/configuration-wire-helper.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index b5b25e1..cbd013c 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,7 +17,7 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import { Configuration } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; -import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; +import { ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -44,12 +44,8 @@ import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; import { BanditModelData, - BanditParameters, - BanditVariation, - Flag, FormatEnum, IPrecomputedBandit, - ObfuscatedFlag, PrecomputedFlag, Variation, VariationType, @@ -100,9 +96,6 @@ export type EppoClientParameters = { // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment // or bandit events). These events are application-specific and captures by EppoClient#track API. eventDispatcher?: EventDispatcher; - flagConfigurationStore: IConfigurationStore; - banditVariationConfigurationStore?: IConfigurationStore; - banditModelConfigurationStore?: IConfigurationStore; overrideStore?: ISyncStore; configurationRequestParameters?: FlagConfigurationRequestParameters; initialConfiguration?: Configuration; @@ -136,19 +129,10 @@ export default class EppoClient { private readonly overrideValidator = new OverrideValidator(); private readonly configurationStore; - /** @deprecated use configurationStore instead. */ - private flagConfigurationStore: IConfigurationStore; - /** @deprecated use configurationStore instead. */ - private banditModelConfigurationStore?: IConfigurationStore; - /** @deprecated use configurationStore instead. */ - private banditVariationConfigurationStore?: IConfigurationStore; constructor({ eventDispatcher = new NoOpEventDispatcher(), isObfuscated, - flagConfigurationStore, - banditVariationConfigurationStore, - banditModelConfigurationStore, overrideStore, configurationRequestParameters, initialConfiguration, @@ -156,9 +140,6 @@ export default class EppoClient { this.configurationStore = new ConfigurationStore(initialConfiguration); this.eventDispatcher = eventDispatcher; - this.flagConfigurationStore = flagConfigurationStore; - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - this.banditModelConfigurationStore = banditModelConfigurationStore; this.overrideStore = overrideStore; this.configurationRequestParameters = configurationRequestParameters; diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index 546ae36..f9c4ce9 100644 --- a/src/client/test-utils.ts +++ b/src/client/test-utils.ts @@ -1,12 +1,9 @@ import ApiEndpoints from '../api-endpoints'; import ConfigurationRequestor from '../configuration-requestor'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { ConfigurationStore } from '../configuration-store'; import FetchHttpClient from '../http-client'; -import { Flag, ObfuscatedFlag } from '../interfaces'; -export async function initConfiguration( - configurationStore: IConfigurationStore, -) { +export async function initConfiguration(configurationStore: ConfigurationStore) { const apiEndpoints = new ApiEndpoints({ baseUrl: 'http://127.0.0.1:4000', queryParams: { @@ -16,11 +13,6 @@ export async function initConfiguration( }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor( - httpClient, - configurationStore, - null, - null, - ); + const configurationRequestor = new ConfigurationRequestor(httpClient, configurationStore); await configurationRequestor.fetchAndStoreConfigurations(); } diff --git a/src/configuration-wire/configuration-wire-helper.spec.ts b/src/configuration-wire/configuration-wire-helper.spec.ts deleted file mode 100644 index c3cc17e..0000000 --- a/src/configuration-wire/configuration-wire-helper.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../http-client'; -import { FormatEnum } from '../interfaces'; -import { getMD5Hash } from '../obfuscation'; - -import { ConfigurationWireHelper } from './configuration-wire-helper'; - -const TEST_BASE_URL = 'https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile'; -const DUMMY_SDK_KEY = 'dummy-sdk-key'; - -// This SDK causes the cloud endpoint below to serve the UFC test file with bandit flags. -const BANDIT_SDK_KEY = 'this-key-serves-bandits'; - -describe('ConfigurationWireHelper', () => { - describe('getBootstrapConfigurationFromApi', () => { - it('should fetch obfuscated flags with android SDK', async () => { - const helper = ConfigurationWireHelper.build(DUMMY_SDK_KEY, { - sdkName: 'android', - sdkVersion: '4.0.0', - baseUrl: TEST_BASE_URL, - }); - - const wirePacket = await helper.fetchBootstrapConfiguration(); - - expect(wirePacket.version).toBe(1); - expect(wirePacket.config).toBeDefined(); - - if (!wirePacket.config) { - throw new Error('Flag config not present in ConfigurationWire'); - } - - const configResponse = JSON.parse(wirePacket.config.response) as IUniversalFlagConfigResponse; - expect(configResponse.format).toBe(FormatEnum.CLIENT); - expect(configResponse.flags).toBeDefined(); - expect(Object.keys(configResponse.flags).length).toBeGreaterThan(1); - expect(Object.keys(configResponse.flags)).toHaveLength(19); - - const testFlagKey = getMD5Hash('numeric_flag'); - expect(Object.keys(configResponse.flags)).toContain(testFlagKey); - - // No bandits. - expect(configResponse.banditReferences).toBeUndefined(); - expect(wirePacket.bandits).toBeUndefined(); - }); - - it('should fetch flags and bandits for node-server SDK', async () => { - const helper = ConfigurationWireHelper.build(BANDIT_SDK_KEY, { - sdkName: 'node-server', - sdkVersion: '4.0.0', - baseUrl: TEST_BASE_URL, - }); - - const wirePacket = await helper.fetchBootstrapConfiguration(); - - expect(wirePacket.version).toBe(1); - expect(wirePacket.config).toBeDefined(); - - if (!wirePacket.config) { - throw new Error('Flag config not present in ConfigurationWire'); - } - - const configResponse = JSON.parse(wirePacket.config.response) as IUniversalFlagConfigResponse; - expect(configResponse.format).toBe(FormatEnum.SERVER); - expect(configResponse.flags).toBeDefined(); - expect(configResponse.banditReferences).toBeDefined(); - expect(Object.keys(configResponse.flags)).toContain('banner_bandit_flag'); - expect(Object.keys(configResponse.flags)).toContain('car_bandit_flag'); - - expect(wirePacket.bandits).toBeDefined(); - const banditResponse = JSON.parse( - wirePacket.bandits?.response ?? '', - ) as IBanditParametersResponse; - expect(Object.keys(banditResponse.bandits).length).toBeGreaterThan(1); - expect(Object.keys(banditResponse.bandits)).toContain('banner_bandit'); - expect(Object.keys(banditResponse.bandits)).toContain('car_bandit'); - }); - - it('should include fetchedAt timestamps', async () => { - const helper = ConfigurationWireHelper.build(BANDIT_SDK_KEY, { - sdkName: 'android', - sdkVersion: '4.0.0', - baseUrl: TEST_BASE_URL, - }); - - const wirePacket = await helper.fetchBootstrapConfiguration(); - - if (!wirePacket.config) { - throw new Error('Flag config not present in ConfigurationWire'); - } - if (!wirePacket.bandits) { - throw new Error('Bandit config not present in ConfigurationWire'); - } - - expect(wirePacket.config.fetchedAt).toBeDefined(); - expect(Date.parse(wirePacket.config.fetchedAt ?? '')).not.toBeNaN(); - expect(Date.parse(wirePacket.bandits.fetchedAt ?? '')).not.toBeNaN(); - }); - }); -}); diff --git a/src/configuration-wire/configuration-wire-helper.ts b/src/configuration-wire/configuration-wire-helper.ts deleted file mode 100644 index b48bf58..0000000 --- a/src/configuration-wire/configuration-wire-helper.ts +++ /dev/null @@ -1,52 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import FetchHttpClient, { - IBanditParametersResponse, - IHttpClient, - IUniversalFlagConfigResponse, -} from '../http-client'; - -import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; - -export type SdkOptions = { - sdkName: string; - sdkVersion: string; - baseUrl?: string; -}; - -/** - * Helper class for fetching and converting configuration from the Eppo API(s). - */ -export class ConfigurationWireHelper { - private httpClient: IHttpClient; - - /** - * Build a new ConfigurationHelper for the target SDK Key. - * @param sdkKey - */ - public static build( - sdkKey: string, - opts: SdkOptions = { sdkName: 'android', sdkVersion: '4.0.0' }, - ) { - const { sdkName, sdkVersion, baseUrl } = opts; - return new ConfigurationWireHelper(sdkKey, sdkName, sdkVersion, baseUrl); - } - - private constructor( - sdkKey: string, - targetSdkName = 'android', - targetSdkVersion = '4.0.0', - baseUrl?: string, - ) { - const queryParams = { - sdkName: targetSdkName, - sdkVersion: targetSdkVersion, - apiKey: sdkKey, - }; - const apiEndpoints = new ApiEndpoints({ - baseUrl, - queryParams, - }); - - this.httpClient = new FetchHttpClient(apiEndpoints, 5000); - } -} diff --git a/src/index.ts b/src/index.ts index a9ebaee..9dd8e6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,6 @@ import { } from './configuration-store/configuration-store'; import { HybridConfigurationStore } from './configuration-store/hybrid.store'; import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; -import { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, @@ -156,7 +155,6 @@ export { IPrecomputedConfigurationResponse, PrecomputedFlag, FlagKey, - ConfigurationWireHelper, // Test helpers decodePrecomputedFlag, From d4c6f069a33ed8bf6e34fd03c3ca0038b24a4837 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Thu, 20 Mar 2025 19:24:37 +0200 Subject: [PATCH 10/25] refactor: remove temp configuration --- src/i-configuration.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/i-configuration.ts b/src/i-configuration.ts index 5858356..4ddf1f6 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -29,12 +29,6 @@ export interface IConfiguration { isInitialized(): boolean; } -// TODO: replace more abstract `IConfiguration` with some concrete -// implementation, so we know what to expect from it (i.e., it's -// probably a bad idea to allow users implementing their own -// configurations). -export type Configuration = IConfiguration; - export type ConfigStoreHydrationPacket = { entries: Record; environment: Environment; From 60c8617f957fc8121e5ea8ddca27d95c1004ada9 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Fri, 21 Mar 2025 18:10:18 +0200 Subject: [PATCH 11/25] refactor: don't fetch bandits if current model is up-to-date [ci skip] --- src/configuration-requestor.ts | 55 ++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index e884f6a..607e61b 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,9 +1,9 @@ -import { Configuration } from './configuration'; +import { BanditsConfig, Configuration, FlagsConfig } from './configuration'; import { ConfigurationStore } from './configuration-store'; import { IHttpClient } from './http-client'; export type ConfigurationRequestorOptions = { - wantsBandits?: boolean; + wantsBandits: boolean; }; // Requests AND stores flag configurations @@ -22,21 +22,14 @@ export default class ConfigurationRequestor { } async fetchConfiguration(): Promise { - const configResponse = await this.httpClient.getUniversalFlagConfiguration(); - if (!configResponse?.response.flags) { + const flags = await this.httpClient.getUniversalFlagConfiguration(); + if (!flags?.response.flags) { return null; } - const needsBandits = - this.options.wantsBandits && - Object.keys(configResponse.response.banditReferences ?? {}).length > 0; - - const banditsConfig = needsBandits ? await this.httpClient.getBanditParameters() : undefined; + const bandits = await this.getBanditsFor(flags); - return Configuration.fromResponses({ - flags: configResponse, - bandits: banditsConfig, - }); + return Configuration.fromResponses({ flags, bandits }); } async fetchAndStoreConfigurations(): Promise { @@ -45,4 +38,40 @@ export default class ConfigurationRequestor { this.configurationStore.setConfiguration(configuration); } } + + /** + * Get bandits configuration matching the flags configuration. + * + * This function does not fetch bandits if the client does not want + * them (`ConfigurationRequestorOptions.wantsBandits === false`) or + * we we can reuse bandit models from `ConfigurationStore`. + */ + private async getBanditsFor(flags: FlagsConfig): Promise { + const needsBandits = + this.options.wantsBandits && Object.keys(flags.response.banditReferences ?? {}).length > 0; + if (!needsBandits) { + return undefined; + } + + const prevBandits = this.configurationStore.getConfiguration().getBanditConfiguration(); + const canReuseBandits = banditsUpToDate(flags, prevBandits); + if (canReuseBandits) { + return prevBandits; + } + + return await this.httpClient.getBanditParameters(); + } } + +/** + * Checks that bandits configuration matches the flags + * configuration. This is done by checking that bandits configuration + * has proper versions for all bandits references in flags + * configuration. + */ +const banditsUpToDate = (flags: FlagsConfig, bandits: BanditsConfig | undefined): boolean => { + const banditParams = bandits?.response.bandits ?? {}; + return Object.entries(flags.response.banditReferences ?? {}).every( + ([banditKey, reference]) => reference.modelVersion === banditParams[banditKey]?.modelVersion, + ); +}; From 51f4abbc12113ee307f815eaa7e084aefe89b6e4 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Sat, 29 Mar 2025 03:35:13 +0200 Subject: [PATCH 12/25] refactor: implement new initialization/poller behavior --- src/client/eppo-client.ts | 490 +++++++++++++----- src/client/eppo-precomputed-client.spec.ts | 10 +- src/client/eppo-precomputed-client.ts | 20 +- src/configuration-poller.ts | 123 +++++ src/configuration-requestor.ts | 17 +- .../configuration-store.ts | 24 +- src/configuration.ts | 29 ++ src/constants.ts | 2 +- src/listener.ts | 26 + src/poller.spec.ts | 4 +- src/poller.ts | 4 +- 11 files changed, 567 insertions(+), 182 deletions(-) create mode 100644 src/configuration-poller.ts create mode 100644 src/listener.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index cbd013c..e35b8a0 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -28,7 +28,7 @@ import { import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_INTERVAL_MS, + DEFAULT_BASE_POLLING_INTERVAL_MS, DEFAULT_REQUEST_TIMEOUT_MS, } from '../constants'; import { EppoValue } from '../eppo_value'; @@ -51,7 +51,7 @@ import { VariationType, } from '../interfaces'; import { OverridePayload, OverrideValidator } from '../override-validator'; -import initPoller, { IPoller } from '../poller'; +import initPoller, { IPoller, randomJitterMs } from '../poller'; import { Attributes, AttributeType, @@ -64,28 +64,14 @@ import { import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; - +import { PersistentConfigurationStorage } from '../persistent-configuration-storage'; +import { ConfigurationPoller } from '../configuration-poller'; export interface IAssignmentDetails { variation: T; action: string | null; evaluationDetails: IFlagEvaluationDetails; } -export type FlagConfigurationRequestParameters = { - apiKey: string; - sdkVersion: string; - sdkName: string; - baseUrl?: string; - requestTimeoutMs?: number; - pollingIntervalMs?: number; - numInitialRequestRetries?: number; - numPollRequestRetries?: number; - pollAfterSuccessfulInitialization?: boolean; - pollAfterFailedInitialization?: boolean; - throwOnFailedInitialization?: boolean; - skipInitialPoll?: boolean; -}; - export interface IContainerExperiment { flagKey: string; controlVariationEntry: T; @@ -93,19 +79,157 @@ export interface IContainerExperiment { } export type EppoClientParameters = { + sdkKey: string; + sdkName: string; + sdkVersion: string; + baseUrl?: string; + // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment // or bandit events). These events are application-specific and captures by EppoClient#track API. eventDispatcher?: EventDispatcher; overrideStore?: ISyncStore; - configurationRequestParameters?: FlagConfigurationRequestParameters; - initialConfiguration?: Configuration; - /** - * Setting this value will have no side effects other than triggering a warning when the actual - * configuration's obfuscated does not match the value set here. - * - * @deprecated obfuscation is determined by inspecting the `format` field of the UFC response. - */ - isObfuscated?: boolean; + + bandits?: { + /** + * Whether to enable bandits. + * + * This influences whether bandits configuration is loaded. + * Disabling bandits helps to save network bandwidth if bandits + * are unused. + * + * @default true + */ + enable?: boolean; + }; + + configuration?: { + /** + * Strategy for fetching initial configuration. + * + * - `stale-while-revalidate`: serve assignments using cached + * configuration (within `maxStaleSeconds`), while fetching a + * fresh configuration (if cached one is stale). If fetch fails + * or times out, use the cached/stale configuration. + * + * - `only-if-cached`: use cached configuration, even if stale. If + * no cached configuration is available, use default + * configuration. + * + * - `no-cache`: ignore cached configuration and always fetch a + * fresh configuration. If fetching fails, use default (empty) + * configuration. + * + * - `none`: consider client initialized without loading any + * configuration (except `initialConfiguration`). Can be useful + * if you want to manually control configuration. + * + * @default 'stale-while-revalidate' + */ + initializationStrategy?: 'stale-while-revalidate' | 'only-if-cached' | 'no-cache' | 'none'; + + persistentStorage?: PersistentConfigurationStorage; + + /** + * You may speed-up initialization process by bootstrapping client + * using `Configuration` received from another Eppo client (e.g., + * initialize client SDK using configuration from server SDK). + * + * For the purposes of initialization, this configuration is + * considered as cached, so the client may still issue a fetch + * request if it detects that configuration is too old. If you + * want to disable any network requests during initialization, set + * `initializationStrategy` to `none`. + * + * @default undefined + */ + initialConfiguration?: Configuration; + + /** + * Maximum time the client is allowed to spend in + * initialization. After timeout is reached, the client will use + * the best configuration that it got and consider initialization + * finished. + * + * @default 5_000 (5 seconds) + */ + initializationTimeoutMs?: number; + + /** + * Allow using cached configuration that is `maxAgeSeconds` old, + * without attempting to fetch a fresh configuration. + * + * @default 30 + */ + maxAgeSeconds?: number; + + /** + * Allow using a stale configuration that is stale within + * `maxStaleSeconds`. Stale configuration may be used if server is + * unreachable. + * + * @default Infinity + */ + maxStaleSeconds?: number; + + /** + * Whether to enable periodic polling for configuration. + * + * If enabled, the client will try to fetch a new configuration + * every `basePollingIntervalMs` milliseconds. + * + * When configuration is successfully fetched, it is stored in + * persistent storage (cache) if available. `activationStrategy` + * determines whether configuration is activated (i.e., becomes + * used for evaluating assignments and bandits). + * + * @default true (for Node.js SDK) + * @default false (for Client SDK) + */ + enablePolling?: boolean; + /** + * How often to poll for configuration. + * + * @default 30_000 (30 seconds) + */ + basePollingIntervalMs?: number; + /** + * Maximum polling interval. + * + * @default 300_000 (5 minutes) + */ + maxPollingIntervalMs?: number; + + /** + * When to activate the fetched configuration, allowing it to be + * used to evaluate assignments and bandits. + * + * - `next-load`: the fetched configuration is stored in persistent storage and + * will be activated on next client initialization. Assignments + * and bandits continue to be served using the currently active + * configuration. This is helpful in client application if you + * want to ensure that user experience is not disrupted in the + * middle of the session. + * + * - `stale`: activate fetched configuration if the current one + * exceeds `maxStaleSeconds`. + * + * - `empty`: activate fetched configuration if the current + * configuration is empty (serving default assignments). + * + * - `always`: always activate the latest fetched configuration. + * + * @default 'always' (for Node.js SDK) + * @default 'stale' (for Client SDK) + */ + activationStrategy?: 'always' | 'stale' | 'empty' | 'next-load'; + + /** + * Timeout for individual network requests. + * + * @default 5_000 (5 seconds) + */ + requestTimeoutMs?: number; + }; }; export default class EppoClient { @@ -117,48 +241,207 @@ export default class EppoClient { private readonly banditEvaluator = new BanditEvaluator(); private banditLogger?: IBanditLogger; private banditAssignmentCache?: AssignmentCache; - private configurationRequestParameters?: FlagConfigurationRequestParameters; private overrideStore?: ISyncStore; private assignmentLogger?: IAssignmentLogger; private assignmentCache?: AssignmentCache; // whether to suppress any errors and return default values instead private isGracefulFailureMode = true; - private requestPoller?: IPoller; private readonly evaluator = new Evaluator(); - private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); private readonly configurationStore; + private readonly configurationRequestor: ConfigurationRequestor; + private readonly requestPoller: ConfigurationPoller; + private initialized = false; + private readonly initializationPromise: Promise; + - constructor({ - eventDispatcher = new NoOpEventDispatcher(), - isObfuscated, - overrideStore, - configurationRequestParameters, - initialConfiguration, - }: EppoClientParameters) { - this.configurationStore = new ConfigurationStore(initialConfiguration); + constructor(options: EppoClientParameters) { + const { eventDispatcher = new NoOpEventDispatcher(), overrideStore, configuration } = options; + + const { + configuration: { + persistentStorage, + initializationStrategy = 'stale-while-revalidate', + initializationTimeoutMs = 5_000, + initialConfiguration, + requestTimeoutMs = 5_000, + basePollingIntervalMs = 30_000, + maxPollingIntervalMs = 300_000, + enablePolling = true, + maxAgeSeconds = 30, + maxStaleSeconds = Infinity, + activationStrategy = 'stale', + } = {}, + } = options; + + this.configurationStore = new ConfigurationStore(configuration?.initialConfiguration); this.eventDispatcher = eventDispatcher; this.overrideStore = overrideStore; - this.configurationRequestParameters = configurationRequestParameters; - if (isObfuscated !== undefined) { - logger.warn( - '[Eppo SDK] specifying isObfuscated no longer has an effect and will be removed in the next major release; obfuscation ' + - 'is now inferred from the configuration, so you can safely remove the option.', - ); + this.configurationRequestor = new ConfigurationRequestor( + new FetchHttpClient( + new ApiEndpoints({ + baseUrl: options.baseUrl, + queryParams: { + apiKey: options.sdkKey, + sdkName: options.sdkName, + sdkVersion: options.sdkVersion, + }, + }), + requestTimeoutMs, + ), + this.configurationStore, + { + wantsBandits: options.bandits?.enable ?? true, + }, + ); + + this.requestPoller = new ConfigurationPoller(this.configurationRequestor, { + basePollingIntervalMs, + maxPollingIntervalMs, + }); + this.requestPoller.onConfigurationFetched((configuration: Configuration) => { + // During initialization, always set the configuration. + // Otherwise, apply the activation strategy. + const shouldActivate = !this.initialized || + EppoClient.shouldActivateConfiguration(activationStrategy, maxAgeSeconds, this.configurationStore.getConfiguration()); + + if (shouldActivate) { + this.configurationStore.setConfiguration(configuration); + } + + try { + persistentStorage?.storeConfiguration(configuration); + } catch (err) { + logger.warn('Eppo SDK failed to store configuration in persistent store', { err }); + } + }); + + this.initializationPromise = withTimeout( + this.initialize(options), + initializationTimeoutMs + ) + .catch((err) => { + logger.warn('Eppo SDK encountered an error during initialization', { err }); + }) + .finally(() => { + this.initialized = true; + if (enablePolling) { + this.requestPoller.start(); + } + }); + } + + private async initialize(options: EppoClientParameters): Promise { + const { + configuration: { + persistentStorage, + initializationStrategy = 'stale-while-revalidate', + initialConfiguration, + basePollingIntervalMs = 30_000, + maxAgeSeconds = 30, + maxStaleSeconds = Infinity, + } = {}, + } = options; + + if (initializationStrategy === 'none') { + this.initialized = true; + return; + } + + if ( + !initialConfiguration && // initial configuration overrides persistent storage for initialization + persistentStorage && + (initializationStrategy === 'stale-while-revalidate' || + initializationStrategy === 'only-if-cached') + ) { + try { + const configuration = await persistentStorage.loadConfiguration(); + if (configuration && !this.initialized) { + const age = configuration.getAge(); + + const isTooOld = age && age > maxStaleSeconds * 1000; + if (!isTooOld) { + // The configuration is too old to be used. + this.configurationStore.setConfiguration(configuration); + } + } + } catch (err) { + logger.warn('Eppo SDK failed to load configuration from persistent store', { err }); + } + } + + if (initializationStrategy === 'only-if-cached') { + return; + } + + // Finish initialization early if cached configuration is fresh. + const configurationAgeMs = this.configurationStore.getConfiguration()?.getAge(); + if (configurationAgeMs && configurationAgeMs < maxAgeSeconds * 1000) { + return; + } + + // Loop until we sucessfully fetch configuration or + // initialization deadline is reached (and sets this.initialized + // to true). + while (!this.initialized) { + try { + // The fetchImmediate method will trigger the listener registered in the constructor, + // which will activate the configuration. + await this.requestPoller.fetchImmediate(); + + // If we got here, the fetch was successful, and we can exit the loop. + return; + } catch (err) { + logger.warn('Eppo SDK failed to fetch initial configuration', { err }); + } + + // Note: this is only using the jitter without the base polling interval. + await new Promise((resolve) => setTimeout(resolve, randomJitterMs(basePollingIntervalMs))); } } + private static shouldActivateConfiguration( + activationStrategy: string, + maxAgeSeconds: number, + prevConfiguration: Configuration + ): boolean { + return activationStrategy === 'always' + || (activationStrategy === 'stale' && (prevConfiguration.isStale(maxAgeSeconds) ?? true)) + || (activationStrategy === 'empty' && (prevConfiguration.isEmpty() ?? true)); + } + public getConfiguration(): Configuration { return this.configurationStore.getConfiguration(); } - public setConfiguration(configuration: Configuration) { + /** + * Activates a new configuration. + */ + public activateConfiguration(configuration: Configuration) { this.configurationStore.setConfiguration(configuration); } + /** + * Register a listener to be notified when a new configuration is fetched. + * @param listener Callback function that receives the fetched `Configuration` object + * @returns A function that can be called to unsubscribe the listener. + */ + public onConfigurationFetched(listener: (configuration: Configuration) => void): void { + this.requestPoller.onConfigurationFetched(listener); + } + + /** + * Register a listener to be notified when a new configuration is activated. + * @param listener Callback function that receives the activated `Configuration` object + * @returns A function that can be called to unsubscribe the listener. + */ + public onConfigurationActivated(listener: (configuration: Configuration) => void): void { + this.configurationStore.onConfigurationChange(listener); + } + /** * Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension */ @@ -188,12 +471,6 @@ export default class EppoClient { return this; } - setConfigurationRequestParameters( - configurationRequestParameters: FlagConfigurationRequestParameters, - ) { - this.configurationRequestParameters = configurationRequestParameters; - } - /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ // noinspection JSUnusedGlobalSymbols setEventDispatcher(eventDispatcher: EventDispatcher) { @@ -233,60 +510,6 @@ export default class EppoClient { ); } - async fetchFlagConfigurations() { - if (!this.configurationRequestParameters) { - throw new Error( - 'Eppo SDK unable to fetch flag configurations without configuration request parameters', - ); - } - // if fetchFlagConfigurations() was previously called, stop any polling process from that call - this.requestPoller?.stop(); - - const { - apiKey, - sdkName, - sdkVersion, - baseUrl, // Default is set in ApiEndpoints constructor if undefined - requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - pollAfterSuccessfulInitialization = false, - pollAfterFailedInitialization = false, - throwOnFailedInitialization = false, - skipInitialPoll = false, - } = this.configurationRequestParameters; - - let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.configurationRequestParameters; - if (pollingIntervalMs <= 0) { - logger.error('pollingIntervalMs must be greater than 0. Using default'); - pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; - } - - // todo: Inject the chain of dependencies below - const apiEndpoints = new ApiEndpoints({ - baseUrl, - queryParams: { apiKey, sdkName, sdkVersion }, - }); - const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); - const configurationRequestor = new ConfigurationRequestor(httpClient, this.configurationStore); - this.configurationRequestor = configurationRequestor; - - const pollingCallback = async () => { - return configurationRequestor.fetchAndStoreConfigurations(); - }; - - this.requestPoller = initPoller(pollingIntervalMs, pollingCallback, { - maxStartRetries: numInitialRequestRetries, - maxPollRetries: numPollRequestRetries, - pollAfterSuccessfulStart: pollAfterSuccessfulInitialization, - pollAfterFailedStart: pollAfterFailedInitialization, - errorOnFailedStart: throwOnFailedInitialization, - skipInitialPoll: skipInitialPoll, - }); - - await this.requestPoller.start(); - } - // noinspection JSUnusedGlobalSymbols stopPolling() { if (this.requestPoller) { @@ -347,18 +570,6 @@ export default class EppoClient { }; } - /** - * @deprecated use getBooleanAssignment instead. - */ - getBoolAssignment( - flagKey: string, - subjectKey: string, - subjectAttributes: Attributes, - defaultValue: boolean, - ): boolean { - return this.getBooleanAssignment(flagKey, subjectKey, subjectAttributes, defaultValue); - } - /** * Maps a subject to a boolean variation for a given experiment. * @@ -1097,13 +1308,7 @@ export default class EppoClient { } isInitialized() { - // We treat configuration as initialized if we have flags config. - return !!this.configurationStore.getConfiguration()?.getFlagsConfiguration(); - } - - /** @deprecated Use `setAssignmentLogger` */ - setLogger(logger: IAssignmentLogger) { - this.setAssignmentLogger(logger); + return this.initialized; } setAssignmentLogger(logger: IAssignmentLogger) { @@ -1299,14 +1504,14 @@ export default class EppoClient { return result ? { - banditKey: bandit.banditKey, - action: result.actionKey, - actionNumericAttributes: result.actionAttributes.numericAttributes, - actionCategoricalAttributes: result.actionAttributes.categoricalAttributes, - actionProbability: result.actionWeight, - modelVersion: bandit.modelVersion, - optimalityGap: result.optimalityGap, - } + banditKey: bandit.banditKey, + action: result.actionKey, + actionNumericAttributes: result.actionAttributes.numericAttributes, + actionCategoricalAttributes: result.actionAttributes.categoricalAttributes, + actionProbability: result.actionWeight, + modelVersion: bandit.modelVersion, + optimalityGap: result.optimalityGap, + } : null; } } @@ -1339,3 +1544,24 @@ export function checkValueTypeMatch( return false; } } + +class TimeoutError extends Error { + constructor(message = 'Operation timed out') { + super(message); + this.name = 'TimeoutError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, TimeoutError); + } + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new TimeoutError()), ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer!)); +} diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index c512f0c..8d1ea44 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -14,7 +14,7 @@ import { import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; -import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; +import { DEFAULT_BASE_POLLING_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; import FetchHttpClient from '../http-client'; import { FormatEnum, @@ -398,7 +398,7 @@ describe('EppoPrecomputedClient E2E test', () => { const precomputedFlagKey = 'string-flag'; const red = 'red'; - const maxRetryDelay = DEFAULT_POLL_INTERVAL_MS * POLL_JITTER_PCT; + const maxRetryDelay = DEFAULT_BASE_POLLING_INTERVAL_MS * POLL_JITTER_PCT; beforeAll(async () => { global.fetch = jest.fn(() => { @@ -513,7 +513,7 @@ describe('EppoPrecomputedClient E2E test', () => { // Expire the cache and advance time until a reload should happen MockStore.expired = true; - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS * 1.5); + await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS * 1.5); variation = client.getStringAssignment(precomputedFlagKey, 'default'); expect(variation).toBe(red); @@ -641,7 +641,7 @@ describe('EppoPrecomputedClient E2E test', () => { expect(variation).toBe(red); expect(callCount).toBe(2); - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS); + await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS); // By default, no more polling expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); }); @@ -706,7 +706,7 @@ describe('EppoPrecomputedClient E2E test', () => { expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe('default'); // Advance timers so a post-init poll can take place - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS * 1.5); + await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS * 1.5); // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index a288e5b..1afcb66 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -14,7 +14,7 @@ import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, - DEFAULT_POLL_INTERVAL_MS, + DEFAULT_BASE_POLLING_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, PRECOMPUTED_BASE_URL, } from '../constants'; @@ -43,13 +43,13 @@ export interface Subject { subjectAttributes: Attributes | ContextAttributes; } -export type PrecomputedFlagsRequestParameters = { +export type PrecomputedRequestParameters = { apiKey: string; sdkVersion: string; sdkName: string; baseUrl?: string; requestTimeoutMs?: number; - pollingIntervalMs?: number; + basePollingIntervalMs?: number; numInitialRequestRetries?: number; numPollRequestRetries?: number; pollAfterSuccessfulInitialization?: boolean; @@ -64,7 +64,7 @@ interface EppoPrecomputedClientOptions { overrideStore?: ISyncStore; subject: Subject; banditActions?: Record>; - requestParameters?: PrecomputedFlagsRequestParameters; + requestParameters?: PrecomputedRequestParameters; } export default class EppoPrecomputedClient { @@ -75,7 +75,7 @@ export default class EppoPrecomputedClient { private banditAssignmentCache?: AssignmentCache; private assignmentCache?: AssignmentCache; private requestPoller?: IPoller; - private requestParameters?: PrecomputedFlagsRequestParameters; + private requestParameters?: PrecomputedRequestParameters; private subject: { subjectKey: string; subjectAttributes: ContextAttributes; @@ -153,10 +153,10 @@ export default class EppoPrecomputedClient { } = this.requestParameters; const { subjectKey, subjectAttributes } = this.subject; - let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.requestParameters; - if (pollingIntervalMs <= 0) { - logger.error('pollingIntervalMs must be greater than 0. Using default'); - pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; + let { basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS } = this.requestParameters; + if (basePollingIntervalMs <= 0) { + logger.error('basePollingIntervalMs must be greater than 0. Using default'); + basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS; } // todo: Inject the chain of dependencies below @@ -180,7 +180,7 @@ export default class EppoPrecomputedClient { } }; - this.requestPoller = initPoller(pollingIntervalMs, pollingCallback, { + this.requestPoller = initPoller(basePollingIntervalMs, pollingCallback, { maxStartRetries: numInitialRequestRetries, maxPollRetries: numPollRequestRetries, pollAfterSuccessfulStart: pollAfterSuccessfulInitialization, diff --git a/src/configuration-poller.ts b/src/configuration-poller.ts new file mode 100644 index 0000000..426c651 --- /dev/null +++ b/src/configuration-poller.ts @@ -0,0 +1,123 @@ +import ConfigurationRequestor from './configuration-requestor'; +import { Listeners } from './listener'; +import { Configuration } from './configuration'; +import { randomJitterMs } from './poller'; +import { logger } from './application-logger'; + +/** + * Polls for new configurations from the Eppo server. When a new configuration is fetched, + * it is passed to the subscribers of `onConfigurationFetched`. + * + * The poller is created in the stopped state. Call `start` to begin polling. + * + * @internal + */ +export class ConfigurationPoller { + private readonly listeners = new Listeners<[Configuration]>(); + private readonly basePollingIntervalMs: number; + private readonly maxPollingIntervalMs: number; + private isRunning = false; + + public constructor( + private readonly configurationRequestor: ConfigurationRequestor, + options: { + basePollingIntervalMs: number; + maxPollingIntervalMs: number; + }, + ) { + this.basePollingIntervalMs = options.basePollingIntervalMs; + this.maxPollingIntervalMs = options.maxPollingIntervalMs; + } + + /** + * Starts the configuration poller. + * + * This method will start polling for new configurations from the Eppo server. + * It will continue to poll until the `stop` method is called. + */ + public start(): void { + if (!this.isRunning) { + this.isRunning = true; + this.poll().finally(() => { + // Just to be safe, reset isRunning if the poll() method throws an error or exits (it + // shouldn't). + this.isRunning = false; + }); + } + } + + /** + * Stops the configuration poller. + * + * This method will stop polling for new configurations from the Eppo server. Note that it will + * not interrupt the current poll cycle / active fetch, but it will make sure that configuration + * listeners are not notified of any new configurations after this method is called. + */ + public stop(): void { + this.isRunning = false; + } + + /** + * Register a listener to be notified when new configuration is fetched. + * @param listener Callback function that receives the fetched `Configuration` object + * @returns A function that can be called to unsubscribe the listener. + */ + public onConfigurationFetched(listener: (configuration: Configuration) => void): () => void { + return this.listeners.addListener(listener); + } + + /** + * Fetch configuration immediately without waiting for the next polling cycle. + * + * Note: This does not coordinate with active polling - polling intervals will not be adjusted + * when using this method. + * + * @throws If there is an error fetching the configuration + */ + public async fetchImmediate(): Promise { + const configuration = await this.configurationRequestor.fetchConfiguration(); + if (configuration) { + this.listeners.notify(configuration); + } + return configuration; + } + + private async poll(): Promise { + // Number of failures we've seen in a row. + let consecutiveFailures = 0; + + while (this.isRunning) { + try { + const configuration = await this.configurationRequestor.fetchConfiguration(); + if (configuration && this.isRunning) { + this.listeners.notify(configuration); + } + // Reset failure counter on success + consecutiveFailures = 0; + } catch (err) { + logger.warn('Eppo SDK encountered an error polling configurations', { err }); + consecutiveFailures++; + } + + if (consecutiveFailures === 0) { + await timeout(this.basePollingIntervalMs + randomJitterMs(this.basePollingIntervalMs)); + } else { + // Exponential backoff capped at maxPollingIntervalMs. + const baseDelayMs = Math.min((Math.pow(2, consecutiveFailures) * this.basePollingIntervalMs), this.maxPollingIntervalMs); + const delayMs = baseDelayMs + randomJitterMs(baseDelayMs); + + logger.warn('Eppo SDK will try polling again', { + delayMs, + consecutiveFailures, + }); + + await timeout(delayMs); + } + } + } +} + +function timeout(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 607e61b..41f7137 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -6,11 +6,13 @@ export type ConfigurationRequestorOptions = { wantsBandits: boolean; }; -// Requests AND stores flag configurations +/** + * @internal + */ export default class ConfigurationRequestor { private readonly options: ConfigurationRequestorOptions; - constructor( + public constructor( private readonly httpClient: IHttpClient, private readonly configurationStore: ConfigurationStore, options: Partial = {}, @@ -21,7 +23,7 @@ export default class ConfigurationRequestor { }; } - async fetchConfiguration(): Promise { + public async fetchConfiguration(): Promise { const flags = await this.httpClient.getUniversalFlagConfiguration(); if (!flags?.response.flags) { return null; @@ -32,19 +34,12 @@ export default class ConfigurationRequestor { return Configuration.fromResponses({ flags, bandits }); } - async fetchAndStoreConfigurations(): Promise { - const configuration = await this.fetchConfiguration(); - if (configuration) { - this.configurationStore.setConfiguration(configuration); - } - } - /** * Get bandits configuration matching the flags configuration. * * This function does not fetch bandits if the client does not want * them (`ConfigurationRequestorOptions.wantsBandits === false`) or - * we we can reuse bandit models from `ConfigurationStore`. + * if we can reuse bandit models from `ConfigurationStore`. */ private async getBanditsFor(flags: FlagsConfig): Promise { const needsBandits = diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index a63e2e0..ec0bbd2 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,5 +1,6 @@ import { Configuration } from '../configuration'; import { Environment } from '../interfaces'; +import { Listeners } from '../listener'; /** * `ConfigurationStore` is a central piece of Eppo SDK and answers a @@ -9,7 +10,7 @@ import { Environment } from '../interfaces'; */ export class ConfigurationStore { private configuration: Configuration; - private readonly listeners: Array<(configuration: Configuration) => void> = []; + private readonly listeners: Listeners<[Configuration]> = new Listeners(); public constructor(configuration: Configuration = Configuration.empty()) { this.configuration = configuration; @@ -21,7 +22,7 @@ export class ConfigurationStore { public setConfiguration(configuration: Configuration): void { this.configuration = configuration; - this.notifyListeners(); + this.listeners.notify(configuration); } /** @@ -31,24 +32,7 @@ export class ConfigurationStore { * Returns a function to unsubscribe from future updates. */ public onConfigurationChange(listener: (configuration: Configuration) => void): () => void { - this.listeners.push(listener); - - return () => { - const idx = this.listeners.indexOf(listener); - if (idx !== -1) { - this.listeners.splice(idx, 1); - } - }; - } - - private notifyListeners(): void { - for (const listener of this.listeners) { - try { - listener(this.configuration); - } catch { - // ignore - } - } + return this.listeners.addListener(listener); } } diff --git a/src/configuration.ts b/src/configuration.ts index e0c6bcd..27fe263 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -87,6 +87,34 @@ export class Configuration { return this.flags; } + /** @internal */ + public getFetchedAt(): Date | undefined { + const flagsFetchedAt = this.flags?.fetchedAt ? new Date(this.flags.fetchedAt).getTime() : 0; + const banditsFetchedAt = this.bandits?.fetchedAt ? new Date(this.bandits.fetchedAt).getTime() : 0; + const maxFetchedAt = Math.max(flagsFetchedAt, banditsFetchedAt); + return maxFetchedAt > 0 ? new Date(maxFetchedAt) : undefined; + } + + /** @internal */ + public isEmpty(): boolean { + return !this.flags; + } + + /** @internal */ + public getAge(): number | undefined { + const fetchedAt = this.getFetchedAt(); + if (!fetchedAt) { + return undefined; + } + return Date.now() - fetchedAt.getTime(); + } + + /** @internal */ + public isStale(maxAgeSeconds: number): boolean { + const age = this.getAge(); + return !!age && age > maxAgeSeconds * 1000; + } + /** @internal * * Returns flag configuration for the given flag key. Obfuscation is @@ -116,6 +144,7 @@ export class Configuration { return this.flagBanditVariations[flagKey] ?? []; } + /** @internal */ public getFlagVariationBandit(flagKey: string, variationValue: string): BanditParameters | null { const banditVariations = this.getFlagBanditVariations(flagKey); const banditKey = banditVariations?.find( diff --git a/src/constants.ts b/src/constants.ts index f7c3b30..03a8a7b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,7 +2,7 @@ import { FormatEnum } from './interfaces'; export const DEFAULT_REQUEST_TIMEOUT_MS = 5000; export const REQUEST_TIMEOUT_MILLIS = DEFAULT_REQUEST_TIMEOUT_MS; // for backwards compatibility -export const DEFAULT_POLL_INTERVAL_MS = 30000; +export const DEFAULT_BASE_POLLING_INTERVAL_MS = 30000; export const POLL_JITTER_PCT = 0.1; export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1; export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; diff --git a/src/listener.ts b/src/listener.ts new file mode 100644 index 0000000..d3fa2ad --- /dev/null +++ b/src/listener.ts @@ -0,0 +1,26 @@ +export type Listener = (...args: T) => void; + +export class Listeners { + private listeners: Array> = []; + + public addListener(listener: Listener): () => void { + this.listeners.push(listener); + + return () => { + const idx = this.listeners.indexOf(listener); + if (idx !== -1) { + this.listeners.splice(idx, 1); + } + }; + } + + public notify(...args: T): void { + for (const listener of this.listeners) { + try { + listener(...args); + } catch { + // ignore + } + } + } +} diff --git a/src/poller.spec.ts b/src/poller.spec.ts index d45d083..07afc38 100644 --- a/src/poller.spec.ts +++ b/src/poller.spec.ts @@ -1,10 +1,10 @@ import * as td from 'testdouble'; -import { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from './constants'; +import { DEFAULT_BASE_POLLING_INTERVAL_MS, POLL_JITTER_PCT } from './constants'; import initPoller from './poller'; describe('poller', () => { - const testIntervalMs = DEFAULT_POLL_INTERVAL_MS; + const testIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS; const maxRetryDelay = testIntervalMs * POLL_JITTER_PCT; const noOpCallback = td.func<() => Promise>(); diff --git a/src/poller.ts b/src/poller.ts index 386b0e7..970fc60 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -149,8 +149,10 @@ export default function initPoller( /** * Compute a random jitter as a percentage of the polling interval. * Will be (5%,10%) of the interval assuming POLL_JITTER_PCT = 0.1 + * + * @internal */ -function randomJitterMs(intervalMs: number) { +export function randomJitterMs(intervalMs: number) { const halfPossibleJitter = (intervalMs * POLL_JITTER_PCT) / 2; // We want the randomly chosen jitter to be at least 1ms so total jitter is slightly more than half the max possible. // This makes things easy for automated tests as two polls cannot execute within the maximum possible time waiting for one. From 34ff6c3edb8dc583ae42f8cf6e9076bc09909749 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Fri, 4 Apr 2025 17:54:00 +0300 Subject: [PATCH 13/25] refactor: introduce configuration feed --- docs/configuration-lifecycle.excalidraw.png | Bin 0 -> 420512 bytes docs/configuration-lifecycle.md | 70 +++ src/broadcast.ts | 32 ++ .../eppo-client-assignment-details.spec.ts | 51 +- .../eppo-client-experiment-container.spec.ts | 24 +- src/client/eppo-client-with-bandits.spec.ts | 24 +- src/client/eppo-client-with-overrides.spec.ts | 34 +- src/client/eppo-client.sdk-test-data.spec.ts | 97 ++++ src/client/eppo-client.spec.ts | 483 ++++++++---------- src/client/eppo-client.ts | 242 +++++---- src/client/eppo-precomputed-client.spec.ts | 4 +- src/client/test-utils.ts | 9 +- src/configuration-feed.ts | 27 + src/configuration-poller.ts | 115 +++-- src/configuration-requestor.spec.ts | 213 ++------ src/configuration-requestor.ts | 24 +- .../configuration-store.ts | 36 -- src/configuration-store/index.ts | 89 +++- src/configuration.ts | 29 +- src/constants.ts | 14 +- src/eppo-assignment-logger.spec.ts | 7 +- src/evaluator.spec.ts | 77 ++- src/listener.ts | 26 - src/persistent-configuration-cache.ts | 67 +++ src/persistent-configuration-storage.ts | 23 - test/testHelpers.ts | 41 +- 26 files changed, 1063 insertions(+), 795 deletions(-) create mode 100644 docs/configuration-lifecycle.excalidraw.png create mode 100644 docs/configuration-lifecycle.md create mode 100644 src/broadcast.ts create mode 100644 src/client/eppo-client.sdk-test-data.spec.ts create mode 100644 src/configuration-feed.ts delete mode 100644 src/listener.ts create mode 100644 src/persistent-configuration-cache.ts delete mode 100644 src/persistent-configuration-storage.ts diff --git a/docs/configuration-lifecycle.excalidraw.png b/docs/configuration-lifecycle.excalidraw.png new file mode 100644 index 0000000000000000000000000000000000000000..4b1970d13eaa5a7b07d6f2e3c3c996adca9faa68 GIT binary patch literal 420512 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%0D@0`ZQ+1`u3^>*E)$jYmoz3&R zM5gK$>iR+~I$4D`=JsyjuN3GEra9eDNh9Q7Ny~4k+d}BIi7wD-X0*eRrO{WC!!nJI zv+xm4Hotn;^*&}rb+sXZS=@6Moiv9bN%JWwj-2zfT>8=t86jqwEwvVIa2e8~;bio059 zBI^2{;JCJA0}W}R=@UZE6@K&~ZHo{6j|BJKJ?H10wKQJIP! zo#8!zgm;{#ht+7Dlx6H8WLLYb)3$9;Qb^G*IOD%lI9}_GHGvtMJjHLBy?%l*=ll-G z$c!aTER)z|^2`n@y=cFq@Ds}xNTzTPa0#cSjC-!=tbL_MfeE3vjHTU*{^dnN!Yfhi z>|EmS)YZAF_|LVTzb#V><{tp6D$J3c9KDzuE-FkQvCD`&*r8f@roJv9a}$ZpB+u;9 z{8$}$$nfOc!T3oq5`Sub#5|%o3ul>Rcp4N3-UaFGWJzr^&x>aE<9{GV9uz<_oHtfD z09L|r`Fj7eZE`|ApY&czTVnj&{A4U6IS&0&eR*2r8J$J`!KtC9lwmJ`Aac$l87lLe zo`$K*6j#4aI{s+IyQ0?iO>~Z4sQ{92gi379ZrNa$ybH~@Ge#>?P#i*_gh-q1Rm56p z!D}?};B0@O??- zHrsQxK0zgA+(R}*9mMH2P`q}FsZypz;io+XQ;iw}ATcRn=V@tB9HWHkl(b~l1G}mY zI%h0S|JE-yug2FgklAJ~1sKo1#h2NXOPo)sAIT9vHNLv`-7Z%XoQPjoF}8y5&OY=* zYQn+qD9jC~RoAwfq-fdyqLnnn-K1$9y+5n{kXicc<3?47`xB=QjkJm(k@2~>U#Hvr zh?Tw1fvBUK4@cE`MJbb)w5r9D#__~Z@!)jkkG6?IfdzZX_D~O2c2=#aR zU&fl}0OtDG??OQ*(`QrAU!EId4mGZcBB^Ci{ur)mA+Ak3g|cO4#0f2?N80$XN9Kpk zoEXAXs2!#nRMN=m0p^z_=+G^XRm0(}HMne$j&vV>e7& zCT}EDV^lju(rqXyOO_?v;jD0wVF>d90G~F)k1GRRYxC^KxJ{c=mAM2bYmV04+;#ND z+=}-Pv+ukY6JnmLoEPK=w(J~!ZrXT$gaJ$~jiaCrjPMpOPkJ0trf$wcx0>o=~x-X1#16Z}oJ#3q~#y*NCd#rH2 zwn}iU-Pn`)_>B`2Re}}?Q%&@O0`DU)*Mc-vc&FBG=4Tu!T3$*L2LP86mC>l4keb5N zq{*cPJoY0=|D8z}MZ#=^G1CH-!wu7Ig5m2&48rVVsdZcqP7Tz>4nN$)*M1-j)N`Ai zXEW23#8@u(ky|P7%=BGMP-Y*VesVfAeZO6gQU+@+<4xxUc>aU zgurlWS#8ns;@ITJmz=T0ocUk_Y0Diau)J-iMm3RQk5p2$eKkjesYntFrduhS%#(Ba zPL+9Wl0v*V=~#VXkP6AkyrJa0S_JJBt{wEUAKGj3q5JZd7;rO%N-DbGUZfy5&`y|c z=@0;skJKLBBCNV_wDC>v zaL#n6$F6i4yjkB?i_|Z@BSvyHriNR%eCY*w?OOq>NC)eAZGUkDv}&dn&|DDHs{@?J zl7f3Su!P>sY|#%f#=3OAAdt9kkF`icg4M30UQyr}G6Tjs?K+f5PMTmvi!wvR`p0sZ z6H5@7mk#(?`RNV$mJa(a;iM-o*o^!R_nkO6dU?wce#lJCC{yT;xHzbvy}nyuSzIP^ zdm==`0q{+G^X5r8^KZH>O+;k9hzoZbe3U*A6QVpa1nFbSBdrEM?)Mi7Mr{3B63mKh zg|@VIW(Nx1*eNWqA0W~NncnR#Ef<0sYOnCpOM@?7uR(^BP5tF>&;jo2B94?nZ1Dq0 zyJ?+ptTlQ^(t{DbWA!26n*ncLU*x_VKdR$0F5v_rsSW{iNd8#*p{mx;c%*3X7w$MU z-KW>cjVkHw6IilD5)Q`*O9&vJgDPX#lj7bhmt|_tQm-)*wqFD|q9D}8 z{3=x)0hvbek7D8*U-frLM;0K_(u^;#U#xAev2MOe$32Wn?fk7LQh_>dpKQPnHvy!f zN7$_OYQ$yVgDeoiM`|Gv;lRAl%*d0Oc2D#U?WZEk{v=ZRfHBnY;Q41a(@RPA)xTTp z^%v0>^ID#*6sZN5bo9Aob(P$%xpCt_)Ut# zsKvK+eDKsRBj3%;rL#=O>_DYW(tXTrJ3xStbuo8<-SVZ{Mg}0# z69pxW?ue$tQPRhtq|0lHAg_2N7jY!#4SDe4k@Py=d|L7+)(Fi4%zzM>e`aa~L=nEE zqNTY_U6og+NT0Uube()X@F+I?MFD>yGlaH_i}YD#l#f%6EH6wTxy>ZNGlQaV@b5Md zaWm&SEV6sN(m{E$oQlE<%KS3dH7rGbyvFQz0*Sp!(?L3Jhm*k9UK#l#mD9>#*z|%( zSx0hJxS^YOdpCNJ%imjp@?aIzFWE@bFh z7dXv7({3GPOTO!sinikgsXRMZL4*`Gu*||Krhh4`ZvXP<+`_m5tUc@}9p zG&ruXFw$%n|3E%4yPz0rr%srK!^)6?Z&=!5iA36>#p!+9v`inD*P3N@G>Q)_YcD-# ziHKUeMd$zmqOyt}#RRhnk3ZdpFd^mv^S05lOsa>(2ekp94@aaF&KXbdGj3EfkSUsL zNT~&kA0DV~{KzYY-o1DmCU%SPq`aMLVz~z?$6bUUpm2`dO(}L3aTL6xV3NYVI}RU` zi0o0(aShPFcOh`{rOwi!B#78*&BjO`);ZW_Q6zJ9$Hf!g!fo2SEv;P!{Ib^ptUlLj z_|Uf&wmv9tKa(LB0iQ_{ar+I|U=@RKNBvfZUSE@uQ+fIX;FX;0)^Vq}EzTjtT;@H6 z(1;){J2k2bLH$>EhmTlec~sQT`SR3)3APRw3XC=r(2ws(Q_(P}cG#Kjcg|$y$=lzg z`~)wD%u!{xw7wXrizvoUTiH>gb@gA=f8cJ<+RwcJX#YSc;0HP$ngNj1iZ|{S`YDmW zA=*C>3gKfQ)Vhkbw-q8=9Rqj`<+c+D6!x~v08Z?1RyFaNc7q64^%1xI1ACY9c9M5e z7wrKwm)>fe5Pi=eJZHC7qwNU%IA|0+yA4#d&WlB5D0;p?LRtnl)@<9*#F+cCAXFDm zx{jAqtECPCZd9|@9V(n34cQE!$~(*Vz!NRImfga@bnM@*t<_X<*s9&CGWsM-SbFkM zu5qtl?mF^~JNlKh%kAv}pcwsja?c}2HCcgyC>y}%{h2V|#kw`?cgS1o&@Rd8g0GU{ z1ishd*YO*y>UoWs^N=yuRl7~6PngOmyzd90gqe|C=pBjc zSDnDLCiq%#RbQ?cY5n+uxao#=eGpqMC zkn30nN_OjC?Mz#TzF!ezh0udp+wD3X`)#^hX97n2fN=ShG}zR4uIDO4EqL~DS)uW# z>`xD+ywSF*c48A}(s3Gy<~FX6gP_I`y}ZybVuZl86*dMGm6k+#9-NUvSD|Yi&{UP1 zqHC+?4|?@SxF1T9+z^s$ zsv9akx=q}6qXxU-v>uuE!Rh=0#g})HD8T*t*fvVu0(g$X^3tNoK>U5)#}x(6GgXc3 zjV7Zx{jvyONjI1<`5?17HtA5RfZ)J26S7Q%%9#Vy_(Ff1ftY8C3>TK$7KqkAv0jOV)#Rv2qQL0le2Dlfs3a`?RUoc*cf1 z8F#q$b@XO5X}|RF09;qb4T--&iFO*6SA$BPrbr*^0Gr9>X?EF>UzBiVEuisMDnekCP zvA1)4DZSHaC>oN4g5(23u)MrsVJq=<<0oa&Vn+AM}U&ZJa5!2)Wi&X~zK~o{(^0yt#hMydVk;O+09)Uc89g|-n)Y!Bw`{TN5tQ5 zIz5upA3oqGIJ!44GhDYjpRNd@+Muf#2?{9!$^3at&FpRx2QFxMmMYU83mpRB;YxL_ z#iq03F4M5X<8rEf`ID6l?$QXE(C|s(vU&s$k1a2TR^sjjLLNznj|h4xjmuywO<11J zD!O1If^c8NQl?Sh>s0NOb$~%H~@^;mddPii}cu^~T zLl=J$ljmdbLmAr2jmga6=Qhd3beB3vI({fwpEhRRNgJq5B~}z-`sAd#eTQvYYER&7 zqhOF~lfzPFPP35Wd{fbqZ7#snArk2^)9TBolplMY9F%8`s}ahSO%SxMq*OKczr9QNBq**@={7;-+tp6B=RO@wz83`n)wR6 zy?OKu;RU65VsRt`qRrLv3_k9l>8$8HQWulsvM^q$_RCqqj^oeX#wR2PGsUr|FzND% zpYtglN4PR?Jq3ERO>fxQf`+d6Qqi(iQU`xEG|Jb=N+2^)DrPdk zUiF697uT5ycT zUd?FYd;9z%PXaYirA?MSK#D$2j;VsipNXO&pc1L0p*Upj!J+QC_jsy8^c;AbG||Yb zJDuVr&iRrRlv8OWo@F54Gu_s;l$nSXTTg%IN(T>ZV}p?5WPpk`>&8enT1`R@2^y^h z>zI4`rxfIbZK(pOi@j8A?^20qEWy_uPR z)V^q5E7YR05TE=Q|1e3+GhHH8v(xQbYS!`6H^DQMdY5N9oh_UY-&APV zY81guB4|kJy>E3t+`n9w;4v zT0*F|SO9j6GSIR5wtB_3H7p7jMzUZ_KjBmY_C7V*l*31Z^f@E@R%MDIs&dm%3_G#~v8S zcd9al@|2!n&KKys+z&~evg4k7P-!E8cM0?CCPyl0Iq`73IXauntR!1TL3g`5W zm@oHu%cdFPoW{ZjFo^mf5cLCexV#R(xY7smIniu|7M(u#M>C)b+%B^p?5r)^9uoH> zEXx_s^Gfjm8Fm4S-xO65OW1!GDH!>&I_d_SaJt93G zBFqTnm^@dmPmOj6ngOq1)S#py!}aRGQJ?-UTKf8}71T4=$zCD3WO+NYVHyvcNA1}~ z$cKWt$MPrihsgEp9tSazJvI~po%qCB8s7S()j6UWTu=UjX<*;(bhdns8p^l z^r3jI(gvoQaikFg0EVl1n_v1mq%|iOD%dY8TAiP!CCd$Jv?@nx$V!ss#Qyo^3TB|ff zuhV?2*d(K-wbl*6+6KGIPb1`{w`e^qg$aJ)QfB$L>(9U;UDVm;gC33&d3LMu2;9wM>q|9n60IU&(n1^p;q}b&r z2C^B0C;C-09yfmcgd~+j>Wy@l)TO${3tisclB62FU3=uC2PJbp7lBhq6F{F3*~9Pd zsC(VKa;Cz*@Qm#wC}}g2bPwl%EI6Ca%eU_BeEdd`=KhecBB4r!;5hV$(B-(~n|x+M zRfY|a>`V^8l!MtDdi&;7J<1fHnNZcX+6X14w}--wkPuSV>wjqx+I3=;N>jm>hpC8U z_=K)+u)JT4-Ee92yI)rP!r44wIXwHL9CjC^3S%mR0$v8$@#UV}nS=*_0X+wgM$Op~cAkLXR-HB`;7vPfVC1 z_b!P(JmU&0&)H=tIGTNKZoX?dTpI##`%7l&y?s+5T?>0cR0!>lcX!q+$b~sg4F>E1 ze>0a-oIoJPu`G!9V1`!VBQ=GElO(%{o&E+ z4ayoB&WzfgIh$w>M4G-wayzQ37G<7Y(?+i@3^zZ1WKbP`p-vq-1Ztr{;?U^KzAMb{ z?(MkvY?o1l>$K6e1mPxKU_G&$-uUKRE%8i*`VI$ao9Rn$Yo@}YiE0t|hPF`f_yhS9 zRr^e+4NM3dkC)%0>^B@vP#=O=a}pBqatAE4;-RXSNPKEEA2g@7_D|b8$_2rlLYo2e zfP8WWYM1)`)`XLd8sm#cfbpxlLanVj7OghlwBl#R zX{!604J5=0$2D(6Vx>3>(i@E^GkyB2p{~;Oz^!#+hY77AvolY45A^&vl{u#=M-k_3 zy6q70NSV$wRg7w{hB~=Nagb1EHcMg0M(Lo zHU564m4BaJ44rwlo_VqQL=|)cjZo;FmdJR)mJn5Pd3`KG@!da|@%8~UIqxRjv_FYB zJAtJ=q4S`0oGWebtF{DkKfAI`eCO+s7Sd_0xL7KvVA`~qWi?no&(0jlSVw+>AL<5g z{l)>EC(@KQ*E}7}-b>&-f8XO{^JP+RgYuF zd2&_koAs_vLhDAo5Efdl_K>RkUmCIRbYxlVWmrC8rLF7~!)LqmzSm7@VUC@;_rNap|&NqJJ#GQ7wy z(sed8Mn8(+zxpOmoz?JAYsCzUn=(QMDuhZtu4d}aF-*|}Mq91h<+Q}L8KgAVSvk&C z0`W1-`8k5gHbc}h2Cg+Z8^9fDnTwCWwV4MFB5d&w4!kAq(18TxB}c;Unxet;0w>oZ zX9ezW{O-wb+kHkZY6k;^Jp>b z(j#Y6m%HLTs}fJ#gY+t7AA}(bJhO24 zq_gtr?6**Y3tqR8re@F4dpGB~_uR<}Vrrj;Rs-o3_Jm*6#KF`$a`ti&@y+tl|W#ahw}~r0KKrw zdAxd-vIj%%(Ukv2kX+0yTCY80bMlJHb3ai5eQXOZJ7*7e&R(Cs_s&6a3g*51oO{!9 zT2kB>hbT(C6=V-z*!2#)$DJyX<%K8J%m=s{nX4*|hr*gZ0k~I~t!lj$u^!vMwcjS= zz*KV^H2a(+<;d2Z-sm@Gu;9H+j~R#lTkGv|gbE0uqLgJ%c>DNXN23oHCP;f1ImMq& zblzq@ak%t8Gj~S1dH#3>!gx{o^*~4GNjRv#f8GVoC-p9Bhvq%L%Ji(TQ5}q8$jOZ| ztlX-IU+`=wq#kVfu0k_Afrom+)ekgtyzR5FTVC73Ai5T*9Mlk+ z^QPX`#7P-9Fv?R<)rPr&f+Z@Cz7FsjkRThKYSM{7$c<84?=joxMQZIHWs8g^+GCcggo}a7l2B*k zy7=nuLCS$Q&IJGjqpq`rXR}#(hHlz^=+|;bg>-##PkR6>l+%ccL_@3`dGVxo zO&O?8${_zo+NVdHpf;3H#a#6^Tk^)k{cPgYQkPFoh&Q~+QKG%0%pLy0kL1eEhiuakVT5T`;W4kZ0o5V4||p1w;*IlI|lyi?vjSUMK}zN4Meeso#XhAQH%9%OEfeU0*9L@;ej=!xzW>-Cu1QzAm5pfULTYetM{hvjYma zl>PK#y2t_KGG2r^pfXgIdaqA-kly7(iP~%nQZMuxQ*+YO?(QqbtgQpeT7G;d`JQa# z=nqtf8o2|BsoJ5W{d?79{y1F+5Q#=m;MkuhR4O9}8PoB+{rTqNV|6sD+Qmu>pX#AA zCGbl3JEh@RKt#Kco;_Ca(-Wl%=Pce{Ij3~gw3_EVoSYe*S@9P%G|Fb1_b=wF>smY% zD`+S+@6Dkyu-BF#Qzh&PP+rh>>Gz3e)#R+g5(i-uhyxQT@nezrhClm}Oah6v96z8B zh4G7R6+&Dc+*1@JjX60UgM*_GvSW-6W3+QL$P&Zg?SM zCLQ4%NVVMi?ip0rZWFs7PxV zf_9nGw#qak@HJTB%3fi;fCqeA|S3d2WEJeQ!eXsme~Xed_U#mPFO;!A5-OCNkRhHxph zey4+O!y_e9g2tgw(w}<)d>9jeO-VQAKa)WxF{|#`+(4_jeT~sd*&wz0N83r z)uin=#&_7%h$*EkLIogT2?~1AkWxN0Zi`t50LUHjU+TlkHf8xWPYmpVU|D{4I$W$wiQP)d8EMJw0!= zi12a_Ma1zpxTb;2NZ^m5vA$8z_=2?3*JMIG%16A1=_W^GbYMKiZ?sL4#hpm6Vsir| z(6=BTR~lC)CtSQLvT#4J=SI^W@ntsGE$g=I483wiIRPnaQ?AWUNU}Y>3=K`?G_)NF zfDR`Mt-D*{x$_dhv-=jy+*rcmz4r}M!V z0@W_&4a8MncHrxP3N2}uQLprk;%K64s1W@WZQcF@BDbzr~EHE<+v4(21@Gx?`B#Pl&5hC8>%kb6iqTvfnhL3G~r0-Fd z*^jg%h-aVr$&>uKi+KUIL+hREw@(6&ajfNh*!N$)Y?FUV6HAzOTW zJDp%sQX+(&M~P1x`7FTN*3S2Zg;hZgGBC$0>tv>)yRTH2LS!0nCm#{LEXmJ=%(c&5q5Im2QrC)!9@1)G%Q# zzYWd@gHZe4J+`N#NE0zd;E!8wXFSX8Hh3nkQM=utPuXbkX<;QskPZUVK)X+OjYe29 zG=g=IR|(L>DWUn#5B;5OAa;v_?00hSB4WpUoXxgJC%MXDRf7_hJ)M1q(_oroI+uC{ zu2p`@Er3k$3k}WLKFJ7Q;42!HFuVhH^%2Q&t0>Qj#)V){7sg;yl&Kl{3i+KgP~y^H z7e+z!tTd+_(yr*S zt(~o4>FWRrn@2UsQgGGzrAw)lD;7)xe9vlGNf;KyilW+UMod0=Cye#12 z`46KtCw5kL*faH~^&fKumM6K#8>vpRetRI3Hu3t{X_g%`TymO#Q7Jo>X`sx5eP75$ zr2>tJ`_OJH$+P!wU21%*jBAQ-KygaLim%obg{xm;Xu+&j^>R?g2+-cPciFr^WeFvp zCm{iJjNPpj_iN#|z(-dK1iZ?ULd$h|n)ls-hpF|-Y)S7@?sI?h|gN# zh&j8PYbfAq-!9591Hz zL{9^8`cy`{jhaT2WUQ7ub0ze#vevj*4`QJ|2c&D0k7cL?(-gcK(-yKBgW*SrJP>X0 z#w8DYWXnxcSN48wiF>@x~^0u4Ta^V2`*BYAZkM)cSO3%^`X(xTk( zw@+=$b43{|ctWeAE|7cM!N;Qj&EU2b-aBnCKQ;E?lLl+3QcA@iGO|PD5v4nY$Hhw0 z$wpN^yF&IV#!mE4WkT!H$$}{HnubFb-M6TsK~65Yx)YkaB3@ebQeGJLvoqxvq976| zn|@RjURcx{QKd}#PyD<4{Mq~U%Er6TEr)V>f=q=MQswDlpbjeF0R z3sbw7(xi9!BlV(hoO?6WUees7E6L} zvK*YnUwiFg8^j=bm46rHeNK~wUR3?C%IP{^ZfM-&+@WbiDv)61yS&g;P$iM;*{k04 zY|xnQbrxb=e7xX8$k*GRt=OeYcv$M*6$4Z`GyrMC=SLqR9}!{NvcMX=X5uwUi@lDR zyAAmW79sC8jamx@^)!u|ew_S`BM^>-d>Up=M PVd9ToCh)F2we=mpQPPMvZCARsRKUMDy+yokJ# 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%0D}DKKh^tb`&P1S- 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∈slo_TVd*yQ8|ZxntL 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 literal 0 HcmV?d00001 diff --git a/docs/configuration-lifecycle.md b/docs/configuration-lifecycle.md new file mode 100644 index 0000000..3915f35 --- /dev/null +++ b/docs/configuration-lifecycle.md @@ -0,0 +1,70 @@ +# Configuration Lifecycle + +This document explains how configuration is managed throughout its lifecycle in the Eppo SDK. + +## Components Overview + +The SDK's configuration management is built around several key components that work together: + +- **ConfigurationFeed**: A broadcast channel that serves as the central communication point between components +- **ConfigurationStore**: Maintains the currently active configuration used for all evaluations +- **ConfigurationPoller**: Periodically fetches new configurations from the Eppo API +- **PersistentConfigurationCache**: Persists configuration between application restarts + +## Communication Flow + +The ConfigurationFeed acts as a central hub through which different components communicate: + +![](./configuration-lifecycle.excalidraw.png) + +When a new configuration is received (either from network or cache), it's broadcast through the ConfigurationFeed. Components subscribe to this feed to react to configuration changes. Importantly, configurations broadcast on the ConfigurationFeed are not necessarily activated - they may never be activated at all, as they represent only the latest discovered configurations. For components interested in the currently active configuration, the ConfigurationStore provides its own broadcast channel that only emits when configurations become active. + +## Initialization Process + +During initialization, the client: + +1. **Configuration Loading Strategy**: + - `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data + - `only-if-cached`: Uses cached config without network requests + - `no-cache`: Always fetches fresh configuration + - `none`: Uses only initial configuration without loading/fetching + +2. **Loading cached configuration**: + - If `initialConfiguration` is provided, uses it immediately + - Otherwise, tries to load cached configuration + +3. **Network Fetching**: + - If fetching is needed, attempts to fetch until success or timeout + - Applies backoff with jitter between retry attempts (with shorter period than normal polling) + - Broadcasts fetched configuration via ConfigurationFeed + +4. **Completion**: + - Initialization completes when either: + - Fresh configuration is fetched (for network strategies) + - Cache is loaded (for cache-only strategies) + - Timeout is reached (using best available configuration) + +## Ongoing Configuration Management + +After initialization: + +1. **Polling** (if enabled): + - ConfigurationPoller periodically fetches new configurations + - Uses exponential backoff with jitter for retries on failure + - Broadcasts new configurations through ConfigurationFeed + +2. **Configuration Activation**: + - When ConfigurationStore receives new configurations, it activates them based on strategy: + - `always`: Activate immediately + - `stale`: Activate if current config exceeds `maxStaleSeconds` + - `empty`: Activate if current config is empty + - `next-load`: Store for next initialization + +3. **Persistent Storage**: + - PersistentConfigurationCache listens to ConfigurationFeed + - Automatically stores new configurations to persistent storage + - Provides cached configurations on initialization + +## Evaluation + +For all feature flag evaluations, EppoClient always uses the currently active configuration from ConfigurationStore. This ensures consistent behavior even as configurations are updated in the background. diff --git a/src/broadcast.ts b/src/broadcast.ts new file mode 100644 index 0000000..b308ed8 --- /dev/null +++ b/src/broadcast.ts @@ -0,0 +1,32 @@ +export type Listener = (...args: T) => void; + +/** + * A broadcast channel for dispatching events to multiple listeners. + * + * @internal + */ +export class BroadcastChannel { + private listeners: Array> = []; + + public addListener(listener: Listener): () => void { + this.listeners.push(listener); + return () => this.removeListener(listener); + } + + public removeListener(listener: Listener): void { + const idx = this.listeners.indexOf(listener); + if (idx !== -1) { + this.listeners.splice(idx, 1); + } + } + + public broadcast(...args: T): void { + for (const listener of this.listeners) { + try { + listener(...args); + } catch { + // ignore + } + } + } +} \ No newline at end of file diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index b0eb09b..1bb7826 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import { IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, + readMockUfcConfiguration, readMockUFCResponse, } from '../../test/testHelpers'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; @@ -13,28 +14,25 @@ import { AttributeType } from '../types'; import EppoClient, { IAssignmentDetails } from './eppo-client'; import { initConfiguration } from './test-utils'; +import { read } from 'fs'; describe('EppoClient get*AssignmentDetails', () => { const testStart = Date.now(); - global.fetch = jest.fn(() => { - const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); + let client: EppoClient; - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(ufc), + beforeEach(() => { + client = new EppoClient({ + sdkKey: 'dummy', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + baseUrl: 'http://127.0.0.1:4000', + configuration: { initialConfiguration: readMockUfcConfiguration() }, }); - }) as jest.Mock; - const storage = new MemoryOnlyConfigurationStore(); - - beforeAll(async () => { - await initConfiguration(storage); + client.setIsGracefulFailureMode(false); }); it('should set the details for a matched rule', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); const subjectAttributes = { email: 'alice@mycompany.com', country: 'US' }; const result = client.getIntegerAssignmentDetails( 'integer-flag', @@ -85,8 +83,6 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should set the details for a matched split', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); const subjectAttributes = { email: 'alice@mycompany.com', country: 'Brazil' }; const result = client.getIntegerAssignmentDetails( 'integer-flag', @@ -128,8 +124,6 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle matching a split allocation with a matched rule', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); const subjectAttributes = { id: 'alice', email: 'alice@external.com', country: 'Brazil' }; const result = client.getStringAssignmentDetails( 'new-user-onboarding', @@ -190,8 +184,6 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle unrecognized flags', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); const result = client.getIntegerAssignmentDetails('asdf', 'alice', {}, 0); expect(result).toEqual({ variation: 0, @@ -215,7 +207,6 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle type mismatches with graceful failure mode enabled', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(true); const result = client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true); expect(result).toEqual({ @@ -252,7 +243,6 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should throw an error for type mismatches with graceful failure mode disabled', () => { - const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); expect(() => client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true)).toThrow(); }); @@ -277,22 +267,6 @@ describe('EppoClient get*AssignmentDetails', () => { } }; - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; - - await initConfiguration(storage); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - describe.each(getTestFilePaths())('for file: %s', (testFilePath: string) => { const testCase = parseJSON(testFilePath); describe.each(testCase.subjects.map(({ subjectKey }) => subjectKey))( @@ -302,9 +276,6 @@ describe('EppoClient get*AssignmentDetails', () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const subject = subjects.find((subject) => subject.subjectKey === subjectKey)!; - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); - const focusOn = { testFilePath: '', // focus on test file paths (don't forget to set back to empty string!) subjectKey: '', // focus on subject (don't forget to set back to empty string!) diff --git a/src/client/eppo-client-experiment-container.spec.ts b/src/client/eppo-client-experiment-container.spec.ts index 9eb4fda..1beb940 100644 --- a/src/client/eppo-client-experiment-container.spec.ts +++ b/src/client/eppo-client-experiment-container.spec.ts @@ -1,4 +1,4 @@ -import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers'; +import { MOCK_UFC_RESPONSE_FILE, readMockUfcConfiguration, readMockUFCResponse } from '../../test/testHelpers'; import * as applicationLogger from '../application-logger'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { Flag, ObfuscatedFlag } from '../interfaces'; @@ -9,15 +9,6 @@ import { initConfiguration } from './test-utils'; type Container = { name: string }; describe('getExperimentContainerEntry', () => { - global.fetch = jest.fn(() => { - const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(ufc), - }); - }) as jest.Mock; - const controlContainer: Container = { name: 'Control Container' }; const treatment1Container: Container = { name: 'Treatment Variation 1 Container' }; const treatment2Container: Container = { name: 'Treatment Variation 2 Container' }; @@ -29,9 +20,16 @@ describe('getExperimentContainerEntry', () => { let loggerWarnSpy: jest.SpyInstance; beforeEach(async () => { - const storage = new MemoryOnlyConfigurationStore(); - await initConfiguration(storage); - client = new EppoClient({ flagConfigurationStore: storage }); + client = new EppoClient({ + configuration: { + initializationStrategy: 'none', + initialConfiguration: readMockUfcConfiguration(), + }, + sdkKey: 'dummy', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + baseUrl: 'http://127.0.0.1:4000', + }); client.setIsGracefulFailureMode(true); flagExperiment = { flagKey: 'my-key', diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 878b909..e8c6bed 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -7,6 +7,7 @@ import { testCasesByFileName, BanditTestCase, BANDIT_TEST_DATA_DIR, + readMockBanditsConfiguration, } from '../../test/testHelpers'; import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; @@ -32,9 +33,6 @@ import { Attributes, BanditActions, ContextAttributes } from '../types'; import EppoClient, { IAssignmentDetails } from './eppo-client'; describe('EppoClient Bandits E2E test', () => { - const flagStore = new MemoryOnlyConfigurationStore(); - const banditVariationStore = new MemoryOnlyConfigurationStore(); - const banditModelStore = new MemoryOnlyConfigurationStore(); let client: EppoClient; const mockLogAssignment = jest.fn(); const mockLogBanditAction = jest.fn(); @@ -63,22 +61,18 @@ describe('EppoClient Bandits E2E test', () => { sdkVersion: '1.0.0', }, }); - const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - await configurationRequestor.fetchAndStoreConfigurations(); }); beforeEach(() => { client = new EppoClient({ - flagConfigurationStore: flagStore, - banditVariationConfigurationStore: banditVariationStore, - banditModelConfigurationStore: banditModelStore, - isObfuscated: false, + sdkKey: 'dummy', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + baseUrl: 'http://127.0.0.1:4000', + configuration: { + initializationStrategy: 'none', + initialConfiguration: readMockBanditsConfiguration(), + }, }); client.setIsGracefulFailureMode(false); client.setAssignmentLogger({ logAssignment: mockLogAssignment }); diff --git a/src/client/eppo-client-with-overrides.spec.ts b/src/client/eppo-client-with-overrides.spec.ts index 4e05cf5..aaafcd3 100644 --- a/src/client/eppo-client-with-overrides.spec.ts +++ b/src/client/eppo-client-with-overrides.spec.ts @@ -1,3 +1,4 @@ +import { Configuration } from '../configuration'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces'; import * as overrideValidatorModule from '../override-validator'; @@ -5,13 +6,31 @@ import * as overrideValidatorModule from '../override-validator'; import EppoClient from './eppo-client'; describe('EppoClient', () => { - const storage = new MemoryOnlyConfigurationStore(); - function setUnobfuscatedFlagEntries( - entries: Record, - ): Promise { - storage.setFormat(FormatEnum.SERVER); - return storage.setEntries(entries); + entries: Record, + ): EppoClient { + return new EppoClient({ + sdkKey: 'dummy', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + baseUrl: 'http://127.0.0.1:4000', + configuration: { + initialConfiguration: Configuration.fromResponses({ + flags: { + fetchedAt: new Date().toISOString(), + response: { + format: FormatEnum.SERVER, + flags: entries, + createdAt: new Date().toISOString(), + environment: { + name: 'test', + }, + banditReferences: {}, + }, + } + }) + }, + }); } const flagKey = 'mock-flag'; @@ -51,9 +70,8 @@ describe('EppoClient', () => { let subjectKey: string; beforeEach(async () => { - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); subjectKey = 'subject-10'; - client = new EppoClient({ flagConfigurationStore: storage }); }); describe('parseOverrides', () => { diff --git a/src/client/eppo-client.sdk-test-data.spec.ts b/src/client/eppo-client.sdk-test-data.spec.ts new file mode 100644 index 0000000..50d53cf --- /dev/null +++ b/src/client/eppo-client.sdk-test-data.spec.ts @@ -0,0 +1,97 @@ +import { + ASSIGNMENT_TEST_DATA_DIR, + IAssignmentTestCase, + readMockUfcConfiguration, + readMockUfcObfuscatedConfiguration, + testCasesByFileName, +} from '../../test/testHelpers'; +import { Configuration } from '../configuration'; +import { VariationType } from '../interfaces'; + +import EppoClient from './eppo-client'; + +describe('SDK Test Data / assignment tests', () => { + const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); + + describe('Not obfuscated', () => { + defineTestCases(readMockUfcConfiguration(), testCases); + }); + + describe('Obfuscated', () => { + defineTestCases(readMockUfcObfuscatedConfiguration(), testCases); + }); +}); + +function defineTestCases( + configuration: Configuration, + testCases: Record, +) { + let client: EppoClient; + + beforeAll(() => { + client = new EppoClient({ + sdkKey: 'test', + sdkName: 'test', + sdkVersion: 'test', + configuration: { + initialConfiguration: configuration, + initializationStrategy: 'none', + enablePolling: false, + }, + }); + client.setIsGracefulFailureMode(false); + }); + + describe.each(Object.keys(testCases))('%s', (fileName) => { + const { flag, variationType, defaultValue, subjects } = testCases[fileName]; + test.each(subjects)('$subjectKey', (subject) => { + let assignment: string | number | boolean | object; + switch (variationType) { + case VariationType.BOOLEAN: + assignment = client.getBooleanAssignment( + flag, + subject.subjectKey, + subject.subjectAttributes, + defaultValue as boolean, + ); + break; + case VariationType.NUMERIC: + assignment = client.getNumericAssignment( + flag, + subject.subjectKey, + subject.subjectAttributes, + defaultValue as number, + ); + break; + case VariationType.INTEGER: + assignment = client.getIntegerAssignment( + flag, + subject.subjectKey, + subject.subjectAttributes, + defaultValue as number, + ); + break; + case VariationType.STRING: + assignment = client.getStringAssignment( + flag, + subject.subjectKey, + subject.subjectAttributes, + defaultValue as string, + ); + break; + case VariationType.JSON: + assignment = client.getJSONAssignment( + flag, + subject.subjectKey, + subject.subjectAttributes, + defaultValue as object, + ); + break; + default: + throw new Error(`Unknown variation type: ${variationType}`); + } + + expect(assignment).toEqual(subject.assignment); + }); + }); +} diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 4ef63f1..f558fae 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -8,6 +8,8 @@ import { IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, OBFUSCATED_MOCK_UFC_RESPONSE_FILE, + readMockUfcConfiguration, + readMockUfcObfuscatedConfiguration, readMockUFCResponse, SubjectTestCase, testCasesByFileName, @@ -15,26 +17,29 @@ import { } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { ConfigurationStore } from '../configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, ObfuscatedPrecomputedConfigurationResponse, } from '../configuration-wire/configuration-wire-types'; -import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; +import { MAX_EVENT_QUEUE_SIZE, DEFAULT_BASE_POLLING_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import { AttributeType } from '../types'; -import EppoClient, { checkTypeMatch, FlagConfigurationRequestParameters } from './eppo-client'; -import { initConfiguration } from './test-utils'; +import EppoClient, { checkTypeMatch } from './eppo-client'; +import { Configuration } from '../configuration'; +import { IUniversalFlagConfigResponse } from '../http-client'; +import { ISyncStore } from '../configuration-store/configuration-store'; // Use a known salt to produce deterministic hashes const salt = base64.fromUint8Array(new Uint8Array([7, 53, 17, 78])); describe('EppoClient E2E test', () => { + // Configure fetch mock for tests that still need it global.fetch = jest.fn(() => { const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); @@ -44,24 +49,36 @@ describe('EppoClient E2E test', () => { json: () => Promise.resolve(ufc), }); }) as jest.Mock; - const storage = new MemoryOnlyConfigurationStore(); /** - * Use this helper instead of directly setting entries on the `storage` ConfigurationStore. - * This method ensures the format field is set as it is required for parsing. - * @param entries + * Creates an EppoClient with the specified flags and initializes with 'none' strategy + * to avoid network requests. + * @param entries The flag entries to use in the configuration */ - function setUnobfuscatedFlagEntries( - entries: Record, - ): Promise { - storage.setFormat(FormatEnum.SERVER); - return storage.setEntries(entries); + function setUnobfuscatedFlagEntries(entries: Record): EppoClient { + return new EppoClient({ + sdkKey: 'test', + sdkName: 'test', + sdkVersion: 'test', + configuration: { + initialConfiguration: Configuration.fromResponses({ + flags: { + response: { + format: FormatEnum.SERVER, + flags: entries, + createdAt: new Date().toISOString(), + environment: { + name: 'test', + }, + banditReferences: {}, + }, + }, + }), + initializationStrategy: 'none', + }, + }); } - beforeAll(async () => { - await initConfiguration(storage); - }); - const flagKey = 'mock-flag'; const variationA = { @@ -102,8 +119,7 @@ describe('EppoClient E2E test', () => { let client: EppoClient; beforeAll(async () => { - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - client = new EppoClient({ flagConfigurationStore: storage }); + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); td.replace(EppoClient.prototype, 'getAssignmentDetail', function () { throw new Error('Mock test error'); @@ -117,8 +133,6 @@ describe('EppoClient E2E test', () => { it('returns default value when graceful failure if error encountered', async () => { client.setIsGracefulFailureMode(true); - expect(client.getBoolAssignment(flagKey, 'subject-identifier', {}, true)).toBe(true); - expect(client.getBoolAssignment(flagKey, 'subject-identifier', {}, false)).toBe(false); expect(client.getBooleanAssignment(flagKey, 'subject-identifier', {}, true)).toBe(true); expect(client.getBooleanAssignment(flagKey, 'subject-identifier', {}, false)).toBe(false); expect(client.getNumericAssignment(flagKey, 'subject-identifier', {}, 1)).toBe(1); @@ -138,7 +152,6 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getBoolAssignment(flagKey, 'subject-identifier', {}, true); client.getBooleanAssignment(flagKey, 'subject-identifier', {}, true); }).toThrow(); @@ -157,14 +170,10 @@ describe('EppoClient E2E test', () => { }); describe('setLogger', () => { - beforeAll(async () => { - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - }); - it('Invokes logger for queued events', () => { const mockLogger = td.object(); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setAssignmentLogger(mockLogger); @@ -177,7 +186,7 @@ describe('EppoClient E2E test', () => { it('Does not log same queued event twice', () => { const mockLogger = td.object(); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setAssignmentLogger(mockLogger); @@ -188,7 +197,7 @@ describe('EppoClient E2E test', () => { it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object(); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); times(MAX_EVENT_QUEUE_SIZE + 100, (i) => client.getStringAssignment(flagKey, `subject-to-be-logged-${i}`, {}, 'default-value'), @@ -199,7 +208,7 @@ describe('EppoClient E2E test', () => { it('should log assignment event with entityId', () => { const mockLogger = td.object(); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.setAssignmentLogger(mockLogger); client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -215,8 +224,10 @@ describe('EppoClient E2E test', () => { }); describe('precomputed flags', () => { - beforeAll(async () => { - await setUnobfuscatedFlagEntries({ + let client: EppoClient; + + beforeEach(() => { + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag, disabledFlag: { ...mockFlag, enabled: false }, anotherFlag: { @@ -238,11 +249,6 @@ describe('EppoClient E2E test', () => { }); }); - let client: EppoClient; - beforeEach(() => { - client = new EppoClient({ flagConfigurationStore: storage }); - }); - it('skips disabled flags', () => { const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}, salt); const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire; @@ -317,129 +323,28 @@ describe('EppoClient E2E test', () => { }); }); - describe('UFC Shared Test Cases', () => { - const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); - - describe('Not obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; - - await initConfiguration(storage); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage }); - client.setIsGracefulFailureMode(false); - - let assignments: { - subject: SubjectTestCase; - assignment: string | boolean | number | null | object; - }[] = []; - - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } - - assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); - - validateTestAssignments(assignments, flag); - }); - }); - - describe('Obfuscated', () => { - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(readMockUFCResponse(OBFUSCATED_MOCK_UFC_RESPONSE_FILE)), - }); - }) as jest.Mock; - - await initConfiguration(storage); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { - const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); - client.setIsGracefulFailureMode(false); - - const typeAssignmentFunctions = { - [VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignment.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignment.bind(client), - [VariationType.STRING]: client.getStringAssignment.bind(client), - [VariationType.JSON]: client.getJSONAssignment.bind(client), - }; - - const assignmentFn = typeAssignmentFunctions[variationType] as ( - flagKey: string, - subjectKey: string, - subjectAttributes: Record, - defaultValue: boolean | string | number | object, - ) => never; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } - - const assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); - - validateTestAssignments(assignments, flag); - }); - }); - }); - it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => { const localClient = new EppoClient({ - flagConfigurationStore: new MemoryOnlyConfigurationStore(), + sdkKey: 'test', + sdkName: 'test', + sdkVersion: 'test', + configuration: { + initialConfiguration: Configuration.empty(), + initializationStrategy: 'none', + }, }); expect(localClient.getStringAssignment(flagKey, 'subject-1', {}, 'hello world')).toEqual( 'hello world', ); - expect(localClient.isInitialized()).toBe(false); + expect(localClient.isInitialized()).toBe(true); }); it('returns default value when key does not exist', async () => { - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); const nonExistentFlag = 'non-existent-flag'; - expect(client.getBoolAssignment(nonExistentFlag, 'subject-identifier', {}, true)).toBe(true); + expect(client.getBooleanAssignment(nonExistentFlag, 'subject-identifier', {}, true)).toBe(true); expect(client.getBooleanAssignment(nonExistentFlag, 'subject-identifier', {}, true)).toBe(true); expect(client.getNumericAssignment(nonExistentFlag, 'subject-identifier', {}, 1)).toBe(1); expect(client.getJSONAssignment(nonExistentFlag, 'subject-identifier', {}, {})).toEqual({}); @@ -450,9 +355,7 @@ describe('EppoClient E2E test', () => { it('logs variation assignment and experiment key', async () => { const mockLogger = td.object(); - - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.setAssignmentLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -477,8 +380,7 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - const client = new EppoClient({ flagConfigurationStore: storage }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.setAssignmentLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -493,9 +395,8 @@ describe('EppoClient E2E test', () => { }); it('exports flag configuration', async () => { - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - const client = new EppoClient({ flagConfigurationStore: storage }); - expect(client.getFlagConfigurations()).toEqual({ [flagKey]: mockFlag }); + const client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); + expect(client.getConfiguration().getFlagsConfiguration()?.response.flags).toEqual({ [flagKey]: mockFlag }); }); describe('assignment logging deduplication', () => { @@ -504,9 +405,7 @@ describe('EppoClient E2E test', () => { beforeEach(async () => { mockLogger = td.object(); - - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - client = new EppoClient({ flagConfigurationStore: storage }); + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); client.setAssignmentLogger(mockLogger); }); @@ -560,7 +459,7 @@ describe('EppoClient E2E test', () => { }); it('logs for each unique flag', async () => { - await setUnobfuscatedFlagEntries({ + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag, 'flag-2': { ...mockFlag, @@ -571,6 +470,7 @@ describe('EppoClient E2E test', () => { key: 'flag-3', }, }); + client.setAssignmentLogger(mockLogger); client.useNonExpiringInMemoryAssignmentCache(); @@ -590,10 +490,13 @@ describe('EppoClient E2E test', () => { it('logs twice for the same flag when allocations change', async () => { client.useNonExpiringInMemoryAssignmentCache(); - await setUnobfuscatedFlagEntries({ + // Initially call with the default allocation + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + // Create a new client with a different allocation + const clientWithNewAllocation = setUnobfuscatedFlagEntries({ [flagKey]: { ...mockFlag, - allocations: [ { key: 'allocation-a-2', @@ -609,9 +512,12 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + clientWithNewAllocation.setAssignmentLogger(mockLogger); + clientWithNewAllocation.useNonExpiringInMemoryAssignmentCache(); + clientWithNewAllocation.getStringAssignment(flagKey, 'subject-10', {}, 'default'); - await setUnobfuscatedFlagEntries({ + // Create a third client with yet another allocation + const clientWithThirdAllocation = setUnobfuscatedFlagEntries({ [flagKey]: { ...mockFlag, allocations: [ @@ -629,21 +535,22 @@ describe('EppoClient E2E test', () => { ], }, }); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); + clientWithThirdAllocation.setAssignmentLogger(mockLogger); + clientWithThirdAllocation.useNonExpiringInMemoryAssignmentCache(); + clientWithThirdAllocation.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); }); it('logs the same subject/flag/variation after two changes', async () => { client.useNonExpiringInMemoryAssignmentCache(); // original configuration version - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the variation - await setUnobfuscatedFlagEntries({ + const clientWithVariationB = setUnobfuscatedFlagEntries({ [flagKey]: { ...mockFlag, allocations: [ @@ -661,18 +568,22 @@ describe('EppoClient E2E test', () => { ], }, }); + clientWithVariationB.setAssignmentLogger(mockLogger); + clientWithVariationB.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log + clientWithVariationB.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment + clientWithVariationB.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the flag again, back to the original - await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); + const clientWithOriginalFlag = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag }); + clientWithOriginalFlag.setAssignmentLogger(mockLogger); + clientWithOriginalFlag.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // important: log this assignment - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log + clientWithOriginalFlag.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // important: log this assignment + clientWithOriginalFlag.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log // change the allocation - await setUnobfuscatedFlagEntries({ + const clientWithDifferentAllocation = setUnobfuscatedFlagEntries({ [flagKey]: { ...mockFlag, allocations: [ @@ -690,9 +601,11 @@ describe('EppoClient E2E test', () => { ], }, }); + clientWithDifferentAllocation.setAssignmentLogger(mockLogger); + clientWithDifferentAllocation.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment - client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log + clientWithDifferentAllocation.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment + clientWithDifferentAllocation.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); }); @@ -700,14 +613,22 @@ describe('EppoClient E2E test', () => { describe('Eppo Client constructed with configuration request parameters', () => { let client: EppoClient; - let thisFlagStorage: IConfigurationStore; - let requestConfiguration: FlagConfigurationRequestParameters; + let requestConfiguration: { + apiKey: string; + sdkName: string; + sdkVersion: string; + baseUrl?: string; + requestTimeoutMs?: number; + basePollingIntervalMs?: number; + pollAfterSuccessfulInitialization?: boolean; + pollAfterFailedInitialization?: boolean; + }; const flagKey = 'numeric_flag'; const subject = 'alice'; const pi = 3.1415926; - const maxRetryDelay = DEFAULT_POLL_INTERVAL_MS * POLL_JITTER_PCT; + const maxRetryDelay = DEFAULT_BASE_POLLING_INTERVAL_MS * POLL_JITTER_PCT; beforeAll(async () => { global.fetch = jest.fn(() => { @@ -726,13 +647,10 @@ describe('EppoClient E2E test', () => { sdkVersion: '1.0.0', }; - thisFlagStorage = new MemoryOnlyConfigurationStore(); - // We only want to fake setTimeout() and clearTimeout() jest.useFakeTimers({ advanceTimers: true, doNotFake: [ - 'Date', 'hrtime', 'nextTick', 'performance', @@ -760,47 +678,50 @@ describe('EppoClient E2E test', () => { it('Fetches initial configuration with parameters in constructor', async () => { client = new EppoClient({ - flagConfigurationStore: thisFlagStorage, - configurationRequestParameters: requestConfiguration, + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, + configuration: { + }, }); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(flagKey, subject, {}, 123.4); expect(variation).toBe(123.4); - // have client fetch configurations - await client.fetchFlagConfigurations(); - variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); - expect(variation).toBe(pi); - }); - it('Fetches initial configuration with parameters provided later', async () => { - client = new EppoClient({ flagConfigurationStore: thisFlagStorage }); - client.setIsGracefulFailureMode(false); - client.setConfigurationRequestParameters(requestConfiguration); - // no configuration loaded - let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); - expect(variation).toBe(0.0); // have client fetch configurations - await client.fetchFlagConfigurations(); + await client.waitForInitialization(); + variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(pi); }); - describe('Poll after successful start', () => { - it('Continues to poll when cache has not expired', async () => { - class MockStore extends MemoryOnlyConfigurationStore { - public static expired = false; - - async isExpired(): Promise { - return MockStore.expired; - } - } - + describe('Configuration polling', () => { + it('Respects activationStrategy: stale', async () => { client = new EppoClient({ - flagConfigurationStore: new MockStore(), - configurationRequestParameters: { - ...requestConfiguration, - pollAfterSuccessfulInitialization: true, + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, + configuration: { + initialConfiguration: Configuration.fromResponses({ + flags: { + fetchedAt: new Date().toISOString(), + response: { + format: FormatEnum.SERVER, + flags: {}, + createdAt: new Date().toISOString(), + environment: { + name: 'test', + }, + banditReferences: {}, + }, + }, + }), + initializationStrategy: 'stale-while-revalidate', + maxAgeSeconds: 30, + enablePolling: true, + activationStrategy: 'stale', + maxStaleSeconds: DEFAULT_BASE_POLLING_INTERVAL_MS / 1000, }, }); client.setIsGracefulFailureMode(false); @@ -809,18 +730,18 @@ describe('EppoClient E2E test', () => { expect(variation).toBe(0.0); // have client fetch configurations; cache is not expired so assignment stays - await client.fetchFlagConfigurations(); + await client.waitForInitialization(); variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(0.0); - // Expire the cache and advance time until a reload should happen - MockStore.expired = true; - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS * 1.5); + // Advance time until a reload should happen + await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS * 1.5); variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(pi); }); }); + it('Does not fetch configurations if the configuration store is unexpired', async () => { class MockStore extends MemoryOnlyConfigurationStore { async isExpired(): Promise { @@ -828,28 +749,30 @@ describe('EppoClient E2E test', () => { } } + // Test needs network fetching approach client = new EppoClient({ - flagConfigurationStore: new MockStore(), - configurationRequestParameters: requestConfiguration, + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, }); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(0.0); // have client fetch configurations - await client.fetchFlagConfigurations(); + await client.getConfiguration(); variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); expect(variation).toBe(0.0); }); it.each([ - { pollAfterSuccessfulInitialization: false }, - { pollAfterSuccessfulInitialization: true }, + { enablePolling: false }, + { enablePolling: true }, ])('retries initial configuration request with config %p', async (configModification) => { let callCount = 0; global.fetch = jest.fn(() => { - if (++callCount === 1) { + if (callCount++ === 0) { // Simulate an error for the first call return Promise.resolve({ ok: false, @@ -868,14 +791,16 @@ describe('EppoClient E2E test', () => { } }) as jest.Mock; - const { pollAfterSuccessfulInitialization } = configModification; - requestConfiguration = { - ...requestConfiguration, - pollAfterSuccessfulInitialization, - }; + const { enablePolling } = configModification; + client = new EppoClient({ - flagConfigurationStore: thisFlagStorage, - configurationRequestParameters: requestConfiguration, + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, + configuration: { + initializationTimeoutMs: 60_000, + enablePolling, + }, }); client.setIsGracefulFailureMode(false); // no configuration loaded @@ -883,7 +808,7 @@ describe('EppoClient E2E test', () => { expect(variation).toBe(0.0); // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - const fetchPromise = client.fetchFlagConfigurations(); + const fetchPromise = client.waitForInitialization(); // Advance timers mid-init to allow retrying await jest.advanceTimersByTimeAsync(maxRetryDelay); @@ -895,19 +820,14 @@ describe('EppoClient E2E test', () => { expect(variation).toBe(pi); expect(callCount).toBe(2); - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS); + await jest.advanceTimersByTimeAsync(1.5 * DEFAULT_BASE_POLLING_INTERVAL_MS); // By default, no more polling - expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); + expect(callCount).toBe(enablePolling ? 3 : 2); }); it.each([ - { - pollAfterFailedInitialization: false, - throwOnFailedInitialization: false, - }, - { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, + { enablePolling: false }, + { enablePolling: true }, ])('initial configuration request fails with config %p', async (configModification) => { let callCount = 0; @@ -929,43 +849,35 @@ describe('EppoClient E2E test', () => { } }); - const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; + const { enablePolling } = configModification; - // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, - // timeout queue, message queue stuff) so we don't allow retries when rethrowing. - const numInitialRequestRetries = 0; - - requestConfiguration = { - ...requestConfiguration, - numInitialRequestRetries, - throwOnFailedInitialization, - pollAfterFailedInitialization, - }; + // This test specifically tests network fetching behavior client = new EppoClient({ - flagConfigurationStore: thisFlagStorage, - configurationRequestParameters: requestConfiguration, + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, + configuration: { + enablePolling, + // Very short initialization timeout to force an initialization failure + initializationTimeoutMs: 100, + activationStrategy: 'always', + }, }); client.setIsGracefulFailureMode(false); // no configuration loaded expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe(0.0); - // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - if (throwOnFailedInitialization) { - await expect(client.fetchFlagConfigurations()).rejects.toThrow(); - } else { - await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined(); - } expect(callCount).toBe(1); // still no configuration loaded - expect(client.getNumericAssignment(flagKey, subject, {}, 10.0)).toBe(10.0); + expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe(0.0); // Advance timers so a post-init poll can take place - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS * 1.5); + await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS); - // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not - expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); + // if enablePolling = true, we will poll later and get a config, otherwise not + expect(callCount).toBe(enablePolling ? 2 : 1); expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe( - pollAfterFailedInitialization ? pi : 0.0, + enablePolling ? pi : 0.0, ); }); }); @@ -973,15 +885,32 @@ describe('EppoClient E2E test', () => { describe('flag overrides', () => { let client: EppoClient; let mockLogger: IAssignmentLogger; - let overrideStore: IConfigurationStore; + let overrideStore: ISyncStore; beforeEach(() => { - storage.setEntries({ [flagKey]: mockFlag }); mockLogger = td.object(); overrideStore = new MemoryOnlyConfigurationStore(); client = new EppoClient({ - flagConfigurationStore: storage, - overrideStore: overrideStore, + sdkKey: 'test', + sdkName: 'test', + sdkVersion: 'test', + configuration: { + initialConfiguration: Configuration.fromResponses({ + flags: { + response: { + format: FormatEnum.SERVER, + flags: { [flagKey]: mockFlag }, + createdAt: new Date().toISOString(), + environment: { + name: 'test', + }, + banditReferences: {}, + }, + }, + }), + initializationStrategy: 'none', + }, + overrideStore, }); client.setAssignmentLogger(mockLogger); client.useNonExpiringInMemoryAssignmentCache(); @@ -1100,7 +1029,25 @@ describe('EppoClient E2E test', () => { it('uses normal assignment when no overrides store is configured', () => { // Create client without overrides store const clientWithoutOverrides = new EppoClient({ - flagConfigurationStore: storage, + sdkKey: 'test', + sdkName: 'test', + sdkVersion: 'test', + configuration: { + initialConfiguration: Configuration.fromResponses({ + flags: { + response: { + format: FormatEnum.SERVER, + flags: { [flagKey]: mockFlag }, + createdAt: new Date().toISOString(), + environment: { + name: 'test', + }, + banditReferences: {}, + }, + }, + }), + initializationStrategy: 'none', + }, }); clientWithoutOverrides.setAssignmentLogger(mockLogger); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index e35b8a0..f260232 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -29,7 +29,15 @@ import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, DEFAULT_BASE_POLLING_INTERVAL_MS, + DEFAULT_MAX_POLLING_INTERVAL_MS, DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_INITIALIZATION_TIMEOUT_MS, + DEFAULT_MAX_AGE_SECONDS, + DEFAULT_MAX_STALE_SECONDS, + DEFAULT_INITIALIZATION_STRATEGY, + DEFAULT_ACTIVATION_STRATEGY, + DEFAULT_ENABLE_POLLING_CLIENT, + DEFAULT_ENABLE_BANDITS, } from '../constants'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; @@ -51,7 +59,7 @@ import { VariationType, } from '../interfaces'; import { OverridePayload, OverrideValidator } from '../override-validator'; -import initPoller, { IPoller, randomJitterMs } from '../poller'; +import { randomJitterMs } from '../poller'; import { Attributes, AttributeType, @@ -64,8 +72,14 @@ import { import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; -import { PersistentConfigurationStorage } from '../persistent-configuration-storage'; +import { + PersistentConfigurationCache, + PersistentConfigurationStorage, +} from '../persistent-configuration-cache'; import { ConfigurationPoller } from '../configuration-poller'; +import { ConfigurationFeed, ConfigurationSource } from '../configuration-feed'; +import { BroadcastChannel } from '../broadcast'; + export interface IAssignmentDetails { variation: T; action: string | null; @@ -93,7 +107,7 @@ export type EppoClientParameters = { /** * Whether to enable bandits. * - * This influences whether bandits configuration is loaded. + * This influences whether bandits configuration is fetched. * Disabling bandits helps to save network bandwidth if bandits * are unused. * @@ -232,6 +246,37 @@ export type EppoClientParameters = { }; }; +/** + * ## Initialization + * + * During initialization, the client will: + * 1. Load initial configuration from `configuration.initialConfiguration` if provided + * 2. If no initial configuration and `configuration.persistentStorage` is provided and strategy is + * not 'no-cache' or 'none', attempt to load cached configuration + * 3. Based on `configuration.initializationStrategy`: + * - 'stale-while-revalidate': Use cached config if within `maxStaleSeconds`, fetch fresh in + * background + * - 'only-if-cached': Use cached config only, no network requests + * - 'no-cache': Always fetch fresh config + * - 'none': Use only initial config, no loading/fetching + * 4. If fetching enabled, attempt fetches until success or `initializationTimeoutMs` reached + * 5. If `configuration.enablePolling` is true, begin polling for updates every + * `basePollingIntervalMs` + * 6. When new configs are fetched, activate based on `configuration.activationStrategy`: + * - 'always': Activate immediately + * - 'stale': Activate if current config exceeds `maxStaleSeconds` + * - 'empty': Activate if current config is empty + * - 'next-load': Store for next initialization + * + * Initialization is considered complete when either: + * - For 'stale-while-revalidate': Fresh configuration is fetched + * - For 'only-if-cached': Cache is loaded or initial configuration applied + * - For 'no-cache': Fresh configuration is fetched + * - For 'none': Immediately + * + * If `configuration.initializationTimeoutMs` is reached before completion, initialization finishes + * with the best available configuration (from cache, initial configuration, or empty). + */ export default class EppoClient { private eventDispatcher: EventDispatcher; private readonly assignmentEventsQueue: BoundedEventQueue = @@ -249,36 +294,53 @@ export default class EppoClient { private readonly evaluator = new Evaluator(); private readonly overrideValidator = new OverrideValidator(); - private readonly configurationStore; + private readonly configurationFeed; + private readonly configurationStore: ConfigurationStore; + private readonly configurationCache?: PersistentConfigurationCache; private readonly configurationRequestor: ConfigurationRequestor; - private readonly requestPoller: ConfigurationPoller; + private readonly configurationPoller: ConfigurationPoller; private initialized = false; private readonly initializationPromise: Promise; - constructor(options: EppoClientParameters) { const { eventDispatcher = new NoOpEventDispatcher(), overrideStore, configuration } = options; + this.eventDispatcher = eventDispatcher; + this.overrideStore = overrideStore; + const { configuration: { persistentStorage, - initializationStrategy = 'stale-while-revalidate', - initializationTimeoutMs = 5_000, - initialConfiguration, - requestTimeoutMs = 5_000, - basePollingIntervalMs = 30_000, - maxPollingIntervalMs = 300_000, - enablePolling = true, - maxAgeSeconds = 30, - maxStaleSeconds = Infinity, - activationStrategy = 'stale', + initializationTimeoutMs = DEFAULT_INITIALIZATION_TIMEOUT_MS, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS, + maxPollingIntervalMs = DEFAULT_MAX_POLLING_INTERVAL_MS, + enablePolling = DEFAULT_ENABLE_POLLING_CLIENT, + maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, + activationStrategy = DEFAULT_ACTIVATION_STRATEGY, } = {}, } = options; + this.configurationFeed = new BroadcastChannel<[Configuration, ConfigurationSource]>(); + this.configurationStore = new ConfigurationStore(configuration?.initialConfiguration); + this.configurationStore.register( + this.configurationFeed, + activationStrategy === 'always' + ? { type: 'always' } + : activationStrategy === 'stale' + ? { type: 'stale', maxAgeSeconds } + : activationStrategy === 'empty' + ? { type: 'empty' } + : { type: 'never' }, + ); - this.eventDispatcher = eventDispatcher; - this.overrideStore = overrideStore; + if (persistentStorage) { + this.configurationCache = new PersistentConfigurationCache( + persistentStorage, + this.configurationFeed, + ); + } this.configurationRequestor = new ConfigurationRequestor( new FetchHttpClient( @@ -292,60 +354,49 @@ export default class EppoClient { }), requestTimeoutMs, ), - this.configurationStore, + this.configurationFeed, { - wantsBandits: options.bandits?.enable ?? true, + wantsBandits: options.bandits?.enable ?? DEFAULT_ENABLE_BANDITS, }, ); - this.requestPoller = new ConfigurationPoller(this.configurationRequestor, { + this.configurationPoller = new ConfigurationPoller(this.configurationRequestor, { + configurationFeed: this.configurationFeed, basePollingIntervalMs, maxPollingIntervalMs, + maxAgeMs: maxAgeSeconds * 1000, }); - this.requestPoller.onConfigurationFetched((configuration: Configuration) => { - // During initialization, always set the configuration. - // Otherwise, apply the activation strategy. - const shouldActivate = !this.initialized || - EppoClient.shouldActivateConfiguration(activationStrategy, maxAgeSeconds, this.configurationStore.getConfiguration()); - - if (shouldActivate) { - this.configurationStore.setConfiguration(configuration); - } - try { - persistentStorage?.storeConfiguration(configuration); - } catch (err) { - logger.warn('Eppo SDK failed to store configuration in persistent store', { err }); - } - }); - - this.initializationPromise = withTimeout( - this.initialize(options), - initializationTimeoutMs - ) + this.initializationPromise = withTimeout(this.initialize(options), initializationTimeoutMs) .catch((err) => { - logger.warn('Eppo SDK encountered an error during initialization', { err }); + logger.warn({ err }, '[Eppo SDK] Encountered an error during initialization'); }) .finally(() => { + logger.debug('[Eppo SDK] Finished initialization'); this.initialized = true; if (enablePolling) { - this.requestPoller.start(); + this.configurationPoller.start(); } }); } private async initialize(options: EppoClientParameters): Promise { + logger.debug('[Eppo SDK] Initializing EppoClient'); const { configuration: { - persistentStorage, - initializationStrategy = 'stale-while-revalidate', + initializationStrategy = DEFAULT_INITIALIZATION_STRATEGY, initialConfiguration, - basePollingIntervalMs = 30_000, - maxAgeSeconds = 30, - maxStaleSeconds = Infinity, + basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS, + maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, + maxStaleSeconds = DEFAULT_MAX_STALE_SECONDS, } = {}, } = options; + if (initialConfiguration) { + this.configurationStore.setConfiguration(initialConfiguration); + this.configurationFeed.broadcast(initialConfiguration, ConfigurationSource.Cache); + } + if (initializationStrategy === 'none') { this.initialized = true; return; @@ -353,20 +404,14 @@ export default class EppoClient { if ( !initialConfiguration && // initial configuration overrides persistent storage for initialization - persistentStorage && + this.configurationCache && (initializationStrategy === 'stale-while-revalidate' || initializationStrategy === 'only-if-cached') ) { try { - const configuration = await persistentStorage.loadConfiguration(); + const configuration = await this.configurationCache.loadConfiguration({ maxStaleSeconds }); if (configuration && !this.initialized) { - const age = configuration.getAge(); - - const isTooOld = age && age > maxStaleSeconds * 1000; - if (!isTooOld) { - // The configuration is too old to be used. - this.configurationStore.setConfiguration(configuration); - } + this.configurationStore.setConfiguration(configuration); } } catch (err) { logger.warn('Eppo SDK failed to load configuration from persistent store', { err }); @@ -378,24 +423,38 @@ export default class EppoClient { } // Finish initialization early if cached configuration is fresh. - const configurationAgeMs = this.configurationStore.getConfiguration()?.getAge(); - if (configurationAgeMs && configurationAgeMs < maxAgeSeconds * 1000) { + const cachedConfiguration = this.configurationStore.getConfiguration(); + const configurationAgeMs = cachedConfiguration?.getAgeMs(); + if (configurationAgeMs !== undefined && configurationAgeMs < maxAgeSeconds * 1000) { + logger.debug( + { configurationAgeMs, maxAgeSeconds }, + '[Eppo SDK] The cached configuration is fresh, skipping fetch', + ); return; + } else if (cachedConfiguration) { + logger.debug( + { configurationAgeMs, maxAgeSeconds }, + '[Eppo SDK] The cached configuration is stale, fetching new configuration', + ); + } else { + logger.debug('[Eppo SDK] No cached configuration found, fetching new configuration'); } - // Loop until we sucessfully fetch configuration or - // initialization deadline is reached (and sets this.initialized - // to true). + // Loop until we sucessfully fetch configuration or initialization deadline is reached (and sets + // this.initialized to true). while (!this.initialized) { try { - // The fetchImmediate method will trigger the listener registered in the constructor, - // which will activate the configuration. - await this.requestPoller.fetchImmediate(); - - // If we got here, the fetch was successful, and we can exit the loop. - return; + logger.debug('[Eppo SDK] Fetching initial configuration'); + const configuration = await this.configurationRequestor.fetchConfiguration(); + if (configuration) { + this.configurationFeed.broadcast(configuration, ConfigurationSource.Network); + this.configurationStore.setConfiguration(configuration); + + // The fetch was successful, so we can exit the loop. + return; + } } catch (err) { - logger.warn('Eppo SDK failed to fetch initial configuration', { err }); + logger.warn({ err }, '[Eppo SDK] Failed to fetch initial configuration'); } // Note: this is only using the jitter without the base polling interval. @@ -403,14 +462,13 @@ export default class EppoClient { } } - private static shouldActivateConfiguration( - activationStrategy: string, - maxAgeSeconds: number, - prevConfiguration: Configuration - ): boolean { - return activationStrategy === 'always' - || (activationStrategy === 'stale' && (prevConfiguration.isStale(maxAgeSeconds) ?? true)) - || (activationStrategy === 'empty' && (prevConfiguration.isEmpty() ?? true)); + /** + * Waits for the client to finish initialization sequence and be ready to serve assignments. + * + * @returns A promise that resolves when the client is initialized. + */ + public waitForInitialization(): Promise { + return this.initializationPromise; } public getConfiguration(): Configuration { @@ -425,12 +483,12 @@ export default class EppoClient { } /** - * Register a listener to be notified when a new configuration is fetched. + * Register a listener to be notified when a new configuration is received. * @param listener Callback function that receives the fetched `Configuration` object * @returns A function that can be called to unsubscribe the listener. */ - public onConfigurationFetched(listener: (configuration: Configuration) => void): void { - this.requestPoller.onConfigurationFetched(listener); + public onNewConfiguration(listener: (configuration: Configuration) => void): () =>void { + return this.configurationFeed.addListener(listener); } /** @@ -438,8 +496,8 @@ export default class EppoClient { * @param listener Callback function that receives the activated `Configuration` object * @returns A function that can be called to unsubscribe the listener. */ - public onConfigurationActivated(listener: (configuration: Configuration) => void): void { - this.configurationStore.onConfigurationChange(listener); + public onConfigurationActivated(listener: (configuration: Configuration) => void): () => void { + return this.configurationStore.onConfigurationChange(listener); } /** @@ -512,8 +570,8 @@ export default class EppoClient { // noinspection JSUnusedGlobalSymbols stopPolling() { - if (this.requestPoller) { - this.requestPoller.stop(); + if (this.configurationPoller) { + this.configurationPoller.stop(); } } @@ -1504,14 +1562,14 @@ export default class EppoClient { return result ? { - banditKey: bandit.banditKey, - action: result.actionKey, - actionNumericAttributes: result.actionAttributes.numericAttributes, - actionCategoricalAttributes: result.actionAttributes.categoricalAttributes, - actionProbability: result.actionWeight, - modelVersion: bandit.modelVersion, - optimalityGap: result.optimalityGap, - } + banditKey: bandit.banditKey, + action: result.actionKey, + actionNumericAttributes: result.actionAttributes.numericAttributes, + actionCategoricalAttributes: result.actionAttributes.categoricalAttributes, + actionProbability: result.actionWeight, + modelVersion: bandit.modelVersion, + optimalityGap: result.optimalityGap, + } : null; } } diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index 8d1ea44..a8676a5 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -27,7 +27,7 @@ import { decodeBase64, encodeBase64, getMD5Hash } from '../obfuscation'; import PrecomputedRequestor from '../precomputed-requestor'; import EppoPrecomputedClient, { - PrecomputedFlagsRequestParameters, + PrecomputedRequestParameters, Subject, } from './eppo-precomputed-client'; @@ -393,7 +393,7 @@ describe('EppoPrecomputedClient E2E test', () => { let client: EppoPrecomputedClient; let precomputedFlagStore: IConfigurationStore; let subject: Subject; - let requestParameters: PrecomputedFlagsRequestParameters; + let requestParameters: PrecomputedRequestParameters; const precomputedFlagKey = 'string-flag'; const red = 'red'; diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index f9c4ce9..d3a3d7a 100644 --- a/src/client/test-utils.ts +++ b/src/client/test-utils.ts @@ -1,9 +1,10 @@ import ApiEndpoints from '../api-endpoints'; +import { BroadcastChannel } from '../broadcast'; +import { Configuration } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; -import { ConfigurationStore } from '../configuration-store'; import FetchHttpClient from '../http-client'; -export async function initConfiguration(configurationStore: ConfigurationStore) { +export async function initConfiguration(): Promise { const apiEndpoints = new ApiEndpoints({ baseUrl: 'http://127.0.0.1:4000', queryParams: { @@ -13,6 +14,6 @@ export async function initConfiguration(configurationStore: ConfigurationStore) }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor(httpClient, configurationStore); - await configurationRequestor.fetchAndStoreConfigurations(); + const configurationRequestor = new ConfigurationRequestor(httpClient, new BroadcastChannel()); + return await configurationRequestor.fetchConfiguration(); } diff --git a/src/configuration-feed.ts b/src/configuration-feed.ts new file mode 100644 index 0000000..9338579 --- /dev/null +++ b/src/configuration-feed.ts @@ -0,0 +1,27 @@ +import { Configuration } from './configuration'; +import { BroadcastChannel } from './broadcast'; + +/** + * Enumeration of possible configuration sources. + */ +export enum ConfigurationSource { + /** + * Configuration was loaded from the local cache. + */ + Cache = 'cache', + /** + * Configuration was loaded from the network. + */ + Network = 'network' +} + +/** + * ConfigurationFeed provides a mechanism for components to communicate about the latest + * configurations (without necessarily activating them). + * + * It serves as a central communication point for configuration updates, allowing components like + * poller, cache, and activation to coordinate without tight coupling. + * + * @internal + */ +export type ConfigurationFeed = BroadcastChannel<[Configuration, ConfigurationSource]>; diff --git a/src/configuration-poller.ts b/src/configuration-poller.ts index 426c651..6cf2b86 100644 --- a/src/configuration-poller.ts +++ b/src/configuration-poller.ts @@ -1,32 +1,57 @@ import ConfigurationRequestor from './configuration-requestor'; -import { Listeners } from './listener'; -import { Configuration } from './configuration'; -import { randomJitterMs } from './poller'; import { logger } from './application-logger'; +import { POLL_JITTER_PCT } from './constants'; +import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; /** * Polls for new configurations from the Eppo server. When a new configuration is fetched, - * it is passed to the subscribers of `onConfigurationFetched`. + * it is published to the configuration feed. * * The poller is created in the stopped state. Call `start` to begin polling. * * @internal */ export class ConfigurationPoller { - private readonly listeners = new Listeners<[Configuration]>(); + private readonly configurationFeed?: ConfigurationFeed; private readonly basePollingIntervalMs: number; private readonly maxPollingIntervalMs: number; + private readonly maxAgeMs: number; + private isRunning = false; + // We're watching configuration feed and recording the latest known fetch time (in milliseconds + // since Unix epoch), so we don't poll for configuration too often. + private lastFetchTime?: number; + public constructor( private readonly configurationRequestor: ConfigurationRequestor, options: { + configurationFeed?: ConfigurationFeed, basePollingIntervalMs: number; maxPollingIntervalMs: number; + maxAgeMs: number; }, ) { this.basePollingIntervalMs = options.basePollingIntervalMs; this.maxPollingIntervalMs = options.maxPollingIntervalMs; + this.maxAgeMs = options.maxAgeMs; + this.configurationFeed = options.configurationFeed; + + this.configurationFeed?.addListener((configuration) => { + const fetchedAt = configuration.getFetchedAt()?.getTime(); + if (!fetchedAt) { + return; + } + + if (this.lastFetchTime !== undefined && fetchedAt < this.lastFetchTime) { + // Ignore configuration if it's not the latest. + return; + } + + // Math.min() ensures that we don't use a fetchedAt time that is in the future. If the time is + // in the future, we use the current time. + this.lastFetchTime = Math.min(fetchedAt, Date.now()); + }); } /** @@ -37,10 +62,11 @@ export class ConfigurationPoller { */ public start(): void { if (!this.isRunning) { + logger.debug('[Eppo SDK] starting configuration poller'); this.isRunning = true; this.poll().finally(() => { - // Just to be safe, reset isRunning if the poll() method throws an error or exits (it - // shouldn't). + // Just to be safe, reset isRunning if the poll() method throws an error or exits + // unexpectedly (it shouldn't). this.isRunning = false; }); } @@ -54,49 +80,32 @@ export class ConfigurationPoller { * listeners are not notified of any new configurations after this method is called. */ public stop(): void { + logger.debug('[Eppo SDK] stopping configuration poller'); this.isRunning = false; } - /** - * Register a listener to be notified when new configuration is fetched. - * @param listener Callback function that receives the fetched `Configuration` object - * @returns A function that can be called to unsubscribe the listener. - */ - public onConfigurationFetched(listener: (configuration: Configuration) => void): () => void { - return this.listeners.addListener(listener); - } - - /** - * Fetch configuration immediately without waiting for the next polling cycle. - * - * Note: This does not coordinate with active polling - polling intervals will not be adjusted - * when using this method. - * - * @throws If there is an error fetching the configuration - */ - public async fetchImmediate(): Promise { - const configuration = await this.configurationRequestor.fetchConfiguration(); - if (configuration) { - this.listeners.notify(configuration); - } - return configuration; - } - private async poll(): Promise { - // Number of failures we've seen in a row. let consecutiveFailures = 0; while (this.isRunning) { - try { - const configuration = await this.configurationRequestor.fetchConfiguration(); - if (configuration && this.isRunning) { - this.listeners.notify(configuration); + if (this.lastFetchTime !== undefined && Date.now() - this.lastFetchTime < this.maxAgeMs) { + // Configuration is still fresh, so we don't need to poll. Skip this iteration. + logger.debug('[Eppo SDK] configuration is still fresh, skipping poll'); + } else { + try { + logger.debug('[Eppo SDK] polling for new configuration'); + const configuration = await this.configurationRequestor.fetchConfiguration(); + if (configuration && this.isRunning) { + logger.debug('[Eppo SDK] fetched configuration'); + this.configurationFeed?.broadcast(configuration, ConfigurationSource.Network); + } + + // Reset failure counter on success + consecutiveFailures = 0; + } catch (err) { + logger.warn({ err }, '[Eppo SDK] encountered an error polling configurations'); + consecutiveFailures++; } - // Reset failure counter on success - consecutiveFailures = 0; - } catch (err) { - logger.warn('Eppo SDK encountered an error polling configurations', { err }); - consecutiveFailures++; } if (consecutiveFailures === 0) { @@ -106,10 +115,7 @@ export class ConfigurationPoller { const baseDelayMs = Math.min((Math.pow(2, consecutiveFailures) * this.basePollingIntervalMs), this.maxPollingIntervalMs); const delayMs = baseDelayMs + randomJitterMs(baseDelayMs); - logger.warn('Eppo SDK will try polling again', { - delayMs, - consecutiveFailures, - }); + logger.warn({ delayMs, consecutiveFailures }, '[Eppo SDK] will try polling again'); await timeout(delayMs); } @@ -121,3 +127,20 @@ function timeout(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } +/** + * Compute a random jitter as a percentage of the polling interval. + * Will be (5%,10%) of the interval assuming POLL_JITTER_PCT = 0.1 + */ +function randomJitterMs(intervalMs: number) { + const halfPossibleJitter = (intervalMs * POLL_JITTER_PCT) / 2; + // We want the randomly chosen jitter to be at least 1ms so total jitter is slightly more than + // half the max possible. + // + // This makes things easy for automated tests as two polls cannot execute within the maximum + // possible time waiting for one. + const randomOtherHalfJitter = Math.max( + Math.floor((Math.random() * intervalMs * POLL_JITTER_PCT) / 2), + 1, + ); + return halfPossibleJitter + randomOtherHalfJitter; +} \ No newline at end of file diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 2bfe61d..f30b853 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -6,9 +6,11 @@ import { } from '../test/testHelpers'; import ApiEndpoints from './api-endpoints'; +import { BroadcastChannel } from './broadcast'; +import { ConfigurationFeed } from './configuration-feed'; import ConfigurationRequestor from './configuration-requestor'; +import { ConfigurationStore } from './configuration-store'; import { IConfigurationStore } from './configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { IBanditParametersResponse, IHttpClient, @@ -18,13 +20,15 @@ import { StoreBackedConfiguration } from './i-configuration'; import { BanditParameters, BanditVariation, Flag } from './interfaces'; describe('ConfigurationRequestor', () => { - let flagStore: IConfigurationStore; - let banditVariationStore: IConfigurationStore; - let banditModelStore: IConfigurationStore; + let configurationFeed: ConfigurationFeed; + let configurationStore: ConfigurationStore; let httpClient: IHttpClient; let configurationRequestor: ConfigurationRequestor; beforeEach(async () => { + configurationFeed = new BroadcastChannel(); + configurationStore = new ConfigurationStore(); + configurationStore.register(configurationFeed, { type: 'always' }); const apiEndpoints = new ApiEndpoints({ baseUrl: 'http://127.0.0.1:4000', queryParams: { @@ -34,14 +38,9 @@ describe('ConfigurationRequestor', () => { }, }); httpClient = new FetchHttpClient(apiEndpoints, 1000); - flagStore = new MemoryOnlyConfigurationStore(); - banditVariationStore = new MemoryOnlyConfigurationStore(); - banditModelStore = new MemoryOnlyConfigurationStore(); configurationRequestor = new ConfigurationRequestor( httpClient, - flagStore, - banditVariationStore, - banditModelStore, + configurationFeed, ); }); @@ -69,12 +68,12 @@ describe('ConfigurationRequestor', () => { }); it('Fetches and stores flag configuration', async () => { - await configurationRequestor.fetchAndStoreConfigurations(); + const configuration = await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(1); // Flags only; no bandits - expect(flagStore.getKeys().length).toBeGreaterThanOrEqual(16); - const killSwitchFlag = flagStore.get('kill-switch'); + expect(configuration?.getFlagKeys().length).toBeGreaterThanOrEqual(16); + const killSwitchFlag = configuration?.getFlag('kill-switch'); expect(killSwitchFlag?.key).toBe('kill-switch'); expect(killSwitchFlag?.enabled).toBe(true); expect(killSwitchFlag?.variationType).toBe('BOOLEAN'); @@ -109,7 +108,7 @@ describe('ConfigurationRequestor', () => { end: 10000, }); - expect(banditModelStore.getKeys().length).toBe(0); + expect(configuration?.getBanditConfiguration()).toBeUndefined(); }); }); @@ -145,17 +144,19 @@ describe('ConfigurationRequestor', () => { }); it('Fetches and populates bandit parameters', async () => { - await configurationRequestor.fetchAndStoreConfigurations(); + const configuration = await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits - expect(flagStore.getKeys().length).toBeGreaterThanOrEqual(2); - expect(flagStore.get('banner_bandit_flag')).toBeDefined(); - expect(flagStore.get('cold_start_bandit')).toBeDefined(); + expect(configuration?.getFlagKeys().length).toBeGreaterThanOrEqual(2); + expect(configuration?.getFlag('banner_bandit_flag')).toBeDefined(); + expect(configuration?.getFlag('cold_start_bandit')).toBeDefined(); - expect(banditModelStore.getKeys().length).toBeGreaterThanOrEqual(2); + const bandits = configuration?.getBanditConfiguration(); + expect(bandits).toBeDefined(); + expect(Object.keys(bandits?.response.bandits ?? {}).length).toBeGreaterThanOrEqual(2); - const bannerBandit = banditModelStore.get('banner_bandit'); + const bannerBandit = bandits?.response.bandits['banner_bandit']; expect(bannerBandit?.banditKey).toBe('banner_bandit'); expect(bannerBandit?.modelName).toBe('falcon'); expect(bannerBandit?.modelVersion).toBe('123'); @@ -206,7 +207,7 @@ describe('ConfigurationRequestor', () => { ], ).toBe(0); - const coldStartBandit = banditModelStore.get('cold_start_bandit'); + const coldStartBandit = bandits?.response.bandits['cold_start_bandit']; expect(coldStartBandit?.banditKey).toBe('cold_start_bandit'); expect(coldStartBandit?.modelName).toBe('falcon'); expect(coldStartBandit?.modelVersion).toBe('cold start'); @@ -217,17 +218,19 @@ describe('ConfigurationRequestor', () => { expect(coldStartModelData?.coefficients).toStrictEqual({}); }); - it('Will not fetch bandit parameters if there is no store', async () => { - configurationRequestor = new ConfigurationRequestor(httpClient, flagStore, null, null); - await configurationRequestor.fetchAndStoreConfigurations(); + it('Will not fetch bandit parameters if does not want bandits', async () => { + configurationRequestor = new ConfigurationRequestor(httpClient, configurationFeed, { + wantsBandits: false, + }); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); it('Should not fetch bandits if model version is un-changed', async () => { - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(2); // Once for UFC, another for bandits - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(3); // Once just for UFC, bandits should be skipped }); @@ -272,12 +275,12 @@ describe('ConfigurationRequestor', () => { initiateFetchSpy(defaultResponseMockGenerator); }); - function expectBanditToBeInModelStore( - store: IConfigurationStore, + function expectBanditToBeInStore( + store: ConfigurationStore, banditKey: string, expectedBanditParameters: BanditParameters, ) { - const bandit = store.get(banditKey); + const bandit = store.getConfiguration()?.getBanditConfiguration()?.response.bandits[banditKey]; expect(bandit).toBeTruthy(); expect(bandit?.banditKey).toBe(expectedBanditParameters.banditKey); expect(bandit?.modelVersion).toBe(expectedBanditParameters.modelVersion); @@ -309,8 +312,8 @@ describe('ConfigurationRequestor', () => { it('Should fetch bandits if new bandit references model versions appeared', async () => { let updateUFC = false; - await configurationRequestor.fetchAndStoreConfigurations(); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(3); const customResponseMockGenerator = (url: string) => { @@ -328,12 +331,12 @@ describe('ConfigurationRequestor', () => { updateUFC = true; initiateFetchSpy(customResponseMockGenerator); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(2); // 2 because fetchSpy was re-initiated, 1UFC and 1bandits // let's check if warm start was hydrated properly! - expectBanditToBeInModelStore( - banditModelStore, + expectBanditToBeInStore( + configurationStore, 'warm_start_bandit', warmStartBanditParameters, ); @@ -341,7 +344,7 @@ describe('ConfigurationRequestor', () => { it('Should not fetch bandits if bandit references model versions shrunk', async () => { // Initial fetch - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); // Let's mock UFC response so that cold_start is no longer retrieved const customResponseMockGenerator = (url: string) => { @@ -358,12 +361,12 @@ describe('ConfigurationRequestor', () => { }; initiateFetchSpy(customResponseMockGenerator); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(1); // only once for UFC // cold start should still be in memory - expectBanditToBeInModelStore( - banditModelStore, + expectBanditToBeInStore( + configurationStore, 'cold_start_bandit', coldStartBanditParameters, ); @@ -379,10 +382,10 @@ describe('ConfigurationRequestor', () => { it('should fetch bandits based on banditReference change in UFC', async () => { let injectWarmStart = false; let removeColdStartBandit = false; - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(2); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(3); const customResponseMockGenerator = (url: string) => { @@ -404,10 +407,10 @@ describe('ConfigurationRequestor', () => { injectWarmStart = true; initiateFetchSpy(customResponseMockGenerator); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(2); - expectBanditToBeInModelStore( - banditModelStore, + expectBanditToBeInStore( + configurationStore, 'warm_start_bandit', warmStartBanditParameters, ); @@ -415,11 +418,11 @@ describe('ConfigurationRequestor', () => { injectWarmStart = false; removeColdStartBandit = true; initiateFetchSpy(customResponseMockGenerator); - await configurationRequestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); expect(fetchSpy).toHaveBeenCalledTimes(1); - expectBanditToBeInModelStore( - banditModelStore, + expectBanditToBeInStore( + configurationStore, 'cold_start_bandit', coldStartBanditParameters, ); @@ -496,120 +499,8 @@ describe('ConfigurationRequestor', () => { jest.restoreAllMocks(); }); - describe('getConfiguration', () => { - it('should return an empty configuration instance before a config has been loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - const config = requestor.getConfiguration(); - expect(config).toBeInstanceOf(StoreBackedConfiguration); - expect(config.getFlagKeys()).toEqual([]); - }); - - it('should return a populated configuration instance', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - - await requestor.fetchAndStoreConfigurations(); - - const config = requestor.getConfiguration(); - expect(config).toBeInstanceOf(StoreBackedConfiguration); - expect(config.getFlagKeys()).toEqual(['test_flag']); - }); - }); - - describe('fetchAndStoreConfigurations', () => { - it('should update configuration with flag data', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); - - await requestor.fetchAndStoreConfigurations(); - - expect(config.getFlagKeys()).toEqual(['test_flag']); - expect(config.getFlagConfigDetails()).toEqual({ - configEnvironment: { name: 'Test' }, - configFetchedAt: expect.any(String), - configFormat: 'SERVER', - configPublishedAt: '2024-01-01', - }); - }); - - it('should update configuration with bandit data when present', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const config = requestor.getConfiguration(); - - await requestor.fetchAndStoreConfigurations(); - - // Verify flag configuration - expect(config.getFlagKeys()).toEqual(['test_flag']); - - // Verify bandit variation configuration - // expect(banditVariationDetails.entries).toEqual({ - // 'test_flag': [ - // { - // flagKey: 'test_flag', - // variationId: 'variation-1', - // // Add other expected properties based on your mock data - // } - // ] - // }); - // expect(banditVariationDetails.environment).toBe('test-env'); - // expect(banditVariationDetails.configFormat).toBe('SERVER'); - - // Verify bandit model configuration - const banditVariations = config.getFlagBanditVariations('test_flag'); - expect(banditVariations).toEqual([ - { - allocationKey: 'analysis', - flagKey: 'test_flag', - key: 'bandit', - variationKey: 'bandit', - variationValue: 'bandit', - }, - ]); - - const banditKey = banditVariations.at(0)?.key; - - expect(banditKey).toEqual('bandit'); - if (!banditKey) { - fail('bandit Key null, appeasing typescript'); - } - const banditModelDetails = config.getBandit(banditKey); - expect(banditModelDetails).toEqual({ - banditKey: 'bandit', - modelName: 'falcon', - modelVersion: '123', - updatedAt: '2023-09-13T04:52:06.462Z', - // Add other expected properties based on your mock data - }); - }); - + describe('fetchConfiguration', () => { it('should not fetch bandit parameters if model versions are already loaded', async () => { - const requestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); - const ufcResponse = { flags: { test_flag: { key: 'test_flag', value: true } }, banditReferences: { @@ -623,7 +514,7 @@ describe('ConfigurationRequestor', () => { format: 'SERVER', }; - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); // const initialFetchCount = fetchSpy.mock.calls.length; // Second call with same model version @@ -635,7 +526,7 @@ describe('ConfigurationRequestor', () => { // }) // ); - await requestor.fetchAndStoreConfigurations(); + await configurationRequestor.fetchConfiguration(); // Should only have one additional fetch (the UFC) and not the bandit parameters // expect(fetchSpy.mock.calls.length).toBe(initialFetchCount + 1); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 41f7137..665a17e 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,5 +1,5 @@ import { BanditsConfig, Configuration, FlagsConfig } from './configuration'; -import { ConfigurationStore } from './configuration-store'; +import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; import { IHttpClient } from './http-client'; export type ConfigurationRequestorOptions = { @@ -12,15 +12,27 @@ export type ConfigurationRequestorOptions = { export default class ConfigurationRequestor { private readonly options: ConfigurationRequestorOptions; + // We track the latest seen configuration to possibly reuse it for flags/bandits. + private latestConfiguration?: Configuration; + public constructor( private readonly httpClient: IHttpClient, - private readonly configurationStore: ConfigurationStore, + private readonly configurationFeed: ConfigurationFeed, options: Partial = {}, ) { this.options = { wantsBandits: true, ...options, }; + + this.configurationFeed.addListener((configuration) => { + const prevFetchedAt = this.latestConfiguration?.getFetchedAt(); + const newFetchedAt = configuration.getFetchedAt(); + + if (!prevFetchedAt || (newFetchedAt && newFetchedAt > prevFetchedAt)) { + this.latestConfiguration = configuration; + } + }); } public async fetchConfiguration(): Promise { @@ -31,7 +43,11 @@ export default class ConfigurationRequestor { const bandits = await this.getBanditsFor(flags); - return Configuration.fromResponses({ flags, bandits }); + const configuration = Configuration.fromResponses({ flags, bandits }); + this.latestConfiguration = configuration; + this.configurationFeed.broadcast(configuration, ConfigurationSource.Network); + + return configuration; } /** @@ -48,7 +64,7 @@ export default class ConfigurationRequestor { return undefined; } - const prevBandits = this.configurationStore.getConfiguration().getBanditConfiguration(); + const prevBandits = this.latestConfiguration?.getBanditConfiguration(); const canReuseBandits = banditsUpToDate(flags, prevBandits); if (canReuseBandits) { return prevBandits; diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index ec0bbd2..20f1a78 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,40 +1,4 @@ -import { Configuration } from '../configuration'; import { Environment } from '../interfaces'; -import { Listeners } from '../listener'; - -/** - * `ConfigurationStore` is a central piece of Eppo SDK and answers a - * simple question: what configuration is currently active? - * - * @internal `ConfigurationStore` shall only be used inside Eppo SDKs. - */ -export class ConfigurationStore { - private configuration: Configuration; - private readonly listeners: Listeners<[Configuration]> = new Listeners(); - - public constructor(configuration: Configuration = Configuration.empty()) { - this.configuration = configuration; - } - - public getConfiguration(): Configuration { - return this.configuration; - } - - public setConfiguration(configuration: Configuration): void { - this.configuration = configuration; - this.listeners.notify(configuration); - } - - /** - * Subscribe to configuration changes. The callback will be called - * every time configuration is changed. - * - * Returns a function to unsubscribe from future updates. - */ - public onConfigurationChange(listener: (configuration: Configuration) => void): () => void { - return this.listeners.addListener(listener); - } -} /** * ConfigurationStore interface diff --git a/src/configuration-store/index.ts b/src/configuration-store/index.ts index 80c3180..1e3428a 100644 --- a/src/configuration-store/index.ts +++ b/src/configuration-store/index.ts @@ -1 +1,88 @@ -export { ConfigurationStore } from './configuration-store'; +import { logger } from '../application-logger'; +import { Configuration } from '../configuration'; +import { ConfigurationFeed } from '../configuration-feed'; +import { BroadcastChannel } from '../broadcast'; + +export type ActivationStrategy = { + /** + * Always activate new configuration. + */ + type: 'always'; +} | { + /** + * Activate new configuration if the current configuration is stale (older than maxAgeSeconds). + */ + type: 'stale'; + maxAgeSeconds: number; +} | { + /** + * Activate new configuration if the current configuration is empty. + */ + type: 'empty'; +} | { + /** + * Never activate new configuration. + */ + type: 'never'; +}; + +/** + * `ConfigurationStore` answers a simple question: what configuration is currently active? + * + * @internal `ConfigurationStore` shall only be used inside Eppo SDKs. + */ +export class ConfigurationStore { + private configuration: Configuration; + private readonly listeners: BroadcastChannel<[Configuration]> = new BroadcastChannel(); + + public constructor(configuration: Configuration = Configuration.empty()) { + this.configuration = configuration; + } + + /** + * Register configuration store to receive updates from a configuration feed using the specified + * activation strategy. + */ + public register(configurationFeed: ConfigurationFeed, activationStrategy: ActivationStrategy): void { + if (activationStrategy.type === 'never') { + // No need to subscribe to configuration feed if we don't want to activate any configuration. + return; + } + + configurationFeed.addListener((configuration) => { + const currentConfiguration = this.getConfiguration(); + const shouldActivate = activationStrategy.type === 'always' + || (activationStrategy.type === 'stale' && currentConfiguration.isStale(activationStrategy.maxAgeSeconds)) + || (activationStrategy.type === 'empty' && currentConfiguration.isEmpty()); + + if (shouldActivate) { + this.setConfiguration(configuration); + } else { + logger.debug('[Eppo SDK] Skipping activation of new configuration'); + } + }); + } + + public getConfiguration(): Configuration { + return this.configuration; + } + + public setConfiguration(configuration: Configuration): void { + if (this.configuration !== configuration) { + // Only broadcast if the configuration has changed. + logger.debug('[Eppo SDK] Activating new configuration'); + this.configuration = configuration; + this.listeners.broadcast(configuration); + } + } + + /** + * Subscribe to configuration changes. The callback will be called + * every time configuration is changed. + * + * Returns a function to unsubscribe from future updates. + */ + public onConfigurationChange(listener: (configuration: Configuration) => void): () => void { + return this.listeners.addListener(listener); + } +} \ No newline at end of file diff --git a/src/configuration.ts b/src/configuration.ts index 27fe263..472cc17 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -41,6 +41,29 @@ export class Configuration { return new Configuration(); } + /** + * Initializes a Configuration from a legacy flags configuration format. New applications should + * use `Configuration.fromString` instead. + * + * @deprecated Use `Configuration.fromString` instead. + */ + public static fromFlagsConfiguration( + flags: Record, + options: { obfuscated: boolean }, + ): Configuration { + return new Configuration({ + response: { + format: options.obfuscated ? FormatEnum.CLIENT : FormatEnum.SERVER, + flags, + createdAt: new Date().toISOString(), + environment: { + name: 'from-flags-configuration', + }, + banditReferences: {}, + }, + }); + } + /** @internal For SDK usage only. */ public static fromResponses({ flags, @@ -101,7 +124,7 @@ export class Configuration { } /** @internal */ - public getAge(): number | undefined { + public getAgeMs(): number | undefined { const fetchedAt = this.getFetchedAt(); if (!fetchedAt) { return undefined; @@ -111,7 +134,7 @@ export class Configuration { /** @internal */ public isStale(maxAgeSeconds: number): boolean { - const age = this.getAge(); + const age = this.getAgeMs(); return !!age && age > maxAgeSeconds * 1000; } @@ -162,7 +185,7 @@ function indexBanditVariationsByFlagKey( flagsResponse: IUniversalFlagConfigResponse, ): Record { const banditVariationsByFlagKey: Record = {}; - Object.values(flagsResponse.banditReferences).forEach((banditReference) => { + Object.values(flagsResponse.banditReferences ?? {}).forEach((banditReference) => { banditReference.flagVariations.forEach((banditVariation) => { let banditVariations = banditVariationsByFlagKey[banditVariation.flagKey]; if (!banditVariations) { diff --git a/src/constants.ts b/src/constants.ts index 03a8a7b..24bb013 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,11 +1,19 @@ import { FormatEnum } from './interfaces'; -export const DEFAULT_REQUEST_TIMEOUT_MS = 5000; -export const REQUEST_TIMEOUT_MILLIS = DEFAULT_REQUEST_TIMEOUT_MS; // for backwards compatibility -export const DEFAULT_BASE_POLLING_INTERVAL_MS = 30000; +export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000; +export const DEFAULT_BASE_POLLING_INTERVAL_MS = 30_000; +export const DEFAULT_MAX_POLLING_INTERVAL_MS = 300_000; export const POLL_JITTER_PCT = 0.1; export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1; export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; +export const DEFAULT_INITIALIZATION_TIMEOUT_MS = 5_000; +export const DEFAULT_MAX_AGE_SECONDS = 30; +export const DEFAULT_MAX_STALE_SECONDS = Infinity; +export const DEFAULT_INITIALIZATION_STRATEGY = 'stale-while-revalidate'; +export const DEFAULT_ACTIVATION_STRATEGY = 'stale'; +export const DEFAULT_ENABLE_POLLING_CLIENT = false; +export const DEFAULT_ENABLE_POLLING_NODE = true; +export const DEFAULT_ENABLE_BANDITS = true; export const BASE_URL = 'https://fscdn.eppo.cloud/api'; export const UFC_ENDPOINT = '/flag-config/v1/config'; export const BANDIT_ENDPOINT = '/flag-config/v1/bandits'; diff --git a/src/eppo-assignment-logger.spec.ts b/src/eppo-assignment-logger.spec.ts index f9d69d4..9c6481e 100644 --- a/src/eppo-assignment-logger.spec.ts +++ b/src/eppo-assignment-logger.spec.ts @@ -13,7 +13,12 @@ describe('EppoAssignmentLogger', () => { beforeEach(() => { jest.clearAllMocks(); mockEppoClient = new EppoClient({ - flagConfigurationStore: {} as IConfigurationStore, + sdkKey: 'test-sdk-key', + sdkName: 'test-sdk-name', + sdkVersion: 'test-sdk-version', + configuration: { + initializationStrategy: 'none', + }, }) as jest.Mocked; mockEppoClient.track = jest.fn(); logger = new EppoAssignmentLogger(mockEppoClient); diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index d51f1af..d5e361c 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,3 +1,4 @@ +import { Configuration } from './configuration'; import { Evaluator, hashKey, isInShardRange, matchesRules } from './evaluator'; import { Flag, Variation, Shard, VariationType, ConfigDetails, FormatEnum } from './interfaces'; import { getMD5Hash } from './obfuscation'; @@ -11,17 +12,19 @@ describe('Evaluator', () => { const evaluator = new Evaluator(); - let configDetails: ConfigDetails; - - beforeEach(() => { - configDetails = { - configEnvironment: { - name: 'Test', + let configuration: Configuration = Configuration.fromResponses({ + flags: { + fetchedAt: new Date().toISOString(), + response: { + environment: { + name: 'Test', + }, + createdAt: new Date().toISOString(), + flags: {}, + format: FormatEnum.SERVER, + banditReferences: {}, }, - configFetchedAt: new Date().toISOString(), - configPublishedAt: new Date().toISOString(), - configFormat: FormatEnum.CLIENT, - }; + }, }); it('should return none result for disabled flag', () => { @@ -47,7 +50,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); expect(result.flagKey).toEqual('disabled_flag'); expect(result.allocationKey).toBeNull(); expect(result.variation).toBeNull(); @@ -98,7 +101,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(emptyFlag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, emptyFlag, 'subject_key', {}); expect(result.flagKey).toEqual('empty'); expect(result.allocationKey).toBeNull(); expect(result.variation).toBeNull(); @@ -128,7 +131,7 @@ describe('Evaluator', () => { totalShards: 10000, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'user-1', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'user-1', {}); expect(result.variation).toEqual({ key: 'control', value: 'control-value' }); }); @@ -161,13 +164,13 @@ describe('Evaluator', () => { totalShards: 10000, }; - let result = evaluator.evaluateFlag(flag, configDetails, 'alice', {}, false); + let result = evaluator.evaluateFlag(configuration, flag, 'alice', {}); expect(result.variation).toEqual({ key: 'control', value: 'control' }); - result = evaluator.evaluateFlag(flag, configDetails, 'bob', {}, false); + result = evaluator.evaluateFlag(configuration, flag, 'bob', {}); expect(result.variation).toEqual({ key: 'control', value: 'control' }); - result = evaluator.evaluateFlag(flag, configDetails, 'charlie', {}, false); + result = evaluator.evaluateFlag(configuration, flag, 'charlie', {}); expect(result.variation).toBeNull(); }); @@ -200,7 +203,7 @@ describe('Evaluator', () => { totalShards: 10000, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'alice', { id: 'charlie' }, false); + const result = evaluator.evaluateFlag(configuration, flag, 'alice', { id: 'charlie' }); expect(result.variation).toBeNull(); }); @@ -227,7 +230,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); @@ -275,13 +278,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@example.com' }, - false, - ); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@example.com' }); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('first'); expect(result.variation).toEqual(VARIATION_B); @@ -328,13 +325,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@test.com' }, - false, - ); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); @@ -385,13 +376,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@test.com' }, - false, - ); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); expect(result.flagKey).toEqual('obfuscated_flag_key'); expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); @@ -455,16 +440,16 @@ describe('Evaluator', () => { ); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'alice', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).variation, ).toEqual(VARIATION_A); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'bob', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).variation, ).toEqual(VARIATION_B); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'charlie', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).variation, ).toEqual(VARIATION_C); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'dave', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).variation, ).toEqual(VARIATION_C); }); @@ -494,7 +479,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toBeNull(); expect(result.variation).toBeNull(); @@ -526,7 +511,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toEqual('default'); expect(result.variation).toEqual(VARIATION_A); @@ -558,7 +543,7 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); expect(result.flagKey).toEqual('flag'); expect(result.allocationKey).toBeNull(); expect(result.variation).toBeNull(); diff --git a/src/listener.ts b/src/listener.ts deleted file mode 100644 index d3fa2ad..0000000 --- a/src/listener.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type Listener = (...args: T) => void; - -export class Listeners { - private listeners: Array> = []; - - public addListener(listener: Listener): () => void { - this.listeners.push(listener); - - return () => { - const idx = this.listeners.indexOf(listener); - if (idx !== -1) { - this.listeners.splice(idx, 1); - } - }; - } - - public notify(...args: T): void { - for (const listener of this.listeners) { - try { - listener(...args); - } catch { - // ignore - } - } - } -} diff --git a/src/persistent-configuration-cache.ts b/src/persistent-configuration-cache.ts new file mode 100644 index 0000000..7bd293a --- /dev/null +++ b/src/persistent-configuration-cache.ts @@ -0,0 +1,67 @@ +import { logger } from './application-logger'; +import { Configuration } from './configuration'; +import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; + +/** + * Persistent configuration storages are responsible for persisting + * configuration between SDK reloads. + */ +export interface PersistentConfigurationStorage { + /** + * Load configuration from the persistent storage. + * + * The method may fail to load a configuration or throw an + * exception (which is generally ignored). + */ + loadConfiguration(): PromiseLike; + + /** + * Store configuration to the persistent storage. + * + * The method is allowed to do async work (which is not awaited) or + * throw exceptions (which are ignored). + */ + storeConfiguration(configuration: Configuration | null): PromiseLike; +} + +/** + * ConfigurationCache is a helper class that subscribes to a configuration feed and stores latest + * configuration in persistent storage. + * + * @internal + */ +export class PersistentConfigurationCache { + constructor( + private readonly storage: PersistentConfigurationStorage, + private readonly configurationFeed?: ConfigurationFeed, + ) { + configurationFeed?.addListener(async (configuration, source) => { + if (source !== ConfigurationSource.Cache) { + try { + await this.storage.storeConfiguration(configuration); + } catch (err) { + logger.error({ err }, '[Eppo SDK] Failed to store configuration to persistent storage'); + } + } + }); + } + + public async loadConfiguration({ maxStaleSeconds = Infinity }: { maxStaleSeconds?: number } = {}): Promise { + try { + const configuration = await this.storage.loadConfiguration(); + if (configuration) { + const age = configuration.getAgeMs(); + if (age !== undefined && age > maxStaleSeconds * 1000) { + logger.debug({ age, maxStaleSeconds }, '[Eppo SDK] Cached configuration is too old to be used'); + return null; + } + + this.configurationFeed?.broadcast(configuration, ConfigurationSource.Cache); + } + return configuration; + } catch (err) { + logger.error({ err }, '[Eppo SDK] Failed to load configuration from persistent storage'); + return null; + } + } +} diff --git a/src/persistent-configuration-storage.ts b/src/persistent-configuration-storage.ts deleted file mode 100644 index 1dbe25b..0000000 --- a/src/persistent-configuration-storage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Configuration } from './configuration'; - -/** - * Persistent configuration storages are responsible for persisting - * configuration between SDK reloads. - */ -export interface PersistentConfigurationStorage { - /** - * Load configuration from the persistent storage. - * - * The method may fail to load a configuration or throw an - * exception (which is generally ignored). - */ - loadConfiguration(): PromiseLike; - - /** - * Store configuration to the persistent storage. - * - * The method is allowed to do async work (which is not awaited) or - * throw exceptions (which are ignored). - */ - storeConfiguration(configuration: Configuration | null): void; -} diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 24b2c49..44023f5 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -5,7 +5,7 @@ import { isEqual } from 'lodash'; import { AttributeType, ContextAttributes, IAssignmentDetails, VariationType } from '../src'; import { IFlagEvaluationDetails } from '../src/flag-evaluation-details-builder'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../src/http-client'; - +import { Configuration } from '../src/configuration'; export const TEST_DATA_DIR = './test/data/ufc/'; export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'tests/'; export const BANDIT_TEST_DATA_DIR = TEST_DATA_DIR + 'bandit-tests/'; @@ -57,6 +57,41 @@ export function readMockUFCResponse( return JSON.parse(fs.readFileSync(TEST_DATA_DIR + filename, 'utf-8')); } +export function readMockUfcConfiguration(): Configuration { + const config = fs.readFileSync(TEST_DATA_DIR + 'flags-v1.json', 'utf-8'); + return Configuration.fromResponses({ + flags: { + response: JSON.parse(config), + fetchedAt: new Date().toISOString(), + }, + }); +} + +export function readMockUfcObfuscatedConfiguration(): Configuration { + const config = fs.readFileSync(TEST_DATA_DIR + 'flags-v1-obfuscated.json', 'utf-8'); + return Configuration.fromResponses({ + flags: { + response: JSON.parse(config), + fetchedAt: new Date().toISOString(), + }, + }); +} + +export function readMockBanditsConfiguration(): Configuration { + const flags = fs.readFileSync(TEST_DATA_DIR + 'bandit-flags-v1.json', 'utf-8'); + const bandits = fs.readFileSync(TEST_DATA_DIR + 'bandit-models-v1.json', 'utf-8'); + return Configuration.fromResponses({ + flags: { + response: JSON.parse(flags), + fetchedAt: new Date().toISOString(), + }, + bandits: { + response: JSON.parse(bandits), + fetchedAt: new Date().toISOString(), + }, + }); +} + export function readMockConfigurationWireResponse(filename: string): string { return fs.readFileSync(TEST_CONFIGURATION_WIRE_DATA_DIR + filename, 'utf-8'); } @@ -86,7 +121,7 @@ export function getTestAssignments( subjectKey: string, subjectAttributes: Record, defaultValue: string | number | boolean | object, - ) => never, + ) => string | number | boolean | object, ): { subject: SubjectTestCase; assignment: string | boolean | number | null | object }[] { const assignments: { subject: SubjectTestCase; @@ -111,7 +146,7 @@ export function getTestAssignmentDetails( subjectKey: string, subjectAttributes: Record, defaultValue: string | number | boolean | object, - ) => never, + ) => IAssignmentDetails, ): { subject: SubjectTestCase; assignmentDetails: IAssignmentDetails; From 6b4725ca5d72180b67ff8aebf083b571e047e115 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Mon, 7 Apr 2025 20:22:48 +0300 Subject: [PATCH 14/25] feat: extend Configuration/Requestor to support precomputed --- src/client/eppo-client.ts | 30 ++++++ src/client/eppo-precomputed-client.ts | 5 +- src/configuration-requestor.spec.ts | 89 +++++++++++++++++ src/configuration-requestor.ts | 45 +++++++-- src/configuration.ts | 131 +++++++++++++++++++------- src/http-client.ts | 50 +++++----- src/interfaces.ts | 6 +- src/precomputed-requestor.spec.ts | 127 ------------------------- src/precomputed-requestor.ts | 56 ----------- 9 files changed, 284 insertions(+), 255 deletions(-) delete mode 100644 src/precomputed-requestor.spec.ts delete mode 100644 src/precomputed-requestor.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index f417389..8440af0 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -118,6 +118,18 @@ export type EppoClientParameters = { }; configuration?: { + /** + * When specified, will run the client in the "precomputed" + * mode. Instead of fetching the full configuration from the + * server, the client will fetch flags and bandits precomputed for + * the specified subject. + */ + precompute?: { + subjectKey: string; + subjectAttributes: Attributes | ContextAttributes; + banditActions?: Record; + }; + /** * Strategy for fetching initial configuration. * @@ -359,6 +371,24 @@ export default class EppoClient { this.configurationFeed, { wantsBandits: options.bandits?.enable ?? DEFAULT_ENABLE_BANDITS, + precomputed: options.configuration?.precompute + ? { + subjectKey: options.configuration.precompute.subjectKey, + subjectAttributes: ensureContextualSubjectAttributes( + options.configuration.precompute.subjectAttributes, + ), + banditActions: options.configuration.precompute.banditActions + ? Object.fromEntries( + Object.entries(options.configuration.precompute.banditActions).map( + ([banditKey, actions]) => [ + banditKey, + ensureActionsWithContextualAttributes(actions), + ], + ), + ) + : undefined, + } + : undefined, }, ); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index c8238c8..b835c9e 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -362,10 +362,6 @@ export default class EppoPrecomputedClient { } private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null { - return this.getObfuscatedFlag(flagKey); - } - - private getObfuscatedFlag(flagKey: string): DecodedPrecomputedFlag | null { const salt = this.precomputedFlagStore.salt; const saltedAndHashedFlagKey = getMD5Hash(flagKey, salt); const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get( @@ -376,6 +372,7 @@ export default class EppoPrecomputedClient { private getPrecomputedBandit(banditKey: string): IPrecomputedBandit | null { const obfuscatedBandit = this.getObfuscatedPrecomputedBandit(banditKey); + return obfuscatedBandit ? decodePrecomputedBandit(obfuscatedBandit) : null; } diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 7de3edb..aaad96d 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -6,6 +6,7 @@ import { } from '../test/testHelpers'; import ApiEndpoints from './api-endpoints'; +import { ensureContextualSubjectAttributes } from './attributes'; import { BroadcastChannel } from './broadcast'; import { ConfigurationFeed } from './configuration-feed'; import ConfigurationRequestor from './configuration-requestor'; @@ -19,6 +20,32 @@ import FetchHttpClient, { import { StoreBackedConfiguration } from './i-configuration'; import { BanditParameters, BanditVariation, Flag, VariationType } from './interfaces'; +const MOCK_PRECOMPUTED_RESPONSE = { + flags: { + 'precomputed-flag-1': { + allocationKey: 'default', + variationKey: 'true-variation', + variationType: 'BOOLEAN', + variationValue: 'true', + extraLogging: {}, + doLog: true, + }, + 'precomputed-flag-2': { + allocationKey: 'test-group', + variationKey: 'variation-a', + variationType: 'STRING', + variationValue: 'variation-a', + extraLogging: {}, + doLog: true, + }, + }, + environment: { + name: 'production', + }, + format: 'PRECOMPUTED', + createdAt: '2024-03-20T00:00:00Z', +}; + describe('ConfigurationRequestor', () => { let configurationFeed: ConfigurationFeed; let configurationStore: ConfigurationStore; @@ -532,4 +559,66 @@ describe('ConfigurationRequestor', () => { }); }); }); + + describe('Precomputed flags', () => { + let fetchSpy: jest.Mock; + beforeEach(() => { + configurationRequestor = new ConfigurationRequestor(httpClient, configurationFeed, { + precomputed: { + subjectKey: 'subject-key', + subjectAttributes: ensureContextualSubjectAttributes({ + 'attribute-key': 'attribute-value', + }), + }, + }); + + fetchSpy = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(MOCK_PRECOMPUTED_RESPONSE), + }); + }) as jest.Mock; + global.fetch = fetchSpy; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('Fetches precomputed flag configuration', async () => { + const configuration = await configurationRequestor.fetchConfiguration(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + + expect(configuration.getFlagKeys().length).toBe(2); + + const precomputed = configuration.getPrecomputedConfiguration(); + + const flag1 = precomputed?.response.flags['precomputed-flag-1']; + expect(flag1?.allocationKey).toBe('default'); + expect(flag1?.variationKey).toBe('true-variation'); + expect(flag1?.variationType).toBe('BOOLEAN'); + expect(flag1?.variationValue).toBe('true'); + expect(flag1?.extraLogging).toEqual({}); + expect(flag1?.doLog).toBe(true); + + const flag2 = precomputed?.response.flags['precomputed-flag-2']; + expect(flag2?.allocationKey).toBe('test-group'); + expect(flag2?.variationKey).toBe('variation-a'); + expect(flag2?.variationType).toBe('STRING'); + expect(flag2?.variationValue).toBe('variation-a'); + expect(flag2?.extraLogging).toEqual({}); + expect(flag2?.doLog).toBe(true); + + expect(precomputed?.response.format).toBe('PRECOMPUTED'); + + expect(precomputed?.response.environment).toStrictEqual({ name: 'production' }); + expect(precomputed?.response.createdAt).toBe('2024-03-20T00:00:00Z'); + }); + }); }); diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 665a17e..42434d5 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,9 +1,24 @@ import { BanditsConfig, Configuration, FlagsConfig } from './configuration'; import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; import { IHttpClient } from './http-client'; +import { ContextAttributes, FlagKey } from './types'; +export class ConfigurationError extends Error { + public constructor(message: string) { + super(message); + this.name = 'ConfigurationError'; + } +} + +/** @internal */ export type ConfigurationRequestorOptions = { wantsBandits: boolean; + + precomputed?: { + subjectKey: string; + subjectAttributes: ContextAttributes; + banditActions?: Record>; + }; }; /** @@ -35,19 +50,37 @@ export default class ConfigurationRequestor { }); } - public async fetchConfiguration(): Promise { + public async fetchConfiguration(): Promise { + const configuration = this.options.precomputed + ? await this.fetchPrecomputedConfiguration(this.options.precomputed) + : await this.fetchRegularConfiguration(); + + this.latestConfiguration = configuration; + this.configurationFeed.broadcast(configuration, ConfigurationSource.Network); + + return configuration; + } + + private async fetchRegularConfiguration(): Promise { const flags = await this.httpClient.getUniversalFlagConfiguration(); if (!flags?.response.flags) { - return null; + throw new ConfigurationError('empty response'); } const bandits = await this.getBanditsFor(flags); - const configuration = Configuration.fromResponses({ flags, bandits }); - this.latestConfiguration = configuration; - this.configurationFeed.broadcast(configuration, ConfigurationSource.Network); + return Configuration.fromResponses({ flags, bandits }); + } - return configuration; + private async fetchPrecomputedConfiguration( + precomputed: NonNullable, + ): Promise { + const response = await this.httpClient.getPrecomputedFlags(precomputed); + if (!response) { + throw new ConfigurationError('empty response'); + } + + return Configuration.fromResponses({ precomputed: response }); } /** diff --git a/src/configuration.ts b/src/configuration.ts index 472cc17..78c96f3 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,3 +1,4 @@ +import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; import { decodeFlag } from './decoding'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; @@ -20,25 +21,47 @@ export type BanditsConfig = { fetchedAt?: string; }; +/** @internal for SDK use only */ +export type PrecomputedConfig = { + response: IObfuscatedPrecomputedConfigurationResponse; + etag?: string; + /** ISO timestamp when configuration was fetched from the server. */ + fetchedAt?: string; + subjectKey: string; + subjectAttributes?: ContextAttributes; + banditActions?: Record>; +}; + /** * *The* Configuration. * * Note: configuration should be treated as immutable. Do not change * any of the fields or returned data. Otherwise, bad things will * happen. + * + * @public */ export class Configuration { private flagBanditVariations: Record; private constructor( - private readonly flags?: FlagsConfig, - private readonly bandits?: BanditsConfig, + private readonly parts: { + readonly flags?: FlagsConfig; + readonly bandits?: BanditsConfig; + readonly precomputed?: PrecomputedConfig; + }, ) { - this.flagBanditVariations = flags ? indexBanditVariationsByFlagKey(flags.response) : {}; + this.flagBanditVariations = parts.flags + ? indexBanditVariationsByFlagKey(parts.flags.response) + : {}; } + /** + * Creates a new empty configuration. + * @public + */ public static empty(): Configuration { - return new Configuration(); + return new Configuration({}); } /** @@ -52,14 +75,16 @@ export class Configuration { options: { obfuscated: boolean }, ): Configuration { return new Configuration({ - response: { - format: options.obfuscated ? FormatEnum.CLIENT : FormatEnum.SERVER, - flags, - createdAt: new Date().toISOString(), - environment: { - name: 'from-flags-configuration', + flags: { + response: { + format: options.obfuscated ? FormatEnum.CLIENT : FormatEnum.SERVER, + flags, + createdAt: new Date().toISOString(), + environment: { + name: 'from-flags-configuration', + }, + banditReferences: {}, }, - banditReferences: {}, }, }); } @@ -68,11 +93,13 @@ export class Configuration { public static fromResponses({ flags, bandits, + precomputed, }: { flags?: FlagsConfig; bandits?: BanditsConfig; + precomputed?: PrecomputedConfig; }): Configuration { - return new Configuration(flags, bandits); + return new Configuration({ flags, bandits, precomputed }); } // TODO: @@ -83,44 +110,66 @@ export class Configuration { const wire: ConfigurationWire = { version: 1, }; - if (this.flags) { + if (this.parts.flags) { wire.config = { - ...this.flags, - response: JSON.stringify(this.flags.response), + ...this.parts.flags, + response: JSON.stringify(this.parts.flags.response), }; } - if (this.bandits) { + if (this.parts.bandits) { wire.bandits = { - ...this.bandits, - response: JSON.stringify(this.bandits.response), + ...this.parts.bandits, + response: JSON.stringify(this.parts.bandits.response), + }; + } + if (this.parts.precomputed) { + wire.precomputed = { + ...this.parts.precomputed, + response: JSON.stringify(this.parts.precomputed.response), }; } return JSON.stringify(wire); } - public getFlagKeys(): FlagKey[] | HashedFlagKey[] { - if (!this.flags) { - return []; + /** + * Returns a list of known flag keys (for debugging purposes). + * + * If underlying flags configuration is obfuscated, the returned + * flag values will be obfuscated as well. + */ + public getFlagKeys(): string[] { + if (this.parts.flags) { + return Object.keys(this.parts.flags.response.flags); + } + if (this.parts.precomputed) { + return Object.keys(this.parts.precomputed.response.flags); } - return Object.keys(this.flags.response.flags); + return []; } /** @internal */ public getFlagsConfiguration(): FlagsConfig | undefined { - return this.flags; + return this.parts.flags; } /** @internal */ public getFetchedAt(): Date | undefined { - const flagsFetchedAt = this.flags?.fetchedAt ? new Date(this.flags.fetchedAt).getTime() : 0; - const banditsFetchedAt = this.bandits?.fetchedAt ? new Date(this.bandits.fetchedAt).getTime() : 0; - const maxFetchedAt = Math.max(flagsFetchedAt, banditsFetchedAt); + const flagsFetchedAt = this.parts.flags?.fetchedAt + ? new Date(this.parts.flags.fetchedAt).getTime() + : 0; + const banditsFetchedAt = this.parts.bandits?.fetchedAt + ? new Date(this.parts.bandits.fetchedAt).getTime() + : 0; + const precomputedFetchedAt = this.parts.precomputed?.fetchedAt + ? new Date(this.parts.precomputed.fetchedAt).getTime() + : 0; + const maxFetchedAt = Math.max(flagsFetchedAt, banditsFetchedAt, precomputedFetchedAt); return maxFetchedAt > 0 ? new Date(maxFetchedAt) : undefined; } /** @internal */ public isEmpty(): boolean { - return !this.flags; + return !this.parts.flags && !this.parts.precomputed; } /** @internal */ @@ -138,28 +187,34 @@ export class Configuration { return !!age && age > maxAgeSeconds * 1000; } - /** @internal - * + /** * Returns flag configuration for the given flag key. Obfuscation is * handled automatically. + * + * @internal */ public getFlag(flagKey: string): Flag | null { - if (!this.flags) { + if (!this.parts.flags) { return null; } - if (this.flags.response.format === FormatEnum.SERVER) { - return this.flags.response.flags[flagKey] ?? null; + if (this.parts.flags.response.format === FormatEnum.SERVER) { + return this.parts.flags.response.flags[flagKey] ?? null; } else { // Obfuscated configuration - const flag = this.flags.response.flags[getMD5Hash(flagKey)]; + const flag = this.parts.flags.response.flags[getMD5Hash(flagKey)]; return flag ? decodeFlag(flag as ObfuscatedFlag) : null; } } /** @internal */ public getBanditConfiguration(): BanditsConfig | undefined { - return this.bandits; + return this.parts.bandits; + } + + /** @internal */ + public getPrecomputedConfiguration(): PrecomputedConfig | undefined { + return this.parts.precomputed; } /** @internal */ @@ -175,7 +230,7 @@ export class Configuration { )?.key; if (banditKey) { - return this.bandits?.response.bandits[banditKey] ?? null; + return this.parts.bandits?.response.bandits[banditKey] ?? null; } return null; } @@ -220,7 +275,13 @@ type ConfigurationWire = { precomputed?: { response: string; + etag?: string; + fetchedAt?: string; subjectKey: string; subjectAttributes?: ContextAttributes; + banditActions?: Record< + /* flagKey: */ string, + Record + >; }; }; diff --git a/src/http-client.ts b/src/http-client.ts index 9df6b28..55ac349 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,5 +1,5 @@ import ApiEndpoints from './api-endpoints'; -import { BanditsConfig, FlagsConfig } from './configuration'; +import { BanditsConfig, FlagsConfig, PrecomputedConfig } from './configuration'; import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; import { BanditParameters, @@ -22,12 +22,9 @@ export interface IQueryParamsWithSubject extends IQueryParams { subjectAttributes: Attributes; } +/** @internal */ export class HttpRequestError extends Error { - constructor( - public message: string, - public status: number, - public cause?: Error, - ) { + constructor(public message: string, public status?: number, public cause?: Error) { super(message); if (cause) { this.cause = cause; @@ -47,27 +44,24 @@ export interface IBanditParametersResponse { bandits: Record; } +/** @internal */ export interface IHttpClient { getUniversalFlagConfiguration(): Promise; getBanditParameters(): Promise; - getPrecomputedFlags( - payload: PrecomputedFlagsPayload, - ): Promise; + getPrecomputedFlags(payload: PrecomputedFlagsPayload): Promise; rawGet(url: string): Promise; rawPost(url: string, payload: P): Promise; } +/** @internal */ export default class FetchHttpClient implements IHttpClient { - constructor( - private readonly apiEndpoints: ApiEndpoints, - private readonly timeout: number, - ) {} + constructor(private readonly apiEndpoints: ApiEndpoints, private readonly timeout: number) {} - async getUniversalFlagConfiguration(): Promise { + async getUniversalFlagConfiguration(): Promise { const url = this.apiEndpoints.ufcEndpoint(); const response = await this.rawGet(url); if (!response) { - return undefined; + throw new HttpRequestError('Empty response'); } return { response, @@ -75,11 +69,11 @@ export default class FetchHttpClient implements IHttpClient { }; } - async getBanditParameters(): Promise { + async getBanditParameters(): Promise { const url = this.apiEndpoints.banditParametersEndpoint(); const response = await this.rawGet(url); if (!response) { - return undefined; + throw new HttpRequestError('Empty response'); } return { response, @@ -87,14 +81,22 @@ export default class FetchHttpClient implements IHttpClient { }; } - async getPrecomputedFlags( - payload: PrecomputedFlagsPayload, - ): Promise { + async getPrecomputedFlags(payload: PrecomputedFlagsPayload): Promise { const url = this.apiEndpoints.precomputedFlagsEndpoint(); - return await this.rawPost( - url, - payload, - ); + const response = await this.rawPost< + IObfuscatedPrecomputedConfigurationResponse, + PrecomputedFlagsPayload + >(url, payload); + if (!response) { + throw new HttpRequestError('Empty response'); + } + return { + response, + fetchedAt: new Date().toISOString(), + subjectKey: payload.subjectKey, + subjectAttributes: payload.subjectAttributes, + banditActions: payload.banditActions, + }; } async rawGet(url: string): Promise { diff --git a/src/interfaces.ts b/src/interfaces.ts index 031610c..b76c076 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -198,7 +198,7 @@ export interface PrecomputedFlagsDetails { } export interface PrecomputedFlagsPayload { - subject_key: string; - subject_attributes: ContextAttributes; - bandit_actions?: Record>; + subjectKey: string; + subjectAttributes: ContextAttributes; + banditActions?: Record>; } diff --git a/src/precomputed-requestor.spec.ts b/src/precomputed-requestor.spec.ts deleted file mode 100644 index e2b1f04..0000000 --- a/src/precomputed-requestor.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import ApiEndpoints from './api-endpoints'; -import { ensureContextualSubjectAttributes } from './attributes'; -import { IConfigurationStore } from './configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; -import FetchHttpClient, { IHttpClient } from './http-client'; -import { PrecomputedFlag } from './interfaces'; -import PrecomputedFlagRequestor from './precomputed-requestor'; - -const MOCK_PRECOMPUTED_RESPONSE = { - flags: { - 'precomputed-flag-1': { - allocationKey: 'default', - variationKey: 'true-variation', - variationType: 'BOOLEAN', - variationValue: 'true', - extraLogging: {}, - doLog: true, - }, - 'precomputed-flag-2': { - allocationKey: 'test-group', - variationKey: 'variation-a', - variationType: 'STRING', - variationValue: 'variation-a', - extraLogging: {}, - doLog: true, - }, - }, - environment: { - name: 'production', - }, - format: 'PRECOMPUTED', - createdAt: '2024-03-20T00:00:00Z', -}; - -describe('PrecomputedRequestor', () => { - let precomputedFlagStore: IConfigurationStore; - let httpClient: IHttpClient; - let precomputedFlagRequestor: PrecomputedFlagRequestor; - let fetchSpy: jest.Mock; - - beforeEach(() => { - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://127.0.0.1:4000', - queryParams: { - apiKey: 'dummy', - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }, - }); - httpClient = new FetchHttpClient(apiEndpoints, 1000); - precomputedFlagStore = new MemoryOnlyConfigurationStore(); - precomputedFlagRequestor = new PrecomputedFlagRequestor( - httpClient, - precomputedFlagStore, - 'subject-key', - ensureContextualSubjectAttributes({ - 'attribute-key': 'attribute-value', - }), - ); - - fetchSpy = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(MOCK_PRECOMPUTED_RESPONSE), - }); - }) as jest.Mock; - global.fetch = fetchSpy; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - describe('Precomputed flags', () => { - it('Fetches and stores precomputed flag configuration', async () => { - await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - - expect(precomputedFlagStore.getKeys().length).toBe(2); - - const flag1 = precomputedFlagStore.get('precomputed-flag-1'); - expect(flag1?.allocationKey).toBe('default'); - expect(flag1?.variationKey).toBe('true-variation'); - expect(flag1?.variationType).toBe('BOOLEAN'); - expect(flag1?.variationValue).toBe('true'); - expect(flag1?.extraLogging).toEqual({}); - expect(flag1?.doLog).toBe(true); - - const flag2 = precomputedFlagStore.get('precomputed-flag-2'); - expect(flag2?.allocationKey).toBe('test-group'); - expect(flag2?.variationKey).toBe('variation-a'); - expect(flag2?.variationType).toBe('STRING'); - expect(flag2?.variationValue).toBe('variation-a'); - expect(flag2?.extraLogging).toEqual({}); - expect(flag2?.doLog).toBe(true); - - // TODO: create a method get format from the response - expect(fetchSpy).toHaveBeenCalledTimes(1); - const response = await (await fetchSpy.mock.results[0].value).json(); - expect(response.format).toBe('PRECOMPUTED'); - - expect(precomputedFlagStore.getEnvironment()).toStrictEqual({ name: 'production' }); - expect(precomputedFlagStore.getConfigPublishedAt()).toBe('2024-03-20T00:00:00Z'); - }); - - it('Handles empty response gracefully', async () => { - fetchSpy.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ flags: null }), - }), - ); - - await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(precomputedFlagStore.getKeys().length).toBe(0); - }); - }); -}); diff --git a/src/precomputed-requestor.ts b/src/precomputed-requestor.ts deleted file mode 100644 index ef8e0fc..0000000 --- a/src/precomputed-requestor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; -import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; -import { IHttpClient } from './http-client'; -import { - IObfuscatedPrecomputedBandit, - PrecomputedFlag, - UNKNOWN_ENVIRONMENT_NAME, -} from './interfaces'; -import { ContextAttributes, FlagKey } from './types'; - -// Requests AND stores precomputed flags, reuses the configuration store -export default class PrecomputedFlagRequestor { - constructor( - private readonly httpClient: IHttpClient, - private readonly precomputedFlagStore: IConfigurationStore, - private readonly subjectKey: string, - private readonly subjectAttributes: ContextAttributes, - private readonly precomputedBanditsStore?: IConfigurationStore, - private readonly banditActions?: Record>, - ) {} - - async fetchAndStorePrecomputedFlags(): Promise { - const precomputedResponse = await this.httpClient.getPrecomputedFlags({ - subject_key: this.subjectKey, - subject_attributes: this.subjectAttributes, - bandit_actions: this.banditActions, - }); - - if (!precomputedResponse) { - return; - } - - const promises: Promise[] = []; - promises.push( - hydrateConfigurationStore(this.precomputedFlagStore, { - entries: precomputedResponse.flags, - environment: precomputedResponse.environment ?? { name: UNKNOWN_ENVIRONMENT_NAME }, - createdAt: precomputedResponse.createdAt, - format: precomputedResponse.format, - salt: precomputedResponse.salt, - }), - ); - if (this.precomputedBanditsStore) { - promises.push( - hydrateConfigurationStore(this.precomputedBanditsStore, { - entries: precomputedResponse.bandits, - environment: precomputedResponse.environment ?? { name: UNKNOWN_ENVIRONMENT_NAME }, - createdAt: precomputedResponse.createdAt, - format: precomputedResponse.format, - salt: precomputedResponse.salt, - }), - ); - } - await Promise.all(promises); - } -} From 311f61f4f44e46c26f05f501dde2a12abeb20b70 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Mon, 7 Apr 2025 20:30:07 +0300 Subject: [PATCH 15/25] feat: support precomputed config for flags evaluation --- src/client/eppo-client.ts | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 8440af0..765fa58 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -14,7 +14,7 @@ import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; -import { Configuration } from '../configuration'; +import { Configuration, PrecomputedConfig } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; import { ISyncStore } from '../configuration-store/configuration-store'; @@ -40,7 +40,13 @@ import { DEFAULT_ENABLE_BANDITS, } from '../constants'; import { EppoValue } from '../eppo_value'; -import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; +import { + Evaluator, + FlagEvaluation, + FlagEvaluationWithoutDetails, + noneResult, + overrideResult, +} from '../evaluator'; import { BoundedEventQueue } from '../events/bounded-event-queue'; import EventDispatcher from '../events/event-dispatcher'; import NoOpEventDispatcher from '../events/no-op-event-dispatcher'; @@ -53,6 +59,7 @@ import FetchHttpClient from '../http-client'; import { BanditModelData, FormatEnum, + IObfuscatedPrecomputedBandit, IPrecomputedBandit, PrecomputedFlag, Variation, @@ -80,6 +87,8 @@ import { import { ConfigurationPoller } from '../configuration-poller'; import { ConfigurationFeed, ConfigurationSource } from '../configuration-feed'; import { BroadcastChannel } from '../broadcast'; +import { getMD5Hash } from '../obfuscation'; +import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; export interface IAssignmentDetails { variation: T; @@ -1297,6 +1306,19 @@ export default class EppoClient { ); } + const precomputed = config.getPrecomputedConfiguration(); + if (precomputed && precomputed.subjectKey === subjectKey) { + // Short-circuit evaluation if we have a matching precomputed configuration. + return this.evaluatePrecomputedAssignment( + precomputed, + flagKey, + subjectKey, + subjectAttributes, + expectedVariationType, + flagEvaluationDetailsBuilder, + ); + } + const flag = config.getFlag(flagKey); if (flag === null) { @@ -1311,7 +1333,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - config.getFlagsConfiguration()?.response.environment.name ?? '', + config.getFlagsConfiguration()?.response.format ?? '', ); } @@ -1371,6 +1393,90 @@ export default class EppoClient { return result; } + private evaluatePrecomputedAssignment( + precomputed: PrecomputedConfig, + flagKey: string, + subjectKey: string, + subjectAttributes: Attributes, + expectedVariationType: VariationType | undefined, + flagEvaluationDetailsBuilder: FlagEvaluationDetailsBuilder, + ): FlagEvaluation { + const obfuscatedKey = getMD5Hash(flagKey, precomputed.response.salt); + const obfuscatedFlag: PrecomputedFlag | undefined = precomputed.response.flags[obfuscatedKey]; + const obfuscatedBandit: IObfuscatedPrecomputedBandit | undefined = + precomputed.response.bandits[obfuscatedKey]; + const flag = obfuscatedFlag && decodePrecomputedFlag(obfuscatedFlag); + const bandit = obfuscatedBandit && decodePrecomputedBandit(obfuscatedBandit); + + if (!flag) { + logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); + // note: this is different from the Python SDK, which returns None instead + const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( + 'FLAG_UNRECOGNIZED_OR_DISABLED', + `Unrecognized or disabled flag: ${flagKey}`, + ); + return noneResult( + flagKey, + subjectKey, + subjectAttributes, + flagEvaluationDetails, + precomputed.response.format, + ); + } + + if (!checkTypeMatch(expectedVariationType, flag.variationType)) { + const errorMessage = `Variation value does not have the correct type. Found ${flag.variationType}, but expected ${expectedVariationType} for flag ${flagKey}`; + if (this.isGracefulFailureMode) { + const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( + 'TYPE_MISMATCH', + errorMessage, + ); + return noneResult( + flagKey, + subjectKey, + subjectAttributes, + flagEvaluationDetails, + precomputed.response.format, + ); + } + throw new TypeError(errorMessage); + } + + const result: FlagEvaluation = { + flagKey, + format: precomputed.response.format, + subjectKey: precomputed.subjectKey, + subjectAttributes: precomputed.subjectAttributes + ? ensureNonContextualSubjectAttributes(precomputed.subjectAttributes) + : {}, + variation: { + key: flag.variationKey ?? '', + value: flag.variationValue, + }, + allocationKey: flag.allocationKey ?? '', + extraLogging: flag.extraLogging ?? {}, + doLog: flag.doLog, + entityId: null, + flagEvaluationDetails: { + environmentName: precomputed.response.environment?.name ?? '', + flagEvaluationCode: 'MATCH', + flagEvaluationDescription: 'Matched precomputed flag', + variationKey: flag.variationKey ?? null, + variationValue: flag.variationValue, + banditKey: null, + banditAction: null, + configFetchedAt: precomputed.fetchedAt ?? '', + configPublishedAt: precomputed.response.createdAt, + matchedRule: null, + matchedAllocation: null, + unmatchedAllocations: [], + unevaluatedAllocations: [], + }, + }; + + return result; + } + /** * Enqueues an arbitrary event. Events must have a type and a payload. */ From e5e5b9f54f7b0774ea1dfe9d26113d06be741114 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Fri, 11 Apr 2025 18:13:00 +0300 Subject: [PATCH 16/25] feat: make EppoClient handle precomputed bandits --- src/client/eppo-client-with-bandits.spec.ts | 69 +- src/client/eppo-client.precomputed.spec.ts | 134 ++ src/client/eppo-client.ts | 361 +++-- ...po-precomputed-client-with-bandits.spec.ts | 124 -- src/client/eppo-precomputed-client.spec.ts | 1413 ----------------- src/client/eppo-precomputed-client.ts | 538 ------- src/configuration.ts | 49 +- src/evaluator.spec.ts | 88 +- src/evaluator.ts | 98 +- src/index.ts | 5 - 10 files changed, 586 insertions(+), 2293 deletions(-) create mode 100644 src/client/eppo-client.precomputed.spec.ts delete mode 100644 src/client/eppo-precomputed-client-with-bandits.spec.ts delete mode 100644 src/client/eppo-precomputed-client.spec.ts delete mode 100644 src/client/eppo-precomputed-client.ts diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index e8c6bed..aeddb0f 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -13,10 +13,7 @@ import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; -import ConfigurationRequestor from '../configuration-requestor'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { - IConfigurationWire, IPrecomputedConfiguration, IObfuscatedPrecomputedConfigurationResponse, } from '../configuration-wire/configuration-wire-types'; @@ -25,8 +22,6 @@ import { AllocationEvaluationCode, IFlagEvaluationDetails, } from '../flag-evaluation-details-builder'; -import FetchHttpClient from '../http-client'; -import { BanditVariation, BanditParameters, Flag } from '../interfaces'; import { attributeEncodeBase64 } from '../obfuscation'; import { Attributes, BanditActions, ContextAttributes } from '../types'; @@ -511,19 +506,59 @@ describe('EppoClient Bandits E2E test', () => { mockEvaluateFlag = jest .spyOn(Evaluator.prototype, 'evaluateFlag') .mockImplementation(() => { + const evaluationDetails = { + flagEvaluationCode: 'MATCH' as const, + flagEvaluationDescription: 'Mocked evaluation', + configFetchedAt: new Date().toISOString(), + configPublishedAt: new Date().toISOString(), + environmentName: 'test', + variationKey: variationToReturn, + variationValue: variationToReturn, + banditKey: null, + banditAction: null, + matchedRule: null, + matchedAllocation: { + key: 'mock-allocation', + allocationEvaluationCode: AllocationEvaluationCode.MATCH, + orderPosition: 1, + }, + unmatchedAllocations: [], + unevaluatedAllocations: [], + }; + return { - flagKey, - subjectKey, - subjectAttributes, - allocationKey: 'mock-allocation', - variation: { key: variationToReturn, value: variationToReturn }, - extraLogging: {}, - doLog: true, - flagEvaluationDetails: { - flagEvaluationCode: 'MATCH', - flagEvaluationDescription: 'Mocked evaluation', + assignmentDetails: { + flagKey, + format: 'SERVER', + subjectKey, + subjectAttributes, + allocationKey: 'mock-allocation', + variation: { key: variationToReturn, value: variationToReturn }, + extraLogging: {}, + doLog: true, + entityId: null, + evaluationDetails, }, - } as FlagEvaluation; + assignmentEvent: { + allocation: 'mock-allocation', + experiment: `${flagKey}-mock-allocation`, + featureFlag: flagKey, + format: 'SERVER', + variation: variationToReturn, + subject: subjectKey, + timestamp: new Date().toISOString(), + subjectAttributes, + metaData: { + obfuscated: false, + sdkLanguage: 'javascript', + sdkLibVersion: '1.0.0', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + }, + evaluationDetails, + entityId: null, + } + }; }); mockEvaluateBandit = jest @@ -662,7 +697,7 @@ describe('EppoClient Bandits E2E test', () => { salt, ); - const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire; + const { precomputed } = JSON.parse(precomputedResults); if (!precomputed) { fail('precomputed result was not parsed'); } diff --git a/src/client/eppo-client.precomputed.spec.ts b/src/client/eppo-client.precomputed.spec.ts new file mode 100644 index 0000000..71deff5 --- /dev/null +++ b/src/client/eppo-client.precomputed.spec.ts @@ -0,0 +1,134 @@ +import * as td from 'testdouble'; + +import { + MOCK_PRECOMPUTED_WIRE_FILE, + MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE, + readMockConfigurationWireResponse, +} from '../../test/testHelpers'; +import { logger } from '../application-logger'; +import { IAssignmentLogger } from '../assignment-logger'; +import { IBanditLogger } from '../bandit-logger'; +import { Configuration } from '../configuration'; +import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { FormatEnum, VariationType, Variation } from '../interfaces'; +import { BanditActions } from '../types'; + +import EppoClient from './eppo-client'; + +describe('EppoClient Precomputed Mode', () => { + // Read both configurations for test reference + const precomputedConfigurationWire = readMockConfigurationWireResponse(MOCK_PRECOMPUTED_WIRE_FILE); + const initialConfiguration = Configuration.fromString(precomputedConfigurationWire); + + // We only use deobfuscated configuration as a reference, not for creating a client + const deobfuscatedPrecomputedWire = readMockConfigurationWireResponse(MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE); + + let client: EppoClient; + let mockAssignmentLogger: jest.Mocked; + let mockBanditLogger: jest.Mocked; + + beforeEach(() => { + mockAssignmentLogger = { logAssignment: jest.fn() } as jest.Mocked; + mockBanditLogger = { logBanditAction: jest.fn() } as jest.Mocked; + + // Create EppoClient with precomputed configuration + client = new EppoClient({ + sdkKey: 'test-key', + sdkName: 'test-sdk', + sdkVersion: '1.0.0', + configuration: { + initialConfiguration, + initializationStrategy: 'none', + enablePolling: false, + }, + }); + + client.setAssignmentLogger(mockAssignmentLogger); + client.setBanditLogger(mockBanditLogger); + }); + + it('correctly evaluates string flag', () => { + const result = client.getStringAssignment('string-flag', 'test-subject-key', {}, 'default'); + expect(result).toBe('red'); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('correctly evaluates boolean flag', () => { + const result = client.getBooleanAssignment('boolean-flag', 'test-subject-key', {}, false); + expect(result).toBe(true); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('correctly evaluates integer flag', () => { + const result = client.getIntegerAssignment('integer-flag', 'test-subject-key', {}, 0); + expect(result).toBe(42); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('correctly evaluates numeric flag', () => { + const result = client.getNumericAssignment('numeric-flag', 'test-subject-key', {}, 0); + expect(result).toBe(3.14); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('correctly evaluates JSON flag', () => { + const result = client.getJSONAssignment('json-flag', 'test-subject-key', {}, {}); + expect(result).toEqual({ key: 'value', number: 123 }); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('correctly evaluates flag with extra logging', () => { + const result = client.getStringAssignment('string-flag-with-extra-logging', 'test-subject-key', {}, 'default'); + expect(result).toBe('red'); + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + }); + + it('logs bandit evaluation for flag with bandit data', () => { + const banditActions = { + 'show_red_button': { + expectedConversion: 0.23, + expectedRevenue: 15.75, + category: 'promotion', + placement: 'home_screen' + } + }; + + const result = client.getBanditAction('string-flag', 'test-subject-key', {}, banditActions, 'default'); + + expect(result.variation).toBe('red'); + expect(result.action).toBe('show_red_button'); + expect(mockBanditLogger.logBanditAction).toHaveBeenCalledTimes(1); + + const call = mockBanditLogger.logBanditAction.mock.calls[0][0]; + expect(call.bandit).toBe('recommendation-model-v1'); + expect(call.action).toBe('show_red_button'); + expect(call.modelVersion).toBe('v2.3.1'); + expect(call.actionProbability).toBe(0.85); + expect(call.optimalityGap).toBe(0.12); + }); + + it('returns default values for nonexistent flags', () => { + const stringResult = client.getStringAssignment('nonexistent-flag', 'test-subject-key', {}, 'default-string'); + expect(stringResult).toBe('default-string'); + + const boolResult = client.getBooleanAssignment('nonexistent-flag', 'test-subject-key', {}, true); + expect(boolResult).toBe(true); + + const intResult = client.getIntegerAssignment('nonexistent-flag', 'test-subject-key', {}, 100); + expect(intResult).toBe(100); + }); + + it('correctly handles assignment details', () => { + const details = client.getStringAssignmentDetails('string-flag', 'test-subject-key', {}, 'default'); + + expect(details.variation).toBe('red'); + expect(details.evaluationDetails.variationKey).toBe('variation-123'); + + // Assignment should be logged + expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); + const call = mockAssignmentLogger.logAssignment.mock.calls[0][0]; + expect(call.allocation).toBe('allocation-123'); + expect(call.featureFlag).toBe('string-flag'); + expect(call.subject).toBe('test-subject-key'); + }); +}); \ No newline at end of file diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 765fa58..5fead1b 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -41,9 +41,9 @@ import { } from '../constants'; import { EppoValue } from '../eppo_value'; import { + AssignmentResult, Evaluator, FlagEvaluation, - FlagEvaluationWithoutDetails, noneResult, overrideResult, } from '../evaluator'; @@ -268,6 +268,18 @@ export type EppoClientParameters = { }; }; +// Define a type mapping from VariationType to TypeScript types +type VariationTypeMap = { + [VariationType.STRING]: string; + [VariationType.INTEGER]: number; + [VariationType.NUMERIC]: number; + [VariationType.BOOLEAN]: boolean; + [VariationType.JSON]: object; +}; + +// Helper type to extract the TypeScript type from a VariationType +type TypeFromVariationType = VariationTypeMap[T]; + /** * ## Initialization * @@ -313,7 +325,7 @@ export default class EppoClient { private assignmentCache?: AssignmentCache; // whether to suppress any errors and return default values instead private isGracefulFailureMode = true; - private readonly evaluator = new Evaluator(); + private readonly evaluator: Evaluator; private readonly overrideValidator = new OverrideValidator(); private readonly configurationFeed; @@ -330,6 +342,11 @@ export default class EppoClient { this.eventDispatcher = eventDispatcher; this.overrideStore = overrideStore; + this.evaluator = new Evaluator({ + sdkName: options.sdkName, + sdkVersion: options.sdkVersion, + }); + const { configuration: { persistentStorage, @@ -938,9 +955,37 @@ export default class EppoClient { 'ASSIGNMENT_ERROR', 'Unexpected error getting assigned variation for bandit action', ); + try { - // Get the assigned variation for the flag with a possible bandit - // Note for getting assignments, we don't care about context + // Check if we have precomputed configuration for this subject + const precomputed = config.getPrecomputedConfiguration(); + if (precomputed && precomputed.subjectKey === subjectKey) { + // Use precomputed results if available + const nonContextualSubjectAttributes = + ensureNonContextualSubjectAttributes(subjectAttributes); + const { flagEvaluation, banditAction, assignmentEvent, banditEvent } = + this.evaluatePrecomputedAssignment(precomputed, flagKey, VariationType.STRING); + + if (flagEvaluation.assignmentDetails.variation) { + variation = flagEvaluation.assignmentDetails.variation.value.toString(); + evaluationDetails = flagEvaluation.assignmentDetails.evaluationDetails; + action = banditAction; + + this.maybeLogAssignment(assignmentEvent); + + if (banditEvent) { + try { + this.logBanditAction(banditEvent); + } catch (err: any) { + logger.error('Error logging precomputed bandit event', err); + } + } + + return { variation, action, evaluationDetails }; + } + } + + // If no precomputed result, continue with regular evaluation const nonContextualSubjectAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const { variation: assignedVariation, evaluationDetails: assignmentEvaluationDetails } = @@ -953,10 +998,6 @@ export default class EppoClient { variation = assignedVariation; evaluationDetails = assignmentEvaluationDetails; - if (!config) { - return { variation, action: null, evaluationDetails }; - } - // Check if the assigned variation is an active bandit // Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or // a rollout having been done. @@ -1159,19 +1200,19 @@ export default class EppoClient { } private parseVariationWithDetails( - { flagEvaluationDetails, variation }: FlagEvaluation, + { assignmentDetails: { variation, evaluationDetails } }: FlagEvaluation, defaultValue: EppoValue, expectedVariationType: VariationType, ): { eppoValue: EppoValue; flagEvaluationDetails: IFlagEvaluationDetails } { try { - if (!variation || flagEvaluationDetails.flagEvaluationCode !== 'MATCH') { - return { eppoValue: defaultValue, flagEvaluationDetails }; + if (!variation || evaluationDetails.flagEvaluationCode !== 'MATCH') { + return { eppoValue: defaultValue, flagEvaluationDetails: evaluationDetails }; } const eppoValue = EppoValue.valueOf(variation.value, expectedVariationType); - return { eppoValue, flagEvaluationDetails }; + return { eppoValue, flagEvaluationDetails: evaluationDetails }; } catch (error: any) { const eppoValue = this.rethrowIfNotGraceful(error, defaultValue); - return { eppoValue, flagEvaluationDetails }; + return { eppoValue, flagEvaluationDetails: evaluationDetails }; } } @@ -1201,9 +1242,10 @@ export default class EppoClient { // Evaluate the flag for this subject. const evaluation = this.evaluator.evaluateFlag(config, flag, subjectKey, subjectAttributes); + const { assignmentDetails } = evaluation; // allocationKey is set along with variation when there is a result. this check appeases typescript below - if (!evaluation.variation || !evaluation.allocationKey) { + if (!assignmentDetails.variation || !assignmentDetails.allocationKey) { logger.debug(`${loggerPrefix} No assigned variation: ${flagKey}`); return; } @@ -1211,12 +1253,12 @@ export default class EppoClient { // Transform into a PrecomputedFlag flags[flagKey] = { flagKey, - allocationKey: evaluation.allocationKey, - doLog: evaluation.doLog, - extraLogging: evaluation.extraLogging, - variationKey: evaluation.variation.key, + allocationKey: assignmentDetails.allocationKey, + doLog: assignmentDetails.doLog, + extraLogging: assignmentDetails.extraLogging, + variationKey: assignmentDetails.variation.key, variationType: flag.variationType, - variationValue: evaluation.variation.value.toString(), + variationValue: assignmentDetails.variation.value.toString(), }; }); @@ -1276,24 +1318,37 @@ export default class EppoClient { * @param expectedVariationType The expected variation type * @returns A detailed return of assignment for a particular subject and flag */ - getAssignmentDetail( + getAssignmentDetail( flagKey: string, subjectKey: string, subjectAttributes: Attributes = {}, - expectedVariationType?: VariationType, + expectedVariationType?: T, + ): FlagEvaluation { + const result = this.evaluateAssignment( + flagKey, + subjectKey, + subjectAttributes, + expectedVariationType, + ); + this.maybeLogAssignment(result.assignmentEvent); + return result; + } + + /** + * Internal helper that evaluates a flag assignment without logging + * Returns the evaluation result that can be used for logging + */ + private evaluateAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Attributes, + expectedVariationType: T | undefined, ): FlagEvaluation { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); const config = this.getConfiguration(); const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(config, flagKey); - if (!config) { - const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( - 'FLAG_UNRECOGNIZED_OR_DISABLED', - "Configuration hasn't being fetched yet", - ); - return noneResult(flagKey, subjectKey, subjectAttributes, flagEvaluationDetails, ''); - } const overrideVariation = this.overrideStore?.get(flagKey); if (overrideVariation) { @@ -1309,14 +1364,13 @@ export default class EppoClient { const precomputed = config.getPrecomputedConfiguration(); if (precomputed && precomputed.subjectKey === subjectKey) { // Short-circuit evaluation if we have a matching precomputed configuration. - return this.evaluatePrecomputedAssignment( + const precomputedResult = this.evaluatePrecomputedAssignment( precomputed, flagKey, - subjectKey, - subjectAttributes, expectedVariationType, - flagEvaluationDetailsBuilder, ); + + return precomputedResult.flagEvaluation; } const flag = config.getFlag(flagKey); @@ -1380,14 +1434,8 @@ export default class EppoClient { ); // if flag.key is obfuscated, replace with requested flag key - result.flagKey = flagKey; - - try { - if (result?.doLog) { - this.maybeLogAssignment(result); - } - } catch (error) { - logger.error(`${loggerPrefix} Error logging assignment event: ${error}`); + if (result.assignmentDetails) { + result.assignmentDetails.flagKey = flagKey; } return result; @@ -1396,53 +1444,104 @@ export default class EppoClient { private evaluatePrecomputedAssignment( precomputed: PrecomputedConfig, flagKey: string, - subjectKey: string, - subjectAttributes: Attributes, expectedVariationType: VariationType | undefined, - flagEvaluationDetailsBuilder: FlagEvaluationDetailsBuilder, - ): FlagEvaluation { + ): { + flagEvaluation: FlagEvaluation; + banditAction: string | null; + assignmentEvent?: IAssignmentEvent; + banditEvent?: IBanditEvent; + } { const obfuscatedKey = getMD5Hash(flagKey, precomputed.response.salt); const obfuscatedFlag: PrecomputedFlag | undefined = precomputed.response.flags[obfuscatedKey]; const obfuscatedBandit: IObfuscatedPrecomputedBandit | undefined = precomputed.response.bandits[obfuscatedKey]; - const flag = obfuscatedFlag && decodePrecomputedFlag(obfuscatedFlag); - const bandit = obfuscatedBandit && decodePrecomputedBandit(obfuscatedBandit); - if (!flag) { + if (!obfuscatedFlag) { logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); - // note: this is different from the Python SDK, which returns None instead - const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( - 'FLAG_UNRECOGNIZED_OR_DISABLED', - `Unrecognized or disabled flag: ${flagKey}`, - ); - return noneResult( + const flagEvaluationDetails: IFlagEvaluationDetails = { + environmentName: precomputed.response.environment?.name ?? '', + flagEvaluationCode: 'FLAG_UNRECOGNIZED_OR_DISABLED' as const, + flagEvaluationDescription: `Unrecognized or disabled flag: ${flagKey}`, + variationKey: null, + variationValue: null, + banditKey: null, + banditAction: null, + configFetchedAt: precomputed.fetchedAt ?? '', + configPublishedAt: precomputed.response.createdAt, + matchedRule: null, + matchedAllocation: null, + unmatchedAllocations: [], + unevaluatedAllocations: [], + }; + + const noneResultValue = noneResult( flagKey, - subjectKey, - subjectAttributes, + precomputed.subjectKey, + ensureNonContextualSubjectAttributes(precomputed.subjectAttributes ?? {}), flagEvaluationDetails, precomputed.response.format, ); + + return { + flagEvaluation: noneResultValue, + banditAction: null, + }; } + const flag = decodePrecomputedFlag(obfuscatedFlag); + const bandit = obfuscatedBandit && decodePrecomputedBandit(obfuscatedBandit); + if (!checkTypeMatch(expectedVariationType, flag.variationType)) { const errorMessage = `Variation value does not have the correct type. Found ${flag.variationType}, but expected ${expectedVariationType} for flag ${flagKey}`; if (this.isGracefulFailureMode) { - const flagEvaluationDetails = flagEvaluationDetailsBuilder.buildForNoneResult( - 'TYPE_MISMATCH', - errorMessage, - ); - return noneResult( - flagKey, - subjectKey, - subjectAttributes, - flagEvaluationDetails, - precomputed.response.format, - ); + const flagEvaluationDetails: IFlagEvaluationDetails = { + environmentName: precomputed.response.environment?.name ?? '', + flagEvaluationCode: 'TYPE_MISMATCH' as const, + flagEvaluationDescription: errorMessage, + variationKey: null, + variationValue: null, + banditKey: null, + banditAction: null, + configFetchedAt: precomputed.fetchedAt ?? '', + configPublishedAt: precomputed.response.createdAt, + matchedRule: null, + matchedAllocation: null, + unmatchedAllocations: [], + unevaluatedAllocations: [], + }; + + return { + flagEvaluation: noneResult( + flagKey, + precomputed.subjectKey, + ensureNonContextualSubjectAttributes(precomputed.subjectAttributes ?? {}), + flagEvaluationDetails, + precomputed.response.format, + ), + banditAction: null, + }; } throw new TypeError(errorMessage); } - const result: FlagEvaluation = { + // Prepare flag evaluation details + const flagEvaluationDetails: IFlagEvaluationDetails = { + environmentName: precomputed.response.environment?.name ?? '', + flagEvaluationCode: 'MATCH' as const, + flagEvaluationDescription: 'Matched precomputed flag', + variationKey: flag.variationKey ?? null, + variationValue: flag.variationValue, + banditKey: bandit?.banditKey ?? null, + banditAction: bandit?.action ?? null, + configFetchedAt: precomputed.fetchedAt ?? '', + configPublishedAt: precomputed.response.createdAt, + matchedRule: null, + matchedAllocation: null, + unmatchedAllocations: [], + unevaluatedAllocations: [], + }; + + const assignmentDetails: AssignmentResult = { flagKey, format: precomputed.response.format, subjectKey: precomputed.subjectKey, @@ -1457,24 +1556,60 @@ export default class EppoClient { extraLogging: flag.extraLogging ?? {}, doLog: flag.doLog, entityId: null, - flagEvaluationDetails: { - environmentName: precomputed.response.environment?.name ?? '', - flagEvaluationCode: 'MATCH', - flagEvaluationDescription: 'Matched precomputed flag', - variationKey: flag.variationKey ?? null, - variationValue: flag.variationValue, - banditKey: null, - banditAction: null, - configFetchedAt: precomputed.fetchedAt ?? '', - configPublishedAt: precomputed.response.createdAt, - matchedRule: null, - matchedAllocation: null, - unmatchedAllocations: [], - unevaluatedAllocations: [], - }, + evaluationDetails: flagEvaluationDetails, }; - return result; + const flagEvaluation: FlagEvaluation = { + assignmentDetails, + }; + + // Create assignment event if needed + if (flag.doLog) { + flagEvaluation.assignmentEvent = { + ...flag.extraLogging, + allocation: flag.allocationKey ?? null, + experiment: flag.allocationKey ? `${flagKey}-${flag.allocationKey}` : null, + featureFlag: flagKey, + format: precomputed.response.format, + variation: flag.variationKey ?? null, + subject: precomputed.subjectKey, + timestamp: new Date().toISOString(), + subjectAttributes: precomputed.subjectAttributes + ? ensureNonContextualSubjectAttributes(precomputed.subjectAttributes) + : {}, + metaData: this.buildLoggerMetadata(), + evaluationDetails: flagEvaluationDetails, + entityId: null, + }; + } + + // Create bandit event if present + let banditEvent: IBanditEvent | undefined; + if (bandit) { + flagEvaluation.banditEvent = { + timestamp: new Date().toISOString(), + featureFlag: flagKey, + bandit: bandit.banditKey, + subject: precomputed.subjectKey, + action: bandit.action, + actionProbability: bandit.actionProbability, + optimalityGap: bandit.optimalityGap, + modelVersion: bandit.modelVersion, + subjectNumericAttributes: precomputed.subjectAttributes?.numericAttributes ?? {}, + subjectCategoricalAttributes: precomputed.subjectAttributes?.categoricalAttributes ?? {}, + actionNumericAttributes: bandit.actionNumericAttributes, + actionCategoricalAttributes: bandit.actionCategoricalAttributes, + metaData: this.buildLoggerMetadata(), + evaluationDetails: flagEvaluationDetails, + }; + } + + return { + flagEvaluation, + banditAction: bandit?.action ?? null, + assignmentEvent: flagEvaluation.assignmentEvent, + banditEvent: flagEvaluation.banditEvent, + }; } /** @@ -1579,47 +1714,28 @@ export default class EppoClient { }); } - private maybeLogAssignment(result: FlagEvaluation) { - const { - flagKey, - format, - subjectKey, - allocationKey = null, - subjectAttributes, - variation, - flagEvaluationDetails, - extraLogging = {}, - entityId, - } = result; - const event: IAssignmentEvent = { - ...extraLogging, - allocation: allocationKey, - experiment: allocationKey ? `${flagKey}-${allocationKey}` : null, - featureFlag: flagKey, - format, - variation: variation?.key ?? null, - subject: subjectKey, - timestamp: new Date().toISOString(), - subjectAttributes, - metaData: this.buildLoggerMetadata(), - evaluationDetails: flagEvaluationDetails, - entityId, - }; + private maybeLogAssignment(event: IAssignmentEvent | undefined) { + try { + if (!event) return; - if (variation && allocationKey) { - // If already logged, don't log again - const hasLoggedAssignment = this.assignmentCache?.has({ - flagKey, - subjectKey, - allocationKey, - variationKey: variation.key, - }); - if (hasLoggedAssignment) { - return; + const flagKey = event.featureFlag; + const subjectKey = event.subject; + const allocationKey = event.allocation; + const variationKey = event.variation; + + if (variationKey && allocationKey) { + // If already logged, don't log again + const hasLoggedAssignment = this.assignmentCache?.has({ + flagKey, + subjectKey, + allocationKey, + variationKey, + }); + if (hasLoggedAssignment) { + return; + } } - } - try { if (this.assignmentLogger) { this.assignmentLogger.logAssignment(event); } else { @@ -1631,7 +1747,7 @@ export default class EppoClient { flagKey, subjectKey, allocationKey: allocationKey ?? '__eppo_no_allocation', - variationKey: variation?.key ?? '__eppo_no_variation', + variationKey: variationKey ?? '__eppo_no_variation', }); } catch (error: any) { logger.error(`${loggerPrefix} Error logging assignment event: ${error.message}`); @@ -1716,6 +1832,7 @@ export function checkTypeMatch(expectedType?: VariationType, actualType?: Variat return expectedType === undefined || actualType === expectedType; } +/** @internal */ export function checkValueTypeMatch( expectedType: VariationType | undefined, value: ValueType, diff --git a/src/client/eppo-precomputed-client-with-bandits.spec.ts b/src/client/eppo-precomputed-client-with-bandits.spec.ts deleted file mode 100644 index c6ef9a3..0000000 --- a/src/client/eppo-precomputed-client-with-bandits.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - MOCK_PRECOMPUTED_WIRE_FILE, - readMockConfigurationWireResponse, -} from '../../test/testHelpers'; -import ApiEndpoints from '../api-endpoints'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import FetchHttpClient from '../http-client'; -import { IObfuscatedPrecomputedBandit, PrecomputedFlag } from '../interfaces'; -import PrecomputedFlagRequestor from '../precomputed-requestor'; - -import EppoPrecomputedClient from './eppo-precomputed-client'; - -describe('EppoPrecomputedClient Bandits E2E test', () => { - const precomputedFlagStore = new MemoryOnlyConfigurationStore(); - const precomputedBanditStore = new MemoryOnlyConfigurationStore(); - let client: EppoPrecomputedClient; - const mockLogAssignment = jest.fn(); - const mockLogBanditAction = jest.fn(); - - const obfuscatedConfigurationWire = readMockConfigurationWireResponse(MOCK_PRECOMPUTED_WIRE_FILE); - const obfuscatedResponse = JSON.parse(obfuscatedConfigurationWire).precomputed.response; - - const testModes = ['offline']; - - testModes.forEach((mode) => { - describe(`${mode} mode`, () => { - beforeAll(async () => { - if (mode === 'online') { - // Mock fetch for online mode - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(JSON.parse(obfuscatedResponse)), - }); - }) as jest.Mock; - - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://127.0.0.1:4000', - queryParams: { - apiKey: 'dummy', - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }, - }); - const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new PrecomputedFlagRequestor( - httpClient, - precomputedFlagStore, - 'test-subject', - { - numericAttributes: {}, - categoricalAttributes: {}, - }, - precomputedBanditStore, - { - banner_bandit_flag: { - nike: { - numericAttributes: { brand_affinity: -2.5 }, - categoricalAttributes: { loyalty_tier: 'bronze' }, - }, - }, - 'not-a-bandit-flag': {}, - }, - ); - await configurationRequestor.fetchAndStorePrecomputedFlags(); - } else if (mode === 'offline') { - const parsed = JSON.parse(obfuscatedResponse); - // Offline mode: directly populate stores with precomputed response - precomputedFlagStore.salt = parsed.salt; - precomputedBanditStore.salt = parsed.salt; - await precomputedFlagStore.setEntries(parsed.flags); - await precomputedBanditStore.setEntries(parsed.bandits); - } - }); - - beforeEach(() => { - // Create precomputed client with required subject and stores - client = new EppoPrecomputedClient({ - precomputedFlagStore, - precomputedBanditStore, - subject: { - subjectKey: 'test-subject', - subjectAttributes: { - numericAttributes: {}, - categoricalAttributes: {}, - }, - }, - }); - client.setAssignmentLogger({ logAssignment: mockLogAssignment }); - client.setBanditLogger({ logBanditAction: mockLogBanditAction }); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it(`should return the default action for the banner_bandit_flag in ${mode} mode`, () => { - const precomputedConfiguration = client.getBanditAction('banner_bandit_flag', 'nike'); - expect(precomputedConfiguration).toEqual({ action: null, variation: 'nike' }); - }); - - it('should return the assigned variation if a flag is not a bandit', () => { - const precomputedConfiguration = client.getBanditAction('not-a-bandit-flag', 'default'); - expect(precomputedConfiguration).toEqual({ action: null, variation: 'control' }); - expect(mockLogBanditAction).not.toHaveBeenCalled(); - }); - - it('should return the bandit variation and action if a flag is a bandit', () => { - const precomputedConfiguration = client.getBanditAction('string-flag', 'default'); - expect(precomputedConfiguration).toEqual({ - action: 'show_red_button', - variation: 'red', - }); - expect(mockLogBanditAction).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts deleted file mode 100644 index df9ab75..0000000 --- a/src/client/eppo-precomputed-client.spec.ts +++ /dev/null @@ -1,1413 +0,0 @@ -import * as td from 'testdouble'; - -import { - MOCK_PRECOMPUTED_WIRE_FILE, - readMockConfigurationWireResponse, -} from '../../test/testHelpers'; -import ApiEndpoints from '../api-endpoints'; -import { logger } from '../application-logger'; -import { IAssignmentLogger } from '../assignment-logger'; -import { - ensureContextualSubjectAttributes, - ensureNonContextualSubjectAttributes, -} from '../attributes'; -import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { IPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; -import { DEFAULT_BASE_POLLING_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; -import FetchHttpClient from '../http-client'; -import { - FormatEnum, - IObfuscatedPrecomputedBandit, - PrecomputedFlag, - Variation, - VariationType, -} from '../interfaces'; -import { decodeBase64, encodeBase64, getMD5Hash } from '../obfuscation'; -import PrecomputedRequestor from '../precomputed-requestor'; - -import EppoPrecomputedClient, { - PrecomputedRequestParameters, - Subject, -} from './eppo-precomputed-client'; - -describe('EppoPrecomputedClient E2E test', () => { - const precomputedConfigurationWire = readMockConfigurationWireResponse( - MOCK_PRECOMPUTED_WIRE_FILE, - ); - const unparsedPrecomputedResponse = JSON.parse(precomputedConfigurationWire).precomputed.response; - const precomputedResponse: IPrecomputedConfigurationResponse = JSON.parse( - unparsedPrecomputedResponse, - ); - - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(precomputedResponse), - }); - }) as jest.Mock; - let storage = new MemoryOnlyConfigurationStore(); - const subject: Subject = { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }; - - beforeEach(async () => { - storage = new MemoryOnlyConfigurationStore(); - storage.setFormat(FormatEnum.PRECOMPUTED); - }); - - beforeAll(async () => { - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://127.0.0.1:4000', - queryParams: { - apiKey: 'dummy', - sdkName: 'js-client-sdk-common', - sdkVersion: '3.0.0', - }, - }); - const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const precomputedFlagRequestor = new PrecomputedRequestor( - httpClient, - storage, - 'subject-key', - ensureContextualSubjectAttributes({ - 'attribute-key': 'attribute-value', - }), - ); - await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); - }); - - const precomputedFlagKey = 'mock-flag'; - const hashedPrecomputedFlagKey = getMD5Hash(precomputedFlagKey); - const hashedFlag2 = getMD5Hash('flag-2'); - const hashedFlag3 = getMD5Hash('flag-3'); - - const mockPrecomputedFlag: PrecomputedFlag = { - flagKey: hashedPrecomputedFlagKey, - variationKey: encodeBase64('a'), - variationValue: encodeBase64('variation-a'), - allocationKey: encodeBase64('allocation-a'), - doLog: true, - variationType: VariationType.STRING, - extraLogging: {}, - }; - - describe('error encountered', () => { - let client: EppoPrecomputedClient; - - beforeAll(() => { - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - client = new EppoPrecomputedClient({ precomputedFlagStore: storage, subject }); - }); - - afterAll(() => { - td.reset(); - }); - - it('returns default value when flag not found', () => { - expect(client.getStringAssignment('non-existent-flag', 'default')).toBe('default'); - }); - }); - - describe('setLogger', () => { - let flagStorage: IConfigurationStore; - let subject: Subject; - beforeAll(() => { - flagStorage = new MemoryOnlyConfigurationStore(); - flagStorage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - subject = { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }; - }); - - it('Invokes logger for queued events', () => { - const mockLogger = td.object(); - - const client = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - }); - client.getStringAssignment(precomputedFlagKey, 'default-value'); - client.setAssignmentLogger(mockLogger); - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - }); - - it('Does not log same queued event twice', () => { - const mockLogger = td.object(); - - const client = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - }); - - client.getStringAssignment(precomputedFlagKey, 'default-value'); - client.setAssignmentLogger(mockLogger); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - client.setAssignmentLogger(mockLogger); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - }); - - it('Does not invoke logger for events that exceed queue size', () => { - const mockLogger = td.object(); - const client = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - }); - - for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { - client.getStringAssignment(precomputedFlagKey, 'default-value'); - } - client.setAssignmentLogger(mockLogger); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE); - }); - }); - - it('returns null if getStringAssignment was called for the subject before any precomputed flags were loaded', () => { - const localClient = new EppoPrecomputedClient({ - precomputedFlagStore: new MemoryOnlyConfigurationStore(), - subject, - }); - expect(localClient.getStringAssignment(precomputedFlagKey, 'hello world')).toEqual( - 'hello world', - ); - expect(localClient.isInitialized()).toBe(false); - }); - - it('returns default value when key does not exist', async () => { - const client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - }); - const nonExistentFlag = 'non-existent-flag'; - expect(client.getStringAssignment(nonExistentFlag, 'default')).toBe('default'); - }); - - it('logs variation assignment with correct metadata', () => { - const mockLogger = td.object(); - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - }); - client.setAssignmentLogger(mockLogger); - - client.getStringAssignment(precomputedFlagKey, 'default'); - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - const loggedEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; - - expect(loggedEvent.featureFlag).toEqual(precomputedFlagKey); - expect(loggedEvent.variation).toEqual(decodeBase64(mockPrecomputedFlag.variationKey ?? '')); - expect(loggedEvent.allocation).toEqual(decodeBase64(mockPrecomputedFlag.allocationKey ?? '')); - expect(loggedEvent.experiment).toEqual( - `${precomputedFlagKey}-${decodeBase64(mockPrecomputedFlag.allocationKey ?? '')}`, - ); - }); - - it('handles logging exception', () => { - const mockLogger = td.object(); - td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); - - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - }); - client.setAssignmentLogger(mockLogger); - - const assignment = client.getStringAssignment(precomputedFlagKey, 'default'); - - expect(assignment).toEqual('variation-a'); - }); - - describe('assignment logging deduplication', () => { - let client: EppoPrecomputedClient; - let mockLogger: IAssignmentLogger; - - beforeEach(() => { - mockLogger = td.object(); - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - }); - client.setAssignmentLogger(mockLogger); - }); - - it('logs duplicate assignments without an assignment cache', async () => { - client.disableAssignmentCache(); - - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment(precomputedFlagKey, 'default'); - - // call count should be 2 because there is no cache. - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); - }); - - it('does not log duplicate assignments', async () => { - client.useNonExpiringInMemoryAssignmentCache(); - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment(precomputedFlagKey, 'default'); - // call count should be 1 because the second call is a cache hit and not logged. - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - }); - - it('logs assignment again after the lru cache is full', async () => { - await storage.setEntries({ - [hashedPrecomputedFlagKey]: mockPrecomputedFlag, - [hashedFlag2]: { - ...mockPrecomputedFlag, - variationKey: encodeBase64('b'), - }, - [hashedFlag3]: { - ...mockPrecomputedFlag, - variationKey: encodeBase64('c'), - }, - }); - - client.useLRUInMemoryAssignmentCache(2); - - client.getStringAssignment(precomputedFlagKey, 'default'); // logged - client.getStringAssignment(precomputedFlagKey, 'default'); // cached - client.getStringAssignment('flag-2', 'default'); // logged - client.getStringAssignment('flag-2', 'default'); // cached - client.getStringAssignment('flag-3', 'default'); // logged - client.getStringAssignment('flag-3', 'default'); // cached - client.getStringAssignment(precomputedFlagKey, 'default'); // logged - client.getStringAssignment('flag-2', 'default'); // logged - client.getStringAssignment('flag-3', 'default'); // logged - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(6); - }); - - it('does not cache assignments if the logger had an exception', () => { - td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow( - new Error('logging error'), - ); - - client.setAssignmentLogger(mockLogger); - - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment(precomputedFlagKey, 'default'); - - // call count should be 2 because the first call had an exception - // therefore we are not sure the logger was successful and try again. - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); - }); - - it('logs for each unique flag', async () => { - await storage.setEntries({ - [hashedPrecomputedFlagKey]: mockPrecomputedFlag, - [hashedFlag2]: mockPrecomputedFlag, - [hashedFlag3]: mockPrecomputedFlag, - }); - - client.useNonExpiringInMemoryAssignmentCache(); - - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment('flag-2', 'default'); - client.getStringAssignment('flag-2', 'default'); - client.getStringAssignment('flag-3', 'default'); - client.getStringAssignment('flag-3', 'default'); - client.getStringAssignment(precomputedFlagKey, 'default'); - client.getStringAssignment('flag-2', 'default'); - client.getStringAssignment('flag-3', 'default'); - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3); - }); - - it('logs twice for the same flag when variation change', () => { - client.useNonExpiringInMemoryAssignmentCache(); - - storage.setEntries({ - [hashedPrecomputedFlagKey]: { - ...mockPrecomputedFlag, - variationKey: encodeBase64('a'), - variationValue: encodeBase64('variation-a'), - }, - }); - client.getStringAssignment(precomputedFlagKey, 'default'); - - storage.setEntries({ - [hashedPrecomputedFlagKey]: { - ...mockPrecomputedFlag, - variationKey: encodeBase64('b'), - variationValue: encodeBase64('variation-b'), - }, - }); - client.getStringAssignment(precomputedFlagKey, 'default'); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2); - }); - - it('logs the same subject/flag/variation after two changes', () => { - client.useNonExpiringInMemoryAssignmentCache(); - - // original configuration version - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - - client.getStringAssignment(precomputedFlagKey, 'default'); // log this assignment - client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log - - // change the variation - storage.setEntries({ - [hashedPrecomputedFlagKey]: { - ...mockPrecomputedFlag, - allocationKey: encodeBase64('allocation-a'), // same allocation key - variationKey: encodeBase64('b'), // but different variation - variationValue: encodeBase64('variation-b'), // but different variation - }, - }); - - client.getStringAssignment(precomputedFlagKey, 'default'); // log this assignment - client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log - - // change the flag again, back to the original - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - - client.getStringAssignment(precomputedFlagKey, 'default'); // important: log this assignment - client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log - - // change the allocation - storage.setEntries({ - [hashedPrecomputedFlagKey]: { - ...mockPrecomputedFlag, - allocationKey: encodeBase64('allocation-b'), // different allocation key - variationKey: encodeBase64('b'), // but same variation - variationValue: encodeBase64('variation-b'), // but same variation - }, - }); - - client.getStringAssignment(precomputedFlagKey, 'default'); // log this assignment - client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(4); - }); - }); - - describe('Eppo Precomputed Client constructed with configuration request parameters', () => { - let client: EppoPrecomputedClient; - let precomputedFlagStore: IConfigurationStore; - let subject: Subject; - let requestParameters: PrecomputedRequestParameters; - - const precomputedFlagKey = 'string-flag'; - const red = 'red'; - - const maxRetryDelay = DEFAULT_BASE_POLLING_INTERVAL_MS * POLL_JITTER_PCT; - - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(precomputedResponse), - }); - }) as jest.Mock; - }); - - beforeEach(async () => { - requestParameters = { - apiKey: 'dummy-key', - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }; - - subject = { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }; - - precomputedFlagStore = new MemoryOnlyConfigurationStore(); - - // We only want to fake setTimeout() and clearTimeout() - jest.useFakeTimers({ - advanceTimers: true, - doNotFake: [ - 'Date', - 'hrtime', - 'nextTick', - 'performance', - 'queueMicrotask', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'requestIdleCallback', - 'cancelIdleCallback', - 'setImmediate', - 'clearImmediate', - 'setInterval', - 'clearInterval', - ], - }); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it('Fetches initial configuration with parameters in constructor', async () => { - client = new EppoPrecomputedClient({ - precomputedFlagStore, - subject, - requestParameters, - }); - // no configuration loaded - let variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - // have client fetch configurations - await client.fetchPrecomputedFlags(); - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe(red); - }); - - it('Fetches initial configuration with parameters provided later', async () => { - client = new EppoPrecomputedClient({ - precomputedFlagStore, - subject, - requestParameters, - }); - // no configuration loaded - let variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - // have client fetch configurations - await client.fetchPrecomputedFlags(); - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe(red); - }); - - describe('Poll after successful start', () => { - it('Continues to poll when cache has not expired', async () => { - class MockStore extends MemoryOnlyConfigurationStore { - public static expired = false; - - async isExpired(): Promise { - return MockStore.expired; - } - } - - client = new EppoPrecomputedClient({ - precomputedFlagStore: new MockStore(), - subject, - requestParameters: { - ...requestParameters, - pollAfterSuccessfulInitialization: true, - }, - }); - // no configuration loaded - let variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - - // have client fetch configurations; cache is not expired so assignment stays - await client.fetchPrecomputedFlags(); - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - - // Expire the cache and advance time until a reload should happen - MockStore.expired = true; - await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS * 1.5); - - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe(red); - }); - }); - - it('Does not fetch configurations if the configuration store is unexpired', async () => { - class MockStore extends MemoryOnlyConfigurationStore { - async isExpired(): Promise { - return false; - } - } - client = new EppoPrecomputedClient({ - precomputedFlagStore: new MockStore(), - subject, - requestParameters, - }); - // no configuration loaded - let variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - // have client fetch configurations - await client.fetchPrecomputedFlags(); - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - }); - - describe('Gets typed assignments', () => { - let client: EppoPrecomputedClient; - - beforeEach(async () => { - client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - requestParameters, - }); - await client.fetchPrecomputedFlags(); - }); - - it('returns string assignment', () => { - expect(client.getStringAssignment('string-flag', 'default')).toBe('red'); - expect(client.getStringAssignment('non-existent', 'default')).toBe('default'); - }); - - it('returns boolean assignment', () => { - expect(client.getBooleanAssignment('boolean-flag', false)).toBe(true); - expect(client.getBooleanAssignment('non-existent', false)).toBe(false); - }); - - it('returns integer assignment', () => { - expect(client.getIntegerAssignment('integer-flag', 0)).toBe(42); - expect(client.getIntegerAssignment('non-existent', 0)).toBe(0); - }); - - it('returns numeric assignment', () => { - expect(client.getNumericAssignment('numeric-flag', 0)).toBe(3.14); - expect(client.getNumericAssignment('non-existent', 0)).toBe(0); - }); - - it('returns JSON assignment', () => { - expect(client.getJSONAssignment('json-flag', {})).toEqual({ - key: 'value', - number: 123, - }); - expect(client.getJSONAssignment('non-existent', {})).toEqual({}); - }); - - it('returns default value when type mismatches', () => { - // Try to get a string value from a boolean flag - expect(client.getStringAssignment('boolean-flag', 'default')).toBe('default'); - // Try to get a boolean value from a string flag - expect(client.getBooleanAssignment('string-flag', false)).toBe(false); - }); - }); - - it.each([ - { pollAfterSuccessfulInitialization: false }, - { pollAfterSuccessfulInitialization: true }, - ])('retries initial configuration request with config %p', async (configModification) => { - let callCount = 0; - - global.fetch = jest.fn(() => { - if (++callCount === 1) { - // Simulate an error for the first call - return Promise.resolve({ - ok: false, - status: 500, - json: () => Promise.reject(new Error('Server error')), - }); - } else { - // Return a successful response for subsequent calls - return Promise.resolve({ - ok: true, - status: 200, - json: () => { - return precomputedResponse; - }, - }); - } - }) as jest.Mock; - - const { pollAfterSuccessfulInitialization } = configModification; - requestParameters = { - ...requestParameters, - pollAfterSuccessfulInitialization, - }; - client = new EppoPrecomputedClient({ - precomputedFlagStore: precomputedFlagStore, - requestParameters, - subject, - }); - // no configuration loaded - let variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe('default'); - - // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - const fetchPromise = client.fetchPrecomputedFlags(); - - // Advance timers mid-init to allow retrying - await jest.advanceTimersByTimeAsync(maxRetryDelay); - - // Await so it can finish its initialization before this test proceeds - await fetchPromise; - - variation = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(variation).toBe(red); - expect(callCount).toBe(2); - - await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS); - // By default, no more polling - expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); - }); - - it.each([ - { - pollAfterFailedInitialization: false, - throwOnFailedInitialization: false, - }, - { pollAfterFailedInitialization: false, throwOnFailedInitialization: true }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: false }, - { pollAfterFailedInitialization: true, throwOnFailedInitialization: true }, - ])('initial configuration request fails with config %p', async (configModification) => { - let callCount = 0; - - global.fetch = jest.fn(() => { - if (++callCount === 1) { - // Simulate an error for the first call - return Promise.resolve({ - ok: false, - status: 500, - json: () => Promise.reject(new Error('Server error')), - } as Response); - } else { - // Return a successful response for subsequent calls - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(precomputedResponse), - } as Response); - } - }); - - const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; - - // Note: fake time does not play well with errors bubbled up after setTimeout (event loop, - // timeout queue, message queue stuff) so we don't allow retries when rethrowing. - const numInitialRequestRetries = 0; - - requestParameters = { - ...requestParameters, - numInitialRequestRetries, - throwOnFailedInitialization, - pollAfterFailedInitialization, - }; - client = new EppoPrecomputedClient({ - precomputedFlagStore: precomputedFlagStore, - subject, - requestParameters, - }); - // no configuration loaded - expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe('default'); - - // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes - if (throwOnFailedInitialization) { - await expect(client.fetchPrecomputedFlags()).rejects.toThrow(); - } else { - await expect(client.fetchPrecomputedFlags()).resolves.toBeUndefined(); - } - expect(callCount).toBe(1); - // still no configuration loaded - expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe('default'); - - // Advance timers so a post-init poll can take place - await jest.advanceTimersByTimeAsync(DEFAULT_BASE_POLLING_INTERVAL_MS * 1.5); - - // if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not - expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1); - expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe( - pollAfterFailedInitialization ? red : 'default', - ); - }); - - describe('Enhanced SDK Token with encoded subdomain', () => { - let urlsRequested: string[] = []; - const SDK_PARAM_SUFFIX = 'sdkName=js-client-sdk-common&sdkVersion=1.0.0'; - - beforeEach(() => { - urlsRequested = []; - global.fetch = jest.fn((url) => { - urlsRequested.push(url.toString()); - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(precomputedResponse), - } as Response); - }); - }); - - it('should request from the encoded subdomain', async () => { - const client = new EppoPrecomputedClient({ - precomputedFlagStore: new MemoryOnlyConfigurationStore(), - subject, - requestParameters: { - apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', // subdomain=experiment - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }, - }); - - await client.fetchPrecomputedFlags(); - - expect(urlsRequested).toHaveLength(1); - expect(urlsRequested[0]).toEqual( - 'https://experiment.fs-edge-assignment.eppo.cloud/assignments?apiKey=zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA%3D%3D&' + - SDK_PARAM_SUFFIX, - ); - }); - - it('should request from the default domain if the encoded subdomain is not present', async () => { - const client = new EppoPrecomputedClient({ - precomputedFlagStore: new MemoryOnlyConfigurationStore(), - subject, - requestParameters: { - apiKey: 'old style key', - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }, - }); - - await client.fetchPrecomputedFlags(); - - expect(urlsRequested).toHaveLength(1); - expect(urlsRequested[0]).toEqual( - 'https://fs-edge-assignment.eppo.cloud/assignments?apiKey=old+style+key&' + - SDK_PARAM_SUFFIX, - ); - }); - - it('should request from the provided baseUrl if present', async () => { - const client = new EppoPrecomputedClient({ - precomputedFlagStore: new MemoryOnlyConfigurationStore(), - subject, - requestParameters: { - apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', // subdomain=experiment - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - baseUrl: 'https://custom-base-url.com', - }, - }); - - await client.fetchPrecomputedFlags(); - - expect(urlsRequested).toHaveLength(1); - expect(urlsRequested[0]).toEqual( - 'https://custom-base-url.com/assignments?apiKey=zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA%3D%3D&' + - SDK_PARAM_SUFFIX, - ); - }); - }); - }); - - describe('Obfuscated precomputed flags', () => { - let precomputedFlagStore: IConfigurationStore; - beforeEach(() => { - precomputedFlagStore = new MemoryOnlyConfigurationStore(); - }); - - it('returns decoded variation value', () => { - const salt = 'NaCl'; - const saltedAndHashedFlagKey = getMD5Hash(precomputedFlagKey, salt); - - precomputedFlagStore.setEntries({ - [saltedAndHashedFlagKey]: { - ...mockPrecomputedFlag, - allocationKey: encodeBase64(mockPrecomputedFlag.allocationKey ?? ''), - variationKey: encodeBase64(mockPrecomputedFlag.variationKey ?? ''), - variationValue: encodeBase64(mockPrecomputedFlag.variationValue), - extraLogging: {}, - }, - }); - precomputedFlagStore.salt = salt; - - const client = new EppoPrecomputedClient({ - precomputedFlagStore, - subject, - }); - - expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe( - mockPrecomputedFlag.variationValue, - ); - - td.reset(); - }); - }); - - it('logs variation assignment with format from precomputed flags response', () => { - const mockLogger = td.object(); - storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient({ - precomputedFlagStore: storage, - subject, - }); - client.setAssignmentLogger(mockLogger); - - client.getStringAssignment(precomputedFlagKey, 'default'); - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - const loggedEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; - - expect(loggedEvent.format).toEqual(FormatEnum.PRECOMPUTED); - }); - - describe('Constructor logs errors according to the store state', () => { - let mockError: jest.SpyInstance; - - beforeEach(() => { - mockError = jest.spyOn(logger, 'error'); - }); - - afterEach(() => { - mockError.mockRestore(); - }); - - it('does not log errors when constructor receives an empty, uninitialized store', () => { - const emptyStore = new MemoryOnlyConfigurationStore(); - new EppoPrecomputedClient({ - precomputedFlagStore: emptyStore, - subject: { - subjectKey: '', - subjectAttributes: {}, - }, - }); - expect(mockError).not.toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided', - ); - expect(mockError).not.toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', - ); - }); - - it('logs errors when constructor receives an uninitialized store without a salt', () => { - const nonemptyStore = new MemoryOnlyConfigurationStore(); - // Incorrectly initialized: no salt, not set to initialized - jest.spyOn(nonemptyStore, 'getKeys').mockReturnValue(['some-key']); - - new EppoPrecomputedClient({ - precomputedFlagStore: nonemptyStore, - subject: { - subjectKey: '', - subjectAttributes: {}, - }, - }); - expect(mockError).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided', - ); - expect(mockError).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', - ); - }); - - it('only logs initialization error when constructor receives an uninitialized store with salt', () => { - const nonemptyStore = new MemoryOnlyConfigurationStore(); - nonemptyStore.salt = 'nacl'; - // Incorrectly initialized: no salt, not set to initialized - jest.spyOn(nonemptyStore, 'getKeys').mockReturnValue(['some-key']); - - new EppoPrecomputedClient({ - precomputedFlagStore: nonemptyStore, - subject: { - subjectKey: '', - subjectAttributes: {}, - }, - }); - expect(mockError).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided', - ); - expect(mockError).not.toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', - ); - }); - }); - - describe('EppoPrecomputedClient subject data and store initialization', () => { - let client: EppoPrecomputedClient; - let store: IConfigurationStore; - let mockLogger: IAssignmentLogger; - - beforeEach(() => { - store = new MemoryOnlyConfigurationStore(); - mockLogger = td.object(); - }); - - it('prints errors if initialized with a store that is not initialized and without requestParameters', () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - expect(() => { - client = new EppoPrecomputedClient({ - precomputedFlagStore: store, - subject, - }); - }).not.toThrow(); - expect(loggerErrorSpy).toHaveBeenCalledTimes(0); - loggerErrorSpy.mockRestore(); - expect(client.getStringAssignment('string-flag', 'default')).toBe('default'); - }); - - it('prints only one error if initialized with a store without a salt and without requestParameters', async () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - await store.setEntries({ - 'test-flag': { - flagKey: 'test-flag', - variationType: VariationType.STRING, - variationKey: encodeBase64('control'), - variationValue: encodeBase64('test-value'), - allocationKey: encodeBase64('allocation-1'), - doLog: true, - extraLogging: {}, - }, - }); - expect(() => { - client = new EppoPrecomputedClient({ - precomputedFlagStore: store, - subject, - }); - }).not.toThrow(); - expect(loggerErrorSpy).toHaveBeenCalledTimes(1); - expect(loggerErrorSpy).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', - ); - loggerErrorSpy.mockRestore(); - expect(client.getStringAssignment('string-flag', 'default')).toBe('default'); - }); - - it('returns assignment and logs subject data after store is initialized with flags', async () => { - const subjectKey = 'test-subject'; - const subjectAttributes = ensureContextualSubjectAttributes({ attr1: 'value1' }); - store.salt = 'test-salt'; - const hashedFlagKey = getMD5Hash('test-flag', store.salt); - - await store.setEntries({ - [hashedFlagKey]: { - flagKey: hashedFlagKey, - variationType: VariationType.STRING, - variationKey: encodeBase64('control'), - variationValue: encodeBase64('test-value'), - allocationKey: encodeBase64('allocation-1'), - doLog: true, - extraLogging: {}, - }, - }); - - client = new EppoPrecomputedClient({ - precomputedFlagStore: store, - subject, - }); - client.setAssignmentLogger(mockLogger); - - expect(client.getStringAssignment('test-flag', 'default')).toBe('test-value'); - - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - const loggedEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; - expect(loggedEvent.subject).toEqual(subjectKey); - - // Convert the ContextAttributes to a flat attribute map - expect(loggedEvent.subjectAttributes).toEqual( - ensureNonContextualSubjectAttributes(subjectAttributes), - ); - }); - }); -}); - -describe('Precomputed Bandit Store', () => { - let precomputedFlagStore: IConfigurationStore; - let precomputedBanditStore: IConfigurationStore; - let subject: Subject; - - beforeEach(() => { - precomputedFlagStore = new MemoryOnlyConfigurationStore(); - precomputedBanditStore = new MemoryOnlyConfigurationStore(); - subject = { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }; - }); - - it('prints errors if initialized with a bandit store that is not initialized and without requestParameters', () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - const loggerWarnSpy = jest.spyOn(logger, 'warn'); - - new EppoPrecomputedClient({ - precomputedFlagStore, - precomputedBanditStore, - subject, - }); - - expect(loggerErrorSpy).toHaveBeenCalledWith( - '[Eppo SDK] Passing banditOptions without requestParameters requires an initialized precomputedBanditStore', - ); - expect(loggerWarnSpy).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient missing or empty salt for precomputedBanditStore', - ); - - loggerErrorSpy.mockRestore(); - loggerWarnSpy.mockRestore(); - }); - - it('prints only salt-related errors if stores are initialized but missing salts', async () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - const loggerWarnSpy = jest.spyOn(logger, 'warn'); - - await precomputedFlagStore.setEntries({ - 'test-flag': { - flagKey: 'test-flag', - variationType: VariationType.STRING, - variationKey: encodeBase64('control'), - variationValue: encodeBase64('test-value'), - allocationKey: encodeBase64('allocation-1'), - doLog: true, - extraLogging: {}, - }, - }); - - await precomputedBanditStore.setEntries({ - 'test-bandit': { - banditKey: encodeBase64('test-bandit'), - action: encodeBase64('action1'), - modelVersion: encodeBase64('v1'), - actionProbability: 0.5, - optimalityGap: 0.1, - actionNumericAttributes: { - [encodeBase64('attr1')]: encodeBase64('1.0'), - }, - actionCategoricalAttributes: { - [encodeBase64('attr2')]: encodeBase64('value2'), - }, - }, - }); - - new EppoPrecomputedClient({ - precomputedFlagStore, - precomputedBanditStore, - subject, - }); - - expect(loggerErrorSpy).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', - ); - expect(loggerWarnSpy).toHaveBeenCalledWith( - '[Eppo SDK] EppoPrecomputedClient missing or empty salt for precomputedBanditStore', - ); - - loggerErrorSpy.mockRestore(); - loggerWarnSpy.mockRestore(); - }); - - it('initializes correctly with both stores having salts', async () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - const loggerWarnSpy = jest.spyOn(logger, 'warn'); - - precomputedFlagStore.salt = 'flag-salt'; - precomputedBanditStore.salt = 'bandit-salt'; - - await precomputedFlagStore.setEntries({ - 'test-flag': { - flagKey: 'test-flag', - variationType: VariationType.STRING, - variationKey: encodeBase64('control'), - variationValue: encodeBase64('test-value'), - allocationKey: encodeBase64('allocation-1'), - doLog: true, - extraLogging: {}, - }, - }); - - await precomputedBanditStore.setEntries({ - 'test-bandit': { - banditKey: encodeBase64('test-bandit'), - action: encodeBase64('action1'), - modelVersion: encodeBase64('v1'), - actionProbability: 0.5, - optimalityGap: 0.1, - actionNumericAttributes: { - [encodeBase64('attr1')]: encodeBase64('1.0'), - }, - actionCategoricalAttributes: { - [encodeBase64('attr2')]: encodeBase64('value2'), - }, - }, - }); - - new EppoPrecomputedClient({ - precomputedFlagStore, - precomputedBanditStore, - subject, - }); - - expect(loggerErrorSpy).not.toHaveBeenCalled(); - expect(loggerWarnSpy).not.toHaveBeenCalled(); - - loggerErrorSpy.mockRestore(); - loggerWarnSpy.mockRestore(); - }); - - it('allows initialization without bandit store', async () => { - const loggerErrorSpy = jest.spyOn(logger, 'error'); - const loggerWarnSpy = jest.spyOn(logger, 'warn'); - - precomputedFlagStore.salt = 'flag-salt'; - - await precomputedFlagStore.setEntries({ - 'test-flag': { - flagKey: 'test-flag', - variationType: VariationType.STRING, - variationKey: encodeBase64('control'), - variationValue: encodeBase64('test-value'), - allocationKey: encodeBase64('allocation-1'), - doLog: true, - extraLogging: {}, - }, - }); - - new EppoPrecomputedClient({ - precomputedFlagStore, - subject, - }); - - expect(loggerErrorSpy).not.toHaveBeenCalled(); - expect(loggerWarnSpy).not.toHaveBeenCalled(); - - loggerErrorSpy.mockRestore(); - loggerWarnSpy.mockRestore(); - }); -}); - -describe('flag overrides', () => { - let client: EppoPrecomputedClient; - let mockLogger: IAssignmentLogger; - let overrideStore: ISyncStore; - let flagStorage: IConfigurationStore; - let subject: Subject; - - const precomputedFlagKey = 'mock-flag'; - const hashedPrecomputedFlagKey = getMD5Hash(precomputedFlagKey); - - const mockPrecomputedFlag: PrecomputedFlag = { - flagKey: hashedPrecomputedFlagKey, - variationKey: encodeBase64('a'), - variationValue: encodeBase64('variation-a'), - allocationKey: encodeBase64('allocation-a'), - doLog: true, - variationType: VariationType.STRING, - extraLogging: {}, - }; - - beforeEach(() => { - flagStorage = new MemoryOnlyConfigurationStore(); - flagStorage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); - mockLogger = td.object(); - overrideStore = new MemoryOnlyConfigurationStore(); - subject = { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }; - - client = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - overrideStore, - }); - client.setAssignmentLogger(mockLogger); - }); - - it('returns override values for all supported types', () => { - overrideStore.setEntries({ - 'string-flag': { - key: 'override-variation', - value: 'override-string', - }, - 'boolean-flag': { - key: 'override-variation', - value: true, - }, - 'numeric-flag': { - key: 'override-variation', - value: 42.5, - }, - 'json-flag': { - key: 'override-variation', - value: '{"foo": "bar"}', - }, - }); - - expect(client.getStringAssignment('string-flag', 'default')).toBe('override-string'); - expect(client.getBooleanAssignment('boolean-flag', false)).toBe(true); - expect(client.getNumericAssignment('numeric-flag', 0)).toBe(42.5); - expect(client.getJSONAssignment('json-flag', {})).toEqual({ foo: 'bar' }); - }); - - it('does not log assignments when override is applied', () => { - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-variation', - value: 'override-value', - }, - }); - - client.getStringAssignment(precomputedFlagKey, 'default'); - - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - }); - - it('uses normal assignment when no override exists for flag', () => { - // Set override for a different flag - overrideStore.setEntries({ - 'other-flag': { - key: 'override-variation', - value: 'override-value', - }, - }); - - const result = client.getStringAssignment(precomputedFlagKey, 'default'); - - // Should get the normal assignment value from mockPrecomputedFlag - expect(result).toBe('variation-a'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - it('uses normal assignment when no overrides store is configured', () => { - // Create client without overrides store - const clientWithoutOverrides = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - }); - clientWithoutOverrides.setAssignmentLogger(mockLogger); - - const result = clientWithoutOverrides.getStringAssignment(precomputedFlagKey, 'default'); - - // Should get the normal assignment value from mockPrecomputedFlag - expect(result).toBe('variation-a'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - it('respects override after initial assignment without override', () => { - // First call without override - const initialAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(initialAssignment).toBe('variation-a'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - - // Set override and make second call - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-variation', - value: 'override-value', - }, - }); - - const overriddenAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(overriddenAssignment).toBe('override-value'); - // No additional logging should occur when using override - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - it('reverts to normal assignment after removing override', () => { - // Set initial override - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-variation', - value: 'override-value', - }, - }); - - const overriddenAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(overriddenAssignment).toBe('override-value'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - - // Remove override and make second call - overrideStore.setEntries({}); - - const normalAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(normalAssignment).toBe('variation-a'); - // Should log the normal assignment - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - describe('setOverrideStore', () => { - it('applies overrides after setting store', () => { - // Create client without overrides store - const clientWithoutOverrides = new EppoPrecomputedClient({ - precomputedFlagStore: flagStorage, - subject, - }); - clientWithoutOverrides.setAssignmentLogger(mockLogger); - - // Initial call without override store - const initialAssignment = clientWithoutOverrides.getStringAssignment( - precomputedFlagKey, - 'default', - ); - expect(initialAssignment).toBe('variation-a'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - - // Set overrides store with override - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-variation', - value: 'override-value', - }, - }); - clientWithoutOverrides.setOverrideStore(overrideStore); - - // Call after setting override store - const overriddenAssignment = clientWithoutOverrides.getStringAssignment( - precomputedFlagKey, - 'default', - ); - expect(overriddenAssignment).toBe('override-value'); - // No additional logging should occur when using override - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - it('reverts to normal assignment after unsetting store', () => { - // Set initial override - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-variation', - value: 'override-value', - }, - }); - - client.getStringAssignment(precomputedFlagKey, 'default'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - - // Unset overrides store - client.unsetOverrideStore(); - - const normalAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(normalAssignment).toBe('variation-a'); - // Should log the normal assignment - expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); - }); - - it('switches between different override stores', () => { - // Create a second override store - const secondOverrideStore = new MemoryOnlyConfigurationStore(); - - // Set up different overrides in each store - overrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-1', - value: 'value-1', - }, - }); - - secondOverrideStore.setEntries({ - [precomputedFlagKey]: { - key: 'override-2', - value: 'value-2', - }, - }); - - // Start with first override store - const firstOverride = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(firstOverride).toBe('value-1'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - - // Switch to second override store - client.setOverrideStore(secondOverrideStore); - const secondOverride = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(secondOverride).toBe('value-2'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - - // Switch back to first override store - client.setOverrideStore(overrideStore); - const backToFirst = client.getStringAssignment(precomputedFlagKey, 'default'); - expect(backToFirst).toBe('value-1'); - expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); - }); - }); -}); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts deleted file mode 100644 index b835c9e..0000000 --- a/src/client/eppo-precomputed-client.ts +++ /dev/null @@ -1,538 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import { logger, loggerPrefix } from '../application-logger'; -import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; -import { - ensureContextualSubjectAttributes, - ensureNonContextualSubjectAttributes, -} from '../attributes'; -import { IBanditEvent, IBanditLogger } from '../bandit-logger'; -import { AssignmentCache } from '../cache/abstract-assignment-cache'; -import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; -import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; -import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { - DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - DEFAULT_REQUEST_TIMEOUT_MS, - DEFAULT_BASE_POLLING_INTERVAL_MS, - MAX_EVENT_QUEUE_SIZE, - PRECOMPUTED_BASE_URL, -} from '../constants'; -import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; -import { FlagEvaluationWithoutDetails } from '../evaluator'; -import FetchHttpClient from '../http-client'; -import { - IPrecomputedBandit, - DecodedPrecomputedFlag, - IObfuscatedPrecomputedBandit, - PrecomputedFlag, - VariationType, - Variation, -} from '../interfaces'; -import { getMD5Hash } from '../obfuscation'; -import initPoller, { IPoller } from '../poller'; -import PrecomputedRequestor from '../precomputed-requestor'; -import SdkTokenDecoder from '../sdk-token-decoder'; -import { Attributes, ContextAttributes, FlagKey } from '../types'; -import { validateNotBlank } from '../validation'; -import { LIB_VERSION } from '../version'; - -import { checkTypeMatch, IAssignmentDetails } from './eppo-client'; - -export interface Subject { - subjectKey: string; - subjectAttributes: Attributes | ContextAttributes; -} - -export type PrecomputedRequestParameters = { - apiKey: string; - sdkVersion: string; - sdkName: string; - baseUrl?: string; - requestTimeoutMs?: number; - basePollingIntervalMs?: number; - numInitialRequestRetries?: number; - numPollRequestRetries?: number; - pollAfterSuccessfulInitialization?: boolean; - pollAfterFailedInitialization?: boolean; - throwOnFailedInitialization?: boolean; - skipInitialPoll?: boolean; -}; - -interface EppoPrecomputedClientOptions { - precomputedFlagStore: IConfigurationStore; - precomputedBanditStore?: IConfigurationStore; - overrideStore?: ISyncStore; - subject: Subject; - banditActions?: Record>; - requestParameters?: PrecomputedRequestParameters; -} - -export default class EppoPrecomputedClient { - private readonly queuedAssignmentEvents: IAssignmentEvent[] = []; - private readonly banditEventsQueue: IBanditEvent[] = []; - private assignmentLogger?: IAssignmentLogger; - private banditLogger?: IBanditLogger; - private banditAssignmentCache?: AssignmentCache; - private assignmentCache?: AssignmentCache; - private requestPoller?: IPoller; - private requestParameters?: PrecomputedRequestParameters; - private subject: { - subjectKey: string; - subjectAttributes: ContextAttributes; - }; - private banditActions?: Record>; - private precomputedFlagStore: IConfigurationStore; - private precomputedBanditStore?: IConfigurationStore; - private overrideStore?: ISyncStore; - - public constructor(options: EppoPrecomputedClientOptions) { - this.precomputedFlagStore = options.precomputedFlagStore; - this.precomputedBanditStore = options.precomputedBanditStore; - this.overrideStore = options.overrideStore; - - const { subjectKey, subjectAttributes } = options.subject; - this.subject = { - subjectKey, - subjectAttributes: ensureContextualSubjectAttributes(subjectAttributes), - }; - this.banditActions = options.banditActions; - if (options.requestParameters) { - // Online-mode - this.requestParameters = options.requestParameters; - } else { - // Offline-mode -- depends on pre-populated IConfigurationStores (flags and bandits) to source configuration. - - // Allow an empty precomputedFlagStore to be passed in, but if it has items, ensure it was initialized properly. - if (this.precomputedFlagStore.getKeys().length > 0) { - if (!this.precomputedFlagStore.isInitialized()) { - logger.error( - `${loggerPrefix} EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided`, - ); - } - - if (!this.precomputedFlagStore.salt) { - logger.error( - `${loggerPrefix} EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided`, - ); - } - } - - if (this.precomputedBanditStore && !this.precomputedBanditStore.isInitialized()) { - logger.error( - `${loggerPrefix} Passing banditOptions without requestParameters requires an initialized precomputedBanditStore`, - ); - } - - if (this.precomputedBanditStore && !this.precomputedBanditStore.salt) { - logger.warn( - `${loggerPrefix} EppoPrecomputedClient missing or empty salt for precomputedBanditStore`, - ); - } - } - } - - public async fetchPrecomputedFlags() { - if (!this.requestParameters) { - throw new Error('Eppo SDK unable to fetch precomputed flags without the request parameters'); - } - // if fetchFlagConfigurations() was previously called, stop any polling process from that call - this.requestPoller?.stop(); - - const { - apiKey, - sdkName, - sdkVersion, - baseUrl, // Default is set before passing to ApiEndpoints constructor if undefined - requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - pollAfterSuccessfulInitialization = false, - pollAfterFailedInitialization = false, - throwOnFailedInitialization = false, - skipInitialPoll = false, - } = this.requestParameters; - const { subjectKey, subjectAttributes } = this.subject; - - let { basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS } = this.requestParameters; - if (basePollingIntervalMs <= 0) { - logger.error('basePollingIntervalMs must be greater than 0. Using default'); - basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS; - } - - // todo: Inject the chain of dependencies below - const apiEndpoints = new ApiEndpoints({ - defaultUrl: PRECOMPUTED_BASE_URL, - baseUrl, - queryParams: { apiKey, sdkName, sdkVersion }, - sdkTokenDecoder: new SdkTokenDecoder(apiKey), - }); - const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); - const precomputedRequestor = new PrecomputedRequestor( - httpClient, - this.precomputedFlagStore, - subjectKey, - subjectAttributes, - this.precomputedBanditStore, - this.banditActions, - ); - - const pollingCallback = async () => { - if (await this.precomputedFlagStore.isExpired()) { - return precomputedRequestor.fetchAndStorePrecomputedFlags(); - } - }; - - this.requestPoller = initPoller(basePollingIntervalMs, pollingCallback, { - maxStartRetries: numInitialRequestRetries, - maxPollRetries: numPollRequestRetries, - pollAfterSuccessfulStart: pollAfterSuccessfulInitialization, - pollAfterFailedStart: pollAfterFailedInitialization, - errorOnFailedStart: throwOnFailedInitialization, - skipInitialPoll: skipInitialPoll, - }); - - await this.requestPoller.start(); - } - - public stopPolling() { - if (this.requestPoller) { - this.requestPoller.stop(); - } - } - - private getPrecomputedAssignment( - flagKey: string, - defaultValue: T, - expectedType: VariationType, - valueTransformer: (value: unknown) => T = (v) => v as T, - ): T { - validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - - const overrideVariation = this.overrideStore?.get(flagKey); - if (overrideVariation) { - return valueTransformer(overrideVariation.value); - } - - const precomputedFlag = this.getPrecomputedFlag(flagKey); - - if (precomputedFlag == null) { - logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); - return defaultValue; - } - - // Add type checking before proceeding - if (!checkTypeMatch(expectedType, precomputedFlag.variationType)) { - const errorMessage = `${loggerPrefix} Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${precomputedFlag.variationType}`; - logger.error(errorMessage); - return defaultValue; - } - - const result: FlagEvaluationWithoutDetails = { - flagKey, - format: this.precomputedFlagStore.getFormat() ?? '', - subjectKey: this.subject.subjectKey ?? '', - subjectAttributes: ensureNonContextualSubjectAttributes(this.subject.subjectAttributes ?? {}), - variation: { - key: precomputedFlag.variationKey ?? '', - value: precomputedFlag.variationValue, - }, - allocationKey: precomputedFlag.allocationKey ?? '', - extraLogging: precomputedFlag.extraLogging ?? {}, - doLog: precomputedFlag.doLog, - entityId: null, - }; - - try { - if (result?.doLog) { - this.logAssignment(result); - } - } catch (error) { - logger.error(`${loggerPrefix} Error logging assignment event: ${error}`); - } - - try { - return result.variation?.value !== undefined - ? valueTransformer(result.variation.value) - : defaultValue; - } catch (error) { - logger.error(`${loggerPrefix} Error transforming value: ${error}`); - return defaultValue; - } - } - - /** - * Maps a subject to a string variation for a given experiment. - * - * @param flagKey feature flag identifier - * @param defaultValue default value to return if the subject is not part of the experiment sample - * @returns a variation value if a flag was precomputed for the subject, otherwise the default value - * @public - */ - public getStringAssignment(flagKey: string, defaultValue: string): string { - return this.getPrecomputedAssignment(flagKey, defaultValue, VariationType.STRING); - } - - /** - * Maps a subject to a boolean variation for a given experiment. - * - * @param flagKey feature flag identifier - * @param defaultValue default value to return if the subject is not part of the experiment sample - * @returns a variation value if a flag was precomputed for the subject, otherwise the default value - * @public - */ - public getBooleanAssignment(flagKey: string, defaultValue: boolean): boolean { - return this.getPrecomputedAssignment(flagKey, defaultValue, VariationType.BOOLEAN); - } - - /** - * Maps a subject to an integer variation for a given experiment. - * - * @param flagKey feature flag identifier - * @param defaultValue default value to return if the subject is not part of the experiment sample - * @returns a variation value if a flag was precomputed for the subject, otherwise the default value - * @public - */ - public getIntegerAssignment(flagKey: string, defaultValue: number): number { - return this.getPrecomputedAssignment(flagKey, defaultValue, VariationType.INTEGER); - } - - /** - * Maps a subject to a numeric (floating point) variation for a given experiment. - * - * @param flagKey feature flag identifier - * @param defaultValue default value to return if the subject is not part of the experiment sample - * @returns a variation value if a flag was precomputed for the subject, otherwise the default value - * @public - */ - public getNumericAssignment(flagKey: string, defaultValue: number): number { - return this.getPrecomputedAssignment(flagKey, defaultValue, VariationType.NUMERIC); - } - - /** - * Maps a subject to a JSON object variation for a given experiment. - * - * @param flagKey feature flag identifier - * @param defaultValue default value to return if the subject is not part of the experiment sample - * @returns a parsed JSON object if a flag was precomputed for the subject, otherwise the default value - * @public - */ - public getJSONAssignment(flagKey: string, defaultValue: object): object { - return this.getPrecomputedAssignment(flagKey, defaultValue, VariationType.JSON, (value) => - typeof value === 'string' ? JSON.parse(value) : defaultValue, - ); - } - - public getBanditAction( - flagKey: string, - defaultValue: string, - ): Omit, 'evaluationDetails'> { - const precomputedFlag = this.getPrecomputedFlag(flagKey); - if (!precomputedFlag) { - logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); - return { variation: defaultValue, action: null }; - } - const banditEvaluation = this.getPrecomputedBandit(flagKey); - const assignedVariation = this.getStringAssignment(flagKey, defaultValue); - if (banditEvaluation) { - const banditEvent: IBanditEvent = { - timestamp: new Date().toISOString(), - featureFlag: flagKey, - bandit: banditEvaluation.banditKey, - subject: this.subject.subjectKey ?? '', - action: banditEvaluation.action, - actionProbability: banditEvaluation.actionProbability, - optimalityGap: banditEvaluation.optimalityGap, - modelVersion: banditEvaluation.modelVersion, - subjectNumericAttributes: banditEvaluation.actionNumericAttributes, - subjectCategoricalAttributes: banditEvaluation.actionCategoricalAttributes, - actionNumericAttributes: banditEvaluation.actionNumericAttributes, - actionCategoricalAttributes: banditEvaluation.actionCategoricalAttributes, - metaData: this.buildLoggerMetadata(), - evaluationDetails: null, - }; - try { - this.logBanditAction(banditEvent); - } catch (error) { - logger.error(`${loggerPrefix} Error logging bandit action: ${error}`); - } - return { variation: assignedVariation, action: banditEvent.action }; - } - return { variation: assignedVariation, action: null }; - } - - private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null { - const salt = this.precomputedFlagStore.salt; - const saltedAndHashedFlagKey = getMD5Hash(flagKey, salt); - const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get( - saltedAndHashedFlagKey, - ) as PrecomputedFlag; - return precomputedFlag ? decodePrecomputedFlag(precomputedFlag) : null; - } - - private getPrecomputedBandit(banditKey: string): IPrecomputedBandit | null { - const obfuscatedBandit = this.getObfuscatedPrecomputedBandit(banditKey); - - return obfuscatedBandit ? decodePrecomputedBandit(obfuscatedBandit) : null; - } - - private getObfuscatedPrecomputedBandit(banditKey: string): IObfuscatedPrecomputedBandit | null { - const salt = this.precomputedBanditStore?.salt; - const saltedAndHashedBanditKey = getMD5Hash(banditKey, salt); - const precomputedBandit: IObfuscatedPrecomputedBandit | null = this.precomputedBanditStore?.get( - saltedAndHashedBanditKey, - ) as IObfuscatedPrecomputedBandit; - return precomputedBandit ?? null; - } - - public isInitialized() { - return this.precomputedFlagStore.isInitialized(); - } - - public setAssignmentLogger(logger: IAssignmentLogger) { - this.assignmentLogger = logger; - // log any assignment events that may have been queued while initializing - this.flushQueuedEvents(this.queuedAssignmentEvents, this.assignmentLogger?.logAssignment); - } - - public setBanditLogger(logger: IBanditLogger) { - this.banditLogger = logger; - // log any bandit events that may have been queued while initializing - this.flushQueuedEvents(this.banditEventsQueue, this.banditLogger?.logBanditAction); - } - - /** - * Assignment cache methods. - */ - public disableAssignmentCache() { - this.assignmentCache = undefined; - } - - public useNonExpiringInMemoryAssignmentCache() { - this.assignmentCache = new NonExpiringInMemoryAssignmentCache(); - } - - public useLRUInMemoryAssignmentCache(maxSize: number) { - this.assignmentCache = new LRUInMemoryAssignmentCache(maxSize); - } - - public useCustomAssignmentCache(cache: AssignmentCache) { - this.assignmentCache = cache; - } - - private flushQueuedEvents(eventQueue: T[], logFunction?: (event: T) => void) { - const eventsToFlush = [...eventQueue]; // defensive copy - eventQueue.length = 0; // Truncate the array - - if (!logFunction) { - return; - } - - eventsToFlush.forEach((event) => { - try { - logFunction(event); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - logger.error(`${loggerPrefix} Error flushing event to logger: ${error.message}`); - } - }); - } - - private logAssignment(result: FlagEvaluationWithoutDetails) { - const { flagKey, subjectKey, allocationKey, subjectAttributes, variation, format } = result; - const event: IAssignmentEvent = { - ...(result.extraLogging ?? {}), - allocation: allocationKey ?? null, - experiment: allocationKey ? `${flagKey}-${allocationKey}` : null, - featureFlag: flagKey, - format, - variation: variation?.key ?? null, - subject: subjectKey, - timestamp: new Date().toISOString(), - subjectAttributes, - metaData: this.buildLoggerMetadata(), - evaluationDetails: null, - }; - - if (variation && allocationKey) { - const hasLoggedAssignment = this.assignmentCache?.has({ - flagKey, - subjectKey, - allocationKey, - variationKey: variation.key, - }); - if (hasLoggedAssignment) { - return; - } - } - - try { - if (this.assignmentLogger) { - this.assignmentLogger.logAssignment(event); - } else if (this.queuedAssignmentEvents.length < MAX_EVENT_QUEUE_SIZE) { - // assignment logger may be null while waiting for initialization, queue up events (up to a max) - // to be flushed when set - this.queuedAssignmentEvents.push(event); - } - this.assignmentCache?.set({ - flagKey, - subjectKey, - allocationKey: allocationKey ?? '__eppo_no_allocation', - variationKey: variation?.key ?? '__eppo_no_variation', - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - logger.error(`${loggerPrefix} Error logging assignment event: ${error.message}`); - } - } - - private logBanditAction(banditEvent: IBanditEvent): void { - // First we check if this bandit action has been logged before - const subjectKey = banditEvent.subject; - const flagKey = banditEvent.featureFlag; - const banditKey = banditEvent.bandit; - const actionKey = banditEvent.action ?? '__eppo_no_action'; - - const banditAssignmentCacheProperties = { - flagKey, - subjectKey, - banditKey, - actionKey, - }; - - if (this.banditAssignmentCache?.has(banditAssignmentCacheProperties)) { - // Ignore repeat assignment - return; - } - - // If here, we have a logger and a new assignment to be logged - try { - if (this.banditLogger) { - this.banditLogger.logBanditAction(banditEvent); - } else { - // If no logger defined, queue up the events (up to a max) to flush if a logger is later defined - this.banditEventsQueue.push(banditEvent); - } - // Record in the assignment cache, if active, to deduplicate subsequent repeat assignments - this.banditAssignmentCache?.set(banditAssignmentCacheProperties); - } catch (err) { - logger.warn('Error encountered logging bandit action', err); - } - } - - private buildLoggerMetadata(): Record { - return { - obfuscated: true, - sdkLanguage: 'javascript', - sdkLibVersion: LIB_VERSION, - }; - } - - public setOverrideStore(store: ISyncStore): void { - this.overrideStore = store; - } - - public unsetOverrideStore(): void { - this.overrideStore = undefined; - } -} diff --git a/src/configuration.ts b/src/configuration.ts index 78c96f3..6a5c4c5 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -102,8 +102,53 @@ export class Configuration { return new Configuration({ flags, bandits, precomputed }); } - // TODO: - // public static fromString(configurationWire: string): Configuration {} + /** + * Initializes a Configuration from a "configuration wire" format (this is the format returned by + * `toString`). + * + * @public + */ + public static fromString(configurationWire: string): Configuration { + // TODO: we're assuming that `configurationWire` is properly formatted. + const wire: ConfigurationWire = JSON.parse(configurationWire); + + let flags: FlagsConfig | undefined; + let bandits: BanditsConfig | undefined; + let precomputed: PrecomputedConfig | undefined; + + if (wire.config) { + flags = { + response: JSON.parse(wire.config.response), + etag: wire.config.etag, + fetchedAt: wire.config.fetchedAt, + }; + } + + if (wire.bandits) { + bandits = { + response: JSON.parse(wire.bandits.response), + etag: wire.bandits.etag, + fetchedAt: wire.bandits.fetchedAt, + }; + } + + if (wire.precomputed) { + precomputed = { + response: JSON.parse(wire.precomputed.response), + etag: wire.precomputed.etag, + fetchedAt: wire.precomputed.fetchedAt, + subjectKey: wire.precomputed.subjectKey, + subjectAttributes: wire.precomputed.subjectAttributes, + banditActions: wire.precomputed.banditActions, + }; + } + + return new Configuration({ + flags, + bandits, + precomputed, + }); + } /** Serializes configuration to "configuration wire" format. */ public toString(): string { diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index d5e361c..06a2ade 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -51,10 +51,10 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); - expect(result.flagKey).toEqual('disabled_flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); - expect(result.doLog).toBeFalsy(); + expect(result.assignmentDetails.flagKey).toEqual('disabled_flag'); + expect(result.assignmentDetails.allocationKey).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); + expect(result.assignmentDetails.doLog).toBeFalsy(); }); it('should match shard with full range', () => { @@ -77,7 +77,7 @@ describe('Evaluator', () => { expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); - const deterministicEvaluator = new Evaluator(new DeterministicSharder({ subject_key: 50 })); + const deterministicEvaluator = new Evaluator({ sharder: new DeterministicSharder({ subject_key: 50 }) }); expect(deterministicEvaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); }); @@ -87,7 +87,7 @@ describe('Evaluator', () => { ranges: [{ start: 0, end: 50 }], }; - const evaluator = new Evaluator(new DeterministicSharder({ 'a-subject_key': 99 })); + const evaluator = new Evaluator({ sharder: new DeterministicSharder({ 'a-subject_key': 99 }) }); expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeFalsy(); }); @@ -102,10 +102,10 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, emptyFlag, 'subject_key', {}); - expect(result.flagKey).toEqual('empty'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); - expect(result.doLog).toBeFalsy(); + expect(result.assignmentDetails.flagKey).toEqual('empty'); + expect(result.assignmentDetails.allocationKey).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); + expect(result.assignmentDetails.doLog).toBeFalsy(); }); it('should evaluate simple flag and return control variation', () => { @@ -132,7 +132,7 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'user-1', {}); - expect(result.variation).toEqual({ key: 'control', value: 'control-value' }); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control-value' }); }); it('should evaluate flag based on a targeting condition based on id', () => { @@ -165,13 +165,13 @@ describe('Evaluator', () => { }; let result = evaluator.evaluateFlag(configuration, flag, 'alice', {}); - expect(result.variation).toEqual({ key: 'control', value: 'control' }); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control' }); result = evaluator.evaluateFlag(configuration, flag, 'bob', {}); - expect(result.variation).toEqual({ key: 'control', value: 'control' }); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control' }); result = evaluator.evaluateFlag(configuration, flag, 'charlie', {}); - expect(result.variation).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should evaluate flag based on a targeting condition with overwritten id', () => { @@ -204,7 +204,7 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'alice', { id: 'charlie' }); - expect(result.variation).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should catch all allocation and return variation A', () => { @@ -231,10 +231,10 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); - expect(result.doLog).toBeTruthy(); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toEqual('default'); + expect(result.assignmentDetails.variation).toEqual(VARIATION_A); + expect(result.assignmentDetails.doLog).toBeTruthy(); }); it('should match first allocation rule and return variation B', () => { @@ -279,9 +279,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@example.com' }); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('first'); - expect(result.variation).toEqual(VARIATION_B); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toEqual('first'); + expect(result.assignmentDetails.variation).toEqual(VARIATION_B); }); it('should not match first allocation rule and return variation A', () => { @@ -326,9 +326,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toEqual('default'); + expect(result.assignmentDetails.variation).toEqual(VARIATION_A); }); it('should not match first allocation rule and return variation A (obfuscated)', () => { @@ -377,9 +377,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); - expect(result.flagKey).toEqual('obfuscated_flag_key'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + expect(result.assignmentDetails.flagKey).toEqual('obfuscated_flag_key'); + expect(result.assignmentDetails.allocationKey).toEqual('default'); + expect(result.assignmentDetails.variation).toEqual(VARIATION_A); }); it('should evaluate sharding and return correct variations', () => { @@ -426,8 +426,8 @@ describe('Evaluator', () => { totalShards: 10, }; - const deterministicEvaluator = new Evaluator( - new DeterministicSharder({ + const deterministicEvaluator = new Evaluator({ + sharder: new DeterministicSharder({ 'traffic-alice': 2, 'traffic-bob': 3, 'traffic-charlie': 4, @@ -437,19 +437,19 @@ describe('Evaluator', () => { 'split-charlie': 8, 'split-dave': 1, }), - ); + }); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).assignmentDetails.variation, ).toEqual(VARIATION_A); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).assignmentDetails.variation, ).toEqual(VARIATION_B); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).assignmentDetails.variation, ).toEqual(VARIATION_C); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).assignmentDetails.variation, ).toEqual(VARIATION_C); }); @@ -480,9 +480,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should return correct variation for evaluation during allocation', () => { @@ -512,9 +512,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toEqual('default'); + expect(result.assignmentDetails.variation).toEqual(VARIATION_A); }); it('should not match on allocation after endAt has passed', () => { @@ -544,9 +544,9 @@ describe('Evaluator', () => { }; const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); + expect(result.assignmentDetails.flagKey).toEqual('flag'); + expect(result.assignmentDetails.allocationKey).toBeNull(); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should create a hash key that appends subject to salt', () => { diff --git a/src/evaluator.ts b/src/evaluator.ts index e65799a..0674dd2 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -20,8 +20,11 @@ import { import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; import { Attributes } from './types'; +import { IAssignmentEvent } from './assignment-logger'; +import { IBanditEvent } from './bandit-logger'; +import { LIB_VERSION } from './version'; -export interface FlagEvaluationWithoutDetails { +export interface AssignmentResult { flagKey: string; format: string; subjectKey: string; @@ -29,20 +32,26 @@ export interface FlagEvaluationWithoutDetails { allocationKey: string | null; variation: Variation | null; extraLogging: Record; - // whether to log assignment event doLog: boolean; entityId: number | null; + evaluationDetails: IFlagEvaluationDetails; } -export interface FlagEvaluation extends FlagEvaluationWithoutDetails { - flagEvaluationDetails: IFlagEvaluationDetails; +export interface FlagEvaluation { + assignmentDetails: AssignmentResult; + assignmentEvent?: IAssignmentEvent; + banditEvent?: IBanditEvent; } export class Evaluator { private readonly sharder: Sharder; + private readonly sdkName: string; + private readonly sdkVersion: string; - constructor(sharder?: Sharder) { - this.sharder = sharder ?? new MD5Sharder(); + constructor(options?: { sharder?: Sharder; sdkName?: string; sdkVersion?: string }) { + this.sharder = options?.sharder ?? new MD5Sharder(); + this.sdkName = options?.sdkName ?? ''; + this.sdkVersion = options?.sdkVersion ?? ''; } evaluateFlag( @@ -116,7 +125,8 @@ export class Evaluator { const flagEvaluationDetails = flagEvaluationDetailsBuilder .setMatch(i, variation, allocation, matchedRule, expectedVariationType) .build(flagEvaluationCode, flagEvaluationDescription); - return { + + const assignmentDetails: AssignmentResult = { flagKey: flag.key, format: configFormat ?? '', subjectKey, @@ -125,9 +135,37 @@ export class Evaluator { variation, extraLogging: split.extraLogging ?? {}, doLog: allocation.doLog, - flagEvaluationDetails, entityId: flag.entityId ?? null, + evaluationDetails: flagEvaluationDetails, }; + + const result: FlagEvaluation = { assignmentDetails }; + + // Create assignment event if doLog is true + if (allocation.doLog) { + result.assignmentEvent = { + ...split.extraLogging, + allocation: allocation.key, + experiment: `${flag.key}-${allocation.key}`, + featureFlag: flag.key, + format: configFormat ?? '', + variation: variation?.key ?? null, + subject: subjectKey, + timestamp: new Date().toISOString(), + subjectAttributes, + metaData: { + obfuscated: configFormat === FormatEnum.CLIENT, + sdkLanguage: 'javascript', + sdkLibVersion: LIB_VERSION, + sdkName: this.sdkName, + sdkVersion: this.sdkVersion, + }, + evaluationDetails: flagEvaluationDetails, + entityId: flag.entityId ?? null, + }; + } + + return result; } } // matched, but does not fall within split range @@ -223,16 +261,18 @@ export function noneResult( format: string, ): FlagEvaluation { return { - flagKey, - format, - subjectKey, - subjectAttributes, - allocationKey: null, - variation: null, - extraLogging: {}, - doLog: false, - flagEvaluationDetails, - entityId: null, + assignmentDetails: { + flagKey, + format, + subjectKey, + subjectAttributes, + allocationKey: null, + variation: null, + extraLogging: {}, + doLog: false, + entityId: null, + evaluationDetails: flagEvaluationDetails, + }, }; } @@ -285,15 +325,17 @@ export function overrideResult( .build('MATCH', 'Flag override applied'); return { - flagKey, - subjectKey, - variation: overrideVariation, - subjectAttributes, - flagEvaluationDetails, - doLog: false, - format: '', - allocationKey: overrideAllocationKey, - extraLogging: {}, - entityId: null, + assignmentDetails: { + flagKey, + subjectKey, + variation: overrideVariation, + subjectAttributes, + doLog: false, + format: '', + allocationKey: overrideAllocationKey, + extraLogging: {}, + entityId: null, + evaluationDetails: flagEvaluationDetails, + }, }; } diff --git a/src/index.ts b/src/index.ts index 8c056de..0833447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,6 @@ import EppoClient, { IAssignmentDetails, IContainerExperiment, } from './client/eppo-client'; -import EppoPrecomputedClient, { Subject } from './client/eppo-precomputed-client'; import FlagConfigRequestor from './configuration-requestor'; import { IConfigurationStore, @@ -93,8 +92,6 @@ export { validation, // Precomputed Client - EppoPrecomputedClient, - PrecomputedFlagsRequestParameters, IObfuscatedPrecomputedConfigurationResponse, IObfuscatedPrecomputedBandit, @@ -118,7 +115,6 @@ export { assignmentCacheValueToString, // Interfaces - FlagConfigurationRequestParameters, Flag, ObfuscatedFlag, Variation, @@ -130,7 +126,6 @@ export { BanditActions, BanditVariation, BanditParameters, - Subject, Environment, FormatEnum, From 9ee48349c8a4f2ca3ac3ea9f99c7999feb410f80 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 19:03:12 +0300 Subject: [PATCH 17/25] feat: add Subject (subject-scoped client) --- src/client/eppo-client.ts | 58 ++++++++ src/client/subject.ts | 285 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 src/client/subject.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 5fead1b..23476a7 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -335,6 +335,11 @@ export default class EppoClient { private readonly configurationPoller: ConfigurationPoller; private initialized = false; private readonly initializationPromise: Promise; + private readonly precomputedConfig?: { + subjectKey: string; + subjectAttributes: ContextAttributes; + banditActions?: Record; + }; constructor(options: EppoClientParameters) { const { eventDispatcher = new NoOpEventDispatcher(), overrideStore, configuration } = options; @@ -360,6 +365,17 @@ export default class EppoClient { } = {}, } = options; + // Store precomputed config options for later use in getPrecomputedSubject(). + if (options.configuration?.precompute) { + this.precomputedConfig = { + subjectKey: options.configuration.precompute.subjectKey, + subjectAttributes: ensureContextualSubjectAttributes( + options.configuration.precompute.subjectAttributes, + ), + banditActions: options.configuration.precompute.banditActions, + }; + } + this.configurationFeed = new BroadcastChannel<[Configuration, ConfigurationSource]>(); this.configurationStore = new ConfigurationStore(configuration?.initialConfiguration); @@ -558,6 +574,48 @@ export default class EppoClient { return this.configurationStore.onConfigurationChange(listener); } + /** + * Creates a Subject-scoped instance. + * + * This is useful if you need to evaluate multiple assignments for the same subject. Returned + * Subject is connected to the EppoClient instance and will use the same configuration. + */ + public getSubject( + subjectKey: string, + subjectAttributes: Attributes | ContextAttributes = {}, + banditActions: Record = {}, + ): Subject { + return new Subject(this, subjectKey, subjectAttributes, banditActions); + } + + /** + * If the client is configured to precompute, returns a Subject-scoped instance for the + * precomputed configuration. + */ + public getPrecomputedSubject(): Subject | undefined { + const configuration = this.getConfiguration(); + const precomputed = configuration.getPrecomputedConfiguration(); + + if (precomputed) { + return this.getSubject( + precomputed.subjectKey, + precomputed.subjectAttributes ?? {}, + precomputed.banditActions, + ); + } + + // Use the stored precomputed config if available and configuration hasn't been loaded yet + if (this.precomputedConfig) { + return this.getSubject( + this.precomputedConfig.subjectKey, + this.precomputedConfig.subjectAttributes, + this.precomputedConfig.banditActions, + ); + } + + return undefined; + } + /** * Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension */ diff --git a/src/client/subject.ts b/src/client/subject.ts new file mode 100644 index 0000000..37b202c --- /dev/null +++ b/src/client/subject.ts @@ -0,0 +1,285 @@ +import EppoClient, { IAssignmentDetails, IContainerExperiment } from './eppo-client'; +import { Attributes, BanditActions, ContextAttributes, FlagKey } from '../types'; +import { ensureNonContextualSubjectAttributes } from '../attributes'; +import { Configuration } from '../configuration'; + +/** + * A wrapper around EppoClient that automatically supplies subject key, attributes, and bandit + * actions for all assignment and bandit methods. + * + * This is useful when you always want to use the same subject and attributes for all flag + * evaluations. + */ +export class Subject { + private client: EppoClient; + private subjectKey: string; + private subjectAttributes: Attributes | ContextAttributes; + private banditActions?: Record; + + /** + * @internal Creates a new Subject instance. + * + * @param client The EppoClient instance to wrap + * @param subjectKey The subject key to use for all assignments + * @param subjectAttributes The subject attributes to use for all assignments + * @param banditActions Optional default bandit actions to use for all bandit evaluations + */ + constructor( + client: EppoClient, + subjectKey: string, + subjectAttributes: Attributes | ContextAttributes, + banditActions: Record + ) { + this.client = client; + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.banditActions = banditActions; + } + + /** + * Gets the underlying EppoClient instance. + */ + public getClient(): EppoClient { + return this.client; + } + + /** + * Maps a subject to a string variation for a given experiment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns a variation value if the subject is part of the experiment sample, otherwise the default value + */ + public getStringAssignment(flagKey: string, defaultValue: string): string { + return this.client.getStringAssignment( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a string variation for a given experiment and provides additional details about the + * variation assigned and the reason for the assignment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an object that includes the variation value along with additional metadata about the assignment + */ + public getStringAssignmentDetails(flagKey: string, defaultValue: string): IAssignmentDetails { + return this.client.getStringAssignmentDetails( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a boolean variation for a given experiment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns a boolean variation value if the subject is part of the experiment sample, otherwise the default value + */ + public getBooleanAssignment(flagKey: string, defaultValue: boolean): boolean { + return this.client.getBooleanAssignment( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a boolean variation for a given experiment and provides additional details about the + * variation assigned and the reason for the assignment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an object that includes the variation value along with additional metadata about the assignment + */ + public getBooleanAssignmentDetails(flagKey: string, defaultValue: boolean): IAssignmentDetails { + return this.client.getBooleanAssignmentDetails( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to an Integer variation for a given experiment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an integer variation value if the subject is part of the experiment sample, otherwise the default value + */ + public getIntegerAssignment(flagKey: string, defaultValue: number): number { + return this.client.getIntegerAssignment( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to an Integer variation for a given experiment and provides additional details about the + * variation assigned and the reason for the assignment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an object that includes the variation value along with additional metadata about the assignment + */ + public getIntegerAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails { + return this.client.getIntegerAssignmentDetails( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a numeric variation for a given experiment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns a number variation value if the subject is part of the experiment sample, otherwise the default value + */ + public getNumericAssignment(flagKey: string, defaultValue: number): number { + return this.client.getNumericAssignment( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a numeric variation for a given experiment and provides additional details about the + * variation assigned and the reason for the assignment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an object that includes the variation value along with additional metadata about the assignment + */ + public getNumericAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails { + return this.client.getNumericAssignmentDetails( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a JSON variation for a given experiment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns a JSON object variation value if the subject is part of the experiment sample, otherwise the default value + */ + public getJSONAssignment(flagKey: string, defaultValue: object): object { + return this.client.getJSONAssignment( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + /** + * Maps a subject to a JSON variation for a given experiment and provides additional details about the + * variation assigned and the reason for the assignment. + * + * @param flagKey feature flag identifier + * @param defaultValue default value to return if the subject is not part of the experiment sample + * @returns an object that includes the variation value along with additional metadata about the assignment + */ + public getJSONAssignmentDetails(flagKey: string, defaultValue: object): IAssignmentDetails { + return this.client.getJSONAssignmentDetails( + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue + ); + } + + public getBanditAction( + flagKey: string, + defaultValue: string, + ): Omit, 'evaluationDetails'> { + return this.client.getBanditAction(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue); + } + + + public getBanditActionDetails( + flagKey: string, + defaultValue: string, + ): IAssignmentDetails { + return this.client.getBanditActionDetails(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue); + } + + /** + * Evaluates the supplied actions using the first bandit associated with `flagKey` and returns the best ranked action. + * + * This method should be considered **preview** and is subject to change as requirements mature. + * + * NOTE: This method does not do any logging or assignment computation and so calling this method will have + * NO IMPACT on bandit and experiment training. + * + * Only use this method under certain circumstances (i.e. where the impact of the choice of bandit cannot be measured, + * but you want to put the "best foot forward", for example, when being web-crawled). + */ + public getBestAction( + flagKey: string, + defaultAction: string, + ): string { + return this.client.getBestAction(flagKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultAction); + } + + /** + * For use with 3rd party CMS tooling, such as the Contentful Eppo plugin. + * + * CMS plugins that integrate with Eppo will follow a common format for + * creating a feature flag. The flag created by the CMS plugin will have + * variations with values 'control', 'treatment-1', 'treatment-2', etc. + * This function allows users to easily return the CMS container entry + * for the assigned variation. + * + * @param flagExperiment the flag key, control container entry and treatment container entries. + * @returns The container entry associated with the experiment. + */ + public getExperimentContainerEntry(flagExperiment: IContainerExperiment): T { + return this.client.getExperimentContainerEntry( + flagExperiment, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes) + ); + } + + /** + * Computes and returns assignments and bandits for the configured subject from all loaded flags. + * + * @returns A JSON string containing the precomputed configuration + */ + public getPrecomputedConfiguration(): Configuration { + return this.client.getPrecomputedConfiguration( + this.subjectKey, + this.subjectAttributes, + this.banditActions || {}, + ); + } + + /** + * Waits for the client to finish initialization sequence and be ready to serve assignments. + * + * @returns A promise that resolves when the client is initialized. + */ + public waitForInitialization(): Promise { + return this.client.waitForInitialization(); + } +} \ No newline at end of file From 3b79e6867c67effd54c64f60eb15b9c396dbe0ef Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 19:03:39 +0300 Subject: [PATCH 18/25] feat: make getPrecomputedConfiguration return Configuration instead of string --- src/client/eppo-client-with-bandits.spec.ts | 28 +- src/client/eppo-client.spec.ts | 26 +- src/client/eppo-client.ts | 116 ++++---- src/configuration-poller.ts | 4 +- src/constants.ts | 7 +- src/index.ts | 2 + src/poller.spec.ts | 279 -------------------- src/poller.ts | 164 ------------ src/salt.ts | 15 ++ 9 files changed, 109 insertions(+), 532 deletions(-) delete mode 100644 src/poller.spec.ts delete mode 100644 src/poller.ts create mode 100644 src/salt.ts diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index aeddb0f..f902ac2 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -26,6 +26,12 @@ import { attributeEncodeBase64 } from '../obfuscation'; import { Attributes, BanditActions, ContextAttributes } from '../types'; import EppoClient, { IAssignmentDetails } from './eppo-client'; +import { Configuration } from '../configuration'; + +const salt = base64.fromUint8Array(new Uint8Array([101, 112, 112, 111])); +jest.mock('../salt', () => ({ + generateSalt: () => salt, +})); describe('EppoClient Bandits E2E test', () => { let client: EppoClient; @@ -688,20 +694,12 @@ describe('EppoClient Bandits E2E test', () => { subjectKey: string, subjectAttributes: ContextAttributes, banditActions: Record, - ): IPrecomputedConfiguration { - const salt = base64.fromUint8Array(new Uint8Array([101, 112, 112, 111])); - const precomputedResults = client.getPrecomputedConfiguration( + ): Configuration { + return client.getPrecomputedConfiguration( subjectKey, subjectAttributes, banditActions, - salt, ); - - const { precomputed } = JSON.parse(precomputedResults); - if (!precomputed) { - fail('precomputed result was not parsed'); - } - return precomputed; } describe('obfuscated results', () => { @@ -713,11 +711,11 @@ describe('EppoClient Bandits E2E test', () => { const adidasB64 = 'YWRpZGFz'; const modelB64 = 'MTIz'; // 123 - const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions); - - const response = JSON.parse( - precomputed.response, - ) as IObfuscatedPrecomputedConfigurationResponse; + const configuration = client.getPrecomputedConfiguration(bob, bobInfo, bobActions); + const response = configuration.getPrecomputedConfiguration()?.response; + if (!response) { + fail('precomputed result was not parsed'); + } const numericAttrs = response.bandits[bannerBanditFlagMd5]['actionNumericAttributes']; const categoricalAttrs = diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 076c506..8a5d6a0 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -41,8 +41,12 @@ import { ISyncStore } from '../configuration-store/configuration-store'; // Use a known salt to produce deterministic hashes const salt = base64.fromUint8Array(new Uint8Array([7, 53, 17, 78])); +jest.mock('../salt', () => ({ + generateSalt: () => salt, +})); describe('EppoClient E2E test', () => { + // Configure fetch mock for tests that still need it global.fetch = jest.fn(() => { const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); @@ -254,14 +258,12 @@ describe('EppoClient E2E test', () => { }); it('skips disabled flags', () => { - const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}, salt); - const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire; + const configuration = client.getPrecomputedConfiguration('subject', {}, {}); + const precomputed = configuration.getPrecomputedConfiguration(); if (!precomputed) { fail('Precomputed data not in Configuration response'); } - const precomputedResponse = JSON.parse( - precomputed.response, - ) as ObfuscatedPrecomputedConfigurationResponse; + const precomputedResponse = precomputed.response; expect(precomputedResponse).toBeTruthy(); const precomputedFlags = precomputedResponse?.flags ?? {}; @@ -273,14 +275,12 @@ describe('EppoClient E2E test', () => { }); it('evaluates and returns assignments', () => { - const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}, salt); - const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire; + const configuration = client.getPrecomputedConfiguration('subject', {}, {}); + const precomputed = configuration.getPrecomputedConfiguration(); if (!precomputed) { fail('Precomputed data not in Configuration response'); } - const precomputedResponse = JSON.parse( - precomputed.response, - ) as IObfuscatedPrecomputedConfigurationResponse; + const precomputedResponse = precomputed.response; expect(precomputedResponse).toBeTruthy(); const precomputedFlags = precomputedResponse?.flags ?? {}; @@ -291,12 +291,12 @@ describe('EppoClient E2E test', () => { }); it('obfuscates assignments', () => { - const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}, salt); - const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire; + const configuration = client.getPrecomputedConfiguration('subject', {}, {}); + const precomputed = configuration.getPrecomputedConfiguration(); if (!precomputed) { fail('Precomputed data not in Configuration response'); } - const precomputedResponse = JSON.parse(precomputed.response); + const precomputedResponse = precomputed.response; expect(precomputedResponse).toBeTruthy(); expect(precomputedResponse.salt).toEqual('BzURTg=='); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 23476a7..617ead3 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -19,15 +19,8 @@ import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; import { ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { IObfuscatedPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; import { - ConfigurationWireV1, - IConfigurationWire, - IPrecomputedConfiguration, - PrecomputedConfiguration, -} from '../configuration-wire/configuration-wire-types'; -import { - DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_CONFIG_REQUEST_RETRIES, DEFAULT_BASE_POLLING_INTERVAL_MS, DEFAULT_MAX_POLLING_INTERVAL_MS, DEFAULT_REQUEST_TIMEOUT_MS, @@ -36,7 +29,7 @@ import { DEFAULT_MAX_STALE_SECONDS, DEFAULT_INITIALIZATION_STRATEGY, DEFAULT_ACTIVATION_STRATEGY, - DEFAULT_ENABLE_POLLING_CLIENT, + DEFAULT_ENABLE_POLLING, DEFAULT_ENABLE_BANDITS, } from '../constants'; import { EppoValue } from '../eppo_value'; @@ -66,7 +59,7 @@ import { VariationType, } from '../interfaces'; import { OverridePayload, OverrideValidator } from '../override-validator'; -import { randomJitterMs } from '../poller'; +import { randomJitterMs } from '../configuration-poller'; import SdkTokenDecoder from '../sdk-token-decoder'; import { Attributes, @@ -85,10 +78,16 @@ import { PersistentConfigurationStorage, } from '../persistent-configuration-cache'; import { ConfigurationPoller } from '../configuration-poller'; -import { ConfigurationFeed, ConfigurationSource } from '../configuration-feed'; +import { ConfigurationSource } from '../configuration-feed'; import { BroadcastChannel } from '../broadcast'; -import { getMD5Hash } from '../obfuscation'; +import { + getMD5Hash, + obfuscatePrecomputedBanditMap, + obfuscatePrecomputedFlags, +} from '../obfuscation'; import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; +import { Subject } from './subject'; +import { generateSalt } from '../salt'; export interface IAssignmentDetails { variation: T; @@ -218,8 +217,7 @@ export type EppoClientParameters = { * determines whether configuration is activated (i.e., becomes * used for evaluating assignments and bandits). * - * @default true (for Node.js SDK) - * @default false (for Client SDK) + * @default false */ enablePolling?: boolean; /** @@ -254,8 +252,7 @@ export type EppoClientParameters = { * * - `always`: always activate the latest fetched configuration. * - * @default 'always' (for Node.js SDK) - * @default 'stale' (for Client SDK) + * @default 'next-load' */ activationStrategy?: 'always' | 'stale' | 'empty' | 'next-load'; @@ -268,7 +265,6 @@ export type EppoClientParameters = { }; }; -// Define a type mapping from VariationType to TypeScript types type VariationTypeMap = { [VariationType.STRING]: string; [VariationType.INTEGER]: number; @@ -277,7 +273,6 @@ type VariationTypeMap = { [VariationType.JSON]: object; }; -// Helper type to extract the TypeScript type from a VariationType type TypeFromVariationType = VariationTypeMap[T]; /** @@ -341,6 +336,11 @@ export default class EppoClient { banditActions?: Record; }; + /** + * @internal This method is intended to be used by downstream SDKs. The constructor requires + * `sdkName` and `sdkVersion` parameters, and default options may differ from defaults for + * client/node SDKs. + */ constructor(options: EppoClientParameters) { const { eventDispatcher = new NoOpEventDispatcher(), overrideStore, configuration } = options; @@ -359,7 +359,7 @@ export default class EppoClient { requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, basePollingIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS, maxPollingIntervalMs = DEFAULT_MAX_POLLING_INTERVAL_MS, - enablePolling = DEFAULT_ENABLE_POLLING_CLIENT, + enablePolling = DEFAULT_ENABLE_POLLING, maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, activationStrategy = DEFAULT_ACTIVATION_STRATEGY, } = {}, @@ -1329,39 +1329,46 @@ export default class EppoClient { * @param subjectKey an identifier of the experiment subject, for example a user ID. * @param subjectAttributes optional attributes associated with the subject, for example name and email. * @param banditActions optional attributes associated with the bandit actions - * @param salt a salt to use for obfuscation */ - getPrecomputedConfiguration( + public getPrecomputedConfiguration( subjectKey: string, subjectAttributes: Attributes | ContextAttributes = {}, banditActions: Record = {}, - salt?: string, - ): string { - const config = this.getConfiguration(); + ): Configuration { + const configuration = this.getConfiguration(); const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); - const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); + const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); const bandits = this.computeBanditsForFlags( - config, + configuration, subjectKey, subjectContextualAttributes, banditActions, flags, ); - const precomputedConfig: IPrecomputedConfiguration = PrecomputedConfiguration.obfuscated( - subjectKey, - flags, - bandits, - salt ?? '', // no salt if not provided - subjectContextualAttributes, - config.getFlagsConfiguration()?.response.environment, - ); + const salt = generateSalt(); + const obfuscatedFlags = obfuscatePrecomputedFlags(salt, flags); + const obfuscatedBandits = obfuscatePrecomputedBanditMap(salt, bandits); + + const response: IObfuscatedPrecomputedConfigurationResponse = { + format: FormatEnum.PRECOMPUTED, + createdAt: new Date().toISOString(), + obfuscated: true, + salt, + flags: obfuscatedFlags, + bandits: obfuscatedBandits, + }; - const configWire: IConfigurationWire = ConfigurationWireV1.precomputed(precomputedConfig); - return JSON.stringify(configWire); + return Configuration.fromResponses({ + precomputed: { + response, + subjectKey, + subjectAttributes: subjectContextualAttributes, + }, + }); } /** @@ -1376,11 +1383,11 @@ export default class EppoClient { * @param expectedVariationType The expected variation type * @returns A detailed return of assignment for a particular subject and flag */ - getAssignmentDetail( + public getAssignmentDetail( flagKey: string, subjectKey: string, subjectAttributes: Attributes = {}, - expectedVariationType?: T, + expectedVariationType?: VariationType, ): FlagEvaluation { const result = this.evaluateAssignment( flagKey, @@ -1396,11 +1403,11 @@ export default class EppoClient { * Internal helper that evaluates a flag assignment without logging * Returns the evaluation result that can be used for logging */ - private evaluateAssignment( + private evaluateAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, - expectedVariationType: T | undefined, + expectedVariationType: VariationType | undefined, ): FlagEvaluation { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); @@ -1641,8 +1648,6 @@ export default class EppoClient { }; } - // Create bandit event if present - let banditEvent: IBanditEvent | undefined; if (bandit) { flagEvaluation.banditEvent = { timestamp: new Date().toISOString(), @@ -1673,7 +1678,7 @@ export default class EppoClient { /** * Enqueues an arbitrary event. Events must have a type and a payload. */ - track(type: string, payload: Record) { + public track(type: string, payload: Record) { this.eventDispatcher.dispatch({ uuid: randomUUID(), type, @@ -1696,17 +1701,17 @@ export default class EppoClient { ); } - isInitialized() { + public isInitialized() { return this.initialized; } - setAssignmentLogger(logger: IAssignmentLogger) { + public setAssignmentLogger(logger: IAssignmentLogger) { this.assignmentLogger = logger; // log any assignment events that may have been queued while initializing this.flushQueuedEvents(this.assignmentEventsQueue, this.assignmentLogger?.logAssignment); } - setBanditLogger(logger: IBanditLogger) { + public setBanditLogger(logger: IBanditLogger) { this.banditLogger = logger; // log any bandit events that may have been queued while initializing this.flushQueuedEvents(this.banditEventsQueue, this.banditLogger?.logBanditAction); @@ -1715,28 +1720,28 @@ export default class EppoClient { /** * Assignment cache methods. */ - disableAssignmentCache() { + public disableAssignmentCache() { this.assignmentCache = undefined; } - useNonExpiringInMemoryAssignmentCache() { + public useNonExpiringInMemoryAssignmentCache() { this.assignmentCache = new NonExpiringInMemoryAssignmentCache(); } - useLRUInMemoryAssignmentCache(maxSize: number) { + public useLRUInMemoryAssignmentCache(maxSize: number) { this.assignmentCache = new LRUInMemoryAssignmentCache(maxSize); } // noinspection JSUnusedGlobalSymbols - useCustomAssignmentCache(cache: AssignmentCache) { + public useCustomAssignmentCache(cache: AssignmentCache) { this.assignmentCache = cache; } - disableBanditAssignmentCache() { + public disableBanditAssignmentCache() { this.banditAssignmentCache = undefined; } - useNonExpiringInMemoryBanditAssignmentCache() { + public useNonExpiringInMemoryBanditAssignmentCache() { this.banditAssignmentCache = new NonExpiringInMemoryAssignmentCache(); } @@ -1744,16 +1749,16 @@ export default class EppoClient { * @param {number} maxSize - Maximum cache size * @param {number} timeout - TTL of cache entries */ - useExpiringInMemoryBanditAssignmentCache(maxSize: number, timeout?: number) { + public useExpiringInMemoryBanditAssignmentCache(maxSize: number, timeout?: number) { this.banditAssignmentCache = new TLRUInMemoryAssignmentCache(maxSize, timeout); } // noinspection JSUnusedGlobalSymbols - useCustomBanditAssignmentCache(cache: AssignmentCache) { + public useCustomBanditAssignmentCache(cache: AssignmentCache) { this.banditAssignmentCache = cache; } - setIsGracefulFailureMode(gracefulFailureMode: boolean) { + public setIsGracefulFailureMode(gracefulFailureMode: boolean) { this.isGracefulFailureMode = gracefulFailureMode; } @@ -1886,6 +1891,7 @@ export default class EppoClient { } } +/** @internal */ export function checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { return expectedType === undefined || actualType === expectedType; } diff --git a/src/configuration-poller.ts b/src/configuration-poller.ts index 6cf2b86..7157496 100644 --- a/src/configuration-poller.ts +++ b/src/configuration-poller.ts @@ -128,10 +128,12 @@ function timeout(ms: number) { } /** + * @internal + * * Compute a random jitter as a percentage of the polling interval. * Will be (5%,10%) of the interval assuming POLL_JITTER_PCT = 0.1 */ -function randomJitterMs(intervalMs: number) { +export function randomJitterMs(intervalMs: number) { const halfPossibleJitter = (intervalMs * POLL_JITTER_PCT) / 2; // We want the randomly chosen jitter to be at least 1ms so total jitter is slightly more than // half the max possible. diff --git a/src/constants.ts b/src/constants.ts index a025a08..07297b4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,15 +4,12 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000; export const DEFAULT_BASE_POLLING_INTERVAL_MS = 30_000; export const DEFAULT_MAX_POLLING_INTERVAL_MS = 300_000; export const POLL_JITTER_PCT = 0.1; -export const DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES = 1; -export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; export const DEFAULT_INITIALIZATION_TIMEOUT_MS = 5_000; export const DEFAULT_MAX_AGE_SECONDS = 30; export const DEFAULT_MAX_STALE_SECONDS = Infinity; export const DEFAULT_INITIALIZATION_STRATEGY = 'stale-while-revalidate'; -export const DEFAULT_ACTIVATION_STRATEGY = 'stale'; -export const DEFAULT_ENABLE_POLLING_CLIENT = false; -export const DEFAULT_ENABLE_POLLING_NODE = true; +export const DEFAULT_ACTIVATION_STRATEGY = 'next-load'; +export const DEFAULT_ENABLE_POLLING = false; export const DEFAULT_ENABLE_BANDITS = true; export const BASE_URL = 'https://fscdn.eppo.cloud/api'; export const UFC_ENDPOINT = '/flag-config/v1/config'; diff --git a/src/index.ts b/src/index.ts index 0833447..fbfa117 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import EppoClient, { IAssignmentDetails, IContainerExperiment, } from './client/eppo-client'; +import { Subject } from './client/subject'; import FlagConfigRequestor from './configuration-requestor'; import { IConfigurationStore, @@ -152,4 +153,5 @@ export { // Utilities buildStorageKeySuffix, + Subject, }; diff --git a/src/poller.spec.ts b/src/poller.spec.ts deleted file mode 100644 index 07afc38..0000000 --- a/src/poller.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import * as td from 'testdouble'; - -import { DEFAULT_BASE_POLLING_INTERVAL_MS, POLL_JITTER_PCT } from './constants'; -import initPoller from './poller'; - -describe('poller', () => { - const testIntervalMs = DEFAULT_BASE_POLLING_INTERVAL_MS; - const maxRetryDelay = testIntervalMs * POLL_JITTER_PCT; - const noOpCallback = td.func<() => Promise>(); - - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - td.reset(); - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - describe('initial startup', () => { - it('skips initial poll if configured to do so', async () => { - const poller = initPoller(testIntervalMs, noOpCallback, { skipInitialPoll: true }); - await poller.start(); - td.verify(noOpCallback(), { times: 0 }); - }); - - it('retries startup poll within same promise', async () => { - const pollerRetries = 3; - let callCount = 0; - const errorThrowingThenSuccessCallback = async () => { - if (++callCount <= pollerRetries) { - throw new Error('Intentional Error For Test'); - } - }; - - const poller = initPoller(testIntervalMs, errorThrowingThenSuccessCallback, { - maxStartRetries: pollerRetries, - pollAfterSuccessfulStart: true, - }); - - // By not awaiting (yet) only the first call should be fired off before execution below resumes - const startPromise = poller.start(); - - expect(callCount).toBe(1); // By this point, the first call will have failed - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(2); // retry 1 fails - - await jest.advanceTimersByTimeAsync(maxRetryDelay * 2); - expect(callCount).toBe(4); // retries 2 and 3 fail - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(4); // no more retries - - // Await poller.start() so it can finish its execution before this test proceeds - await startPromise; - expect(callCount).toBe(4); // still no more retries - - await jest.advanceTimersByTimeAsync(testIntervalMs); - expect(callCount).toBe(5); // polling has begun - }); - - it('gives up initial request after exhausting all start retries', async () => { - const pollerRetries = 1; - let callCount = 0; - const errorThrowingCallback = async () => { - ++callCount; - throw new Error('Intentional Error For Test'); - }; - - const poller = initPoller(testIntervalMs, errorThrowingCallback, { - maxStartRetries: pollerRetries, - }); - - // By not awaiting (yet) only the first call should be fired off before execution below resumes - const startPromise = poller.start(); - expect(callCount).toBe(1); // By this point, the first call will have failed - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(2); // retry 1 fails and stops - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(2); // no more retries - - // Await poller.start() so it can finish its execution before this test proceeds - await startPromise; - // By this point, both initial failed requests will have happened - expect(callCount).toBe(2); - - // There should be no more polling (Since pollAfterFailedStart: true was not passed as an option) - await jest.advanceTimersByTimeAsync(testIntervalMs * 2); - expect(callCount).toBe(2); - }); - - it('throws an error on failed start (if configured to do so)', async () => { - // Fake time does not play well with errors bubbled up after setTimeout (event loop, - // timeout queue, message queue stuff) so we don't allow retries when rethrowing. - const pollerRetries = 0; - let callCount = 0; - const errorThrowingCallback = async () => { - ++callCount; - throw new Error('Intentional Error For Test'); - }; - - const poller = initPoller(testIntervalMs, errorThrowingCallback, { - maxStartRetries: pollerRetries, - errorOnFailedStart: true, - }); - - await expect(poller.start()).rejects.toThrow(); - expect(callCount).toBe(1); // The call failed - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(1); // We set to no retries - - await jest.advanceTimersByTimeAsync(testIntervalMs); - expect(callCount).toBe(1); // No polling after failure - }); - - it('still polls after initial request fails if configured to do so', async () => { - const pollerRetries = 1; - let callCount = 0; - const errorThrowingCallback = async () => { - if (++callCount <= 3) { - throw new Error('Intentional Error For Test'); - } - }; - - const poller = initPoller(testIntervalMs, errorThrowingCallback, { - maxStartRetries: pollerRetries, - errorOnFailedStart: false, - pollAfterSuccessfulStart: true, - pollAfterFailedStart: true, - }); - - // By not awaiting (yet) only the first call should be fired off before execution below resumes - const startPromise = poller.start(); - expect(callCount).toBe(1); // By this point, the first call will have failed - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(2); // retry 1 fails and stops - - await jest.advanceTimersByTimeAsync(maxRetryDelay); - expect(callCount).toBe(2); // no more initialization retries - - // Await poller.start() so it can finish its execution before this test proceeds - await startPromise; - // By this point, both initial failed requests will have happened - expect(callCount).toBe(2); - - // Advance time enough for regular polling to have begun (as configured) - await jest.advanceTimersByTimeAsync(testIntervalMs); - // First regular poll fails - expect(callCount).toBe(3); - // Advance time for exponential backoff - await jest.advanceTimersByTimeAsync(testIntervalMs * 2 + maxRetryDelay); - // Second regular poll succeeds - expect(callCount).toBe(4); - // Advance time normal interval - await jest.advanceTimersByTimeAsync(testIntervalMs); - // Third regular poll also succeeds - expect(callCount).toBe(5); - }); - }); - - describe('polling after startup', () => { - it('starts polling at interval', async () => { - const poller = initPoller(testIntervalMs, noOpCallback, { pollAfterSuccessfulStart: true }); - await poller.start(); - td.verify(noOpCallback(), { times: 1 }); - await jest.advanceTimersByTimeAsync(testIntervalMs); - td.verify(noOpCallback(), { times: 2 }); - await jest.advanceTimersByTimeAsync(testIntervalMs * 10); - td.verify(noOpCallback(), { times: 12 }); - }); - - it('stops polling', async () => { - const poller = initPoller(testIntervalMs, noOpCallback, { pollAfterSuccessfulStart: true }); - await poller.start(); - td.verify(noOpCallback(), { times: 1 }); - poller.stop(); - await jest.advanceTimersByTimeAsync(testIntervalMs * 10); - td.verify(noOpCallback(), { times: 1 }); - }); - - it('retries polling with exponential backoff', async () => { - const pollerRetries = 3; - let callCount = 0; - let failures = 0; - let successes = 0; - const mostlyErrorThrowingCallback = async () => { - // This mock _mostly_ throws errors: - // - First call succeeds - // - Then calls will fail - // - Above repeats (✓ ✕ ✕ ✕ ✓ ✕ ✕) - if ((++callCount - 1) % (pollerRetries + 1) !== 0) { - failures += 1; - throw new Error('Intentional Error For Test'); - } - successes += 1; - }; - - const poller = initPoller(testIntervalMs, mostlyErrorThrowingCallback, { - pollAfterSuccessfulStart: true, - maxPollRetries: pollerRetries, - }); - await poller.start(); - expect(callCount).toBe(1); // initial request call succeeds - expect(failures).toBe(0); - expect(successes).toBe(1); - - await jest.advanceTimersByTimeAsync(testIntervalMs); - expect(callCount).toBe(2); // first poll fails - expect(failures).toBe(1); - expect(successes).toBe(1); - - await jest.advanceTimersByTimeAsync(testIntervalMs * 2 + maxRetryDelay); // 2^1 backoff plus jitter - expect(callCount).toBe(3); // second poll fails - expect(failures).toBe(2); - expect(successes).toBe(1); - - await jest.advanceTimersByTimeAsync(testIntervalMs * 4 + maxRetryDelay); // 2^2 backoff plus jitter - expect(callCount).toBe(4); // third poll fails - expect(failures).toBe(3); - expect(successes).toBe(1); - - await jest.advanceTimersByTimeAsync(testIntervalMs * 8 + maxRetryDelay); // 2^3 backoff plus jitter - expect(callCount).toBe(5); // fourth poll succeeds (backoff reset) - expect(failures).toBe(3); - expect(successes).toBe(2); - - await jest.advanceTimersByTimeAsync(testIntervalMs); // normal wait - expect(callCount).toBe(6); // fifth poll fails - expect(failures).toBe(4); - expect(successes).toBe(2); - - await jest.advanceTimersByTimeAsync(testIntervalMs * 2 + maxRetryDelay); // 2^1 backoff plus jitter - expect(callCount).toBe(7); // sixth poll fails - expect(failures).toBe(5); - expect(successes).toBe(2); - }); - - it('aborts after exhausting polling retries', async () => { - const pollerRetries = 3; - let callCount = 0; - const alwaysErrorAfterFirstCallback = async () => { - if (++callCount > 1) { - throw new Error('Intentional Error For Test'); - } - }; - - const poller = initPoller(testIntervalMs, alwaysErrorAfterFirstCallback, { - pollAfterSuccessfulStart: true, - maxPollRetries: pollerRetries, - }); - await poller.start(); - expect(callCount).toBe(1); // successful initial request - - await jest.advanceTimersByTimeAsync(testIntervalMs); - expect(callCount).toBe(2); // first regular poll fails - - await jest.advanceTimersByTimeAsync(testIntervalMs * 2 + maxRetryDelay); // 2^1 backoff plus jitter - expect(callCount).toBe(3); // second poll fails - - await jest.advanceTimersByTimeAsync(testIntervalMs * 4 + maxRetryDelay); // 2^2 backoff plus jitter - expect(callCount).toBe(4); // third poll fails - - await jest.advanceTimersByTimeAsync(testIntervalMs * 8 + maxRetryDelay); // 2^3 backoff plus jitter - expect(callCount).toBe(5); // fourth poll fails and stops - - await jest.advanceTimersByTimeAsync(testIntervalMs * 16 + maxRetryDelay); // 2^4 backoff plus jitter - expect(callCount).toBe(5); // no new polls - }); - }); -}); diff --git a/src/poller.ts b/src/poller.ts deleted file mode 100644 index 970fc60..0000000 --- a/src/poller.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { logger } from './application-logger'; -import { - DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - POLL_JITTER_PCT, -} from './constants'; -import { waitForMs } from './util'; - -export interface IPoller { - start: () => Promise; - stop: () => void; -} - -// TODO: change this to a class with methods instead of something that returns a function - -export default function initPoller( - intervalMs: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback: () => Promise, - options?: { - maxPollRetries?: number; - maxStartRetries?: number; - // TODO: consider enum for polling behavior (NONE, SUCCESS, ALWAYS) - pollAfterSuccessfulStart?: boolean; - errorOnFailedStart?: boolean; - pollAfterFailedStart?: boolean; - skipInitialPoll?: boolean; - }, -): IPoller { - let stopped = false; - let failedAttempts = 0; - let nextPollMs = intervalMs; - let previousPollFailed = false; - let nextTimer: NodeJS.Timeout | undefined = undefined; - - const start = async () => { - stopped = false; - let startRequestSuccess = false; - let startAttemptsRemaining = options?.skipInitialPoll - ? 0 - : 1 + (options?.maxStartRetries ?? DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES); - - let startErrorToThrow = null; - - while (!startRequestSuccess && startAttemptsRemaining > 0) { - try { - await callback(); - startRequestSuccess = true; - previousPollFailed = false; - logger.info('Eppo SDK successfully requested initial configuration'); - } catch (pollingError: any) { - previousPollFailed = true; - logger.warn( - `Eppo SDK encountered an error with initial poll of configurations: ${pollingError.message}`, - ); - if (--startAttemptsRemaining > 0) { - const jitterMs = randomJitterMs(intervalMs); - logger.warn( - `Eppo SDK will retry the initial poll again in ${jitterMs} ms (${startAttemptsRemaining} attempts remaining)`, - ); - await waitForMs(jitterMs); - } else { - if (options?.pollAfterFailedStart) { - logger.warn('Eppo SDK initial poll failed; will attempt regular polling'); - } else { - logger.error('Eppo SDK initial poll failed. Aborting polling'); - stop(); - } - - if (options?.errorOnFailedStart) { - startErrorToThrow = pollingError; - } - } - } - } - - const startRegularPolling = - !stopped && - ((startRequestSuccess && options?.pollAfterSuccessfulStart) || - (!startRequestSuccess && options?.pollAfterFailedStart)); - - if (startRegularPolling) { - logger.info(`Eppo SDK starting regularly polling every ${intervalMs} ms`); - nextTimer = setTimeout(poll, intervalMs); - } else { - logger.info(`Eppo SDK will not poll for configuration updates`); - } - - if (startErrorToThrow) { - logger.info('Eppo SDK rethrowing start error'); - throw startErrorToThrow; - } - }; - - const stop = () => { - if (!stopped) { - stopped = true; - if (nextTimer) { - clearTimeout(nextTimer); - } - logger.info('Eppo SDK polling stopped'); - } - }; - - async function poll() { - if (stopped) { - return; - } - - try { - await callback(); - // If no error, reset any retrying - failedAttempts = 0; - nextPollMs = intervalMs; - if (previousPollFailed) { - previousPollFailed = false; - logger.info('Eppo SDK poll successful; resuming normal polling'); - } - } catch (error: any) { - previousPollFailed = true; - logger.warn(`Eppo SDK encountered an error polling configurations: ${error.message}`); - const maxTries = 1 + (options?.maxPollRetries ?? DEFAULT_POLL_CONFIG_REQUEST_RETRIES); - if (++failedAttempts < maxTries) { - const failureWaitMultiplier = Math.pow(2, failedAttempts); - const jitterMs = randomJitterMs(intervalMs); - nextPollMs = failureWaitMultiplier * intervalMs + jitterMs; - logger.warn( - `Eppo SDK will try polling again in ${nextPollMs} ms (${ - maxTries - failedAttempts - } attempts remaining)`, - ); - } else { - logger.error( - `Eppo SDK reached maximum of ${failedAttempts} failed polling attempts. Stopping polling`, - ); - stop(); - } - } - - setTimeout(poll, nextPollMs); - } - - return { - start, - stop, - }; -} - -/** - * Compute a random jitter as a percentage of the polling interval. - * Will be (5%,10%) of the interval assuming POLL_JITTER_PCT = 0.1 - * - * @internal - */ -export function randomJitterMs(intervalMs: number) { - const halfPossibleJitter = (intervalMs * POLL_JITTER_PCT) / 2; - // We want the randomly chosen jitter to be at least 1ms so total jitter is slightly more than half the max possible. - // This makes things easy for automated tests as two polls cannot execute within the maximum possible time waiting for one. - const randomOtherHalfJitter = Math.max( - Math.floor((Math.random() * intervalMs * POLL_JITTER_PCT) / 2), - 1, - ); - return halfPossibleJitter + randomOtherHalfJitter; -} diff --git a/src/salt.ts b/src/salt.ts new file mode 100644 index 0000000..4f3a7b9 --- /dev/null +++ b/src/salt.ts @@ -0,0 +1,15 @@ +// Moved to a separate module for easier mocking in tests. +import { v4 as uuidv4 } from 'uuid'; + +/** + * Generate a random salt for use in obfuscation. The returned value is guaranteed to be a valid + * UTF-8 string and have enough entropy for obfuscations. Other than that, the output format is not + * defined. + * + * @internal + */ +export function generateSalt() { + // UUIDv4 has enough entropy for our purposes. Where available, uuid uses crypto.randomUUID(), + // which uses secure random number generation. + return uuidv4(); +} \ No newline at end of file From bef03936d0c08d1f769d1e6d538fa3594b3bd7e6 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 20:31:49 +0300 Subject: [PATCH 19/25] refactor: remove old configuration store --- .../eppo-client-assignment-details.spec.ts | 7 +- .../eppo-client-experiment-container.spec.ts | 5 +- src/client/eppo-client-with-bandits.spec.ts | 14 - src/client/eppo-client-with-overrides.spec.ts | 3 +- src/client/eppo-client.precomputed.spec.ts | 11 +- src/client/eppo-client.spec.ts | 30 +- src/client/eppo-client.ts | 11 +- src/configuration-requestor.spec.ts | 4 +- .../index.ts => configuration-store.ts} | 8 +- .../configuration-store-utils.ts | 41 -- .../configuration-store.ts | 61 --- src/configuration-store/hybrid.store.spec.ts | 96 ---- src/configuration-store/hybrid.store.ts | 105 ----- src/configuration-store/memory.store.spec.ts | 56 --- src/configuration-store/memory.store.ts | 102 ----- src/eppo-assignment-logger.spec.ts | 2 - src/i-configuration.spec.ts | 431 ------------------ src/i-configuration.ts | 134 ------ src/index.ts | 14 +- src/kvstore.ts | 29 ++ 20 files changed, 48 insertions(+), 1116 deletions(-) rename src/{configuration-store/index.ts => configuration-store.ts} (92%) delete mode 100644 src/configuration-store/configuration-store-utils.ts delete mode 100644 src/configuration-store/configuration-store.ts delete mode 100644 src/configuration-store/hybrid.store.spec.ts delete mode 100644 src/configuration-store/hybrid.store.ts delete mode 100644 src/configuration-store/memory.store.spec.ts delete mode 100644 src/configuration-store/memory.store.ts delete mode 100644 src/i-configuration.spec.ts delete mode 100644 src/i-configuration.ts create mode 100644 src/kvstore.ts diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index 1bb7826..f0e3927 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -2,19 +2,14 @@ import * as fs from 'fs'; import { IAssignmentTestCase, - MOCK_UFC_RESPONSE_FILE, readMockUfcConfiguration, - readMockUFCResponse, } from '../../test/testHelpers'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { AllocationEvaluationCode } from '../flag-evaluation-details-builder'; -import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces'; +import { Variation, VariationType } from '../interfaces'; import { OperatorType } from '../rules'; import { AttributeType } from '../types'; import EppoClient, { IAssignmentDetails } from './eppo-client'; -import { initConfiguration } from './test-utils'; -import { read } from 'fs'; describe('EppoClient get*AssignmentDetails', () => { const testStart = Date.now(); diff --git a/src/client/eppo-client-experiment-container.spec.ts b/src/client/eppo-client-experiment-container.spec.ts index 1beb940..12d4ca8 100644 --- a/src/client/eppo-client-experiment-container.spec.ts +++ b/src/client/eppo-client-experiment-container.spec.ts @@ -1,10 +1,7 @@ -import { MOCK_UFC_RESPONSE_FILE, readMockUfcConfiguration, readMockUFCResponse } from '../../test/testHelpers'; +import { readMockUfcConfiguration } from '../../test/testHelpers'; import * as applicationLogger from '../application-logger'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { Flag, ObfuscatedFlag } from '../interfaces'; import EppoClient, { IContainerExperiment } from './eppo-client'; -import { initConfiguration } from './test-utils'; type Container = { name: string }; diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index f902ac2..f1d0764 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -13,10 +13,6 @@ import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; -import { - IPrecomputedConfiguration, - IObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire/configuration-wire-types'; import { Evaluator, FlagEvaluation } from '../evaluator'; import { AllocationEvaluationCode, @@ -52,16 +48,6 @@ describe('EppoClient Bandits E2E test', () => { json: () => Promise.resolve(response), }); }) as jest.Mock; - - // Initialize a configuration requestor - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://127.0.0.1:4000', - queryParams: { - apiKey: 'dummy', - sdkName: 'js-client-sdk-common', - sdkVersion: '1.0.0', - }, - }); }); beforeEach(() => { diff --git a/src/client/eppo-client-with-overrides.spec.ts b/src/client/eppo-client-with-overrides.spec.ts index aaafcd3..69a5247 100644 --- a/src/client/eppo-client-with-overrides.spec.ts +++ b/src/client/eppo-client-with-overrides.spec.ts @@ -1,6 +1,5 @@ import { Configuration } from '../configuration'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces'; +import { Flag, FormatEnum, VariationType } from '../interfaces'; import * as overrideValidatorModule from '../override-validator'; import EppoClient from './eppo-client'; diff --git a/src/client/eppo-client.precomputed.spec.ts b/src/client/eppo-client.precomputed.spec.ts index 71deff5..7d3d50c 100644 --- a/src/client/eppo-client.precomputed.spec.ts +++ b/src/client/eppo-client.precomputed.spec.ts @@ -1,17 +1,11 @@ -import * as td from 'testdouble'; - import { MOCK_PRECOMPUTED_WIRE_FILE, MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE, readMockConfigurationWireResponse, } from '../../test/testHelpers'; -import { logger } from '../application-logger'; import { IAssignmentLogger } from '../assignment-logger'; import { IBanditLogger } from '../bandit-logger'; import { Configuration } from '../configuration'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { FormatEnum, VariationType, Variation } from '../interfaces'; -import { BanditActions } from '../types'; import EppoClient from './eppo-client'; @@ -19,10 +13,7 @@ describe('EppoClient Precomputed Mode', () => { // Read both configurations for test reference const precomputedConfigurationWire = readMockConfigurationWireResponse(MOCK_PRECOMPUTED_WIRE_FILE); const initialConfiguration = Configuration.fromString(precomputedConfigurationWire); - - // We only use deobfuscated configuration as a reference, not for creating a client - const deobfuscatedPrecomputedWire = readMockConfigurationWireResponse(MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE); - + let client: EppoClient; let mockAssignmentLogger: jest.Mocked; let mockBanditLogger: jest.Mocked; diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 8a5d6a0..65cf721 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -3,27 +3,11 @@ import { times } from 'lodash'; import * as td from 'testdouble'; import { - ASSIGNMENT_TEST_DATA_DIR, - getTestAssignments, - IAssignmentTestCase, MOCK_UFC_RESPONSE_FILE, - OBFUSCATED_MOCK_UFC_RESPONSE_FILE, - readMockUfcConfiguration, - readMockUfcObfuscatedConfiguration, readMockUFCResponse, - SubjectTestCase, - testCasesByFileName, - validateTestAssignments, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; -import { ConfigurationStore } from '../configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { - IConfigurationWire, - IObfuscatedPrecomputedConfigurationResponse, - ObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire/configuration-wire-types'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_BASE_POLLING_INTERVAL_MS, @@ -32,12 +16,10 @@ import { import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; -import { AttributeType } from '../types'; import EppoClient, { checkTypeMatch } from './eppo-client'; import { Configuration } from '../configuration'; -import { IUniversalFlagConfigResponse } from '../http-client'; -import { ISyncStore } from '../configuration-store/configuration-store'; +import { KVStore, MemoryStore } from '../kvstore'; // Use a known salt to produce deterministic hashes const salt = base64.fromUint8Array(new Uint8Array([7, 53, 17, 78])); @@ -748,12 +730,6 @@ describe('EppoClient E2E test', () => { }); it('Does not fetch configurations if the configuration store is unexpired', async () => { - class MockStore extends MemoryOnlyConfigurationStore { - async isExpired(): Promise { - return false; - } - } - // Test needs network fetching approach client = new EppoClient({ sdkKey: requestConfiguration.apiKey, @@ -953,11 +929,11 @@ describe('EppoClient E2E test', () => { describe('flag overrides', () => { let client: EppoClient; let mockLogger: IAssignmentLogger; - let overrideStore: ISyncStore; + let overrideStore: KVStore; beforeEach(() => { mockLogger = td.object(); - overrideStore = new MemoryOnlyConfigurationStore(); + overrideStore = new MemoryStore(); client = new EppoClient({ sdkKey: 'test', sdkName: 'test', diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 617ead3..a1e63f8 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,8 +17,6 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import { Configuration, PrecomputedConfig } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; -import { ISyncStore } from '../configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { IObfuscatedPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; import { DEFAULT_BASE_POLLING_INTERVAL_MS, @@ -88,6 +86,7 @@ import { import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; import { Subject } from './subject'; import { generateSalt } from '../salt'; +import { KVStore, MemoryStore } from '../kvstore'; export interface IAssignmentDetails { variation: T; @@ -110,7 +109,7 @@ export type EppoClientParameters = { // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment // or bandit events). These events are application-specific and captures by EppoClient#track API. eventDispatcher?: EventDispatcher; - overrideStore?: ISyncStore; + overrideStore?: KVStore; bandits?: { /** @@ -315,7 +314,7 @@ export default class EppoClient { private readonly banditEvaluator = new BanditEvaluator(); private banditLogger?: IBanditLogger; private banditAssignmentCache?: AssignmentCache; - private overrideStore?: ISyncStore; + private overrideStore?: KVStore; private assignmentLogger?: IAssignmentLogger; private assignmentCache?: AssignmentCache; // whether to suppress any errors and return default values instead @@ -638,7 +637,7 @@ export default class EppoClient { withOverrides(overrides: Record | undefined): EppoClient { if (overrides && Object.keys(overrides).length) { const copy = shallowClone(this); - copy.overrideStore = new MemoryOnlyConfigurationStore(); + copy.overrideStore = new MemoryStore(); copy.overrideStore.setEntries(overrides); return copy; } @@ -666,7 +665,7 @@ export default class EppoClient { this.eventDispatcher?.attachContext(key, value); } - setOverrideStore(store: ISyncStore): void { + setOverrideStore(store: KVStore): void { this.overrideStore = store; } diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index aaad96d..b214b5c 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -11,14 +11,12 @@ import { BroadcastChannel } from './broadcast'; import { ConfigurationFeed } from './configuration-feed'; import ConfigurationRequestor from './configuration-requestor'; import { ConfigurationStore } from './configuration-store'; -import { IConfigurationStore } from './configuration-store/configuration-store'; import FetchHttpClient, { IBanditParametersResponse, IHttpClient, IUniversalFlagConfigResponse, } from './http-client'; -import { StoreBackedConfiguration } from './i-configuration'; -import { BanditParameters, BanditVariation, Flag, VariationType } from './interfaces'; +import { BanditParameters } from './interfaces'; const MOCK_PRECOMPUTED_RESPONSE = { flags: { diff --git a/src/configuration-store/index.ts b/src/configuration-store.ts similarity index 92% rename from src/configuration-store/index.ts rename to src/configuration-store.ts index 1e3428a..841f6e1 100644 --- a/src/configuration-store/index.ts +++ b/src/configuration-store.ts @@ -1,7 +1,7 @@ -import { logger } from '../application-logger'; -import { Configuration } from '../configuration'; -import { ConfigurationFeed } from '../configuration-feed'; -import { BroadcastChannel } from '../broadcast'; +import { logger } from './application-logger'; +import { Configuration } from './configuration'; +import { ConfigurationFeed } from './configuration-feed'; +import { BroadcastChannel } from './broadcast'; export type ActivationStrategy = { /** diff --git a/src/configuration-store/configuration-store-utils.ts b/src/configuration-store/configuration-store-utils.ts deleted file mode 100644 index 6375de2..0000000 --- a/src/configuration-store/configuration-store-utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - BanditParameters, - BanditVariation, - Environment, - Flag, - IObfuscatedPrecomputedBandit, - PrecomputedFlag, -} from '../interfaces'; - -import { IConfigurationStore } from './configuration-store'; - -export type Entry = - | Flag - | BanditVariation[] - | BanditParameters - | PrecomputedFlag - | IObfuscatedPrecomputedBandit; - -export async function hydrateConfigurationStore( - configurationStore: IConfigurationStore | null, - response: { - entries: Record; - environment: Environment; - createdAt: string; - format: string; - salt?: string; - }, -): Promise { - if (configurationStore) { - const didUpdate = await configurationStore.setEntries(response.entries); - if (didUpdate) { - configurationStore.setEnvironment(response.environment); - configurationStore.setConfigFetchedAt(new Date().toISOString()); - configurationStore.setConfigPublishedAt(response.createdAt); - configurationStore.setFormat(response.format); - configurationStore.salt = response.salt; - } - return didUpdate; - } - return false; -} diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts deleted file mode 100644 index 20f1a78..0000000 --- a/src/configuration-store/configuration-store.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Environment } from '../interfaces'; - -/** - * ConfigurationStore interface - * - * The interface guides implementation - * of a policy for handling a mixture of async and sync storage. - * - * The goal is to support remote API responses to be written to the sync and async store, - * while also supporting reading from the sync store to maintain public SDK APIs. - * - * Implementation is handled in upstream libraries to best support their use case, some ideas: - * - * - Javascript frontend: - * - SyncStore: backed by localStorage - * - AsyncStore: backed by IndexedDB or chrome.storage.local - * - * - NodeJS backend: - * - SyncStore: backed by LRU cache - * - AsyncStore: none - * - * The policy choices surrounding the use of one or more underlying storages are - * implementation specific and handled upstream. - * - * @deprecated To be replaced with ConfigurationStore and PersistentStorage. - */ -export interface IConfigurationStore { - init(): Promise; - get(key: string): T | null; - entries(): Record; - getKeys(): string[]; - isInitialized(): boolean; - isExpired(): Promise; - setEntries(entries: Record): Promise; - setEnvironment(environment: Environment): void; - getEnvironment(): Environment | null; - getConfigFetchedAt(): string | null; - setConfigFetchedAt(configFetchedAt: string): void; - getConfigPublishedAt(): string | null; - setConfigPublishedAt(configPublishedAt: string): void; - getFormat(): string | null; - setFormat(format: string): void; - salt?: string; -} - -/** @deprecated To be replaced with ConfigurationStore and PersistentStorage. */ -export interface ISyncStore { - get(key: string): T | null; - entries(): Record; - getKeys(): string[]; - isInitialized(): boolean; - setEntries(entries: Record): void; -} - -/** @deprecated To be replaced with ConfigurationStore and PersistentStorage. */ -export interface IAsyncStore { - isInitialized(): boolean; - isExpired(): Promise; - entries(): Promise>; - setEntries(entries: Record): Promise; -} diff --git a/src/configuration-store/hybrid.store.spec.ts b/src/configuration-store/hybrid.store.spec.ts deleted file mode 100644 index 07276a2..0000000 --- a/src/configuration-store/hybrid.store.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { IAsyncStore, ISyncStore } from './configuration-store'; -import { HybridConfigurationStore } from './hybrid.store'; - -describe('HybridConfigurationStore', () => { - let syncStoreMock: ISyncStore; - let asyncStoreMock: IAsyncStore; - let store: HybridConfigurationStore; - - beforeEach(() => { - syncStoreMock = { - get: jest.fn(), - entries: jest.fn(), - getKeys: jest.fn(), - isInitialized: jest.fn(), - setEntries: jest.fn(), - }; - - asyncStoreMock = { - entries: jest.fn(), - isInitialized: jest.fn(), - isExpired: jest.fn(), - setEntries: jest.fn(), - }; - - store = new HybridConfigurationStore(syncStoreMock, asyncStoreMock); - }); - - describe('init', () => { - it('should initialize the serving store with entries from the persistent store if the persistent store is initialized', async () => { - const entries = { key1: 'value1', key2: 'value2' }; - (asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true); - (asyncStoreMock.entries as jest.Mock).mockResolvedValue(entries); - - await store.init(); - - expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries); - }); - }); - - describe('isExpired', () => { - it("is the persistent store's expired value", async () => { - (asyncStoreMock.isExpired as jest.Mock).mockResolvedValue(true); - expect(await store.isExpired()).toBe(true); - - (asyncStoreMock.isExpired as jest.Mock).mockResolvedValue(false); - expect(await store.isExpired()).toBe(false); - }); - - it('is true without a persistent store', async () => { - const mixedStoreWithNull = new HybridConfigurationStore(syncStoreMock, null); - expect(await mixedStoreWithNull.isExpired()).toBe(true); - }); - }); - - describe('isInitialized', () => { - it('should return true if both stores are initialized', () => { - (syncStoreMock.isInitialized as jest.Mock).mockReturnValue(true); - (asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true); - - expect(store.isInitialized()).toBe(true); - }); - - it('should return false if either store is not initialized', () => { - (syncStoreMock.isInitialized as jest.Mock).mockReturnValue(false); - (asyncStoreMock.isInitialized as jest.Mock).mockReturnValue(true); - - expect(store.isInitialized()).toBe(false); - }); - }); - - describe('entries', () => { - it('should return all entries from the serving store', () => { - const entries = { key1: 'value1', key2: 'value2' }; - (syncStoreMock.entries as jest.Mock).mockReturnValue(entries); - expect(store.entries()).toEqual(entries); - }); - }); - - describe('setEntries', () => { - it('should set entries in both stores if the persistent store is present', async () => { - const entries = { key1: 'value1', key2: 'value2' }; - await store.setEntries(entries); - - expect(asyncStoreMock.setEntries).toHaveBeenCalledWith(entries); - expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries); - }); - - it('should only set entries in the serving store if the persistent store is null', async () => { - const mixedStoreWithNull = new HybridConfigurationStore(syncStoreMock, null); - const entries = { key1: 'value1', key2: 'value2' }; - await mixedStoreWithNull.setEntries(entries); - - expect(syncStoreMock.setEntries).toHaveBeenCalledWith(entries); - }); - }); -}); diff --git a/src/configuration-store/hybrid.store.ts b/src/configuration-store/hybrid.store.ts deleted file mode 100644 index a3dff70..0000000 --- a/src/configuration-store/hybrid.store.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { logger, loggerPrefix } from '../application-logger'; -import { Environment, FormatEnum } from '../interfaces'; - -import { IAsyncStore, IConfigurationStore, ISyncStore } from './configuration-store'; - -export class HybridConfigurationStore implements IConfigurationStore { - constructor( - protected readonly servingStore: ISyncStore, - protected readonly persistentStore: IAsyncStore | null, - ) {} - private environment: Environment | null = null; - private configFetchedAt: string | null = null; - private configPublishedAt: string | null = null; - private format: FormatEnum | null = null; - - /** - * Initialize the configuration store by loading the entries from the persistent store into the serving store. - */ - async init(): Promise { - if (!this.persistentStore) { - return; - } - - if (!this.persistentStore.isInitialized()) { - /** - * The initial remote request to the remote API failed - * or never happened because we are in the cool down period. - * - * Shows a log message that the assignments served from the serving store - * may be stale. - */ - logger.warn( - `${loggerPrefix} Persistent store is not initialized from remote configuration. Serving assignments that may be stale.`, - ); - } - - const entries = await this.persistentStore.entries(); - this.servingStore.setEntries(entries); - } - - public isInitialized(): boolean { - return this.servingStore.isInitialized() && (this.persistentStore?.isInitialized() ?? true); - } - - public async isExpired(): Promise { - const isExpired = await this.persistentStore?.isExpired(); - return isExpired ?? true; - } - - public get(key: string): T | null { - if (!this.servingStore.isInitialized()) { - logger.warn(`${loggerPrefix} getting a value from a ServingStore that is not initialized.`); - } - return this.servingStore.get(key); - } - - public entries(): Record { - return this.servingStore.entries(); - } - - public getKeys(): string[] { - return this.servingStore.getKeys(); - } - - public async setEntries(entries: Record): Promise { - if (this.persistentStore) { - // Persistence store is now initialized and should mark itself accordingly. - await this.persistentStore.setEntries(entries); - } - this.servingStore.setEntries(entries); - return true; - } - - setEnvironment(environment: Environment): void { - this.environment = environment; - } - - getEnvironment(): Environment | null { - return this.environment; - } - - public getConfigFetchedAt(): string | null { - return this.configFetchedAt; - } - - public setConfigFetchedAt(configFetchedAt: string): void { - this.configFetchedAt = configFetchedAt; - } - - public getConfigPublishedAt(): string | null { - return this.configPublishedAt; - } - - public setConfigPublishedAt(configPublishedAt: string): void { - this.configPublishedAt = configPublishedAt; - } - - public getFormat(): FormatEnum | null { - return this.format; - } - - public setFormat(format: FormatEnum): void { - this.format = format; - } -} diff --git a/src/configuration-store/memory.store.spec.ts b/src/configuration-store/memory.store.spec.ts deleted file mode 100644 index 547e204..0000000 --- a/src/configuration-store/memory.store.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { MemoryOnlyConfigurationStore } from './memory.store'; - -describe('MemoryOnlyConfigurationStore', () => { - let memoryStore: MemoryOnlyConfigurationStore; - - beforeEach(() => { - memoryStore = new MemoryOnlyConfigurationStore(); - }); - - it('should initialize without any entries', () => { - expect(memoryStore.isInitialized()).toBe(false); - expect(memoryStore.getKeys()).toEqual([]); - }); - - it('is always expired', async () => { - expect(await memoryStore.isExpired()).toBe(true); - }); - - it('should return null for non-existent keys', () => { - expect(memoryStore.get('nonexistent')).toBeNull(); - }); - - it('should allow setting and retrieving entries', async () => { - await memoryStore.setEntries({ key1: 'value1', key2: 'value2' }); - expect(memoryStore.get('key1')).toBe('value1'); - expect(memoryStore.get('key2')).toBe('value2'); - }); - - it('should report initialized after setting entries', async () => { - await memoryStore.setEntries({ key1: 'value1' }); - expect(memoryStore.isInitialized()).toBe(true); - }); - - it('should return all keys', async () => { - await memoryStore.setEntries({ key1: 'value1', key2: 'value2', key3: 'value3' }); - expect(memoryStore.getKeys()).toEqual(['key1', 'key2', 'key3']); - }); - - it('should return all entries', async () => { - const entries = { key1: 'value1', key2: 'value2', key3: 'value3' }; - await memoryStore.setEntries(entries); - expect(memoryStore.entries()).toEqual(entries); - }); - - it('should overwrite existing entries', async () => { - await memoryStore.setEntries({ toBeReplaced: 'old value', toBeRemoved: 'delete me' }); - expect(memoryStore.get('toBeReplaced')).toBe('old value'); - expect(memoryStore.get('toBeRemoved')).toBe('delete me'); - expect(memoryStore.get('toBeAdded')).toBeNull(); - - await memoryStore.setEntries({ toBeReplaced: 'new value', toBeAdded: 'add me' }); - expect(memoryStore.get('toBeReplaced')).toBe('new value'); - expect(memoryStore.get('toBeRemoved')).toBeNull(); - expect(memoryStore.get('toBeAdded')).toBe('add me'); - }); -}); diff --git a/src/configuration-store/memory.store.ts b/src/configuration-store/memory.store.ts deleted file mode 100644 index 6f29824..0000000 --- a/src/configuration-store/memory.store.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Environment, FormatEnum } from '../interfaces'; - -import { IConfigurationStore, ISyncStore } from './configuration-store'; - -export class MemoryStore implements ISyncStore { - private store: Record = {}; - private initialized = false; - - get(key: string): T | null { - return this.store[key] ?? null; - } - - entries(): Record { - return this.store; - } - - getKeys(): string[] { - return Object.keys(this.store); - } - - isInitialized(): boolean { - return this.initialized; - } - - setEntries(entries: Record): void { - this.store = { ...entries }; - this.initialized = true; - } -} - -export class MemoryOnlyConfigurationStore implements IConfigurationStore { - private readonly servingStore: ISyncStore = new MemoryStore(); - private initialized = false; - private configFetchedAt: string | null = null; - private configPublishedAt: string | null = null; - private environment: Environment | null = null; - private format: FormatEnum | null = null; - salt?: string; - - init(): Promise { - this.initialized = true; - return Promise.resolve(); - } - - get(key: string): T | null { - return this.servingStore.get(key); - } - - entries(): Record { - return this.servingStore.entries(); - } - - getKeys(): string[] { - return this.servingStore.getKeys(); - } - - async isExpired(): Promise { - return true; - } - - isInitialized(): boolean { - return this.initialized; - } - - async setEntries(entries: Record): Promise { - this.servingStore.setEntries(entries); - this.initialized = true; - return true; - } - - public getEnvironment(): Environment | null { - return this.environment; - } - - public setEnvironment(environment: Environment): void { - this.environment = environment; - } - - public getConfigFetchedAt(): string | null { - return this.configFetchedAt; - } - - public setConfigFetchedAt(configFetchedAt: string): void { - this.configFetchedAt = configFetchedAt; - } - - public getConfigPublishedAt(): string | null { - return this.configPublishedAt; - } - - public setConfigPublishedAt(configPublishedAt: string): void { - this.configPublishedAt = configPublishedAt; - } - - public getFormat(): FormatEnum | null { - return this.format; - } - - public setFormat(format: FormatEnum): void { - this.format = format; - } -} diff --git a/src/eppo-assignment-logger.spec.ts b/src/eppo-assignment-logger.spec.ts index 9c6481e..cf0fb83 100644 --- a/src/eppo-assignment-logger.spec.ts +++ b/src/eppo-assignment-logger.spec.ts @@ -1,8 +1,6 @@ import { IAssignmentEvent } from './assignment-logger'; import EppoClient from './client/eppo-client'; -import { IConfigurationStore } from './configuration-store/configuration-store'; import { EppoAssignmentLogger } from './eppo-assignment-logger'; -import { Flag } from './interfaces'; jest.mock('./client/eppo-client'); diff --git a/src/i-configuration.spec.ts b/src/i-configuration.spec.ts deleted file mode 100644 index e368579..0000000 --- a/src/i-configuration.spec.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; -import { StoreBackedConfiguration } from './i-configuration'; -import { BanditParameters, BanditVariation, Environment, Flag, ObfuscatedFlag } from './interfaces'; -import { BanditKey, FlagKey } from './types'; - -describe('StoreBackedConfiguration', () => { - let mockFlagStore: jest.Mocked>; - let mockBanditVariationStore: jest.Mocked>; - let mockBanditModelStore: jest.Mocked>; - - beforeEach(() => { - mockFlagStore = { - get: jest.fn(), - getKeys: jest.fn(), - entries: jest.fn(), - setEntries: jest.fn(), - setEnvironment: jest.fn(), - setConfigFetchedAt: jest.fn(), - setConfigPublishedAt: jest.fn(), - setFormat: jest.fn(), - getConfigFetchedAt: jest.fn(), - getConfigPublishedAt: jest.fn(), - getEnvironment: jest.fn(), - getFormat: jest.fn(), - salt: undefined, - init: jest.fn(), - isInitialized: jest.fn(), - isExpired: jest.fn(), - }; - - mockBanditVariationStore = { - get: jest.fn(), - getKeys: jest.fn(), - entries: jest.fn(), - setEntries: jest.fn(), - setEnvironment: jest.fn(), - setConfigFetchedAt: jest.fn(), - setConfigPublishedAt: jest.fn(), - setFormat: jest.fn(), - getConfigFetchedAt: jest.fn(), - getConfigPublishedAt: jest.fn(), - getEnvironment: jest.fn(), - getFormat: jest.fn(), - salt: undefined, - init: jest.fn(), - isInitialized: jest.fn(), - isExpired: jest.fn(), - }; - - mockBanditModelStore = { - get: jest.fn(), - getKeys: jest.fn(), - entries: jest.fn(), - setEntries: jest.fn(), - setEnvironment: jest.fn(), - setConfigFetchedAt: jest.fn(), - setConfigPublishedAt: jest.fn(), - setFormat: jest.fn(), - getConfigFetchedAt: jest.fn(), - getConfigPublishedAt: jest.fn(), - getEnvironment: jest.fn(), - getFormat: jest.fn(), - salt: undefined, - init: jest.fn(), - isInitialized: jest.fn(), - isExpired: jest.fn(), - }; - }); - - describe('hydrateConfigurationStores', () => { - it('should hydrate flag store and return true if updates occurred', async () => { - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - mockFlagStore.setEntries.mockResolvedValue(true); - mockBanditVariationStore.setEntries.mockResolvedValue(true); - mockBanditModelStore.setEntries.mockResolvedValue(true); - - const result = await config.hydrateConfigurationStores( - { - entries: { testFlag: { key: 'test' } as Flag }, - environment: { name: 'test' }, - createdAt: '2024-01-01', - format: 'SERVER', - }, - { - entries: { testVar: [] }, - environment: { name: 'test' }, - createdAt: '2024-01-01', - format: 'SERVER', - }, - { - entries: { testBandit: {} as BanditParameters }, - environment: { name: 'test' }, - createdAt: '2024-01-01', - format: 'SERVER', - }, - ); - - expect(result).toBe(true); - expect(mockFlagStore.setEntries).toHaveBeenCalled(); - expect(mockBanditVariationStore.setEntries).toHaveBeenCalled(); - expect(mockBanditModelStore.setEntries).toHaveBeenCalled(); - }); - }); - - describe('getFlag', () => { - it('should return flag when it exists', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - const mockFlag: Flag = { key: 'test-flag' } as Flag; - mockFlagStore.get.mockReturnValue(mockFlag); - - const result = config.getFlag('test-flag'); - expect(result).toEqual(mockFlag); - }); - - it('should return null when flag does not exist', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.get.mockReturnValue(null); - - const result = config.getFlag('non-existent'); - expect(result).toBeNull(); - }); - }); - - describe('getFlagVariationBandit', () => { - it('should return bandit parameters when variation exists', () => { - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - const mockVariations: BanditVariation[] = [ - { - key: 'bandit-1', - variationValue: 'var-1', - flagKey: 'test-flag', - variationKey: 'test-variation', - }, - ]; - const mockBanditParams: BanditParameters = {} as BanditParameters; - - mockBanditVariationStore.get.mockReturnValue(mockVariations); - mockBanditModelStore.get.mockReturnValue(mockBanditParams); - - const result = config.getFlagVariationBandit('test-flag', 'var-1'); - expect(result).toEqual(mockBanditParams); - }); - - it('should return null when variation does not exist', () => { - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - mockBanditVariationStore.get.mockReturnValue([]); - - const result = config.getFlagVariationBandit('test-flag', 'non-existent'); - expect(result).toBeNull(); - }); - }); - - describe('getFlagConfigDetails', () => { - it('should return config details with default values when store returns null', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.getConfigFetchedAt.mockReturnValue(null); - mockFlagStore.getConfigPublishedAt.mockReturnValue(null); - mockFlagStore.getEnvironment.mockReturnValue(null); - mockFlagStore.getFormat.mockReturnValue(null); - - const result = config.getFlagConfigDetails(); - expect(result).toEqual({ - configFetchedAt: '', - configPublishedAt: '', - configEnvironment: { name: '' }, - configFormat: '', - }); - }); - - it('should return actual config details when available', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - const mockEnvironment: Environment = { name: 'test' }; - - mockFlagStore.getConfigFetchedAt.mockReturnValue('2024-01-01T00:00:00Z'); - mockFlagStore.getConfigPublishedAt.mockReturnValue('2024-01-01T00:00:00Z'); - mockFlagStore.getEnvironment.mockReturnValue(mockEnvironment); - mockFlagStore.getFormat.mockReturnValue('SERVER'); - - const result = config.getFlagConfigDetails(); - expect(result).toEqual({ - configFetchedAt: '2024-01-01T00:00:00Z', - configPublishedAt: '2024-01-01T00:00:00Z', - configEnvironment: mockEnvironment, - configFormat: 'SERVER', - }); - }); - }); - - describe('getBanditVariations', () => { - it('should return variations when they exist', () => { - const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); - const mockVariations: BanditVariation[] = [ - { - key: 'bandit-1', - variationValue: 'var-1', - flagKey: 'test-flag', - variationKey: 'test-variation', - }, - ]; - mockBanditVariationStore.get.mockReturnValue(mockVariations); - - const result = config.getFlagBanditVariations('test-flag'); - expect(result).toEqual(mockVariations); - }); - - it('should return empty array when variations do not exist', () => { - const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); - mockBanditVariationStore.get.mockReturnValue(null); - - const result = config.getFlagBanditVariations('test-flag'); - expect(result).toEqual([]); - }); - }); - - describe('getFlagKeys', () => { - it('should return flag keys from store', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - const mockKeys = ['flag-1', 'flag-2']; - mockFlagStore.getKeys.mockReturnValue(mockKeys); - - const result = config.getFlagKeys(); - expect(result).toEqual(mockKeys); - }); - }); - - describe('getFlags', () => { - it('should return all flags from store', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - const mockFlags: Record = { - 'flag-1': { key: 'flag-1' } as Flag, - 'flag-2': { key: 'flag-2' } as Flag, - }; - mockFlagStore.entries.mockReturnValue(mockFlags); - - const result = config.getFlags(); - expect(result).toEqual(mockFlags); - }); - }); - - describe('isObfuscated', () => { - it('should return true for CLIENT format', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.getFormat.mockReturnValue('CLIENT'); - - expect(config.isObfuscated()).toBe(true); - }); - - it('should return true for PRECOMPUTED format', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.getFormat.mockReturnValue('PRECOMPUTED'); - - expect(config.isObfuscated()).toBe(true); - }); - - it('should return false for SERVER format', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.getFormat.mockReturnValue('SERVER'); - - expect(config.isObfuscated()).toBe(false); - }); - - it('should return false when format is undefined', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - mockFlagStore.getFormat.mockReturnValue(null); - - expect(config.isObfuscated()).toBe(false); - }); - }); - - describe('isInitialized', () => { - it('should return false when no stores are initialized', () => { - mockFlagStore.isInitialized.mockReturnValue(false); - mockBanditVariationStore.isInitialized.mockReturnValue(false); - mockBanditModelStore.isInitialized.mockReturnValue(false); - - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - expect(config.isInitialized()).toBe(false); - }); - - it('should return true when all stores are initialized', () => { - mockFlagStore.isInitialized.mockReturnValue(true); - mockBanditVariationStore.isInitialized.mockReturnValue(true); - mockBanditModelStore.isInitialized.mockReturnValue(true); - - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - expect(config.isInitialized()).toBe(true); - }); - - it('should return true when flag store is initialized and no bandit stores are provided', () => { - mockFlagStore.isInitialized.mockReturnValue(true); - - const config = new StoreBackedConfiguration(mockFlagStore); - - expect(config.isInitialized()).toBe(true); - }); - - it('should return false if flag store is uninitialized even if bandit stores are initialized', () => { - mockFlagStore.isInitialized.mockReturnValue(false); - mockBanditVariationStore.isInitialized.mockReturnValue(true); - mockBanditModelStore.isInitialized.mockReturnValue(true); - - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - expect(config.isInitialized()).toBe(false); - }); - - it('should return false if any bandit store is uninitialized', () => { - mockFlagStore.isInitialized.mockReturnValue(true); - mockBanditVariationStore.isInitialized.mockReturnValue(true); - mockBanditModelStore.isInitialized.mockReturnValue(false); - - const config = new StoreBackedConfiguration( - mockFlagStore, - mockBanditVariationStore, - mockBanditModelStore, - ); - - expect(config.isInitialized()).toBe(false); - }); - }); - - describe('getBandits', () => { - it('should return empty object when bandit store is null', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - expect(config.getBandits()).toEqual({}); - }); - - it('should return bandits from store', () => { - const mockBandits: Record = { - 'bandit-1': { - banditKey: 'bandit-1', - modelName: 'falcon', - modelVersion: '123', - modelData: { - gamma: 0, - defaultActionScore: 0, - actionProbabilityFloor: 0, - coefficients: {}, - }, - }, - 'bandit-2': { - banditKey: 'bandit-2', - modelName: 'falcon', - modelVersion: '123', - modelData: { - gamma: 0, - defaultActionScore: 0, - actionProbabilityFloor: 0, - coefficients: {}, - }, - }, - }; - - mockBanditModelStore.entries.mockReturnValue(mockBandits); - - const config = new StoreBackedConfiguration(mockFlagStore, null, mockBanditModelStore); - - expect(config.getBandits()).toEqual(mockBandits); - }); - }); - - describe('getBanditVariations', () => { - it('should return empty variations when bandit variation store is null', () => { - const config = new StoreBackedConfiguration(mockFlagStore); - expect(config.getBanditVariations()).toEqual({}); - }); - - it('should return flag variations from store', () => { - const mockVariations: Record = { - 'bandit-1': [ - { - key: 'bandit-1', - variationValue: 'true', - flagKey: 'flag_with_bandit', - variationKey: 'bandit-1', - }, - ], - 'bandit-2': [ - { - key: 'bandit-2', - variationValue: 'true', - flagKey: 'flag_with_bandit2', - variationKey: 'bandit-2', - }, - ], - }; - - mockBanditVariationStore.entries.mockReturnValue(mockVariations); - - const config = new StoreBackedConfiguration(mockFlagStore, mockBanditVariationStore); - - expect(config.getBanditVariations()['bandit-1']).toEqual([ - { - key: 'bandit-1', - variationValue: 'true', - flagKey: 'flag_with_bandit', - variationKey: 'bandit-1', - }, - ]); - }); - }); -}); diff --git a/src/i-configuration.ts b/src/i-configuration.ts deleted file mode 100644 index 4ddf1f6..0000000 --- a/src/i-configuration.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; -import { Entry, hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; -import { OBFUSCATED_FORMATS } from './constants'; -import { - BanditParameters, - BanditVariation, - ConfigDetails, - Environment, - Flag, - ObfuscatedFlag, -} from './interfaces'; -import { BanditKey, FlagKey, HashedFlagKey } from './types'; - -// TODO(v5): remove IConfiguration once all users migrate to Configuration. -export interface IConfiguration { - getFlag(key: FlagKey | HashedFlagKey): Flag | ObfuscatedFlag | null; - getFlags(): Record; - getBandits(): Record; - getBanditVariations(): Record; - getFlagBanditVariations(flagKey: FlagKey | HashedFlagKey): BanditVariation[]; - getFlagVariationBandit( - flagKey: FlagKey | HashedFlagKey, - variationValue: string, - ): BanditParameters | null; - getBandit(key: BanditKey): BanditParameters | null; - getFlagConfigDetails(): ConfigDetails; - getFlagKeys(): FlagKey[] | HashedFlagKey[]; - isObfuscated(): boolean; - isInitialized(): boolean; -} - -export type ConfigStoreHydrationPacket = { - entries: Record; - environment: Environment; - createdAt: string; - format: string; - salt?: string; -}; - -export class StoreBackedConfiguration implements IConfiguration { - constructor( - private readonly flagConfigurationStore: IConfigurationStore, - private readonly banditVariationConfigurationStore?: IConfigurationStore< - BanditVariation[] - > | null, - private readonly banditModelConfigurationStore?: IConfigurationStore | null, - ) {} - - public async hydrateConfigurationStores( - flagConfig: ConfigStoreHydrationPacket, - banditVariationConfig?: ConfigStoreHydrationPacket, - banditModelConfig?: ConfigStoreHydrationPacket, - ) { - const didUpdateFlags = await hydrateConfigurationStore(this.flagConfigurationStore, flagConfig); - const promises: Promise[] = []; - if (this.banditVariationConfigurationStore && banditVariationConfig) { - promises.push( - hydrateConfigurationStore(this.banditVariationConfigurationStore, banditVariationConfig), - ); - } - if (this.banditModelConfigurationStore && banditModelConfig) { - promises.push( - hydrateConfigurationStore(this.banditModelConfigurationStore, banditModelConfig), - ); - } - await Promise.all(promises); - return didUpdateFlags; - } - - getBandit(key: string): BanditParameters | null { - return this.banditModelConfigurationStore?.get(key) ?? null; - } - - getFlagVariationBandit(flagKey: string, variationValue: string): BanditParameters | null { - const banditVariations = this.banditVariationConfigurationStore?.get(flagKey); - const banditKey = banditVariations?.find( - (banditVariation) => banditVariation.variationValue === variationValue, - )?.key; - - if (banditKey) { - // Retrieve the model parameters for the bandit - return this.getBandit(banditKey); - } - return null; - } - - getFlag(key: string): Flag | ObfuscatedFlag | null { - return this.flagConfigurationStore.get(key) ?? null; - } - - getFlagConfigDetails(): ConfigDetails { - return { - configFetchedAt: this.flagConfigurationStore.getConfigFetchedAt() ?? '', - configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', - configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { - name: '', - }, - configFormat: this.flagConfigurationStore.getFormat() ?? '', - }; - } - - getFlagBanditVariations(flagKey: string): BanditVariation[] { - return this.banditVariationConfigurationStore?.get(flagKey) ?? []; - } - - getFlagKeys(): string[] { - return this.flagConfigurationStore.getKeys(); - } - - getFlags(): Record { - return this.flagConfigurationStore.entries(); - } - - isObfuscated(): boolean { - return OBFUSCATED_FORMATS.includes(this.getFlagConfigDetails().configFormat ?? 'SERVER'); - } - - isInitialized() { - return ( - this.flagConfigurationStore.isInitialized() && - (!this.banditVariationConfigurationStore || - this.banditVariationConfigurationStore.isInitialized()) && - (!this.banditModelConfigurationStore || this.banditModelConfigurationStore.isInitialized()) - ); - } - - getBandits(): Record { - return this.banditModelConfigurationStore?.entries() ?? {}; - } - - getBanditVariations(): Record { - return this.banditVariationConfigurationStore?.entries() ?? {}; - } -} diff --git a/src/index.ts b/src/index.ts index fbfa117..b8b88a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,13 +22,6 @@ import EppoClient, { } from './client/eppo-client'; import { Subject } from './client/subject'; import FlagConfigRequestor from './configuration-requestor'; -import { - IConfigurationStore, - IAsyncStore, - ISyncStore, -} from './configuration-store/configuration-store'; -import { HybridConfigurationStore } from './configuration-store/hybrid.store'; -import { MemoryStore, MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, @@ -61,6 +54,7 @@ import { Variation, Environment, } from './interfaces'; +import { KVStore, MemoryStore } from './kvstore'; import { buildStorageKeySuffix } from './obfuscation'; import { AttributeType, @@ -97,12 +91,8 @@ export { IObfuscatedPrecomputedBandit, // Configuration store - IConfigurationStore, - IAsyncStore, - ISyncStore, + KVStore, MemoryStore, - HybridConfigurationStore, - MemoryOnlyConfigurationStore, // Assignment cache AssignmentCacheKey, diff --git a/src/kvstore.ts b/src/kvstore.ts new file mode 100644 index 0000000..1f22440 --- /dev/null +++ b/src/kvstore.ts @@ -0,0 +1,29 @@ +/** + * Simple key-value store interface. JS client SDK has its own implementation based on localStorage. + */ +export interface KVStore { + get(key: string): T | null; + entries(): Record; + getKeys(): string[]; + setEntries(entries: Record): void; +} + +export class MemoryStore implements KVStore { + private store: Record = {}; + + get(key: string): T | null { + return this.store[key] ?? null; + } + + entries(): Record { + return this.store; + } + + getKeys(): string[] { + return Object.keys(this.store); + } + + setEntries(entries: Record): void { + this.store = { ...entries }; + } +} From 311364d5a2ca82d44c79ff1deec74158449260a4 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 20:52:53 +0300 Subject: [PATCH 20/25] refactor: revise public/internal APIs --- package.json | 4 ++ src/index.ts | 150 +++++------------------------------------------- src/internal.ts | 53 +++++++++++++++++ 3 files changed, 71 insertions(+), 136 deletions(-) create mode 100644 src/internal.ts diff --git a/package.json b/package.json index 313b1c7..18c50c3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./internal": { + "types": "./dist/internal.d.ts", + "default": "./dist/internal.js" } }, "scripts": { diff --git a/src/index.ts b/src/index.ts index b8b88a4..b99c967 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,62 +1,21 @@ -import ApiEndpoints from './api-endpoints'; -import { logger as applicationLogger, loggerPrefix } from './application-logger'; -import { IAssignmentHooks } from './assignment-hooks'; -import { IAssignmentLogger, IAssignmentEvent } from './assignment-logger'; -import { IBanditLogger, IBanditEvent } from './bandit-logger'; -import { - AbstractAssignmentCache, - AssignmentCache, - AsyncMap, - AssignmentCacheKey, - AssignmentCacheValue, - AssignmentCacheEntry, - assignmentCacheKeyToString, - assignmentCacheValueToString, -} from './cache/abstract-assignment-cache'; -import { LRUInMemoryAssignmentCache } from './cache/lru-in-memory-assignment-cache'; -import { NonExpiringInMemoryAssignmentCache } from './cache/non-expiring-in-memory-cache-assignment'; -import EppoClient, { +// Public APIs. +// +// The section below is intended for public usage and may be re-exported by SDKs. +export { KVStore, MemoryStore } from './kvstore'; +export { IAssignmentHooks } from './assignment-hooks'; +export { IAssignmentLogger, IAssignmentEvent } from './assignment-logger'; +export { IBanditLogger, IBanditEvent } from './bandit-logger'; +export { + default as EppoClient, EppoClientParameters, IAssignmentDetails, IContainerExperiment, } from './client/eppo-client'; -import { Subject } from './client/subject'; -import FlagConfigRequestor from './configuration-requestor'; -import { - IConfigurationWire, - IObfuscatedPrecomputedConfigurationResponse, - IPrecomputedConfigurationResponse, -} from './configuration-wire/configuration-wire-types'; -import * as constants from './constants'; -import { decodePrecomputedFlag } from './decoding'; -import { EppoAssignmentLogger } from './eppo-assignment-logger'; -import BatchEventProcessor from './events/batch-event-processor'; -import { BoundedEventQueue } from './events/bounded-event-queue'; -import DefaultEventDispatcher, { - DEFAULT_EVENT_DISPATCHER_CONFIG, - DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, - newDefaultEventDispatcher, -} from './events/default-event-dispatcher'; -import Event from './events/event'; -import EventDispatcher from './events/event-dispatcher'; -import NamedEventQueue from './events/named-event-queue'; -import NetworkStatusListener from './events/network-status-listener'; -import HttpClient from './http-client'; -import { - PrecomputedFlag, - Flag, - ObfuscatedFlag, - VariationType, - FormatEnum, - BanditParameters, - BanditVariation, - IObfuscatedPrecomputedBandit, - Variation, - Environment, -} from './interfaces'; -import { KVStore, MemoryStore } from './kvstore'; -import { buildStorageKeySuffix } from './obfuscation'; -import { +export { Subject } from './client/subject'; +export * as constants from './constants'; +export { EppoAssignmentLogger } from './eppo-assignment-logger'; + +export { AttributeType, Attributes, BanditActions, @@ -64,84 +23,3 @@ import { ContextAttributes, FlagKey, } from './types'; -import * as validation from './validation'; - -export { - loggerPrefix, - applicationLogger, - AbstractAssignmentCache, - IAssignmentDetails, - IAssignmentHooks, - IAssignmentLogger, - EppoAssignmentLogger, - IAssignmentEvent, - IBanditLogger, - IBanditEvent, - IContainerExperiment, - EppoClientParameters, - EppoClient, - constants, - ApiEndpoints, - FlagConfigRequestor, - HttpClient, - validation, - - // Precomputed Client - IObfuscatedPrecomputedConfigurationResponse, - IObfuscatedPrecomputedBandit, - - // Configuration store - KVStore, - MemoryStore, - - // Assignment cache - AssignmentCacheKey, - AssignmentCacheValue, - AssignmentCacheEntry, - AssignmentCache, - AsyncMap, - NonExpiringInMemoryAssignmentCache, - LRUInMemoryAssignmentCache, - assignmentCacheKeyToString, - assignmentCacheValueToString, - - // Interfaces - Flag, - ObfuscatedFlag, - Variation, - VariationType, - AttributeType, - Attributes, - ContextAttributes, - BanditSubjectAttributes, - BanditActions, - BanditVariation, - BanditParameters, - Environment, - FormatEnum, - - // event dispatcher types - NamedEventQueue, - EventDispatcher, - BoundedEventQueue, - DEFAULT_EVENT_DISPATCHER_CONFIG, - DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, - newDefaultEventDispatcher, - BatchEventProcessor, - NetworkStatusListener, - DefaultEventDispatcher, - Event, - - // Configuration interchange. - IConfigurationWire, - IPrecomputedConfigurationResponse, - PrecomputedFlag, - FlagKey, - - // Test helpers - decodePrecomputedFlag, - - // Utilities - buildStorageKeySuffix, - Subject, -}; diff --git a/src/internal.ts b/src/internal.ts new file mode 100644 index 0000000..91490be --- /dev/null +++ b/src/internal.ts @@ -0,0 +1,53 @@ +// Internal APIs. +// +// The section below is intended for internal usage in SDKs and is not part of the public API. It is +// not subjected to semantic versioning and may change at any time. +export { loggerPrefix, logger as applicationLogger } from './application-logger'; +export { default as ApiEndpoints } from './api-endpoints'; +export { default as ConfigurationRequestor } from './configuration-requestor'; +export { default as HttpClient } from './http-client'; +export { validateNotBlank } from './validation'; +export { LRUInMemoryAssignmentCache } from './cache/lru-in-memory-assignment-cache'; +export { buildStorageKeySuffix } from './obfuscation'; +export { + AbstractAssignmentCache, + AssignmentCache, + AsyncMap, + AssignmentCacheKey, + AssignmentCacheValue, + AssignmentCacheEntry, + assignmentCacheKeyToString, + assignmentCacheValueToString, +} from './cache/abstract-assignment-cache'; +export { NonExpiringInMemoryAssignmentCache } from './cache/non-expiring-in-memory-cache-assignment'; +export { + IConfigurationWire, + IObfuscatedPrecomputedConfigurationResponse, + IPrecomputedConfigurationResponse, +} from './configuration-wire/configuration-wire-types'; +export { decodePrecomputedFlag } from './decoding'; +export { default as BatchEventProcessor } from './events/batch-event-processor'; +export { BoundedEventQueue } from './events/bounded-event-queue'; +export { + default as DefaultEventDispatcher, + DEFAULT_EVENT_DISPATCHER_CONFIG, + DEFAULT_EVENT_DISPATCHER_BATCH_SIZE, + newDefaultEventDispatcher, +} from './events/default-event-dispatcher'; +export { default as Event } from './events/event'; +export { default as EventDispatcher } from './events/event-dispatcher'; +export { default as NamedEventQueue } from './events/named-event-queue'; +export { default as NetworkStatusListener } from './events/network-status-listener'; +export { + PrecomputedFlag, + Flag, + ObfuscatedFlag, + VariationType, + FormatEnum, + BanditParameters, + BanditVariation, + IObfuscatedPrecomputedBandit, + Variation, + Environment, +} from './interfaces'; +export { FlagKey } from './types'; From 9647e30e72517b59caaa4d154cf7a955ac0d41fd Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 20:53:45 +0300 Subject: [PATCH 21/25] refactor: cleanup/prettier --- src/application-logger.ts | 3 +- src/broadcast.ts | 4 +- src/cache/tlru-cache.ts | 5 +- .../eppo-client-assignment-details.spec.ts | 5 +- .../eppo-client-experiment-container.spec.ts | 2 +- src/client/eppo-client-with-bandits.spec.ts | 10 +- src/client/eppo-client-with-overrides.spec.ts | 8 +- src/client/eppo-client.precomputed.spec.ts | 68 +++++--- src/client/eppo-client.spec.ts | 6 +- src/client/subject.ts | 157 ++++++++++-------- src/client/test-utils.ts | 19 --- src/configuration-feed.ts | 2 +- src/configuration-poller.ts | 19 ++- src/configuration-store.ts | 63 ++++--- src/evaluator.spec.ts | 28 +++- src/events/batch-event-processor.ts | 5 +- src/events/event-delivery.ts | 5 +- src/flag-evaluation-details-builder.ts | 2 +- src/override-validator.ts | 8 +- src/persistent-configuration-cache.ts | 9 +- src/rules.ts | 32 ++-- src/salt.ts | 2 +- 22 files changed, 254 insertions(+), 208 deletions(-) delete mode 100644 src/client/test-utils.ts diff --git a/src/application-logger.ts b/src/application-logger.ts index a4ae490..ff36fe1 100644 --- a/src/application-logger.ts +++ b/src/application-logger.ts @@ -1,8 +1,9 @@ import pino from 'pino'; +/** @internal */ export const loggerPrefix = '[Eppo SDK]'; -// Create a Pino logger instance +/** @internal */ export const logger = pino({ // eslint-disable-next-line no-restricted-globals level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'warn' : 'info'), diff --git a/src/broadcast.ts b/src/broadcast.ts index b308ed8..eff2233 100644 --- a/src/broadcast.ts +++ b/src/broadcast.ts @@ -2,7 +2,7 @@ export type Listener = (...args: T) => void; /** * A broadcast channel for dispatching events to multiple listeners. - * + * * @internal */ export class BroadcastChannel { @@ -29,4 +29,4 @@ export class BroadcastChannel { } } } -} \ No newline at end of file +} diff --git a/src/cache/tlru-cache.ts b/src/cache/tlru-cache.ts index ad5201e..c16f14e 100644 --- a/src/cache/tlru-cache.ts +++ b/src/cache/tlru-cache.ts @@ -8,10 +8,7 @@ import { LRUCache } from './lru-cache'; **/ export class TLRUCache extends LRUCache { private readonly cacheEntriesTTLRegistry = new Map(); - constructor( - readonly maxSize: number, - readonly ttl: number, - ) { + constructor(readonly maxSize: number, readonly ttl: number) { super(maxSize); } diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index f0e3927..e7ab8fc 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -1,9 +1,6 @@ import * as fs from 'fs'; -import { - IAssignmentTestCase, - readMockUfcConfiguration, -} from '../../test/testHelpers'; +import { IAssignmentTestCase, readMockUfcConfiguration } from '../../test/testHelpers'; import { AllocationEvaluationCode } from '../flag-evaluation-details-builder'; import { Variation, VariationType } from '../interfaces'; import { OperatorType } from '../rules'; diff --git a/src/client/eppo-client-experiment-container.spec.ts b/src/client/eppo-client-experiment-container.spec.ts index 12d4ca8..e704cb9 100644 --- a/src/client/eppo-client-experiment-container.spec.ts +++ b/src/client/eppo-client-experiment-container.spec.ts @@ -17,7 +17,7 @@ describe('getExperimentContainerEntry', () => { let loggerWarnSpy: jest.SpyInstance; beforeEach(async () => { - client = new EppoClient({ + client = new EppoClient({ configuration: { initializationStrategy: 'none', initialConfiguration: readMockUfcConfiguration(), diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index f1d0764..35c576d 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -517,7 +517,7 @@ describe('EppoClient Bandits E2E test', () => { unmatchedAllocations: [], unevaluatedAllocations: [], }; - + return { assignmentDetails: { flagKey, @@ -549,7 +549,7 @@ describe('EppoClient Bandits E2E test', () => { }, evaluationDetails, entityId: null, - } + }, }; }); @@ -681,11 +681,7 @@ describe('EppoClient Bandits E2E test', () => { subjectAttributes: ContextAttributes, banditActions: Record, ): Configuration { - return client.getPrecomputedConfiguration( - subjectKey, - subjectAttributes, - banditActions, - ); + return client.getPrecomputedConfiguration(subjectKey, subjectAttributes, banditActions); } describe('obfuscated results', () => { diff --git a/src/client/eppo-client-with-overrides.spec.ts b/src/client/eppo-client-with-overrides.spec.ts index 69a5247..98e9b71 100644 --- a/src/client/eppo-client-with-overrides.spec.ts +++ b/src/client/eppo-client-with-overrides.spec.ts @@ -5,9 +5,7 @@ import * as overrideValidatorModule from '../override-validator'; import EppoClient from './eppo-client'; describe('EppoClient', () => { - function setUnobfuscatedFlagEntries( - entries: Record, - ): EppoClient { + function setUnobfuscatedFlagEntries(entries: Record): EppoClient { return new EppoClient({ sdkKey: 'dummy', sdkName: 'js-client-sdk-common', @@ -26,8 +24,8 @@ describe('EppoClient', () => { }, banditReferences: {}, }, - } - }) + }, + }), }, }); } diff --git a/src/client/eppo-client.precomputed.spec.ts b/src/client/eppo-client.precomputed.spec.ts index 7d3d50c..c0c1537 100644 --- a/src/client/eppo-client.precomputed.spec.ts +++ b/src/client/eppo-client.precomputed.spec.ts @@ -11,7 +11,9 @@ import EppoClient from './eppo-client'; describe('EppoClient Precomputed Mode', () => { // Read both configurations for test reference - const precomputedConfigurationWire = readMockConfigurationWireResponse(MOCK_PRECOMPUTED_WIRE_FILE); + const precomputedConfigurationWire = readMockConfigurationWireResponse( + MOCK_PRECOMPUTED_WIRE_FILE, + ); const initialConfiguration = Configuration.fromString(precomputedConfigurationWire); let client: EppoClient; @@ -21,7 +23,7 @@ describe('EppoClient Precomputed Mode', () => { beforeEach(() => { mockAssignmentLogger = { logAssignment: jest.fn() } as jest.Mocked; mockBanditLogger = { logBanditAction: jest.fn() } as jest.Mocked; - + // Create EppoClient with precomputed configuration client = new EppoClient({ sdkKey: 'test-key', @@ -33,7 +35,7 @@ describe('EppoClient Precomputed Mode', () => { enablePolling: false, }, }); - + client.setAssignmentLogger(mockAssignmentLogger); client.setBanditLogger(mockBanditLogger); }); @@ -67,29 +69,40 @@ describe('EppoClient Precomputed Mode', () => { expect(result).toEqual({ key: 'value', number: 123 }); expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); }); - + it('correctly evaluates flag with extra logging', () => { - const result = client.getStringAssignment('string-flag-with-extra-logging', 'test-subject-key', {}, 'default'); + const result = client.getStringAssignment( + 'string-flag-with-extra-logging', + 'test-subject-key', + {}, + 'default', + ); expect(result).toBe('red'); expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); }); it('logs bandit evaluation for flag with bandit data', () => { const banditActions = { - 'show_red_button': { + show_red_button: { expectedConversion: 0.23, expectedRevenue: 15.75, category: 'promotion', - placement: 'home_screen' - } + placement: 'home_screen', + }, }; - - const result = client.getBanditAction('string-flag', 'test-subject-key', {}, banditActions, 'default'); - + + const result = client.getBanditAction( + 'string-flag', + 'test-subject-key', + {}, + banditActions, + 'default', + ); + expect(result.variation).toBe('red'); expect(result.action).toBe('show_red_button'); expect(mockBanditLogger.logBanditAction).toHaveBeenCalledTimes(1); - + const call = mockBanditLogger.logBanditAction.mock.calls[0][0]; expect(call.bandit).toBe('recommendation-model-v1'); expect(call.action).toBe('show_red_button'); @@ -99,22 +112,37 @@ describe('EppoClient Precomputed Mode', () => { }); it('returns default values for nonexistent flags', () => { - const stringResult = client.getStringAssignment('nonexistent-flag', 'test-subject-key', {}, 'default-string'); + const stringResult = client.getStringAssignment( + 'nonexistent-flag', + 'test-subject-key', + {}, + 'default-string', + ); expect(stringResult).toBe('default-string'); - - const boolResult = client.getBooleanAssignment('nonexistent-flag', 'test-subject-key', {}, true); + + const boolResult = client.getBooleanAssignment( + 'nonexistent-flag', + 'test-subject-key', + {}, + true, + ); expect(boolResult).toBe(true); - + const intResult = client.getIntegerAssignment('nonexistent-flag', 'test-subject-key', {}, 100); expect(intResult).toBe(100); }); it('correctly handles assignment details', () => { - const details = client.getStringAssignmentDetails('string-flag', 'test-subject-key', {}, 'default'); - + const details = client.getStringAssignmentDetails( + 'string-flag', + 'test-subject-key', + {}, + 'default', + ); + expect(details.variation).toBe('red'); expect(details.evaluationDetails.variationKey).toBe('variation-123'); - + // Assignment should be logged expect(mockAssignmentLogger.logAssignment).toHaveBeenCalledTimes(1); const call = mockAssignmentLogger.logAssignment.mock.calls[0][0]; @@ -122,4 +150,4 @@ describe('EppoClient Precomputed Mode', () => { expect(call.featureFlag).toBe('string-flag'); expect(call.subject).toBe('test-subject-key'); }); -}); \ No newline at end of file +}); diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 65cf721..b555854 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -2,10 +2,7 @@ import * as base64 from 'js-base64'; import { times } from 'lodash'; import * as td from 'testdouble'; -import { - MOCK_UFC_RESPONSE_FILE, - readMockUFCResponse, -} from '../../test/testHelpers'; +import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { @@ -28,7 +25,6 @@ jest.mock('../salt', () => ({ })); describe('EppoClient E2E test', () => { - // Configure fetch mock for tests that still need it global.fetch = jest.fn(() => { const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); diff --git a/src/client/subject.ts b/src/client/subject.ts index 37b202c..a914c7f 100644 --- a/src/client/subject.ts +++ b/src/client/subject.ts @@ -18,7 +18,7 @@ export class Subject { /** * @internal Creates a new Subject instance. - * + * * @param client The EppoClient instance to wrap * @param subjectKey The subject key to use for all assignments * @param subjectAttributes The subject attributes to use for all assignments @@ -28,7 +28,7 @@ export class Subject { client: EppoClient, subjectKey: string, subjectAttributes: Attributes | ContextAttributes, - banditActions: Record + banditActions: Record, ) { this.client = client; this.subjectKey = subjectKey; @@ -52,10 +52,10 @@ export class Subject { */ public getStringAssignment(flagKey: string, defaultValue: string): string { return this.client.getStringAssignment( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -67,12 +67,15 @@ export class Subject { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an object that includes the variation value along with additional metadata about the assignment */ - public getStringAssignmentDetails(flagKey: string, defaultValue: string): IAssignmentDetails { + public getStringAssignmentDetails( + flagKey: string, + defaultValue: string, + ): IAssignmentDetails { return this.client.getStringAssignmentDetails( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -85,10 +88,10 @@ export class Subject { */ public getBooleanAssignment(flagKey: string, defaultValue: boolean): boolean { return this.client.getBooleanAssignment( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -100,12 +103,15 @@ export class Subject { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an object that includes the variation value along with additional metadata about the assignment */ - public getBooleanAssignmentDetails(flagKey: string, defaultValue: boolean): IAssignmentDetails { + public getBooleanAssignmentDetails( + flagKey: string, + defaultValue: boolean, + ): IAssignmentDetails { return this.client.getBooleanAssignmentDetails( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -118,10 +124,10 @@ export class Subject { */ public getIntegerAssignment(flagKey: string, defaultValue: number): number { return this.client.getIntegerAssignment( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -133,12 +139,15 @@ export class Subject { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an object that includes the variation value along with additional metadata about the assignment */ - public getIntegerAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails { + public getIntegerAssignmentDetails( + flagKey: string, + defaultValue: number, + ): IAssignmentDetails { return this.client.getIntegerAssignmentDetails( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -151,10 +160,10 @@ export class Subject { */ public getNumericAssignment(flagKey: string, defaultValue: number): number { return this.client.getNumericAssignment( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -166,12 +175,15 @@ export class Subject { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an object that includes the variation value along with additional metadata about the assignment */ - public getNumericAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails { + public getNumericAssignmentDetails( + flagKey: string, + defaultValue: number, + ): IAssignmentDetails { return this.client.getNumericAssignmentDetails( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -184,10 +196,10 @@ export class Subject { */ public getJSONAssignment(flagKey: string, defaultValue: object): object { return this.client.getJSONAssignment( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } @@ -199,28 +211,39 @@ export class Subject { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an object that includes the variation value along with additional metadata about the assignment */ - public getJSONAssignmentDetails(flagKey: string, defaultValue: object): IAssignmentDetails { + public getJSONAssignmentDetails( + flagKey: string, + defaultValue: object, + ): IAssignmentDetails { return this.client.getJSONAssignmentDetails( - flagKey, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes), - defaultValue + flagKey, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), + defaultValue, ); } public getBanditAction( - flagKey: string, + flagKey: string, defaultValue: string, ): Omit, 'evaluationDetails'> { - return this.client.getBanditAction(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue); + return this.client.getBanditAction( + flagKey, + this.subjectKey, + this.subjectAttributes, + this.banditActions?.[flagKey] ?? {}, + defaultValue, + ); } - - public getBanditActionDetails( - flagKey: string, - defaultValue: string, - ): IAssignmentDetails { - return this.client.getBanditActionDetails(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue); + public getBanditActionDetails(flagKey: string, defaultValue: string): IAssignmentDetails { + return this.client.getBanditActionDetails( + flagKey, + this.subjectKey, + this.subjectAttributes, + this.banditActions?.[flagKey] ?? {}, + defaultValue, + ); } /** @@ -234,11 +257,13 @@ export class Subject { * Only use this method under certain circumstances (i.e. where the impact of the choice of bandit cannot be measured, * but you want to put the "best foot forward", for example, when being web-crawled). */ - public getBestAction( - flagKey: string, - defaultAction: string, - ): string { - return this.client.getBestAction(flagKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultAction); + public getBestAction(flagKey: string, defaultAction: string): string { + return this.client.getBestAction( + flagKey, + this.subjectAttributes, + this.banditActions?.[flagKey] ?? {}, + defaultAction, + ); } /** @@ -255,9 +280,9 @@ export class Subject { */ public getExperimentContainerEntry(flagExperiment: IContainerExperiment): T { return this.client.getExperimentContainerEntry( - flagExperiment, - this.subjectKey, - ensureNonContextualSubjectAttributes(this.subjectAttributes) + flagExperiment, + this.subjectKey, + ensureNonContextualSubjectAttributes(this.subjectAttributes), ); } @@ -268,8 +293,8 @@ export class Subject { */ public getPrecomputedConfiguration(): Configuration { return this.client.getPrecomputedConfiguration( - this.subjectKey, - this.subjectAttributes, + this.subjectKey, + this.subjectAttributes, this.banditActions || {}, ); } @@ -282,4 +307,4 @@ export class Subject { public waitForInitialization(): Promise { return this.client.waitForInitialization(); } -} \ No newline at end of file +} diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts deleted file mode 100644 index d3a3d7a..0000000 --- a/src/client/test-utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import { BroadcastChannel } from '../broadcast'; -import { Configuration } from '../configuration'; -import ConfigurationRequestor from '../configuration-requestor'; -import FetchHttpClient from '../http-client'; - -export async function initConfiguration(): Promise { - const apiEndpoints = new ApiEndpoints({ - baseUrl: 'http://127.0.0.1:4000', - queryParams: { - apiKey: 'dummy', - sdkName: 'js-client-sdk-common', - sdkVersion: '3.0.0', - }, - }); - const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const configurationRequestor = new ConfigurationRequestor(httpClient, new BroadcastChannel()); - return await configurationRequestor.fetchConfiguration(); -} diff --git a/src/configuration-feed.ts b/src/configuration-feed.ts index 9338579..fe5b8eb 100644 --- a/src/configuration-feed.ts +++ b/src/configuration-feed.ts @@ -12,7 +12,7 @@ export enum ConfigurationSource { /** * Configuration was loaded from the network. */ - Network = 'network' + Network = 'network', } /** diff --git a/src/configuration-poller.ts b/src/configuration-poller.ts index 7157496..143643d 100644 --- a/src/configuration-poller.ts +++ b/src/configuration-poller.ts @@ -6,9 +6,9 @@ import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; /** * Polls for new configurations from the Eppo server. When a new configuration is fetched, * it is published to the configuration feed. - * + * * The poller is created in the stopped state. Call `start` to begin polling. - * + * * @internal */ export class ConfigurationPoller { @@ -26,7 +26,7 @@ export class ConfigurationPoller { public constructor( private readonly configurationRequestor: ConfigurationRequestor, options: { - configurationFeed?: ConfigurationFeed, + configurationFeed?: ConfigurationFeed; basePollingIntervalMs: number; maxPollingIntervalMs: number; maxAgeMs: number; @@ -56,7 +56,7 @@ export class ConfigurationPoller { /** * Starts the configuration poller. - * + * * This method will start polling for new configurations from the Eppo server. * It will continue to poll until the `stop` method is called. */ @@ -74,7 +74,7 @@ export class ConfigurationPoller { /** * Stops the configuration poller. - * + * * This method will stop polling for new configurations from the Eppo server. Note that it will * not interrupt the current poll cycle / active fetch, but it will make sure that configuration * listeners are not notified of any new configurations after this method is called. @@ -112,7 +112,10 @@ export class ConfigurationPoller { await timeout(this.basePollingIntervalMs + randomJitterMs(this.basePollingIntervalMs)); } else { // Exponential backoff capped at maxPollingIntervalMs. - const baseDelayMs = Math.min((Math.pow(2, consecutiveFailures) * this.basePollingIntervalMs), this.maxPollingIntervalMs); + const baseDelayMs = Math.min( + Math.pow(2, consecutiveFailures) * this.basePollingIntervalMs, + this.maxPollingIntervalMs, + ); const delayMs = baseDelayMs + randomJitterMs(baseDelayMs); logger.warn({ delayMs, consecutiveFailures }, '[Eppo SDK] will try polling again'); @@ -124,7 +127,7 @@ export class ConfigurationPoller { } function timeout(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } /** @@ -145,4 +148,4 @@ export function randomJitterMs(intervalMs: number) { 1, ); return halfPossibleJitter + randomOtherHalfJitter; -} \ No newline at end of file +} diff --git a/src/configuration-store.ts b/src/configuration-store.ts index 841f6e1..a293527 100644 --- a/src/configuration-store.ts +++ b/src/configuration-store.ts @@ -3,28 +3,32 @@ import { Configuration } from './configuration'; import { ConfigurationFeed } from './configuration-feed'; import { BroadcastChannel } from './broadcast'; -export type ActivationStrategy = { - /** - * Always activate new configuration. - */ - type: 'always'; -} | { - /** - * Activate new configuration if the current configuration is stale (older than maxAgeSeconds). - */ - type: 'stale'; - maxAgeSeconds: number; -} | { - /** - * Activate new configuration if the current configuration is empty. - */ - type: 'empty'; -} | { - /** - * Never activate new configuration. - */ - type: 'never'; -}; +export type ActivationStrategy = + | { + /** + * Always activate new configuration. + */ + type: 'always'; + } + | { + /** + * Activate new configuration if the current configuration is stale (older than maxAgeSeconds). + */ + type: 'stale'; + maxAgeSeconds: number; + } + | { + /** + * Activate new configuration if the current configuration is empty. + */ + type: 'empty'; + } + | { + /** + * Never activate new configuration. + */ + type: 'never'; + }; /** * `ConfigurationStore` answers a simple question: what configuration is currently active? @@ -43,7 +47,10 @@ export class ConfigurationStore { * Register configuration store to receive updates from a configuration feed using the specified * activation strategy. */ - public register(configurationFeed: ConfigurationFeed, activationStrategy: ActivationStrategy): void { + public register( + configurationFeed: ConfigurationFeed, + activationStrategy: ActivationStrategy, + ): void { if (activationStrategy.type === 'never') { // No need to subscribe to configuration feed if we don't want to activate any configuration. return; @@ -51,9 +58,11 @@ export class ConfigurationStore { configurationFeed.addListener((configuration) => { const currentConfiguration = this.getConfiguration(); - const shouldActivate = activationStrategy.type === 'always' - || (activationStrategy.type === 'stale' && currentConfiguration.isStale(activationStrategy.maxAgeSeconds)) - || (activationStrategy.type === 'empty' && currentConfiguration.isEmpty()); + const shouldActivate = + activationStrategy.type === 'always' || + (activationStrategy.type === 'stale' && + currentConfiguration.isStale(activationStrategy.maxAgeSeconds)) || + (activationStrategy.type === 'empty' && currentConfiguration.isEmpty()); if (shouldActivate) { this.setConfiguration(configuration); @@ -85,4 +94,4 @@ export class ConfigurationStore { public onConfigurationChange(listener: (configuration: Configuration) => void): () => void { return this.listeners.addListener(listener); } -} \ No newline at end of file +} diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index 06a2ade..3bf238d 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -77,7 +77,9 @@ describe('Evaluator', () => { expect(evaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); - const deterministicEvaluator = new Evaluator({ sharder: new DeterministicSharder({ subject_key: 50 }) }); + const deterministicEvaluator = new Evaluator({ + sharder: new DeterministicSharder({ subject_key: 50 }), + }); expect(deterministicEvaluator.matchesShard(shard, 'subject_key', 100)).toBeTruthy(); }); @@ -278,7 +280,9 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@example.com' }); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { + email: 'eppo@example.com', + }); expect(result.assignmentDetails.flagKey).toEqual('flag'); expect(result.assignmentDetails.allocationKey).toEqual('first'); expect(result.assignmentDetails.variation).toEqual(VARIATION_B); @@ -325,7 +329,9 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { + email: 'eppo@test.com', + }); expect(result.assignmentDetails.flagKey).toEqual('flag'); expect(result.assignmentDetails.allocationKey).toEqual('default'); expect(result.assignmentDetails.variation).toEqual(VARIATION_A); @@ -376,7 +382,9 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { email: 'eppo@test.com' }); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', { + email: 'eppo@test.com', + }); expect(result.assignmentDetails.flagKey).toEqual('obfuscated_flag_key'); expect(result.assignmentDetails.allocationKey).toEqual('default'); expect(result.assignmentDetails.variation).toEqual(VARIATION_A); @@ -440,16 +448,20 @@ describe('Evaluator', () => { }); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).assignmentDetails.variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).assignmentDetails + .variation, ).toEqual(VARIATION_A); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).assignmentDetails.variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).assignmentDetails + .variation, ).toEqual(VARIATION_B); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).assignmentDetails.variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).assignmentDetails + .variation, ).toEqual(VARIATION_C); expect( - deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).assignmentDetails.variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).assignmentDetails + .variation, ).toEqual(VARIATION_C); }); diff --git a/src/events/batch-event-processor.ts b/src/events/batch-event-processor.ts index 666d47d..4b1c1cf 100644 --- a/src/events/batch-event-processor.ts +++ b/src/events/batch-event-processor.ts @@ -7,10 +7,7 @@ const MAX_BATCH_SIZE = 10_000; export default class BatchEventProcessor { private readonly batchSize: number; - constructor( - private readonly eventQueue: NamedEventQueue, - batchSize: number, - ) { + constructor(private readonly eventQueue: NamedEventQueue, batchSize: number) { // clamp batch size between min and max this.batchSize = Math.max(MIN_BATCH_SIZE, Math.min(MAX_BATCH_SIZE, batchSize)); } diff --git a/src/events/event-delivery.ts b/src/events/event-delivery.ts index 310afd2..a300474 100644 --- a/src/events/event-delivery.ts +++ b/src/events/event-delivery.ts @@ -9,10 +9,7 @@ export type EventDeliveryResult = { }; export default class EventDelivery implements IEventDelivery { - constructor( - private readonly sdkKey: string, - private readonly ingestionUrl: string, - ) {} + constructor(private readonly sdkKey: string, private readonly ingestionUrl: string) {} /** * Delivers a batch of events to the ingestion URL endpoint. Returns the UUIDs of any events from diff --git a/src/flag-evaluation-details-builder.ts b/src/flag-evaluation-details-builder.ts index a932d5d..8f45bb3 100644 --- a/src/flag-evaluation-details-builder.ts +++ b/src/flag-evaluation-details-builder.ts @@ -141,7 +141,7 @@ export class FlagEvaluationDetailsBuilder { key: allocation.key, allocationEvaluationCode: AllocationEvaluationCode.UNEVALUATED, orderPosition: unevaluatedStartOrderPosition + i, - }) as AllocationEvaluation, + } as AllocationEvaluation), ); }; } diff --git a/src/override-validator.ts b/src/override-validator.ts index c2a5986..4a81e1b 100644 --- a/src/override-validator.ts +++ b/src/override-validator.ts @@ -53,12 +53,16 @@ export class OverrideValidator { } if (typeof parsed['browserExtensionKey'] !== 'string') { throw new Error( - `Invalid type for 'browserExtensionKey'. Expected string, but received ${typeof parsed['browserExtensionKey']}`, + `Invalid type for 'browserExtensionKey'. Expected string, but received ${typeof parsed[ + 'browserExtensionKey' + ]}`, ); } if (typeof parsed['overrides'] !== 'object') { throw new Error( - `Invalid type for 'overrides'. Expected object, but received ${typeof parsed['overrides']}.`, + `Invalid type for 'overrides'. Expected object, but received ${typeof parsed[ + 'overrides' + ]}.`, ); } } diff --git a/src/persistent-configuration-cache.ts b/src/persistent-configuration-cache.ts index 7bd293a..10b4319 100644 --- a/src/persistent-configuration-cache.ts +++ b/src/persistent-configuration-cache.ts @@ -46,13 +46,18 @@ export class PersistentConfigurationCache { }); } - public async loadConfiguration({ maxStaleSeconds = Infinity }: { maxStaleSeconds?: number } = {}): Promise { + public async loadConfiguration({ + maxStaleSeconds = Infinity, + }: { maxStaleSeconds?: number } = {}): Promise { try { const configuration = await this.storage.loadConfiguration(); if (configuration) { const age = configuration.getAgeMs(); if (age !== undefined && age > maxStaleSeconds * 1000) { - logger.debug({ age, maxStaleSeconds }, '[Eppo SDK] Cached configuration is too old to be used'); + logger.debug( + { age, maxStaleSeconds }, + '[Eppo SDK] Cached configuration is too old to be used', + ); return null; } diff --git a/src/rules.ts b/src/rules.ts index 901c2b0..09ddf1d 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -173,10 +173,10 @@ function evaluateCondition(subjectAttributes: Record, condition: Co condition.operator === OperatorType.GTE ? semverGte : condition.operator === OperatorType.GT - ? semverGt - : condition.operator === OperatorType.LTE - ? semverLte - : semverLt; + ? semverGt + : condition.operator === OperatorType.LTE + ? semverLte + : semverLt; return compareSemVer(value, condition.value, comparator); } @@ -184,10 +184,10 @@ function evaluateCondition(subjectAttributes: Record, condition: Co condition.operator === OperatorType.GTE ? a >= b : condition.operator === OperatorType.GT - ? a > b - : condition.operator === OperatorType.LTE - ? a <= b - : a < b; + ? a > b + : condition.operator === OperatorType.LTE + ? a <= b + : a < b; return compareNumber(value, condition.value, comparator); } case OperatorType.MATCHES: @@ -229,10 +229,10 @@ function evaluateObfuscatedCondition( condition.operator === ObfuscatedOperatorType.GTE ? semverGte : condition.operator === ObfuscatedOperatorType.GT - ? semverGt - : condition.operator === ObfuscatedOperatorType.LTE - ? semverLte - : semverLt; + ? semverGt + : condition.operator === ObfuscatedOperatorType.LTE + ? semverLte + : semverLt; return compareSemVer(value, conditionValue, comparator); } @@ -240,10 +240,10 @@ function evaluateObfuscatedCondition( condition.operator === ObfuscatedOperatorType.GTE ? a >= b : condition.operator === ObfuscatedOperatorType.GT - ? a > b - : condition.operator === ObfuscatedOperatorType.LTE - ? a <= b - : a < b; + ? a > b + : condition.operator === ObfuscatedOperatorType.LTE + ? a <= b + : a < b; return compareNumber(value, Number(conditionValue), comparator); } case ObfuscatedOperatorType.MATCHES: diff --git a/src/salt.ts b/src/salt.ts index 4f3a7b9..3422416 100644 --- a/src/salt.ts +++ b/src/salt.ts @@ -12,4 +12,4 @@ export function generateSalt() { // UUIDv4 has enough entropy for our purposes. Where available, uuid uses crypto.randomUUID(), // which uses secure random number generation. return uuidv4(); -} \ No newline at end of file +} From 4c9a8f82fec0bca212cdd2c7168f7f1aa90b3569 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 21:01:18 +0300 Subject: [PATCH 22/25] refactor: remove IConfigurationWire --- src/client/eppo-client.ts | 7 +- .../configuration-wire-types.spec.ts | 113 --------- .../configuration-wire-types.ts | 233 ------------------ src/configuration.ts | 2 +- src/http-client.ts | 2 +- src/index.ts | 2 +- src/internal.ts | 3 +- src/precomputed-configuration.ts | 40 +++ 8 files changed, 50 insertions(+), 352 deletions(-) delete mode 100644 src/configuration-wire/configuration-wire-types.spec.ts delete mode 100644 src/configuration-wire/configuration-wire-types.ts create mode 100644 src/precomputed-configuration.ts diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index a1e63f8..c6566c1 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -17,7 +17,7 @@ import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment- import { Configuration, PrecomputedConfig } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; -import { IObfuscatedPrecomputedConfigurationResponse } from '../configuration-wire/configuration-wire-types'; +import { IObfuscatedPrecomputedConfigurationResponse } from '../precomputed-configuration'; import { DEFAULT_BASE_POLLING_INTERVAL_MS, DEFAULT_MAX_POLLING_INTERVAL_MS, @@ -1401,6 +1401,8 @@ export default class EppoClient { /** * Internal helper that evaluates a flag assignment without logging * Returns the evaluation result that can be used for logging + * + * @todo This belongs to Evaluator class. */ private evaluateAssignment( flagKey: string, @@ -1505,6 +1507,9 @@ export default class EppoClient { return result; } + /** + * @todo This belongs to Evaluator class. + */ private evaluatePrecomputedAssignment( precomputed: PrecomputedConfig, flagKey: string, diff --git a/src/configuration-wire/configuration-wire-types.spec.ts b/src/configuration-wire/configuration-wire-types.spec.ts deleted file mode 100644 index c90e8ac..0000000 --- a/src/configuration-wire/configuration-wire-types.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - MOCK_BANDIT_MODELS_RESPONSE_FILE, - MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE, - readMockUFCResponse, -} from '../../test/testHelpers'; -import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; -import { FormatEnum } from '../interfaces'; - -import { ConfigurationWireV1, deflateResponse, inflateResponse } from './configuration-wire-types'; - -describe('Response String Type Safety', () => { - const mockFlagConfig: IUniversalFlagConfigResponse = readMockUFCResponse( - MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE, - ) as IUniversalFlagConfigResponse; - const mockBanditConfig: IBanditParametersResponse = readMockUFCResponse( - MOCK_BANDIT_MODELS_RESPONSE_FILE, - ) as IBanditParametersResponse; - - describe('deflateResponse and inflateResponse', () => { - it('should correctly serialize and deserialize flag config', () => { - const serialized = deflateResponse(mockFlagConfig); - const deserialized = inflateResponse(serialized); - - expect(deserialized).toEqual(mockFlagConfig); - }); - - it('should correctly serialize and deserialize bandit config', () => { - const serialized = deflateResponse(mockBanditConfig); - const deserialized = inflateResponse(serialized); - - expect(deserialized).toEqual(mockBanditConfig); - }); - - it('should maintain type information through serialization', () => { - const serialized = deflateResponse(mockFlagConfig); - const deserialized = inflateResponse(serialized); - - // TypeScript compilation check: these should work - expect(deserialized.format).toBe(FormatEnum.SERVER); - expect(deserialized.environment).toStrictEqual({ name: 'Test' }); - }); - }); - - describe('ConfigurationWireV1', () => { - it('should create configuration with flag config', () => { - const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig); - - expect(wirePacket.version).toBe(1); - expect(wirePacket.config).toBeDefined(); - expect(wirePacket.bandits).toBeUndefined(); - - // Verify we can deserialize the response - expect(wirePacket.config).toBeTruthy(); - if (!wirePacket.config) { - fail('Flag config not present in ConfigurationWire'); - } - const deserializedConfig = inflateResponse(wirePacket.config.response); - expect(deserializedConfig).toEqual(mockFlagConfig); - }); - - it('should create configuration with both flag and bandit configs', () => { - const wirePacket = ConfigurationWireV1.fromResponses( - mockFlagConfig, - mockBanditConfig, - 'flag-etag', - 'bandit-etag', - ); - - if (!wirePacket.config) { - fail('Flag config not present in ConfigurationWire'); - } - if (!wirePacket.bandits) { - fail('Bandit Model Parameters not present in ConfigurationWire'); - } - - expect(wirePacket.version).toBe(1); - expect(wirePacket.config).toBeDefined(); - expect(wirePacket.bandits).toBeDefined(); - expect(wirePacket.config.etag).toBe('flag-etag'); - expect(wirePacket.bandits.etag).toBe('bandit-etag'); - - // Verify we can deserialize both responses - const deserializedConfig = inflateResponse(wirePacket.config.response); - const deserializedBandits = inflateResponse(wirePacket.bandits.response); - - expect(deserializedConfig).toEqual(mockFlagConfig); - expect(deserializedBandits).toEqual(mockBanditConfig); - }); - - it('should create empty configuration', () => { - const config = ConfigurationWireV1.empty(); - - expect(config.version).toBe(1); - expect(config.config).toBeUndefined(); - expect(config.bandits).toBeUndefined(); - expect(config.precomputed).toBeUndefined(); - }); - - it('should include fetchedAt timestamps', () => { - const wirePacket = ConfigurationWireV1.fromResponses(mockFlagConfig, mockBanditConfig); - - if (!wirePacket.config) { - fail('Flag config not present in ConfigurationWire'); - } - if (!wirePacket.bandits) { - fail('Bandit Model Parameters not present in ConfigurationWire'); - } - expect(wirePacket.config.fetchedAt).toBeDefined(); - expect(Date.parse(wirePacket.config.fetchedAt ?? '')).not.toBeNaN(); - expect(Date.parse(wirePacket.bandits.fetchedAt ?? '')).not.toBeNaN(); - }); - }); -}); diff --git a/src/configuration-wire/configuration-wire-types.ts b/src/configuration-wire/configuration-wire-types.ts deleted file mode 100644 index 9d1a8d2..0000000 --- a/src/configuration-wire/configuration-wire-types.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { IUniversalFlagConfigResponse, IBanditParametersResponse } from '../http-client'; -import { - Environment, - FormatEnum, - IObfuscatedPrecomputedBandit, - IPrecomputedBandit, - PrecomputedFlag, -} from '../interfaces'; -import { obfuscatePrecomputedBanditMap, obfuscatePrecomputedFlags } from '../obfuscation'; -import { ContextAttributes, FlagKey, HashedFlagKey } from '../types'; - -// Base interface for all configuration responses -interface IBasePrecomputedConfigurationResponse { - readonly format: FormatEnum.PRECOMPUTED; - readonly obfuscated: boolean; - readonly createdAt: string; - readonly environment?: Environment; -} - -export interface IPrecomputedConfigurationResponse extends IBasePrecomputedConfigurationResponse { - readonly obfuscated: false; // Always false - readonly flags: Record; - readonly bandits: Record; -} - -export interface IObfuscatedPrecomputedConfigurationResponse - extends IBasePrecomputedConfigurationResponse { - readonly obfuscated: true; // Always true - readonly salt: string; // Salt used for hashing md5-encoded strings - - // PrecomputedFlag ships values as string and uses ValueType to cast back on the client. - // Values are obfuscated as strings, so a separate Obfuscated interface is not needed for flags. - readonly flags: Record; - readonly bandits: Record; -} - -export interface IPrecomputedConfiguration { - // JSON encoded configuration response (obfuscated or unobfuscated) - readonly response: string; - readonly subjectKey: string; - readonly subjectAttributes?: ContextAttributes; -} - -// Base class for configuration responses with common fields -abstract class BasePrecomputedConfigurationResponse { - readonly createdAt: string; - readonly format = FormatEnum.PRECOMPUTED; - - constructor( - public readonly subjectKey: string, - public readonly subjectAttributes?: ContextAttributes, - public readonly environment?: Environment, - ) { - this.createdAt = new Date().toISOString(); - } -} - -export class PrecomputedConfiguration implements IPrecomputedConfiguration { - private constructor( - public readonly response: string, - public readonly subjectKey: string, - public readonly subjectAttributes?: ContextAttributes, - ) {} - - public static obfuscated( - subjectKey: string, - flags: Record, - bandits: Record, - salt: string, - subjectAttributes?: ContextAttributes, - environment?: Environment, - ): IPrecomputedConfiguration { - const response = new ObfuscatedPrecomputedConfigurationResponse( - subjectKey, - flags, - bandits, - salt, - subjectAttributes, - environment, - ); - - return new PrecomputedConfiguration(JSON.stringify(response), subjectKey, subjectAttributes); - } - - public static unobfuscated( - subjectKey: string, - flags: Record, - bandits: Record, - subjectAttributes?: ContextAttributes, - environment?: Environment, - ): IPrecomputedConfiguration { - const response = new PrecomputedConfigurationResponse( - subjectKey, - flags, - bandits, - subjectAttributes, - environment, - ); - - return new PrecomputedConfiguration(JSON.stringify(response), subjectKey, subjectAttributes); - } -} - -export class PrecomputedConfigurationResponse - extends BasePrecomputedConfigurationResponse - implements IPrecomputedConfigurationResponse -{ - readonly obfuscated = false; - - constructor( - subjectKey: string, - public readonly flags: Record, - public readonly bandits: Record, - subjectAttributes?: ContextAttributes, - environment?: Environment, - ) { - super(subjectKey, subjectAttributes, environment); - } -} - -export class ObfuscatedPrecomputedConfigurationResponse - extends BasePrecomputedConfigurationResponse - implements IObfuscatedPrecomputedConfigurationResponse -{ - readonly bandits: Record; - readonly flags: Record; - readonly obfuscated = true; - readonly salt: string; - - constructor( - subjectKey: string, - flags: Record, - bandits: Record, - salt: string, - subjectAttributes?: ContextAttributes, - environment?: Environment, - ) { - super(subjectKey, subjectAttributes, environment); - - this.salt = salt; - this.bandits = obfuscatePrecomputedBanditMap(this.salt, bandits); - this.flags = obfuscatePrecomputedFlags(this.salt, flags); - } -} - -// "Wire" in the name means "in-transit"/"file" format. -// In-memory representation may differ significantly and is up to SDKs. -export interface IConfigurationWire { - /** - * Version field should be incremented for breaking format changes. - * For example, removing required fields or changing field type/meaning. - */ - readonly version: number; - - /** - * Wrapper around an IUniversalFlagConfig payload - */ - readonly config?: IConfigResponse; - - /** - * Wrapper around an IBanditParametersResponse payload. - */ - readonly bandits?: IConfigResponse; - - readonly precomputed?: IPrecomputedConfiguration; -} - -// These response types are stringified in the wire format. -type UfcResponseType = IUniversalFlagConfigResponse | IBanditParametersResponse; - -// The UFC responses are JSON-encoded strings so we can treat them as opaque blobs, but we also want to enforce type safety. -type ResponseString = string & { - readonly __brand: unique symbol; - readonly __type: T; -}; - -/** - * A wrapper around a server response that includes the response, etag, and fetchedAt timestamp. - */ -interface IConfigResponse { - readonly response: ResponseString; // JSON-encoded server response - readonly etag?: string; // Entity Tag - denotes a snapshot or version of the config. - readonly fetchedAt?: string; // ISO timestamp for when this config was fetched -} - -export function inflateResponse(response: ResponseString): T { - return JSON.parse(response) as T; -} - -export function deflateResponse(value: T): ResponseString { - return JSON.stringify(value) as ResponseString; -} - -export class ConfigurationWireV1 implements IConfigurationWire { - public readonly version = 1; - - private constructor( - readonly precomputed?: IPrecomputedConfiguration, - readonly config?: IConfigResponse, - readonly bandits?: IConfigResponse, - ) {} - - public static fromResponses( - flagConfig: IUniversalFlagConfigResponse, - banditConfig?: IBanditParametersResponse, - flagConfigEtag?: string, - banditConfigEtag?: string, - ): ConfigurationWireV1 { - return new ConfigurationWireV1( - undefined, - { - response: deflateResponse(flagConfig), - fetchedAt: new Date().toISOString(), - etag: flagConfigEtag, - }, - banditConfig - ? { - response: deflateResponse(banditConfig), - fetchedAt: new Date().toISOString(), - etag: banditConfigEtag, - } - : undefined, - ); - } - - public static precomputed(precomputedConfig: IPrecomputedConfiguration) { - return new ConfigurationWireV1(precomputedConfig); - } - - static empty() { - return new ConfigurationWireV1(); - } -} diff --git a/src/configuration.ts b/src/configuration.ts index 6a5c4c5..7ddd00d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,4 +1,4 @@ -import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; +import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { decodeFlag } from './decoding'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; diff --git a/src/http-client.ts b/src/http-client.ts index 55ac349..6503355 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,6 +1,6 @@ import ApiEndpoints from './api-endpoints'; import { BanditsConfig, FlagsConfig, PrecomputedConfig } from './configuration'; -import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; +import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { BanditParameters, BanditReference, diff --git a/src/index.ts b/src/index.ts index b99c967..6deca50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ export { export { Subject } from './client/subject'; export * as constants from './constants'; export { EppoAssignmentLogger } from './eppo-assignment-logger'; - export { AttributeType, Attributes, @@ -23,3 +22,4 @@ export { ContextAttributes, FlagKey, } from './types'; +export { VariationType } from './interfaces'; diff --git a/src/internal.ts b/src/internal.ts index 91490be..df48edd 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -21,10 +21,9 @@ export { } from './cache/abstract-assignment-cache'; export { NonExpiringInMemoryAssignmentCache } from './cache/non-expiring-in-memory-cache-assignment'; export { - IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, IPrecomputedConfigurationResponse, -} from './configuration-wire/configuration-wire-types'; +} from './precomputed-configuration'; export { decodePrecomputedFlag } from './decoding'; export { default as BatchEventProcessor } from './events/batch-event-processor'; export { BoundedEventQueue } from './events/bounded-event-queue'; diff --git a/src/precomputed-configuration.ts b/src/precomputed-configuration.ts new file mode 100644 index 0000000..c9fa03c --- /dev/null +++ b/src/precomputed-configuration.ts @@ -0,0 +1,40 @@ +import { + Environment, + FormatEnum, + IObfuscatedPrecomputedBandit, + IPrecomputedBandit, + PrecomputedFlag, +} from './interfaces'; +import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; + +// Base interface for all configuration responses +interface IBasePrecomputedConfigurationResponse { + readonly format: FormatEnum.PRECOMPUTED; + readonly obfuscated: boolean; + readonly createdAt: string; + readonly environment?: Environment; +} + +export interface IPrecomputedConfigurationResponse extends IBasePrecomputedConfigurationResponse { + readonly obfuscated: false; // Always false + readonly flags: Record; + readonly bandits: Record; +} + +export interface IObfuscatedPrecomputedConfigurationResponse + extends IBasePrecomputedConfigurationResponse { + readonly obfuscated: true; // Always true + readonly salt: string; // Salt used for hashing md5-encoded strings + + // PrecomputedFlag ships values as string and uses ValueType to cast back on the client. + // Values are obfuscated as strings, so a separate Obfuscated interface is not needed for flags. + readonly flags: Record; + readonly bandits: Record; +} + +export interface IPrecomputedConfiguration { + // JSON encoded configuration response (obfuscated or unobfuscated) + readonly response: string; + readonly subjectKey: string; + readonly subjectAttributes?: ContextAttributes; +} From a8866fbb569092b9c1b0d72f8548c297b2c2ef9c Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 21:52:33 +0300 Subject: [PATCH 23/25] refactor: re-run prettier --- src/cache/tlru-cache.ts | 5 +++- src/client/eppo-client.ts | 8 +++---- src/events/batch-event-processor.ts | 5 +++- src/events/event-delivery.ts | 5 +++- src/flag-evaluation-details-builder.ts | 2 +- src/http-client.ts | 11 +++++++-- src/rules.ts | 32 +++++++++++++------------- 7 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/cache/tlru-cache.ts b/src/cache/tlru-cache.ts index c16f14e..ad5201e 100644 --- a/src/cache/tlru-cache.ts +++ b/src/cache/tlru-cache.ts @@ -8,7 +8,10 @@ import { LRUCache } from './lru-cache'; **/ export class TLRUCache extends LRUCache { private readonly cacheEntriesTTLRegistry = new Map(); - constructor(readonly maxSize: number, readonly ttl: number) { + constructor( + readonly maxSize: number, + readonly ttl: number, + ) { super(maxSize); } diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index c6566c1..37ee553 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -383,10 +383,10 @@ export default class EppoClient { activationStrategy === 'always' ? { type: 'always' } : activationStrategy === 'stale' - ? { type: 'stale', maxAgeSeconds } - : activationStrategy === 'empty' - ? { type: 'empty' } - : { type: 'never' }, + ? { type: 'stale', maxAgeSeconds } + : activationStrategy === 'empty' + ? { type: 'empty' } + : { type: 'never' }, ); if (persistentStorage) { diff --git a/src/events/batch-event-processor.ts b/src/events/batch-event-processor.ts index 4b1c1cf..666d47d 100644 --- a/src/events/batch-event-processor.ts +++ b/src/events/batch-event-processor.ts @@ -7,7 +7,10 @@ const MAX_BATCH_SIZE = 10_000; export default class BatchEventProcessor { private readonly batchSize: number; - constructor(private readonly eventQueue: NamedEventQueue, batchSize: number) { + constructor( + private readonly eventQueue: NamedEventQueue, + batchSize: number, + ) { // clamp batch size between min and max this.batchSize = Math.max(MIN_BATCH_SIZE, Math.min(MAX_BATCH_SIZE, batchSize)); } diff --git a/src/events/event-delivery.ts b/src/events/event-delivery.ts index a300474..310afd2 100644 --- a/src/events/event-delivery.ts +++ b/src/events/event-delivery.ts @@ -9,7 +9,10 @@ export type EventDeliveryResult = { }; export default class EventDelivery implements IEventDelivery { - constructor(private readonly sdkKey: string, private readonly ingestionUrl: string) {} + constructor( + private readonly sdkKey: string, + private readonly ingestionUrl: string, + ) {} /** * Delivers a batch of events to the ingestion URL endpoint. Returns the UUIDs of any events from diff --git a/src/flag-evaluation-details-builder.ts b/src/flag-evaluation-details-builder.ts index 8f45bb3..a932d5d 100644 --- a/src/flag-evaluation-details-builder.ts +++ b/src/flag-evaluation-details-builder.ts @@ -141,7 +141,7 @@ export class FlagEvaluationDetailsBuilder { key: allocation.key, allocationEvaluationCode: AllocationEvaluationCode.UNEVALUATED, orderPosition: unevaluatedStartOrderPosition + i, - } as AllocationEvaluation), + }) as AllocationEvaluation, ); }; } diff --git a/src/http-client.ts b/src/http-client.ts index 216504c..0e19a6d 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -24,7 +24,11 @@ export interface IQueryParamsWithSubject extends IQueryParams { /** @internal */ export class HttpRequestError extends Error { - constructor(public message: string, public status?: number, public cause?: Error) { + constructor( + public message: string, + public status?: number, + public cause?: Error, + ) { super(message); if (cause) { this.cause = cause; @@ -56,7 +60,10 @@ export interface IHttpClient { /** @internal */ export default class FetchHttpClient implements IHttpClient { - constructor(private readonly apiEndpoints: ApiEndpoints, private readonly timeout: number) {} + constructor( + private readonly apiEndpoints: ApiEndpoints, + private readonly timeout: number, + ) {} async getUniversalFlagConfiguration(): Promise { const url = this.apiEndpoints.ufcEndpoint(); diff --git a/src/rules.ts b/src/rules.ts index 09ddf1d..901c2b0 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -173,10 +173,10 @@ function evaluateCondition(subjectAttributes: Record, condition: Co condition.operator === OperatorType.GTE ? semverGte : condition.operator === OperatorType.GT - ? semverGt - : condition.operator === OperatorType.LTE - ? semverLte - : semverLt; + ? semverGt + : condition.operator === OperatorType.LTE + ? semverLte + : semverLt; return compareSemVer(value, condition.value, comparator); } @@ -184,10 +184,10 @@ function evaluateCondition(subjectAttributes: Record, condition: Co condition.operator === OperatorType.GTE ? a >= b : condition.operator === OperatorType.GT - ? a > b - : condition.operator === OperatorType.LTE - ? a <= b - : a < b; + ? a > b + : condition.operator === OperatorType.LTE + ? a <= b + : a < b; return compareNumber(value, condition.value, comparator); } case OperatorType.MATCHES: @@ -229,10 +229,10 @@ function evaluateObfuscatedCondition( condition.operator === ObfuscatedOperatorType.GTE ? semverGte : condition.operator === ObfuscatedOperatorType.GT - ? semverGt - : condition.operator === ObfuscatedOperatorType.LTE - ? semverLte - : semverLt; + ? semverGt + : condition.operator === ObfuscatedOperatorType.LTE + ? semverLte + : semverLt; return compareSemVer(value, conditionValue, comparator); } @@ -240,10 +240,10 @@ function evaluateObfuscatedCondition( condition.operator === ObfuscatedOperatorType.GTE ? a >= b : condition.operator === ObfuscatedOperatorType.GT - ? a > b - : condition.operator === ObfuscatedOperatorType.LTE - ? a <= b - : a < b; + ? a > b + : condition.operator === ObfuscatedOperatorType.LTE + ? a <= b + : a < b; return compareNumber(value, Number(conditionValue), comparator); } case ObfuscatedOperatorType.MATCHES: From 6bea66dc4991ee51a36d82bd772a77d98e2de9ce Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 21:53:54 +0300 Subject: [PATCH 24/25] refactor: remove ConfigDetails --- src/interfaces.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/interfaces.ts b/src/interfaces.ts index b76c076..71b1386 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -44,15 +44,6 @@ export interface Environment { } export const UNKNOWN_ENVIRONMENT_NAME = 'UNKNOWN'; -/** @deprecated(v5) `ConfigDetails` is too naive about how configurations actually work. */ -export interface ConfigDetails { - configFetchedAt: string; - configPublishedAt: string; - configEnvironment: Environment; - configFormat: string; - salt?: string; -} - export interface Flag { key: string; enabled: boolean; From badd643f135fc0a28633540c1539c1d5e0eb2a81 Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Wed, 23 Apr 2025 22:10:04 +0300 Subject: [PATCH 25/25] refactor: fix lints --- src/client/eppo-client-with-bandits.spec.ts | 13 +----- src/client/eppo-client.precomputed.spec.ts | 1 - src/client/eppo-client.spec.ts | 4 +- src/client/eppo-client.ts | 50 ++++++++------------- src/client/subject.ts | 5 ++- src/configuration-feed.ts | 2 +- src/configuration-poller.ts | 18 +++++--- src/configuration-requestor.spec.ts | 24 +++++----- src/configuration-store.ts | 2 +- src/configuration.ts | 2 +- src/evaluator.spec.ts | 4 +- src/evaluator.ts | 4 +- src/http-client.ts | 2 +- src/tools/commands/bootstrap-config.ts | 4 +- test/testHelpers.ts | 2 +- 15 files changed, 59 insertions(+), 78 deletions(-) diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 35c576d..c91f655 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -9,11 +9,10 @@ import { BANDIT_TEST_DATA_DIR, readMockBanditsConfiguration, } from '../../test/testHelpers'; -import ApiEndpoints from '../api-endpoints'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; -import { Evaluator, FlagEvaluation } from '../evaluator'; +import { Evaluator } from '../evaluator'; import { AllocationEvaluationCode, IFlagEvaluationDetails, @@ -22,7 +21,6 @@ import { attributeEncodeBase64 } from '../obfuscation'; import { Attributes, BanditActions, ContextAttributes } from '../types'; import EppoClient, { IAssignmentDetails } from './eppo-client'; -import { Configuration } from '../configuration'; const salt = base64.fromUint8Array(new Uint8Array([101, 112, 112, 111])); jest.mock('../salt', () => ({ @@ -675,15 +673,6 @@ describe('EppoClient Bandits E2E test', () => { }, }; - function getPrecomputedResults( - client: EppoClient, - subjectKey: string, - subjectAttributes: ContextAttributes, - banditActions: Record, - ): Configuration { - return client.getPrecomputedConfiguration(subjectKey, subjectAttributes, banditActions); - } - describe('obfuscated results', () => { it('obfuscates precomputed bandits', () => { const bannerBanditFlagMd5 = '3ac89e06235484aa6f2aec8c33109a02'; diff --git a/src/client/eppo-client.precomputed.spec.ts b/src/client/eppo-client.precomputed.spec.ts index c0c1537..90ecc20 100644 --- a/src/client/eppo-client.precomputed.spec.ts +++ b/src/client/eppo-client.precomputed.spec.ts @@ -1,6 +1,5 @@ import { MOCK_PRECOMPUTED_WIRE_FILE, - MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE, readMockConfigurationWireResponse, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index b555854..a820d9c 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -5,6 +5,7 @@ import * as td from 'testdouble'; import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; +import { Configuration } from '../configuration'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_BASE_POLLING_INTERVAL_MS, @@ -12,11 +13,10 @@ import { } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces'; +import { KVStore, MemoryStore } from '../kvstore'; import { getMD5Hash } from '../obfuscation'; import EppoClient, { checkTypeMatch } from './eppo-client'; -import { Configuration } from '../configuration'; -import { KVStore, MemoryStore } from '../kvstore'; // Use a known salt to produce deterministic hashes const salt = base64.fromUint8Array(new Uint8Array([7, 53, 17, 78])); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 37ee553..28d0d5b 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -10,14 +10,16 @@ import { } from '../attributes'; import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; +import { BroadcastChannel } from '../broadcast'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache'; import { Configuration, PrecomputedConfig } from '../configuration'; +import { ConfigurationSource } from '../configuration-feed'; +import { randomJitterMs, ConfigurationPoller } from '../configuration-poller'; import ConfigurationRequestor from '../configuration-requestor'; import { ConfigurationStore } from '../configuration-store'; -import { IObfuscatedPrecomputedConfigurationResponse } from '../precomputed-configuration'; import { DEFAULT_BASE_POLLING_INTERVAL_MS, DEFAULT_MAX_POLLING_INTERVAL_MS, @@ -30,6 +32,7 @@ import { DEFAULT_ENABLE_POLLING, DEFAULT_ENABLE_BANDITS, } from '../constants'; +import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; import { AssignmentResult, @@ -56,8 +59,19 @@ import { Variation, VariationType, } from '../interfaces'; +import { KVStore, MemoryStore } from '../kvstore'; +import { + getMD5Hash, + obfuscatePrecomputedBanditMap, + obfuscatePrecomputedFlags, +} from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; -import { randomJitterMs } from '../configuration-poller'; +import { + PersistentConfigurationCache, + PersistentConfigurationStorage, +} from '../persistent-configuration-cache'; +import { IObfuscatedPrecomputedConfigurationResponse } from '../precomputed-configuration'; +import { generateSalt } from '../salt'; import SdkTokenDecoder from '../sdk-token-decoder'; import { Attributes, @@ -71,22 +85,8 @@ import { import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; -import { - PersistentConfigurationCache, - PersistentConfigurationStorage, -} from '../persistent-configuration-cache'; -import { ConfigurationPoller } from '../configuration-poller'; -import { ConfigurationSource } from '../configuration-feed'; -import { BroadcastChannel } from '../broadcast'; -import { - getMD5Hash, - obfuscatePrecomputedBanditMap, - obfuscatePrecomputedFlags, -} from '../obfuscation'; -import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; + import { Subject } from './subject'; -import { generateSalt } from '../salt'; -import { KVStore, MemoryStore } from '../kvstore'; export interface IAssignmentDetails { variation: T; @@ -264,16 +264,6 @@ export type EppoClientParameters = { }; }; -type VariationTypeMap = { - [VariationType.STRING]: string; - [VariationType.INTEGER]: number; - [VariationType.NUMERIC]: number; - [VariationType.BOOLEAN]: boolean; - [VariationType.JSON]: object; -}; - -type TypeFromVariationType = VariationTypeMap[T]; - /** * ## Initialization * @@ -1018,8 +1008,6 @@ export default class EppoClient { const precomputed = config.getPrecomputedConfiguration(); if (precomputed && precomputed.subjectKey === subjectKey) { // Use precomputed results if available - const nonContextualSubjectAttributes = - ensureNonContextualSubjectAttributes(subjectAttributes); const { flagEvaluation, banditAction, assignmentEvent, banditEvent } = this.evaluatePrecomputedAssignment(precomputed, flagKey, VariationType.STRING); @@ -1940,9 +1928,9 @@ class TimeoutError extends Error { function withTimeout(promise: Promise, ms: number): Promise { let timer: NodeJS.Timeout; - const timeoutPromise = new Promise((_, reject) => { + const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new TimeoutError()), ms); }); - return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer!)); + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); } diff --git a/src/client/subject.ts b/src/client/subject.ts index a914c7f..4de1da6 100644 --- a/src/client/subject.ts +++ b/src/client/subject.ts @@ -1,7 +1,8 @@ -import EppoClient, { IAssignmentDetails, IContainerExperiment } from './eppo-client'; -import { Attributes, BanditActions, ContextAttributes, FlagKey } from '../types'; import { ensureNonContextualSubjectAttributes } from '../attributes'; import { Configuration } from '../configuration'; +import { Attributes, BanditActions, ContextAttributes, FlagKey } from '../types'; + +import EppoClient, { IAssignmentDetails, IContainerExperiment } from './eppo-client'; /** * A wrapper around EppoClient that automatically supplies subject key, attributes, and bandit diff --git a/src/configuration-feed.ts b/src/configuration-feed.ts index fe5b8eb..f072fd3 100644 --- a/src/configuration-feed.ts +++ b/src/configuration-feed.ts @@ -1,5 +1,5 @@ -import { Configuration } from './configuration'; import { BroadcastChannel } from './broadcast'; +import { Configuration } from './configuration'; /** * Enumeration of possible configuration sources. diff --git a/src/configuration-poller.ts b/src/configuration-poller.ts index 143643d..3283523 100644 --- a/src/configuration-poller.ts +++ b/src/configuration-poller.ts @@ -1,7 +1,7 @@ -import ConfigurationRequestor from './configuration-requestor'; import { logger } from './application-logger'; -import { POLL_JITTER_PCT } from './constants'; import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; +import ConfigurationRequestor from './configuration-requestor'; +import { POLL_JITTER_PCT } from './constants'; /** * Polls for new configurations from the Eppo server. When a new configuration is fetched, @@ -64,11 +64,15 @@ export class ConfigurationPoller { if (!this.isRunning) { logger.debug('[Eppo SDK] starting configuration poller'); this.isRunning = true; - this.poll().finally(() => { - // Just to be safe, reset isRunning if the poll() method throws an error or exits - // unexpectedly (it shouldn't). - this.isRunning = false; - }); + this.poll() + .finally(() => { + // Just to be safe, reset isRunning if the poll() method throws an error or exits + // unexpectedly (it shouldn't). + this.isRunning = false; + }) + .catch((err) => { + logger.warn({ err }, '[Eppo SDK] unexpected error in poller'); + }); } } diff --git a/src/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index b214b5c..c88f9d3 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -525,18 +525,18 @@ describe('ConfigurationRequestor', () => { describe('fetchConfiguration', () => { it('should not fetch bandit parameters if model versions are already loaded', async () => { - const ufcResponse = { - flags: { test_flag: { key: 'test_flag', value: true } }, - banditReferences: { - bandit: { - modelVersion: 'v1', - flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], - }, - }, - environment: 'test', - createdAt: '2024-01-01', - format: 'SERVER', - }; + // const ufcResponse = { + // flags: { test_flag: { key: 'test_flag', value: true } }, + // banditReferences: { + // bandit: { + // modelVersion: 'v1', + // flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], + // }, + // }, + // environment: 'test', + // createdAt: '2024-01-01', + // format: 'SERVER', + // }; await configurationRequestor.fetchConfiguration(); // const initialFetchCount = fetchSpy.mock.calls.length; diff --git a/src/configuration-store.ts b/src/configuration-store.ts index a293527..3d769e1 100644 --- a/src/configuration-store.ts +++ b/src/configuration-store.ts @@ -1,7 +1,7 @@ import { logger } from './application-logger'; +import { BroadcastChannel } from './broadcast'; import { Configuration } from './configuration'; import { ConfigurationFeed } from './configuration-feed'; -import { BroadcastChannel } from './broadcast'; export type ActivationStrategy = | { diff --git a/src/configuration.ts b/src/configuration.ts index 7ddd00d..ad1fe75 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,8 +1,8 @@ -import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { decodeFlag } from './decoding'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; import { getMD5Hash } from './obfuscation'; +import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; /** @internal for SDK use only */ diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index 3bf238d..7a4e70c 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,6 +1,6 @@ import { Configuration } from './configuration'; import { Evaluator, hashKey, isInShardRange, matchesRules } from './evaluator'; -import { Flag, Variation, Shard, VariationType, ConfigDetails, FormatEnum } from './interfaces'; +import { Flag, Variation, Shard, VariationType, FormatEnum } from './interfaces'; import { getMD5Hash } from './obfuscation'; import { ObfuscatedOperatorType, OperatorType, Rule } from './rules'; import { DeterministicSharder } from './sharders'; @@ -12,7 +12,7 @@ describe('Evaluator', () => { const evaluator = new Evaluator(); - let configuration: Configuration = Configuration.fromResponses({ + const configuration: Configuration = Configuration.fromResponses({ flags: { fetchedAt: new Date().toISOString(), response: { diff --git a/src/evaluator.ts b/src/evaluator.ts index 028c5e9..a012020 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,3 +1,5 @@ +import { IAssignmentEvent } from './assignment-logger'; +import { IBanditEvent } from './bandit-logger'; import { checkValueTypeMatch } from './client/eppo-client'; import { Configuration } from './configuration'; import { @@ -20,8 +22,6 @@ import { import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; import { Attributes } from './types'; -import { IAssignmentEvent } from './assignment-logger'; -import { IBanditEvent } from './bandit-logger'; import { LIB_VERSION } from './version'; export interface AssignmentResult { diff --git a/src/http-client.ts b/src/http-client.ts index 0e19a6d..5a1454c 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,6 +1,5 @@ import ApiEndpoints from './api-endpoints'; import { BanditsConfig, FlagsConfig, PrecomputedConfig } from './configuration'; -import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { BanditParameters, BanditReference, @@ -9,6 +8,7 @@ import { FormatEnum, PrecomputedFlagsPayload, } from './interfaces'; +import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { Attributes } from './types'; export interface IQueryParams { diff --git a/src/tools/commands/bootstrap-config.ts b/src/tools/commands/bootstrap-config.ts index 7ce1521..e2f4b42 100644 --- a/src/tools/commands/bootstrap-config.ts +++ b/src/tools/commands/bootstrap-config.ts @@ -2,10 +2,10 @@ import * as fs from 'fs'; import type { CommandModule } from 'yargs'; -import ConfigurationRequestor from '../../configuration-requestor'; +import ApiEndpoints from '../../api-endpoints'; import { BroadcastChannel } from '../../broadcast'; +import ConfigurationRequestor from '../../configuration-requestor'; import FetchHttpClient from '../../http-client'; -import ApiEndpoints from '../../api-endpoints'; import { LIB_VERSION } from '../../version'; export const bootstrapConfigCommand: CommandModule = { diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 96b4de5..bcefcd8 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -3,9 +3,9 @@ import * as fs from 'fs'; import { isEqual } from 'lodash'; import { AttributeType, ContextAttributes, IAssignmentDetails, VariationType } from '../src'; +import { Configuration } from '../src/configuration'; import { IFlagEvaluationDetails } from '../src/flag-evaluation-details-builder'; import { IBanditParametersResponse, IUniversalFlagConfigResponse } from '../src/http-client'; -import { Configuration } from '../src/configuration'; import { Variation } from '../src/interfaces'; export const TEST_DATA_DIR = './test/data/ufc/'; export const ASSIGNMENT_TEST_DATA_DIR = TEST_DATA_DIR + 'tests/';