diff --git a/.eslintrc.js b/.eslintrc.js index 846d775..5741378 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -79,14 +79,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 +} diff --git a/docs/configuration-lifecycle.excalidraw.png b/docs/configuration-lifecycle.excalidraw.png new file mode 100644 index 0000000..4b1970d Binary files /dev/null and b/docs/configuration-lifecycle.excalidraw.png differ 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/package.json b/package.json index 92f6ea8..094d579 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/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 new file mode 100644 index 0000000..eff2233 --- /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 + } + } + } +} diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index 6dfecd3..5e74262 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -3,39 +3,32 @@ import * as fs from 'fs'; import { AssignmentVariationValue, IAssignmentTestCase, - MOCK_UFC_RESPONSE_FILE, - readMockUFCResponse, + readMockUfcConfiguration, } 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'; 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', @@ -86,8 +79,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', @@ -129,8 +120,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', @@ -191,8 +180,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, @@ -216,7 +203,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({ @@ -253,7 +239,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(); }); @@ -278,22 +263,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))( @@ -303,9 +272,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..e704cb9 100644 --- a/src/client/eppo-client-experiment-container.spec.ts +++ b/src/client/eppo-client-experiment-container.spec.ts @@ -1,23 +1,11 @@ -import { MOCK_UFC_RESPONSE_FILE, 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 }; 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 +17,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..c91f655 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -7,34 +7,27 @@ import { testCasesByFileName, BanditTestCase, 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 ConfigurationRequestor from '../configuration-requestor'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { - IConfigurationWire, - IPrecomputedConfiguration, - IObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire/configuration-wire-types'; -import { Evaluator, FlagEvaluation } from '../evaluator'; +import { Evaluator } from '../evaluator'; 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'; import EppoClient, { IAssignmentDetails } from './eppo-client'; +const salt = base64.fromUint8Array(new Uint8Array([101, 112, 112, 111])); +jest.mock('../salt', () => ({ + generateSalt: () => salt, +})); + 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(); @@ -53,32 +46,18 @@ 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', - }, - }); - 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 }); @@ -517,19 +496,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, + }, + 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, }, - } as FlagEvaluation; + }; }); mockEvaluateBandit = jest @@ -654,27 +673,6 @@ describe('EppoClient Bandits E2E test', () => { }, }; - function getPrecomputedResults( - client: EppoClient, - subjectKey: string, - subjectAttributes: ContextAttributes, - banditActions: Record, - ): IPrecomputedConfiguration { - const salt = base64.fromUint8Array(new Uint8Array([101, 112, 112, 111])); - const precomputedResults = client.getPrecomputedConfiguration( - subjectKey, - subjectAttributes, - banditActions, - salt, - ); - - const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire; - if (!precomputed) { - fail('precomputed result was not parsed'); - } - return precomputed; - } - describe('obfuscated results', () => { it('obfuscates precomputed bandits', () => { const bannerBanditFlagMd5 = '3ac89e06235484aa6f2aec8c33109a02'; @@ -684,11 +682,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-with-overrides.spec.ts b/src/client/eppo-client-with-overrides.spec.ts index 4e05cf5..98e9b71 100644 --- a/src/client/eppo-client-with-overrides.spec.ts +++ b/src/client/eppo-client-with-overrides.spec.ts @@ -1,17 +1,33 @@ -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; -import { Flag, FormatEnum, ObfuscatedFlag, VariationType } from '../interfaces'; +import { Configuration } from '../configuration'; +import { Flag, FormatEnum, VariationType } from '../interfaces'; 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); + function setUnobfuscatedFlagEntries(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 +67,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.precomputed.spec.ts b/src/client/eppo-client.precomputed.spec.ts new file mode 100644 index 0000000..90ecc20 --- /dev/null +++ b/src/client/eppo-client.precomputed.spec.ts @@ -0,0 +1,152 @@ +import { + MOCK_PRECOMPUTED_WIRE_FILE, + readMockConfigurationWireResponse, +} from '../../test/testHelpers'; +import { IAssignmentLogger } from '../assignment-logger'; +import { IBanditLogger } from '../bandit-logger'; +import { Configuration } from '../configuration'; + +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); + + 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'); + }); +}); 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 d4e8266..a820d9c 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -2,44 +2,30 @@ import * as base64 from 'js-base64'; import { times } from 'lodash'; import * as td from 'testdouble'; -import { - ASSIGNMENT_TEST_DATA_DIR, - AssignmentVariationValue, - getTestAssignments, - IAssignmentTestCase, - MOCK_UFC_RESPONSE_FILE, - OBFUSCATED_MOCK_UFC_RESPONSE_FILE, - readMockUFCResponse, - SubjectTestCase, - testCasesByFileName, - validateTestAssignments, -} 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 { IConfigurationStore } from '../configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { Configuration } from '../configuration'; import { - IConfigurationWire, - IObfuscatedPrecomputedConfigurationResponse, - ObfuscatedPrecomputedConfigurationResponse, -} from '../configuration-wire/configuration-wire-types'; -import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; + 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 { KVStore, MemoryStore } from '../kvstore'; import { getMD5Hash } from '../obfuscation'; -import { AttributeType } from '../types'; -import EppoClient, { - checkTypeMatch, - FlagConfigurationRequestParameters, - IAssignmentDetails, -} from './eppo-client'; -import { initConfiguration } from './test-utils'; +import EppoClient, { checkTypeMatch } from './eppo-client'; // 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); @@ -49,24 +35,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 = { @@ -107,8 +105,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'); @@ -122,8 +119,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); @@ -143,7 +138,6 @@ describe('EppoClient E2E test', () => { client.setIsGracefulFailureMode(false); expect(() => { - client.getBoolAssignment(flagKey, 'subject-identifier', {}, true); client.getBooleanAssignment(flagKey, 'subject-identifier', {}, true); }).toThrow(); @@ -162,14 +156,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); @@ -182,7 +172,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); @@ -193,7 +183,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'), @@ -204,7 +194,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); @@ -220,8 +210,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: { @@ -243,20 +235,13 @@ 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; + 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 ?? {}; @@ -268,14 +253,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 ?? {}; @@ -286,12 +269,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=='); @@ -322,96 +305,28 @@ describe('EppoClient E2E test', () => { }); }); - describe.each(['Not Obfuscated', 'Obfuscated'])('UFC Shared Test Cases %s', (obfuscationType) => { - const testCases = testCasesByFileName(ASSIGNMENT_TEST_DATA_DIR); - const isObfuscated = obfuscationType === 'Obfuscated'; - - beforeAll(async () => { - global.fetch = jest.fn(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => - Promise.resolve( - readMockUFCResponse( - isObfuscated ? OBFUSCATED_MOCK_UFC_RESPONSE_FILE : MOCK_UFC_RESPONSE_FILE, - ), - ), - }); - }) as jest.Mock; - - await initConfiguration(storage); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - describe.each(['Scalar', 'With Details'])('%s', (assignmentType) => { - const assignmentWithDetails = assignmentType === 'With Details'; - - 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 }); - client.setIsGracefulFailureMode(false); - - let assignments: { - subject: SubjectTestCase; - assignment: AssignmentVariationValue; - }[] = []; - - const typeAssignmentFunctions = assignmentWithDetails - ? { - [VariationType.BOOLEAN]: client.getBooleanAssignmentDetails.bind(client), - [VariationType.NUMERIC]: client.getNumericAssignmentDetails.bind(client), - [VariationType.INTEGER]: client.getIntegerAssignmentDetails.bind(client), - [VariationType.STRING]: client.getStringAssignmentDetails.bind(client), - [VariationType.JSON]: client.getJSONAssignmentDetails.bind(client), - } - : { - [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: AssignmentVariationValue, - ) => AssignmentVariationValue | IAssignmentDetails; - if (!assignmentFn) { - throw new Error(`Unknown variation type: ${variationType}`); - } - - assignments = getTestAssignments( - { flag, variationType, defaultValue, subjects }, - assignmentFn, - ); - - validateTestAssignments(assignments, flag, assignmentWithDetails, isObfuscated); - }); - }); - }); - 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({}); @@ -422,9 +337,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 }; @@ -449,8 +362,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 }; @@ -465,9 +377,10 @@ 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', () => { @@ -476,9 +389,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); }); @@ -532,7 +443,7 @@ describe('EppoClient E2E test', () => { }); it('logs for each unique flag', async () => { - await setUnobfuscatedFlagEntries({ + client = setUnobfuscatedFlagEntries({ [flagKey]: mockFlag, 'flag-2': { ...mockFlag, @@ -543,6 +454,7 @@ describe('EppoClient E2E test', () => { key: 'flag-3', }, }); + client.setAssignmentLogger(mockLogger); client.useNonExpiringInMemoryAssignmentCache(); @@ -562,10 +474,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', @@ -581,9 +496,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: [ @@ -601,21 +519,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: [ @@ -633,18 +552,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: [ @@ -662,9 +585,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); }); @@ -672,14 +597,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(() => { @@ -698,13 +631,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', @@ -732,47 +662,49 @@ 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); @@ -781,165 +713,150 @@ 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 { - return false; - } - } + it('Does not fetch configurations if the configuration store is unexpired', async () => { + // 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 }, - ])('retries initial configuration request with config %p', async (configModification) => { - let callCount = 0; + it.each([{ enablePolling: false }, { enablePolling: true }])( + 'retries initial configuration request with config %p', + async (configModification) => { + let callCount = 0; + + global.fetch = jest.fn(() => { + if (callCount++ === 0) { + // 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 readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); + }, + }); + } + }) as jest.Mock; - 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 readMockUFCResponse(MOCK_UFC_RESPONSE_FILE); - }, - }); - } - }) as jest.Mock; + const { enablePolling } = configModification; - const { pollAfterSuccessfulInitialization } = configModification; - requestConfiguration = { - ...requestConfiguration, - pollAfterSuccessfulInitialization, - }; - client = new EppoClient({ - flagConfigurationStore: thisFlagStorage, - configurationRequestParameters: requestConfiguration, - }); - client.setIsGracefulFailureMode(false); - // no configuration loaded - let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); - 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(); + client = new EppoClient({ + sdkKey: requestConfiguration.apiKey, + sdkName: requestConfiguration.sdkName, + sdkVersion: requestConfiguration.sdkVersion, + configuration: { + initializationTimeoutMs: 60_000, + enablePolling, + }, + }); + client.setIsGracefulFailureMode(false); + // no configuration loaded + let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); + expect(variation).toBe(0.0); - // Advance timers mid-init to allow retrying - await jest.advanceTimersByTimeAsync(maxRetryDelay); + // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes + const fetchPromise = client.waitForInitialization(); - // Await so it can finish its initialization before this test proceeds - await fetchPromise; + // Advance timers mid-init to allow retrying + await jest.advanceTimersByTimeAsync(maxRetryDelay); - variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); - expect(variation).toBe(pi); - expect(callCount).toBe(2); + // Await so it can finish its initialization before this test proceeds + await fetchPromise; - await jest.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL_MS); - // By default, no more polling - expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2); - }); + variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); + expect(variation).toBe(pi); + expect(callCount).toBe(2); - it.each([ - { - pollAfterFailedInitialization: false, - throwOnFailedInitialization: false, + await jest.advanceTimersByTimeAsync(1.5 * DEFAULT_BASE_POLLING_INTERVAL_MS); + // By default, no more polling + expect(callCount).toBe(enablePolling ? 3 : 2); }, - { 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(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), - } as Response); - } - }); + ); - const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification; + it.each([{ enablePolling: false }, { enablePolling: 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(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)), + } as Response); + } + }); - // 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; + const { enablePolling } = configModification; - requestConfiguration = { - ...requestConfiguration, - numInitialRequestRetries, - throwOnFailedInitialization, - pollAfterFailedInitialization, - }; - client = new EppoClient({ - flagConfigurationStore: thisFlagStorage, - configurationRequestParameters: requestConfiguration, - }); - client.setIsGracefulFailureMode(false); - // no configuration loaded - expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe(0.0); + // This test specifically tests network fetching behavior + client = new EppoClient({ + 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(callCount).toBe(1); + // still no configuration loaded + 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); + // Advance timers so a post-init poll can take place + 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); - expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe( - pollAfterFailedInitialization ? pi : 0.0, - ); - }); + // 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( + enablePolling ? pi : 0.0, + ); + }, + ); }); describe('Contstructed with enhanced SDK Token', () => { @@ -963,18 +880,12 @@ describe('EppoClient E2E test', () => { it('uses the default base URL when the API Key is not an enhanced token', async () => { const client = new EppoClient({ - configurationRequestParameters: { - apiKey: 'basic-token', - pollAfterSuccessfulInitialization: false, - pollAfterFailedInitialization: false, - sdkVersion: '', - sdkName: '', - }, - - flagConfigurationStore: storage, + sdkKey: 'basic-token', + sdkName: '', + sdkVersion: '', }); - await client.fetchFlagConfigurations(); + await client.waitForInitialization(); expect(urlRequests).toEqual([ 'https://fscdn.eppo.cloud/api/flag-config/v1/config?apiKey=basic-token&sdkName=&sdkVersion=', @@ -983,18 +894,12 @@ describe('EppoClient E2E test', () => { it('uses the customer-specific subdomain when provided', async () => { const client = new EppoClient({ - configurationRequestParameters: { - apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', - pollAfterSuccessfulInitialization: false, - pollAfterFailedInitialization: false, - sdkVersion: '', - sdkName: '', - }, - - flagConfigurationStore: storage, + sdkKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', + sdkName: '', + sdkVersion: '', }); - await client.fetchFlagConfigurations(); + await client.waitForInitialization(); expect(urlRequests).toHaveLength(1); expect(urlRequests[0]).toContain( @@ -1004,19 +909,13 @@ describe('EppoClient E2E test', () => { it('prefers a provided baseUrl over encoded subdomain', async () => { const client = new EppoClient({ - configurationRequestParameters: { - baseUrl: 'http://override.base.url', - apiKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', - pollAfterSuccessfulInitialization: false, - pollAfterFailedInitialization: false, - sdkVersion: '', - sdkName: '', - }, - - flagConfigurationStore: storage, + sdkKey: 'zCsQuoHJxVPp895.Y3M9ZXhwZXJpbWVudA==', + sdkName: '', + sdkVersion: '', + baseUrl: 'http://override.base.url', }); - await client.fetchFlagConfigurations(); + await client.waitForInitialization(); expect(urlRequests).toHaveLength(1); expect(urlRequests[0]).toContain('http://override.base.url/flag-config/v1/config'); @@ -1026,15 +925,32 @@ describe('EppoClient E2E test', () => { describe('flag overrides', () => { let client: EppoClient; let mockLogger: IAssignmentLogger; - let overrideStore: IConfigurationStore; + let overrideStore: KVStore; beforeEach(() => { - storage.setEntries({ [flagKey]: mockFlag }); mockLogger = td.object(); - overrideStore = new MemoryOnlyConfigurationStore(); + overrideStore = new MemoryStore(); 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(); @@ -1153,7 +1069,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 e7d9c6d..28d0d5b 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -10,28 +10,37 @@ 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 { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { ConfigurationStore } from '../configuration-store'; import { - ConfigurationWireV1, - IConfigurationWire, - IPrecomputedConfiguration, - PrecomputedConfiguration, -} from '../configuration-wire/configuration-wire-types'; -import { - DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - DEFAULT_POLL_INTERVAL_MS, + 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, + DEFAULT_ENABLE_BANDITS, } from '../constants'; -import { decodeFlag } from '../decoding'; +import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; -import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; +import { + AssignmentResult, + Evaluator, + FlagEvaluation, + 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'; @@ -41,21 +50,28 @@ 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, + IObfuscatedPrecomputedBandit, IPrecomputedBandit, - ObfuscatedFlag, PrecomputedFlag, Variation, VariationType, } from '../interfaces'; -import { getMD5Hash } from '../obfuscation'; +import { KVStore, MemoryStore } from '../kvstore'; +import { + getMD5Hash, + obfuscatePrecomputedBanditMap, + obfuscatePrecomputedFlags, +} from '../obfuscation'; import { OverridePayload, OverrideValidator } from '../override-validator'; -import initPoller, { IPoller } from '../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, @@ -70,27 +86,14 @@ import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; +import { Subject } from './subject'; + 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; @@ -98,23 +101,200 @@ 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; - flagConfigurationStore: IConfigurationStore; - banditVariationConfigurationStore?: IConfigurationStore; - banditModelConfigurationStore?: IConfigurationStore; - overrideStore?: ISyncStore; - configurationRequestParameters?: FlagConfigurationRequestParameters; - /** - * 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; + overrideStore?: KVStore; + + bandits?: { + /** + * Whether to enable bandits. + * + * This influences whether bandits configuration is fetched. + * Disabling bandits helps to save network bandwidth if bandits + * are unused. + * + * @default true + */ + enable?: boolean; + }; + + 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. + * + * - `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 false + */ + 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 'next-load' + */ + activationStrategy?: 'always' | 'stale' | 'empty' | 'next-load'; + + /** + * Timeout for individual network requests. + * + * @default 5_000 (5 seconds) + */ + requestTimeoutMs?: number; + }; }; +/** + * ## 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 = @@ -124,52 +304,305 @@ export default class EppoClient { private readonly banditEvaluator = new BanditEvaluator(); private banditLogger?: IBanditLogger; private banditAssignmentCache?: AssignmentCache; - private configurationRequestParameters?: FlagConfigurationRequestParameters; - private banditModelConfigurationStore?: IConfigurationStore; - private banditVariationConfigurationStore?: IConfigurationStore; - private overrideStore?: ISyncStore; - private flagConfigurationStore: IConfigurationStore; + private overrideStore?: KVStore; 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 evaluator: Evaluator; private readonly overrideValidator = new OverrideValidator(); - constructor({ - eventDispatcher = new NoOpEventDispatcher(), - isObfuscated, - flagConfigurationStore, - banditVariationConfigurationStore, - banditModelConfigurationStore, - overrideStore, - configurationRequestParameters, - }: EppoClientParameters) { + private readonly configurationFeed; + private readonly configurationStore: ConfigurationStore; + private readonly configurationCache?: PersistentConfigurationCache; + private readonly configurationRequestor: ConfigurationRequestor; + private readonly configurationPoller: ConfigurationPoller; + private initialized = false; + private readonly initializationPromise: Promise; + private readonly precomputedConfig?: { + subjectKey: string; + subjectAttributes: ContextAttributes; + 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; + this.eventDispatcher = eventDispatcher; - this.flagConfigurationStore = flagConfigurationStore; - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - this.banditModelConfigurationStore = banditModelConfigurationStore; 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.evaluator = new Evaluator({ + sdkName: options.sdkName, + sdkVersion: options.sdkVersion, + }); + + const { + configuration: { + persistentStorage, + 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, + maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, + activationStrategy = DEFAULT_ACTIVATION_STRATEGY, + } = {}, + } = 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); + this.configurationStore.register( + this.configurationFeed, + activationStrategy === 'always' + ? { type: 'always' } + : activationStrategy === 'stale' + ? { type: 'stale', maxAgeSeconds } + : activationStrategy === 'empty' + ? { type: 'empty' } + : { type: 'never' }, + ); + + if (persistentStorage) { + this.configurationCache = new PersistentConfigurationCache( + persistentStorage, + this.configurationFeed, ); } + + this.configurationRequestor = new ConfigurationRequestor( + new FetchHttpClient( + new ApiEndpoints({ + sdkTokenDecoder: new SdkTokenDecoder(options.sdkKey), + baseUrl: options.baseUrl, + queryParams: { + apiKey: options.sdkKey, + sdkName: options.sdkName, + sdkVersion: options.sdkVersion, + }, + }), + requestTimeoutMs, + ), + 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, + }, + ); + + this.configurationPoller = new ConfigurationPoller(this.configurationRequestor, { + configurationFeed: this.configurationFeed, + basePollingIntervalMs, + maxPollingIntervalMs, + maxAgeMs: maxAgeSeconds * 1000, + }); + + this.initializationPromise = withTimeout(this.initialize(options), initializationTimeoutMs) + .catch((err) => { + logger.warn({ err }, '[Eppo SDK] Encountered an error during initialization'); + }) + .finally(() => { + logger.debug('[Eppo SDK] Finished initialization'); + this.initialized = true; + if (enablePolling) { + this.configurationPoller.start(); + } + }); } - private getConfiguration(): IConfiguration { - return this.configurationRequestor - ? this.configurationRequestor.getConfiguration() - : new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); + private async initialize(options: EppoClientParameters): Promise { + logger.debug('[Eppo SDK] Initializing EppoClient'); + const { + configuration: { + initializationStrategy = DEFAULT_INITIALIZATION_STRATEGY, + initialConfiguration, + 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; + } + + if ( + !initialConfiguration && // initial configuration overrides persistent storage for initialization + this.configurationCache && + (initializationStrategy === 'stale-while-revalidate' || + initializationStrategy === 'only-if-cached') + ) { + try { + const configuration = await this.configurationCache.loadConfiguration({ maxStaleSeconds }); + if (configuration && !this.initialized) { + 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 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). + while (!this.initialized) { + try { + 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({ err }, '[Eppo SDK] Failed to fetch initial configuration'); + } + + // Note: this is only using the jitter without the base polling interval. + await new Promise((resolve) => setTimeout(resolve, randomJitterMs(basePollingIntervalMs))); + } + } + + /** + * 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 { + return this.configurationStore.getConfiguration(); + } + + /** + * Activates a new configuration. + */ + public activateConfiguration(configuration: Configuration) { + this.configurationStore.setConfiguration(configuration); + } + + /** + * 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 onNewConfiguration(listener: (configuration: Configuration) => void): () => void { + return this.configurationFeed.addListener(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 { + 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; } /** @@ -194,35 +627,13 @@ 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; } return this; } - setConfigurationRequestParameters( - configurationRequestParameters: FlagConfigurationRequestParameters, - ) { - this.configurationRequestParameters = configurationRequestParameters; - } - - // noinspection JSUnusedGlobalSymbols - setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { - this.flagConfigurationStore = flagConfigurationStore; - - this.updateConfigRequestorIfExists(); - } - - // noinspection JSUnusedGlobalSymbols - setBanditVariationConfigurationStore( - banditVariationConfigurationStore: IConfigurationStore, - ) { - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - - this.updateConfigRequestorIfExists(); - } - /** Sets the EventDispatcher instance to use when tracking events with {@link track}. */ // noinspection JSUnusedGlobalSymbols setEventDispatcher(eventDispatcher: EventDispatcher) { @@ -244,43 +655,7 @@ export default class EppoClient { this.eventDispatcher?.attachContext(key, value); } - // noinspection JSUnusedGlobalSymbols - setBanditModelConfigurationStore( - banditModelConfigurationStore: IConfigurationStore, - ) { - this.banditModelConfigurationStore = banditModelConfigurationStore; - - this.updateConfigRequestorIfExists(); - } - - private updateConfigRequestorIfExists() { - // Update the ConfigurationRequestor if it exists - if (this.configurationRequestor) { - this.configurationRequestor.setConfigurationStores( - this.flagConfigurationStore, - this.banditVariationConfigurationStore || null, - this.banditModelConfigurationStore || null, - ); - } - } - - // 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 { + setOverrideStore(store: KVStore): void { this.overrideStore = store; } @@ -298,72 +673,10 @@ 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; - } - - const apiEndpoints = new ApiEndpoints({ - baseUrl, - queryParams: { apiKey, sdkName, sdkVersion }, - sdkTokenDecoder: new SdkTokenDecoder(apiKey), - }); - - const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); - const configurationRequestor = new ConfigurationRequestor( - httpClient, - this.flagConfigurationStore, - this.banditVariationConfigurationStore ?? null, - this.banditModelConfigurationStore ?? null, - ); - this.configurationRequestor = configurationRequestor; - - const pollingCallback = async () => { - if (await configurationRequestor.isFlagConfigExpired()) { - 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) { - this.requestPoller.stop(); + if (this.configurationPoller) { + this.configurationPoller.stop(); } } @@ -420,18 +733,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. * @@ -663,10 +964,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); @@ -701,9 +1002,35 @@ 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 { 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 } = @@ -720,7 +1047,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 }; } @@ -919,19 +1245,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 }; } } @@ -948,29 +1274,23 @@ 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); + 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; } @@ -978,12 +1298,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(), }; }); @@ -996,40 +1316,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(); - const configDetails = config.getFlagConfigDetails(); + ): 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, - configDetails.configEnvironment, - ); + 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, + }, + }); } /** @@ -1044,17 +1370,40 @@ 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?: VariationType, + ): 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 + * + * @todo This belongs to Evaluator class. + */ + private evaluateAssignment( + flagKey: string, + subjectKey: string, + subjectAttributes: Attributes, + expectedVariationType: VariationType | 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); + const overrideVariation = this.overrideStore?.get(flagKey); if (overrideVariation) { return overrideResult( @@ -1066,8 +1415,19 @@ export default class EppoClient { ); } - const configDetails = config.getFlagConfigDetails(); - const flag = this.getNormalizedFlag(config, flagKey); + const precomputed = config.getPrecomputedConfiguration(); + if (precomputed && precomputed.subjectKey === subjectKey) { + // Short-circuit evaluation if we have a matching precomputed configuration. + const precomputedResult = this.evaluatePrecomputedAssignment( + precomputed, + flagKey, + expectedVariationType, + ); + + return precomputedResult.flagEvaluation; + } + + const flag = config.getFlag(flagKey); if (flag === null) { logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); @@ -1081,7 +1441,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.format ?? '', ); } @@ -1097,7 +1457,7 @@ export default class EppoClient { subjectKey, subjectAttributes, flagEvaluationDetails, - configDetails.configFormat, + config.getFlagsConfiguration()?.response.format ?? '', ); } throw new TypeError(errorMessage); @@ -1115,39 +1475,202 @@ 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 + if (result.assignmentDetails) { + result.assignmentDetails.flagKey = flagKey; } - try { - if (result?.doLog) { - this.maybeLogAssignment(result); + return result; + } + + /** + * @todo This belongs to Evaluator class. + */ + private evaluatePrecomputedAssignment( + precomputed: PrecomputedConfig, + flagKey: string, + expectedVariationType: VariationType | undefined, + ): { + 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]; + + if (!obfuscatedFlag) { + logger.warn(`${loggerPrefix} No assigned variation. Flag not found: ${flagKey}`); + 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, + 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: 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, + }; } - } catch (error) { - logger.error(`${loggerPrefix} Error logging assignment event: ${error}`); + throw new TypeError(errorMessage); } - return result; + // 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, + subjectAttributes: precomputed.subjectAttributes + ? ensureNonContextualSubjectAttributes(precomputed.subjectAttributes) + : {}, + variation: { + key: flag.variationKey ?? '', + value: flag.variationValue, + }, + allocationKey: flag.allocationKey ?? '', + extraLogging: flag.extraLogging ?? {}, + doLog: flag.doLog, + entityId: null, + evaluationDetails: flagEvaluationDetails, + }; + + 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, + }; + } + + 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, + }; } /** * 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, @@ -1157,57 +1680,30 @@ 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(); + public isInitialized() { + return this.initialized; } - /** @deprecated Use `setAssignmentLogger` */ - setLogger(logger: IAssignmentLogger) { - this.setAssignmentLogger(logger); - } - - 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); @@ -1216,28 +1712,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(); } @@ -1245,23 +1741,19 @@ 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; } - getFlagConfigurations(): Record { - return this.getConfiguration().getFlags(); - } - private flushQueuedEvents(eventQueue: BoundedEventQueue, logFunction?: (event: T) => void) { const eventsToFlush = eventQueue.flush(); if (!logFunction) { @@ -1277,47 +1769,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 { @@ -1329,7 +1802,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}`); @@ -1338,14 +1811,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, @@ -1375,7 +1849,7 @@ export default class EppoClient { } private getPrecomputedBandit( - config: IConfiguration, + config: Configuration, flagKey: string, variationValue: string, subjectKey: string, @@ -1409,10 +1883,12 @@ export default class EppoClient { } } +/** @internal */ export function checkTypeMatch(expectedType?: VariationType, actualType?: VariationType): boolean { return expectedType === undefined || actualType === expectedType; } +/** @internal */ export function checkValueTypeMatch( expectedType: VariationType | undefined, value: ValueType, @@ -1437,3 +1913,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((_resolve, reject) => { + timer = setTimeout(() => reject(new TimeoutError()), ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); +} 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 4316eb6..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_POLL_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, { - PrecomputedFlagsRequestParameters, - 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: PrecomputedFlagsRequestParameters; - - const precomputedFlagKey = 'string-flag'; - const red = 'red'; - - const maxRetryDelay = DEFAULT_POLL_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_POLL_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_POLL_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_POLL_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 590ed87..0000000 --- a/src/client/eppo-precomputed-client.ts +++ /dev/null @@ -1,541 +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_POLL_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 PrecomputedFlagsRequestParameters = { - apiKey: string; - sdkVersion: string; - sdkName: string; - baseUrl?: string; - requestTimeoutMs?: number; - pollingIntervalMs?: 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?: PrecomputedFlagsRequestParameters; -} - -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?: PrecomputedFlagsRequestParameters; - 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 { 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; - } - - // 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(pollingIntervalMs, 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 { - 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( - 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/client/subject.ts b/src/client/subject.ts new file mode 100644 index 0000000..4de1da6 --- /dev/null +++ b/src/client/subject.ts @@ -0,0 +1,311 @@ +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 + * 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(); + } +} diff --git a/src/client/test-utils.ts b/src/client/test-utils.ts deleted file mode 100644 index 546ae36..0000000 --- a/src/client/test-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import ConfigurationRequestor from '../configuration-requestor'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; -import FetchHttpClient from '../http-client'; -import { Flag, ObfuscatedFlag } from '../interfaces'; - -export async function initConfiguration( - configurationStore: IConfigurationStore, -) { - 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, - configurationStore, - null, - null, - ); - await configurationRequestor.fetchAndStoreConfigurations(); -} diff --git a/src/configuration-feed.ts b/src/configuration-feed.ts new file mode 100644 index 0000000..f072fd3 --- /dev/null +++ b/src/configuration-feed.ts @@ -0,0 +1,27 @@ +import { BroadcastChannel } from './broadcast'; +import { Configuration } from './configuration'; + +/** + * 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 new file mode 100644 index 0000000..3283523 --- /dev/null +++ b/src/configuration-poller.ts @@ -0,0 +1,155 @@ +import { logger } from './application-logger'; +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, + * 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 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()); + }); + } + + /** + * 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) { + 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; + }) + .catch((err) => { + logger.warn({ err }, '[Eppo SDK] unexpected error in poller'); + }); + } + } + + /** + * 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 { + logger.debug('[Eppo SDK] stopping configuration poller'); + this.isRunning = false; + } + + private async poll(): Promise { + let consecutiveFailures = 0; + + while (this.isRunning) { + 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++; + } + } + + 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({ delayMs, consecutiveFailures }, '[Eppo SDK] will try polling again'); + + await timeout(delayMs); + } + } + } +} + +function timeout(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * @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 + */ +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/configuration-requestor.spec.ts b/src/configuration-requestor.spec.ts index 419c566..c88f9d3 100644 --- a/src/configuration-requestor.spec.ts +++ b/src/configuration-requestor.spec.ts @@ -6,25 +6,54 @@ 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'; -import { IConfigurationStore } from './configuration-store/configuration-store'; -import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; +import { ConfigurationStore } from './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: { + '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 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,15 +63,7 @@ describe('ConfigurationRequestor', () => { }, }); httpClient = new FetchHttpClient(apiEndpoints, 1000); - flagStore = new MemoryOnlyConfigurationStore(); - banditVariationStore = new MemoryOnlyConfigurationStore(); - banditModelStore = new MemoryOnlyConfigurationStore(); - configurationRequestor = new ConfigurationRequestor( - httpClient, - flagStore, - banditVariationStore, - banditModelStore, - ); + configurationRequestor = new ConfigurationRequestor(httpClient, configurationFeed); }); afterEach(() => { @@ -69,12 +90,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 +130,7 @@ describe('ConfigurationRequestor', () => { end: 10000, }); - expect(banditModelStore.getKeys().length).toBe(0); + expect(configuration?.getBanditConfiguration()).toBeUndefined(); }); }); @@ -145,17 +166,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 +229,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 +240,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 +297,14 @@ 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 +336,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 +355,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 +368,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 +385,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 +406,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 +431,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 +442,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,134 +523,22 @@ 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: { - bandit: { - modelVersion: 'v1', - flagVariations: [{ flagKey: 'test_flag', variationId: '1' }], - }, - }, - environment: 'test', - createdAt: '2024-01-01', - format: 'SERVER', - }; - - await requestor.fetchAndStoreConfigurations(); + // 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; // Second call with same model version @@ -635,78 +550,73 @@ 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); }); }); + }); - describe('IConfigurationStore updates', () => { - it('should update configuration when stores are changed', async () => { - // Create new stores - const newFlagStore = new MemoryOnlyConfigurationStore(); - const newBanditVariationStore = new MemoryOnlyConfigurationStore(); - const newBanditModelStore = new MemoryOnlyConfigurationStore(); - - // Add a test flag to the new flag store - await newFlagStore.setEntries({ - 'test-flag': { - key: 'test-flag', - enabled: true, - variationType: VariationType.STRING, - variations: { - control: { key: 'control', value: 'control-value' }, - treatment: { key: 'treatment', value: 'treatment-value' }, - }, - allocations: [ - { - key: 'allocation-1', - rules: [], - splits: [ - { - shards: [{ salt: '', ranges: [{ start: 0, end: 10000 }] }], - variationKey: 'treatment', - }, - ], - doLog: true, - }, - ], - totalShards: 10000, - }, - }); + describe('Precomputed flags', () => { + let fetchSpy: jest.Mock; + beforeEach(() => { + configurationRequestor = new ConfigurationRequestor(httpClient, configurationFeed, { + precomputed: { + subjectKey: 'subject-key', + subjectAttributes: ensureContextualSubjectAttributes({ + 'attribute-key': 'attribute-value', + }), + }, + }); - await newBanditModelStore.setEntries({ - 'test-bandit': { - banditKey: 'test-bandt', - modelVersion: 'v123', - modelName: 'falcon', - modelData: { - coefficients: {}, - gamma: 0, - defaultActionScore: 0, - actionProbabilityFloor: 0, - }, - }, + fetchSpy = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(MOCK_PRECOMPUTED_RESPONSE), }); + }) as jest.Mock; + global.fetch = fetchSpy; + }); - // Get the configuration and verify it has the test flag - const initialConfig = configurationRequestor.getConfiguration(); - expect(initialConfig.getFlagKeys()).toEqual([]); - expect(Object.keys(initialConfig.getBandits())).toEqual([]); - - // Update the stores - configurationRequestor.setConfigurationStores( - newFlagStore, - newBanditVariationStore, - newBanditModelStore, - ); - - // Get the configuration and verify it has the test flag - const config = configurationRequestor.getConfiguration(); - expect(config.getFlagKeys()).toEqual(['test-flag']); - expect(Object.keys(config.getBandits())).toEqual(['test-bandit']); - }); + 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 d8f56d2..42434d5 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,150 +1,121 @@ -import { IConfigurationStore } from './configuration-store/configuration-store'; +import { BanditsConfig, Configuration, FlagsConfig } from './configuration'; +import { ConfigurationFeed, ConfigurationSource } from './configuration-feed'; import { IHttpClient } from './http-client'; -import { - ConfigStoreHydrationPacket, - IConfiguration, - StoreBackedConfiguration, -} from './i-configuration'; -import { BanditVariation, BanditParameters, Flag, BanditReference } from './interfaces'; - -// Requests AND stores flag configurations +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>; + }; +}; + +/** + * @internal + */ export default class ConfigurationRequestor { - private banditModelVersions: string[] = []; - private configuration: StoreBackedConfiguration; + private readonly options: ConfigurationRequestorOptions; - constructor( + // We track the latest seen configuration to possibly reuse it for flags/bandits. + private latestConfiguration?: Configuration; + + public constructor( private readonly httpClient: IHttpClient, - private flagConfigurationStore: IConfigurationStore, - private banditVariationConfigurationStore: IConfigurationStore | null, - private banditModelConfigurationStore: IConfigurationStore | null, + private readonly configurationFeed: ConfigurationFeed, + options: Partial = {}, ) { - this.configuration = new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); - } + this.options = { + wantsBandits: true, + ...options, + }; - /** - * Updates the configuration stores and recreates the StoreBackedConfiguration - */ - public setConfigurationStores( - flagConfigurationStore: IConfigurationStore, - banditVariationConfigurationStore: IConfigurationStore | null, - banditModelConfigurationStore: IConfigurationStore | null, - ): void { - this.flagConfigurationStore = flagConfigurationStore; - this.banditVariationConfigurationStore = banditVariationConfigurationStore; - this.banditModelConfigurationStore = banditModelConfigurationStore; - - // Recreate the configuration with the new stores - this.configuration = new StoreBackedConfiguration( - this.flagConfigurationStore, - this.banditVariationConfigurationStore, - this.banditModelConfigurationStore, - ); - } + this.configurationFeed.addListener((configuration) => { + const prevFetchedAt = this.latestConfiguration?.getFetchedAt(); + const newFetchedAt = configuration.getFetchedAt(); - public isFlagConfigExpired(): Promise { - return this.flagConfigurationStore.isExpired(); + if (!prevFetchedAt || (newFetchedAt && newFetchedAt > prevFetchedAt)) { + this.latestConfiguration = configuration; + } + }); } - public getConfiguration(): IConfiguration { - return this.configuration; + 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; } - async fetchAndStoreConfigurations(): Promise { - const configResponse = await this.httpClient.getUniversalFlagConfiguration(); - if (!configResponse?.flags) { - return; + private async fetchRegularConfiguration(): Promise { + const flags = await this.httpClient.getUniversalFlagConfiguration(); + if (!flags?.response.flags) { + throw new ConfigurationError('empty response'); } - const flagResponsePacket: ConfigStoreHydrationPacket = { - entries: configResponse.flags, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; + const bandits = await this.getBanditsFor(flags); - 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); - } - } - } + return Configuration.fromResponses({ flags, bandits }); + } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { - // TODO: Notify that config updated. + private async fetchPrecomputedConfiguration( + precomputed: NonNullable, + ): Promise { + const response = await this.httpClient.getPrecomputedFlags(precomputed); + if (!response) { + throw new ConfigurationError('empty response'); } - } - private getLoadedBanditModelVersions(entries: Record): string[] { - return Object.values(entries).map((banditParam: BanditParameters) => banditParam.modelVersion); + return Configuration.fromResponses({ precomputed: response }); } - private requiresBanditModelConfigurationStoreUpdate( - currentBanditModelVersions: string[], - banditReferences: Record, - ): boolean { - const referencedModelVersions = Object.values(banditReferences).map( - (banditReference: BanditReference) => banditReference.modelVersion, - ); - - return !referencedModelVersions.every((modelVersion) => - currentBanditModelVersions.includes(modelVersion), - ); - } + /** + * Get bandits configuration matching the flags configuration. + * + * This function does not fetch bandits if the client does not want + * them (`ConfigurationRequestorOptions.wantsBandits === false`) or + * if 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; + } - 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; + const prevBandits = this.latestConfiguration?.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, + ); +}; diff --git a/src/configuration-store.ts b/src/configuration-store.ts new file mode 100644 index 0000000..3d769e1 --- /dev/null +++ b/src/configuration-store.ts @@ -0,0 +1,97 @@ +import { logger } from './application-logger'; +import { BroadcastChannel } from './broadcast'; +import { Configuration } from './configuration'; +import { ConfigurationFeed } from './configuration-feed'; + +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); + } +} 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 ff43a61..0000000 --- a/src/configuration-store/configuration-store.ts +++ /dev/null @@ -1,57 +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. - */ -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; -} - -export interface ISyncStore { - get(key: string): T | null; - entries(): Record; - getKeys(): string[]; - isInitialized(): boolean; - setEntries(entries: Record): void; -} - -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/configuration-wire/configuration-wire-helper.spec.ts b/src/configuration-wire/configuration-wire-helper.spec.ts deleted file mode 100644 index 4b1c768..0000000 --- a/src/configuration-wire/configuration-wire-helper.spec.ts +++ /dev/null @@ -1,99 +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.fetchConfiguration(); - - 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, - fetchBandits: true, - }); - - const wirePacket = await helper.fetchConfiguration(); - - 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: 'node-server', - baseUrl: TEST_BASE_URL, - fetchBandits: true, - }); - - const wirePacket = await helper.fetchConfiguration(); - - 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 4a4eb7e..0000000 --- a/src/configuration-wire/configuration-wire-helper.ts +++ /dev/null @@ -1,81 +0,0 @@ -import ApiEndpoints from '../api-endpoints'; -import FetchHttpClient, { - IBanditParametersResponse, - IHttpClient, - IUniversalFlagConfigResponse, -} from '../http-client'; -import SdkTokenDecoder from '../sdk-token-decoder'; - -import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; - -export type SdkOptions = { - sdkName?: string; - sdkVersion?: string; - baseUrl?: string; - fetchBandits?: boolean; -}; - -/** - * 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 - * @param opts - */ - public static build( - sdkKey: string, - opts: SdkOptions = { sdkName: 'js-client-sdk', sdkVersion: '4.0.0' }, - ) { - const { sdkName, sdkVersion, baseUrl, fetchBandits } = opts; - return new ConfigurationWireHelper(sdkKey, sdkName, sdkVersion, baseUrl, fetchBandits); - } - - private constructor( - sdkKey: string, - targetSdkName = 'js-client-sdk', - targetSdkVersion = '4.0.0', - baseUrl?: string, - private readonly fetchBandits = false, - ) { - const queryParams = { - sdkName: targetSdkName, - sdkVersion: targetSdkVersion, - apiKey: sdkKey, - sdkProxy: 'config-wire-helper', - }; - const apiEndpoints = new ApiEndpoints({ - baseUrl, - queryParams, - sdkTokenDecoder: new SdkTokenDecoder(sdkKey), - }); - - 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 fetchConfiguration(): 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 (this.fetchBandits && flagsHaveBandits) { - banditResponse = await this.httpClient.getBanditParameters(); - } - - return ConfigurationWireV1.fromResponses(configResponse, banditResponse); - } -} 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 d523652..0000000 --- a/src/configuration-wire/configuration-wire-types.ts +++ /dev/null @@ -1,237 +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 toString(): string { - return JSON.stringify(this as IConfigurationWire); - } - - 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 new file mode 100644 index 0000000..ad1fe75 --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,332 @@ +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 */ +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; +}; + +/** @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 parts: { + readonly flags?: FlagsConfig; + readonly bandits?: BanditsConfig; + readonly precomputed?: PrecomputedConfig; + }, + ) { + this.flagBanditVariations = parts.flags + ? indexBanditVariationsByFlagKey(parts.flags.response) + : {}; + } + + /** + * Creates a new empty configuration. + * @public + */ + public static empty(): 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({ + flags: { + 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, + bandits, + precomputed, + }: { + flags?: FlagsConfig; + bandits?: BanditsConfig; + precomputed?: PrecomputedConfig; + }): Configuration { + return new Configuration({ flags, bandits, precomputed }); + } + + /** + * 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 { + const wire: ConfigurationWire = { + version: 1, + }; + if (this.parts.flags) { + wire.config = { + ...this.parts.flags, + response: JSON.stringify(this.parts.flags.response), + }; + } + if (this.parts.bandits) { + wire.bandits = { + ...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); + } + + /** + * 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 []; + } + + /** @internal */ + public getFlagsConfiguration(): FlagsConfig | undefined { + return this.parts.flags; + } + + /** @internal */ + public getFetchedAt(): Date | undefined { + 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.parts.flags && !this.parts.precomputed; + } + + /** @internal */ + public getAgeMs(): number | undefined { + const fetchedAt = this.getFetchedAt(); + if (!fetchedAt) { + return undefined; + } + return Date.now() - fetchedAt.getTime(); + } + + /** @internal */ + public isStale(maxAgeSeconds: number): boolean { + const age = this.getAgeMs(); + return !!age && age > maxAgeSeconds * 1000; + } + + /** + * Returns flag configuration for the given flag key. Obfuscation is + * handled automatically. + * + * @internal + */ + public getFlag(flagKey: string): Flag | null { + if (!this.parts.flags) { + return null; + } + + if (this.parts.flags.response.format === FormatEnum.SERVER) { + return this.parts.flags.response.flags[flagKey] ?? null; + } else { + // Obfuscated configuration + const flag = this.parts.flags.response.flags[getMD5Hash(flagKey)]; + return flag ? decodeFlag(flag as ObfuscatedFlag) : null; + } + } + + /** @internal */ + public getBanditConfiguration(): BanditsConfig | undefined { + return this.parts.bandits; + } + + /** @internal */ + public getPrecomputedConfiguration(): PrecomputedConfig | undefined { + return this.parts.precomputed; + } + + /** @internal */ + public getFlagBanditVariations(flagKey: FlagKey | HashedFlagKey): BanditVariation[] { + return this.flagBanditVariations[flagKey] ?? []; + } + + /** @internal */ + 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.parts.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; + etag?: string; + fetchedAt?: string; + subjectKey: string; + subjectAttributes?: ContextAttributes; + banditActions?: Record< + /* flagKey: */ string, + Record + >; + }; +}; diff --git a/src/constants.ts b/src/constants.ts index b76096e..07297b4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,11 +1,16 @@ 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_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 = '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'; 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..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'); @@ -13,7 +11,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..7a4e70c 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,5 +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'; @@ -11,17 +12,19 @@ describe('Evaluator', () => { const evaluator = new Evaluator(); - let configDetails: ConfigDetails; - - beforeEach(() => { - configDetails = { - configEnvironment: { - name: 'Test', + const 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,11 +50,11 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('disabled_flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); - expect(result.doLog).toBeFalsy(); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); + 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', () => { @@ -74,7 +77,9 @@ 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(); }); @@ -84,7 +89,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(); }); @@ -98,11 +103,11 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(emptyFlag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('empty'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); - expect(result.doLog).toBeFalsy(); + const result = evaluator.evaluateFlag(configuration, emptyFlag, 'subject_key', {}); + 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', () => { @@ -128,8 +133,8 @@ describe('Evaluator', () => { totalShards: 10000, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'user-1', {}, false); - expect(result.variation).toEqual({ key: 'control', value: 'control-value' }); + const result = evaluator.evaluateFlag(configuration, flag, 'user-1', {}); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control-value' }); }); it('should evaluate flag based on a targeting condition based on id', () => { @@ -161,14 +166,14 @@ describe('Evaluator', () => { totalShards: 10000, }; - let result = evaluator.evaluateFlag(flag, configDetails, 'alice', {}, false); - expect(result.variation).toEqual({ key: 'control', value: 'control' }); + let result = evaluator.evaluateFlag(configuration, flag, 'alice', {}); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control' }); - result = evaluator.evaluateFlag(flag, configDetails, 'bob', {}, false); - expect(result.variation).toEqual({ key: 'control', value: 'control' }); + result = evaluator.evaluateFlag(configuration, flag, 'bob', {}); + expect(result.assignmentDetails.variation).toEqual({ key: 'control', value: 'control' }); - result = evaluator.evaluateFlag(flag, configDetails, 'charlie', {}, false); - expect(result.variation).toBeNull(); + result = evaluator.evaluateFlag(configuration, flag, 'charlie', {}); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should evaluate flag based on a targeting condition with overwritten id', () => { @@ -200,8 +205,8 @@ describe('Evaluator', () => { totalShards: 10000, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'alice', { id: 'charlie' }, false); - expect(result.variation).toBeNull(); + const result = evaluator.evaluateFlag(configuration, flag, 'alice', { id: 'charlie' }); + expect(result.assignmentDetails.variation).toBeNull(); }); it('should catch all allocation and return variation A', () => { @@ -227,11 +232,11 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); - expect(result.doLog).toBeTruthy(); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); + 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', () => { @@ -275,16 +280,12 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@example.com' }, - false, - ); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('first'); - expect(result.variation).toEqual(VARIATION_B); + 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); }); it('should not match first allocation rule and return variation A', () => { @@ -328,16 +329,12 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@test.com' }, - false, - ); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + 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); }); it('should not match first allocation rule and return variation A (obfuscated)', () => { @@ -385,16 +382,12 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag( - flag, - configDetails, - 'subject_key', - { email: 'eppo@test.com' }, - false, - ); - expect(result.flagKey).toEqual('obfuscated_flag_key'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + 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); }); it('should evaluate sharding and return correct variations', () => { @@ -441,8 +434,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, @@ -452,19 +445,23 @@ describe('Evaluator', () => { 'split-charlie': 8, 'split-dave': 1, }), - ); + }); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'alice', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'alice', {}).assignmentDetails + .variation, ).toEqual(VARIATION_A); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'bob', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'bob', {}).assignmentDetails + .variation, ).toEqual(VARIATION_B); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'charlie', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'charlie', {}).assignmentDetails + .variation, ).toEqual(VARIATION_C); expect( - deterministicEvaluator.evaluateFlag(flag, configDetails, 'dave', {}, false).variation, + deterministicEvaluator.evaluateFlag(configuration, flag, 'dave', {}).assignmentDetails + .variation, ).toEqual(VARIATION_C); }); @@ -494,10 +491,10 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); + 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', () => { @@ -526,10 +523,10 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toEqual('default'); - expect(result.variation).toEqual(VARIATION_A); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); + 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', () => { @@ -558,10 +555,10 @@ describe('Evaluator', () => { totalShards: 10, }; - const result = evaluator.evaluateFlag(flag, configDetails, 'subject_key', {}, false); - expect(result.flagKey).toEqual('flag'); - expect(result.allocationKey).toBeNull(); - expect(result.variation).toBeNull(); + const result = evaluator.evaluateFlag(configuration, flag, 'subject_key', {}); + 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 ce7e381..a012020 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -1,4 +1,7 @@ +import { IAssignmentEvent } from './assignment-logger'; +import { IBanditEvent } from './bandit-logger'; import { checkValueTypeMatch } from './client/eppo-client'; +import { Configuration } from './configuration'; import { AllocationEvaluationCode, IFlagEvaluationDetails, @@ -14,13 +17,14 @@ import { Allocation, Split, VariationType, - ConfigDetails, + FormatEnum, } from './interfaces'; import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; import { Attributes } from './types'; +import { LIB_VERSION } from './version'; -export interface FlagEvaluationWithoutDetails { +export interface AssignmentResult { flagKey: string; format: string; subjectKey: string; @@ -28,36 +32,44 @@ 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( + 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 +80,7 @@ export class Evaluator { 'FLAG_UNRECOGNIZED_OR_DISABLED', `Unrecognized or disabled flag: ${flag.key}`, ), - configDetails.configFormat, + configFormat ?? '', ); } @@ -113,18 +125,47 @@ export class Evaluator { const flagEvaluationDetails = flagEvaluationDetailsBuilder .setMatch(i, variation, allocation, matchedRule, expectedVariationType) .build(flagEvaluationCode, flagEvaluationDescription); - return { + + const assignmentDetails: AssignmentResult = { flagKey: flag.key, - format: configDetails.configFormat, + format: configFormat ?? '', subjectKey, subjectAttributes, allocationKey: allocation.key, 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 @@ -141,7 +182,7 @@ export class Evaluator { 'DEFAULT_ALLOCATION_NULL', 'No allocations matched. Falling back to "Default Allocation", serving NULL', ), - configDetails.configFormat, + configFormat ?? '', ); } catch (err: any) { const flagEvaluationDetails = flagEvaluationDetailsBuilder.gracefulBuild( @@ -219,16 +260,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, + }, }; } @@ -281,15 +324,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/http-client.ts b/src/http-client.ts index 7d13b81..5a1454c 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,5 +1,5 @@ import ApiEndpoints from './api-endpoints'; -import { IObfuscatedPrecomputedConfigurationResponse } from './configuration-wire/configuration-wire-types'; +import { BanditsConfig, FlagsConfig, PrecomputedConfig } from './configuration'; import { BanditParameters, BanditReference, @@ -8,6 +8,7 @@ import { FormatEnum, PrecomputedFlagsPayload, } from './interfaces'; +import { IObfuscatedPrecomputedConfigurationResponse } from './precomputed-configuration'; import { Attributes } from './types'; export interface IQueryParams { @@ -21,10 +22,11 @@ export interface IQueryParamsWithSubject extends IQueryParams { subjectAttributes: Attributes; } +/** @internal */ export class HttpRequestError extends Error { constructor( public message: string, - public status: number, + public status?: number, public cause?: Error, ) { super(message); @@ -47,40 +49,62 @@ export interface IBanditParametersResponse { bandits: Record; } +/** @internal */ export interface IHttpClient { - getUniversalFlagConfiguration(): Promise; - getBanditParameters(): Promise; - getPrecomputedFlags( - payload: PrecomputedFlagsPayload, - ): Promise; + getUniversalFlagConfiguration(): Promise; + getBanditParameters(): 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, ) {} - async getUniversalFlagConfiguration(): Promise { + async getUniversalFlagConfiguration(): Promise { const url = this.apiEndpoints.ufcEndpoint(); - return await this.rawGet(url); + const response = await this.rawGet(url); + if (!response) { + throw new HttpRequestError('Empty response'); + } + 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) { + throw new HttpRequestError('Empty response'); + } + return { + response, + fetchedAt: new Date().toISOString(), + }; } - 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/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 f8d3b0d..0000000 --- a/src/i-configuration.ts +++ /dev/null @@ -1,133 +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'; - -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 a9ebaee..6deca50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,73 +1,20 @@ -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, - FlagConfigurationRequestParameters, IAssignmentDetails, IContainerExperiment, } from './client/eppo-client'; -import EppoPrecomputedClient, { - PrecomputedFlagsRequestParameters, - Subject, -} from './client/eppo-precomputed-client'; -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 { ConfigurationWireHelper } from './configuration-wire/configuration-wire-helper'; -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 { buildStorageKeySuffix } from './obfuscation'; -import { +export { Subject } from './client/subject'; +export * as constants from './constants'; +export { EppoAssignmentLogger } from './eppo-assignment-logger'; +export { AttributeType, Attributes, BanditActions, @@ -75,92 +22,4 @@ 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 - EppoPrecomputedClient, - PrecomputedFlagsRequestParameters, - IObfuscatedPrecomputedConfigurationResponse, - IObfuscatedPrecomputedBandit, - - // Configuration store - IConfigurationStore, - IAsyncStore, - ISyncStore, - MemoryStore, - HybridConfigurationStore, - MemoryOnlyConfigurationStore, - - // Assignment cache - AssignmentCacheKey, - AssignmentCacheValue, - AssignmentCacheEntry, - AssignmentCache, - AsyncMap, - NonExpiringInMemoryAssignmentCache, - LRUInMemoryAssignmentCache, - assignmentCacheKeyToString, - assignmentCacheValueToString, - - // Interfaces - FlagConfigurationRequestParameters, - Flag, - ObfuscatedFlag, - Variation, - VariationType, - AttributeType, - Attributes, - ContextAttributes, - BanditSubjectAttributes, - BanditActions, - BanditVariation, - BanditParameters, - Subject, - 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, - ConfigurationWireHelper, - - // Test helpers - decodePrecomputedFlag, - - // Utilities - buildStorageKeySuffix, -}; +export { VariationType } from './interfaces'; diff --git a/src/interfaces.ts b/src/interfaces.ts index a84a848..71b1386 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -44,14 +44,6 @@ export interface Environment { } export const UNKNOWN_ENVIRONMENT_NAME = 'UNKNOWN'; -export interface ConfigDetails { - configFetchedAt: string; - configPublishedAt: string; - configEnvironment: Environment; - configFormat: string; - salt?: string; -} - export interface Flag { key: string; enabled: boolean; @@ -197,7 +189,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/internal.ts b/src/internal.ts new file mode 100644 index 0000000..df48edd --- /dev/null +++ b/src/internal.ts @@ -0,0 +1,52 @@ +// 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 { + IObfuscatedPrecomputedConfigurationResponse, + IPrecomputedConfigurationResponse, +} from './precomputed-configuration'; +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'; 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 }; + } +} 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 new file mode 100644 index 0000000..10b4319 --- /dev/null +++ b/src/persistent-configuration-cache.ts @@ -0,0 +1,72 @@ +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/poller.spec.ts b/src/poller.spec.ts deleted file mode 100644 index d45d083..0000000 --- a/src/poller.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import * as td from 'testdouble'; - -import { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from './constants'; -import initPoller from './poller'; - -describe('poller', () => { - const testIntervalMs = DEFAULT_POLL_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 386b0e7..0000000 --- a/src/poller.ts +++ /dev/null @@ -1,162 +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 - */ -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/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; +} 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); - } -} diff --git a/src/salt.ts b/src/salt.ts new file mode 100644 index 0000000..3422416 --- /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(); +} diff --git a/src/tools/commands/bootstrap-config.ts b/src/tools/commands/bootstrap-config.ts index 32db56f..e2f4b42 100644 --- a/src/tools/commands/bootstrap-config.ts +++ b/src/tools/commands/bootstrap-config.ts @@ -2,7 +2,11 @@ import * as fs from 'fs'; import type { CommandModule } from 'yargs'; -import { ConfigurationWireHelper } from '../../configuration-wire/configuration-wire-helper'; +import ApiEndpoints from '../../api-endpoints'; +import { BroadcastChannel } from '../../broadcast'; +import ConfigurationRequestor from '../../configuration-requestor'; +import FetchHttpClient from '../../http-client'; +import { LIB_VERSION } from '../../version'; export const bootstrapConfigCommand: CommandModule = { command: 'bootstrap-config', @@ -41,12 +45,22 @@ export const bootstrapConfigCommand: CommandModule = { } try { - const helper = ConfigurationWireHelper.build(argv.key as string, { - sdkName: argv.sdk as string, + const apiEndpoints = new ApiEndpoints({ baseUrl: argv['base-url'] as string, - fetchBandits: true, + queryParams: { + apiKey: argv.key as string, + sdkName: argv.sdk as string, + sdkVersion: LIB_VERSION, + }, }); - const config = await helper.fetchConfiguration(); + + const httpClient = new FetchHttpClient(apiEndpoints, 10000); + + const requestor = new ConfigurationRequestor(httpClient, new BroadcastChannel(), { + wantsBandits: true, + }); + + const config = await requestor.fetchConfiguration(); if (!config) { console.error('Error: Failed to fetch configuration'); diff --git a/test/testHelpers.ts b/test/testHelpers.ts index dadbc74..bcefcd8 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -2,16 +2,11 @@ import * as fs from 'fs'; import { isEqual } from 'lodash'; -import { - AttributeType, - ContextAttributes, - IAssignmentDetails, - Variation, - VariationType, -} from '../src'; +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 { Variation } from '../src/interfaces'; 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/'; @@ -65,6 +60,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'); }