diff --git a/.eslintrc.js b/.eslintrc.js index 3f1fbde8..f22f6d90 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 00000000..2fec1f28 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index fb58fa21..cbd013ce 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -14,8 +14,10 @@ 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 { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; +import { ConfigurationStore } from '../configuration-store'; +import { ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { ConfigurationWireV1, @@ -29,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'; @@ -41,19 +42,14 @@ import { } from '../flag-evaluation-details-builder'; import { FlagEvaluationError } from '../flag-evaluation-error'; import FetchHttpClient from '../http-client'; -import { IConfiguration, StoreBackedConfiguration } 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 { @@ -100,11 +96,9 @@ 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; /** * 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. @@ -124,10 +118,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,19 +128,18 @@ export default class EppoClient { private configurationRequestor?: ConfigurationRequestor; private readonly overrideValidator = new OverrideValidator(); + private readonly configurationStore; + constructor({ eventDispatcher = new NoOpEventDispatcher(), isObfuscated, - flagConfigurationStore, - banditVariationConfigurationStore, - banditModelConfigurationStore, overrideStore, configurationRequestParameters, + initialConfiguration, }: EppoClientParameters) { + this.configurationStore = new ConfigurationStore(initialConfiguration); + this.eventDispatcher = eventDispatcher; - this.flagConfigurationStore = flagConfigurationStore; - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - this.banditModelConfigurationStore = banditModelConfigurationStore; this.overrideStore = overrideStore; this.configurationRequestParameters = configurationRequestParameters; @@ -161,14 +151,12 @@ export default class EppoClient { } } - private getConfiguration(): IConfiguration { - return this.configurationRequestor - ? this.configurationRequestor.getConfiguration() - : new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); + public getConfiguration(): Configuration { + return this.configurationStore.getConfiguration(); + } + + public setConfiguration(configuration: Configuration) { + this.configurationStore.setConfiguration(configuration); } /** @@ -206,18 +194,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 +215,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; } @@ -315,18 +268,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, { @@ -644,10 +590,10 @@ export default class EppoClient { 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); @@ -697,11 +643,14 @@ 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. const bandit = config.getFlagVariationBandit(flagKey, variation); - if (!bandit) { return { variation, action: null, evaluationDetails }; } @@ -929,26 +878,19 @@ export default class EppoClient { subjectAttributes: Attributes = {}, ): Record { const config = this.getConfiguration(); - const configDetails = config.getFlagConfigDetails(); - const flagKeys = this.getFlagKeys(); + 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) { @@ -986,7 +928,6 @@ export default class EppoClient { salt?: string, ): string { const config = this.getConfiguration(); - const configDetails = config.getFlagConfigDetails(); const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); @@ -1006,7 +947,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); @@ -1036,6 +977,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( @@ -1047,8 +996,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}`); @@ -1062,7 +1010,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.environment.name ?? '', ); } @@ -1078,7 +1026,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.format ?? '', ); } throw new TypeError(errorMessage); @@ -1096,23 +1044,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) { @@ -1138,43 +1083,22 @@ export default class EppoClient { } private newFlagEvaluationDetailsBuilder( - config: IConfiguration, + config: Configuration, flagKey: string, ): 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; - } - - // 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(); + // We treat configuration as initialized if we have flags config. + return !!this.configurationStore.getConfiguration()?.getFlagsConfiguration(); } /** @deprecated Use `setAssignmentLogger` */ @@ -1239,10 +1163,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,14 +1239,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, @@ -1356,7 +1277,7 @@ export default class EppoClient { } private getPrecomputedBandit( - config: IConfiguration, + config: Configuration, flagKey: string, variationValue: string, subjectKey: string, diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts index 546ae366..f9c4ce96 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-requestor.ts b/src/configuration-requestor.ts index 1f48e09e..607e61bc 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,132 +1,77 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; +import { BanditsConfig, Configuration, FlagsConfig } 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, - ); + this.options = { + wantsBandits: true, + ...options, + }; } - public isFlagConfigExpired(): Promise { - return this.flagConfigurationStore.isExpired(); - } + async fetchConfiguration(): Promise { + const flags = await this.httpClient.getUniversalFlagConfiguration(); + if (!flags?.response.flags) { + return null; + } + + const bandits = await this.getBanditsFor(flags); - public getConfiguration(): IConfiguration { - return this.configuration; + return Configuration.fromResponses({ flags, bandits }); } async fetchAndStoreConfigurations(): Promise { - const configResponse = await this.httpClient.getUniversalFlagConfiguration(); - if (!configResponse?.flags) { - return; + const configuration = await this.fetchConfiguration(); + if (configuration) { + this.configurationStore.setConfiguration(configuration); } + } - 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); - } - } + /** + * 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; } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { - // TODO: Notify that config updated. + const prevBandits = this.configurationStore.getConfiguration().getBanditConfiguration(); + const canReuseBandits = banditsUpToDate(flags, prevBandits); + if (canReuseBandits) { + return prevBandits; } - } - private getLoadedBanditModelVersions(entries: Record): string[] { - return Object.values(entries).map((banditParam: BanditParameters) => banditParam.modelVersion); - } - - private requiresBanditModelConfigurationStoreUpdate( - currentBanditModelVersions: string[], - banditReferences: Record, - ): boolean { - const referencedModelVersions = Object.values(banditReferences).map( - (banditReference: BanditReference) => banditReference.modelVersion, - ); - - return !referencedModelVersions.every((modelVersion) => - currentBanditModelVersions.includes(modelVersion), - ); - } - - 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; + 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, + ); +}; diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index ff43a617..a63e2e0c 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -1,5 +1,57 @@ +import { Configuration } from '../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 configuration: Configuration; + private readonly listeners: Array<(configuration: Configuration) => void> = []; + + public constructor(configuration: Configuration = Configuration.empty()) { + this.configuration = configuration; + } + + public getConfiguration(): Configuration { + return this.configuration; + } + + public setConfiguration(configuration: Configuration): 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: 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 + } + } + } +} + /** * 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 00000000..80c3180c --- /dev/null +++ b/src/configuration-store/index.ts @@ -0,0 +1 @@ +export { ConfigurationStore } from './configuration-store'; 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 c3cc17ef..00000000 --- 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 0a065116..00000000 --- a/src/configuration-wire/configuration-wire-helper.ts +++ /dev/null @@ -1,75 +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); - } - - /** - * 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 new file mode 100644 index 00000000..e0c6bcd5 --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,174 @@ +import { decodeFlag } from './decoding'; +import { IBanditParametersResponse, IUniversalFlagConfigResponse } from './http-client'; +import { BanditParameters, BanditVariation, Flag, FormatEnum, ObfuscatedFlag } from './interfaces'; +import { getMD5Hash } from './obfuscation'; +import { ContextAttributes, 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: + // public static fromString(configurationWire: string): Configuration {} + + /** 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) { + 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; +} + +/** @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/evaluator.ts b/src/evaluator.ts index 98ab3fd9..e65799a8 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/http-client.ts b/src/http-client.ts index 063ecb37..9df6b283 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( diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0df..4ddf1f6c 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/index.ts b/src/index.ts index a9ebaee9..9dd8e6d8 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, diff --git a/src/interfaces.ts b/src/interfaces.ts index a84a8489..031610cb 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 new file mode 100644 index 00000000..1dbe25bc --- /dev/null +++ b/src/persistent-configuration-storage.ts @@ -0,0 +1,23 @@ +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; +}