From 0121c3c6765ba5f02047bfa1da4440eec6b81f23 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 20:17:48 -0500 Subject: [PATCH 1/5] Add offlineInit() for synchronous SDK initialization without network Adds the ability to initialize the SDK with pre-fetched configuration JSON, enabling: - Synchronous initialization without network requests - Use cases where configuration is bootstrapped from another source - Edge/serverless environments where polling isn't desired Also includes: - IOfflineClientConfig interface for offline initialization options - DEFAULT_ASSIGNMENT_CACHE_SIZE constant for consistent cache sizing - Deprecation annotations on event tracking (discontinued feature) Co-Authored-By: Claude Opus 4.5 --- src/i-client-config.ts | 57 ++++++++- src/index.spec.ts | 284 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 154 +++++++++++++++++++++- 3 files changed, 487 insertions(+), 8 deletions(-) diff --git a/src/i-client-config.ts b/src/i-client-config.ts index 359de91..3c84a9a 100644 --- a/src/i-client-config.ts +++ b/src/i-client-config.ts @@ -52,7 +52,10 @@ export interface IClientConfig { /** Amount of time in milliseconds to wait between API calls to refresh configuration data. Default of 30_000 (30s). */ pollingIntervalMs?: number; - /** Configuration settings for the event dispatcher */ + /** + * Configuration settings for the event dispatcher. + * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. + */ eventTracking?: { /** Maximum number of events to send per delivery request. Defaults to 1000 events. */ batchSize?: number; @@ -74,3 +77,55 @@ export interface IClientConfig { retryIntervalMs?: number; }; } + +/** + * Configuration used for offline initialization of the Eppo client. + * Offline initialization allows the SDK to be used without making any network requests. + * @public + */ +export interface IOfflineClientConfig { + /** + * The full flags configuration JSON string as returned by the Eppo API. + * This should be the complete response from the /flag-config/v1/config endpoint. + * + * Expected format: + * ```json + * { + * "createdAt": "2024-04-17T19:40:53.716Z", + * "format": "SERVER", + * "environment": { "name": "production" }, + * "flags": { ... } + * } + * ``` + */ + flagsConfiguration: string; + + /** + * Optional bandit models configuration JSON string as returned by the Eppo API. + * This should be the complete response from the bandit parameters endpoint. + * + * Expected format: + * ```json + * { + * "updatedAt": "2024-04-17T19:40:53.716Z", + * "bandits": { ... } + * } + * ``` + */ + banditsConfiguration?: string; + + /** + * Optional assignment logger for sending variation assignments to your data warehouse. + * Required for experiment analysis. + */ + assignmentLogger?: IAssignmentLogger; + + /** Optional bandit logger for sending bandit actions to your data warehouse */ + banditLogger?: IBanditLogger; + + /** + * Whether to throw an error if initialization fails. (default: true) + * If false, the client will be initialized with an empty configuration. + */ + throwOnFailedInitialization?: boolean; +} diff --git a/src/index.spec.ts b/src/index.spec.ts index c501f29..e20e4c4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -40,6 +40,7 @@ import { IAssignmentLogger, init, NO_OP_EVENT_DISPATCHER, + offlineInit, } from '.'; import SpyInstance = jest.SpyInstance; @@ -917,3 +918,286 @@ describe('EppoClient E2E test', () => { }); }); }); + +describe('offlineInit', () => { + const flagKey = 'mock-experiment'; + + // Configuration for a single flag within the UFC. + const mockUfcFlagConfig: Flag = { + key: flagKey, + enabled: true, + variationType: VariationType.STRING, + variations: { + control: { + key: 'control', + value: 'control', + }, + 'variant-1': { + key: 'variant-1', + value: 'variant-1', + }, + 'variant-2': { + key: 'variant-2', + value: 'variant-2', + }, + }, + allocations: [ + { + key: 'traffic-split', + rules: [], + splits: [ + { + variationKey: 'control', + shards: [ + { + salt: 'some-salt', + ranges: [{ start: 0, end: 3400 }], + }, + ], + }, + { + variationKey: 'variant-1', + shards: [ + { + salt: 'some-salt', + ranges: [{ start: 3400, end: 6700 }], + }, + ], + }, + { + variationKey: 'variant-2', + shards: [ + { + salt: 'some-salt', + ranges: [{ start: 6700, end: 10000 }], + }, + ], + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + // Helper to create a full configuration JSON string + const createFlagsConfigJson = ( + flags: Record, + options: { createdAt?: string; format?: string } = {}, + ): string => { + return JSON.stringify({ + createdAt: options.createdAt ?? '2024-04-17T19:40:53.716Z', + format: options.format ?? 'SERVER', + environment: { name: 'Test' }, + flags, + }); + }; + + describe('basic initialization', () => { + it('initializes with flag configurations and returns correct assignments', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + // subject-10 should get variant-1 based on the hash + const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('variant-1'); + }); + + it('returns default value when flag is not found', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + const assignment = client.getStringAssignment( + 'non-existent-flag', + 'subject-10', + {}, + 'default-value', + ); + expect(assignment).toEqual('default-value'); + }); + + it('initializes with empty configuration', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({}), + }); + + const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('default-value'); + }); + + it('makes client available via getInstance()', () => { + offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + const client = getInstance(); + const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('variant-1'); + }); + }); + + describe('assignment logging', () => { + it('logs assignments when assignment logger is provided', () => { + const mockLogger = td.object(); + + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + assignmentLogger: mockLogger, + }); + + client.getStringAssignment(flagKey, 'subject-10', { foo: 'bar' }, 'default-value'); + + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); + const loggedAssignment = td.explain(mockLogger.logAssignment).calls[0].args[0]; + expect(loggedAssignment.subject).toEqual('subject-10'); + expect(loggedAssignment.featureFlag).toEqual(flagKey); + expect(loggedAssignment.allocation).toEqual('traffic-split'); + }); + + it('does not throw when assignment logger throws', () => { + const mockLogger = td.object(); + td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow( + new Error('logging error'), + ); + + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + assignmentLogger: mockLogger, + }); + + // Should not throw, even though logger throws + const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); + expect(assignment).toEqual('variant-1'); + }); + }); + + describe('configuration metadata', () => { + it('extracts createdAt from configuration as configPublishedAt', () => { + const createdAt = '2024-01-15T10:00:00.000Z'; + + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }, { createdAt }), + }); + + const result = client.getStringAssignmentDetails(flagKey, 'subject-10', {}, 'default-value'); + expect(result.evaluationDetails.configPublishedAt).toBe(createdAt); + }); + }); + + describe('error handling', () => { + it('throws error by default when JSON parsing fails', () => { + expect(() => { + offlineInit({ + flagsConfiguration: 'invalid json', + }); + }).toThrow(); + }); + + it('does not throw when throwOnFailedInitialization is false', () => { + expect(() => { + offlineInit({ + flagsConfiguration: 'invalid json', + throwOnFailedInitialization: false, + }); + }).not.toThrow(); + }); + + it('does not throw with valid empty flags configuration', () => { + expect(() => { + offlineInit({ + flagsConfiguration: createFlagsConfigJson({}), + }); + }).not.toThrow(); + }); + }); + + describe('no network requests', () => { + it('does not have configurationRequestParameters (no polling)', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + // Access the internal configurationRequestParameters - should be undefined for offline mode + const configurationRequestParameters = client['configurationRequestParameters']; + expect(configurationRequestParameters).toBeUndefined(); + }); + }); + + describe('bandit support', () => { + it('initializes with bandit references from configuration', () => { + const banditFlagKey = 'bandit-flag'; + const banditKey = 'test-bandit'; + + const banditFlagConfig: Flag = { + key: banditFlagKey, + enabled: true, + variationType: VariationType.STRING, + variations: { + bandit: { + key: 'bandit', + value: 'bandit', + }, + control: { + key: 'control', + value: 'control', + }, + }, + allocations: [ + { + key: 'bandit-allocation', + rules: [], + splits: [ + { + variationKey: 'bandit', + shards: [ + { + salt: 'salt', + ranges: [{ start: 0, end: 10000 }], + }, + ], + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + // Create a configuration with bandit references + const flagsConfigJson = JSON.stringify({ + createdAt: '2024-04-17T19:40:53.716Z', + format: 'SERVER', + environment: { name: 'Test' }, + flags: { [banditFlagKey]: banditFlagConfig }, + banditReferences: { + [banditKey]: { + modelVersion: 'v1', + flagVariations: [ + { + key: 'bandit', + flagKey: banditFlagKey, + variationKey: 'bandit', + variationValue: 'bandit', + }, + ], + }, + }, + }); + + const client = offlineInit({ + flagsConfiguration: flagsConfigJson, + }); + + // Verify the client is initialized and can make assignments + const assignment = client.getStringAssignment( + banditFlagKey, + 'subject-1', + {}, + 'default-value', + ); + expect(assignment).toEqual('bandit'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 33c62c8..08cf7cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { } from '@eppo/js-client-sdk-common'; import FileBackedNamedEventQueue from './events/file-backed-named-event-queue'; -import { IClientConfig } from './i-client-config'; +import { IClientConfig, IOfflineClientConfig } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; import { generateSalt } from './util'; import { isReadOnlyFs } from './util/index'; @@ -39,7 +39,7 @@ export { EppoAssignmentLogger, } from '@eppo/js-client-sdk-common'; -export { IClientConfig }; +export { IClientConfig, IOfflineClientConfig }; let clientInstance: EppoClient; @@ -90,6 +90,15 @@ interface BanditsConfigurationResponse { bandits: Record; } +/** + * Default assignment cache size for server-side use cases. + * We estimate this will use no more than 10 MB of memory. + */ +const DEFAULT_ASSIGNMENT_CACHE_SIZE = 50_000; + +/** + * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. + */ export const NO_OP_EVENT_DISPATCHER: EventDispatcher = { // eslint-disable-next-line @typescript-eslint/no-empty-function attachContext: () => {}, @@ -152,11 +161,8 @@ export async function init(config: IClientConfig): Promise { clientInstance.setBanditLogger(config.banditLogger); } - // default to LRU cache with 50_000 entries. - // we estimate this will use no more than 10 MB of memory - // and should be appropriate for most server-side use cases. - clientInstance.useLRUInMemoryAssignmentCache(50_000); - clientInstance.useExpiringInMemoryBanditAssignmentCache(50_000); + clientInstance.useLRUInMemoryAssignmentCache(DEFAULT_ASSIGNMENT_CACHE_SIZE); + clientInstance.useExpiringInMemoryBanditAssignmentCache(DEFAULT_ASSIGNMENT_CACHE_SIZE); // Fetch configurations (which will also start regular polling per requestConfiguration) await clientInstance.fetchFlagConfigurations(); @@ -276,6 +282,140 @@ export function getBanditsConfiguration(): string { return JSON.stringify(configuration); } +/** + * Initializes the Eppo client in offline mode with a provided configuration. + * This method is synchronous and does not make any network requests. + * Use this when you want to initialize the SDK with a previously fetched configuration. + * @param config offline client configuration containing flag configurations as JSON strings + * @returns the initialized client instance + * @public + */ +export function offlineInit(config: IOfflineClientConfig): EppoClient { + const { + flagsConfiguration, + banditsConfiguration, + assignmentLogger, + banditLogger, + throwOnFailedInitialization = true, + } = config; + + try { + // Parse the flags configuration JSON + const flagsConfigResponse = JSON.parse(flagsConfiguration) as { + createdAt?: string; + format?: string; + environment?: { name: string }; + flags: Record; + banditReferences?: Record< + string, + { + modelVersion: string; + flagVariations: BanditVariation[]; + } + >; + }; + + // Create memory-only configuration stores + flagConfigurationStore = new MemoryOnlyConfigurationStore(); + banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); + banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + + // Set format from the configuration (default to SERVER) + const format = (flagsConfigResponse.format as FormatEnum) ?? FormatEnum.SERVER; + flagConfigurationStore.setFormat(format); + + // Load flag configurations into store + // Note: setEntries is async but MemoryOnlyConfigurationStore performs synchronous operations internally, + // so there's no race condition. We add .catch() for defensive error handling, matching JS client SDK pattern. + flagConfigurationStore + .setEntries(flagsConfigResponse.flags ?? {}) + .catch((err) => + applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`), + ); + + // Set configuration timestamp if available + if (flagsConfigResponse.createdAt) { + flagConfigurationStore.setConfigPublishedAt(flagsConfigResponse.createdAt); + } + + // Set environment if available + if (flagsConfigResponse.environment) { + flagConfigurationStore.setEnvironment(flagsConfigResponse.environment); + } + + // Process bandit references from the flags configuration + // Index by flag key for quick lookup (instead of by bandit key) + if (flagsConfigResponse.banditReferences) { + const banditVariationsByFlagKey: Record = {}; + for (const banditReference of Object.values(flagsConfigResponse.banditReferences)) { + for (const flagVariation of banditReference.flagVariations) { + const { flagKey } = flagVariation; + if (!banditVariationsByFlagKey[flagKey]) { + banditVariationsByFlagKey[flagKey] = []; + } + banditVariationsByFlagKey[flagKey].push(flagVariation); + } + } + banditVariationConfigurationStore + .setEntries(banditVariationsByFlagKey) + .catch((err) => + applicationLogger.warn( + `Error setting bandit variations for memory-only configuration store: ${err}`, + ), + ); + } + + // Parse and load bandit models if provided + if (banditsConfiguration) { + const banditsConfigResponse = JSON.parse(banditsConfiguration) as { + updatedAt?: string; + bandits: Record; + }; + banditModelConfigurationStore + .setEntries(banditsConfigResponse.bandits ?? {}) + .catch((err) => + applicationLogger.warn( + `Error setting bandit models for memory-only configuration store: ${err}`, + ), + ); + } + + // Create client without request parameters (offline mode - no polling) + clientInstance = new EppoClient({ + flagConfigurationStore, + banditVariationConfigurationStore, + banditModelConfigurationStore, + // No configurationRequestParameters = offline mode, no network requests + }); + + // Set loggers if provided + if (assignmentLogger) { + clientInstance.setAssignmentLogger(assignmentLogger); + } + if (banditLogger) { + clientInstance.setBanditLogger(banditLogger); + } + + clientInstance.useLRUInMemoryAssignmentCache(DEFAULT_ASSIGNMENT_CACHE_SIZE); + clientInstance.useExpiringInMemoryBanditAssignmentCache(DEFAULT_ASSIGNMENT_CACHE_SIZE); + + return clientInstance; + } catch (error) { + if (throwOnFailedInitialization) { + throw error; + } + applicationLogger.warn( + `Eppo SDK offline initialization failed: ${error instanceof Error ? error.message : error}`, + ); + // Return the client instance even if initialization failed + // It will return default values for all assignments + return clientInstance; + } +} + +/** + * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. + */ function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {}, From 0b5c5eb07e415fabbd43714be15bce06502fe51f Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 22:59:05 -0500 Subject: [PATCH 2/5] Add getBanditAction verification to offlineInit bandit test Use realistic bandit model coefficients from bandit-models-v1.json and test subject "alice" from test-case-banner-bandit.json to verify that getBanditAction returns the expected variation and action after offline initialization. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 130 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index e20e4c4..557e7a9 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1126,34 +1126,36 @@ describe('offlineInit', () => { }); describe('bandit support', () => { - it('initializes with bandit references from configuration', () => { - const banditFlagKey = 'bandit-flag'; - const banditKey = 'test-bandit'; + it('initializes with bandit references and supports getBanditAction', () => { + // Use realistic names inspired by bandit-flags-v1.json and bandit-models-v1.json + const banditFlagKey = 'banner_bandit_flag'; + const banditKey = 'banner_bandit'; + // Flag configuration matching banner_bandit_flag structure const banditFlagConfig: Flag = { key: banditFlagKey, enabled: true, variationType: VariationType.STRING, variations: { - bandit: { - key: 'bandit', - value: 'bandit', - }, control: { key: 'control', value: 'control', }, + [banditKey]: { + key: banditKey, + value: banditKey, + }, }, allocations: [ { - key: 'bandit-allocation', + key: 'training', rules: [], splits: [ { - variationKey: 'bandit', + variationKey: banditKey, shards: [ { - salt: 'salt', + salt: 'traffic-split', ranges: [{ start: 0, end: 10000 }], }, ], @@ -1165,7 +1167,7 @@ describe('offlineInit', () => { totalShards: 10000, }; - // Create a configuration with bandit references + // Flags configuration with bandit references (matching bandit-flags-v1.json structure) const flagsConfigJson = JSON.stringify({ createdAt: '2024-04-17T19:40:53.716Z', format: 'SERVER', @@ -1173,31 +1175,119 @@ describe('offlineInit', () => { flags: { [banditFlagKey]: banditFlagConfig }, banditReferences: { [banditKey]: { - modelVersion: 'v1', + modelVersion: '123', flagVariations: [ { - key: 'bandit', + key: banditKey, flagKey: banditFlagKey, - variationKey: 'bandit', - variationValue: 'bandit', + allocationKey: 'training', + variationKey: banditKey, + variationValue: banditKey, }, ], }, }, }); + // Bandit model configuration (matching bandit-models-v1.json structure for banner_bandit) + const banditsConfigJson = JSON.stringify({ + bandits: { + [banditKey]: { + banditKey, + modelName: 'falcon', + modelVersion: '123', + updatedAt: '2023-09-13T04:52:06.462Z', + modelData: { + gamma: 1.0, + defaultActionScore: 0.0, + actionProbabilityFloor: 0.0, + coefficients: { + nike: { + actionKey: 'nike', + intercept: 1.0, + actionNumericCoefficients: [ + { + attributeKey: 'brand_affinity', + coefficient: 1.0, + missingValueCoefficient: -0.1, + }, + ], + actionCategoricalCoefficients: [ + { + attributeKey: 'loyalty_tier', + valueCoefficients: { gold: 4.5, silver: 3.2, bronze: 1.9 }, + missingValueCoefficient: 0.0, + }, + ], + subjectNumericCoefficients: [ + { attributeKey: 'account_age', coefficient: 0.3, missingValueCoefficient: 0.0 }, + ], + subjectCategoricalCoefficients: [ + { + attributeKey: 'gender_identity', + valueCoefficients: { female: 0.5, male: -0.5 }, + missingValueCoefficient: 2.3, + }, + ], + }, + adidas: { + actionKey: 'adidas', + intercept: 1.1, + actionNumericCoefficients: [ + { + attributeKey: 'brand_affinity', + coefficient: 2.0, + missingValueCoefficient: 1.2, + }, + ], + actionCategoricalCoefficients: [], + subjectNumericCoefficients: [], + subjectCategoricalCoefficients: [ + { + attributeKey: 'gender_identity', + valueCoefficients: { female: -1.0, male: 1.0 }, + missingValueCoefficient: 0.0, + }, + ], + }, + }, + }, + }, + }, + }); + const client = offlineInit({ flagsConfiguration: flagsConfigJson, + banditsConfiguration: banditsConfigJson, }); - // Verify the client is initialized and can make assignments - const assignment = client.getStringAssignment( + // Verify the client is initialized and can make flag assignments + const assignment = client.getStringAssignment(banditFlagKey, 'alice', {}, 'default-value'); + expect(assignment).toEqual(banditKey); + + // Verify bandit action selection using "alice" from test-case-banner-bandit.json + // alice with her attributes and actions should get nike + const banditResult = client.getBanditAction( banditFlagKey, - 'subject-1', - {}, + 'alice', + { + numericAttributes: { age: 25 }, + categoricalAttributes: { country: 'USA', gender_identity: 'female' }, + }, + { + nike: { + numericAttributes: { brand_affinity: 1.5 }, + categoricalAttributes: { loyalty_tier: 'silver' }, + }, + adidas: { + numericAttributes: { brand_affinity: -1.0 }, + categoricalAttributes: { loyalty_tier: 'bronze' }, + }, + }, 'default-value', ); - expect(assignment).toEqual('bandit'); + expect(banditResult.variation).toEqual(banditKey); + expect(banditResult.action).toEqual('nike'); }); }); }); From 44be8c29dd20d985f2297951cc881a2c32902c9e Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Mon, 19 Jan 2026 23:09:52 -0500 Subject: [PATCH 3/5] Refactor offlineInit tests for clarity - Rename "makes client available via getInstance()" to "can request assignment" - Move "no network requests" test into "basic initialization" section - Add assignment verification to "empty flags configuration" test Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 557e7a9..3f7b90f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1027,7 +1027,7 @@ describe('offlineInit', () => { expect(assignment).toEqual('default-value'); }); - it('makes client available via getInstance()', () => { + it('can request assignment', () => { offlineInit({ flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), }); @@ -1036,6 +1036,16 @@ describe('offlineInit', () => { const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); expect(assignment).toEqual('variant-1'); }); + + it('does not have configurationRequestParameters (no polling)', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + // Access the internal configurationRequestParameters - should be undefined for offline mode + const configurationRequestParameters = client['configurationRequestParameters']; + expect(configurationRequestParameters).toBeUndefined(); + }); }); describe('assignment logging', () => { @@ -1105,23 +1115,12 @@ describe('offlineInit', () => { }); it('does not throw with valid empty flags configuration', () => { - expect(() => { - offlineInit({ - flagsConfiguration: createFlagsConfigJson({}), - }); - }).not.toThrow(); - }); - }); - - describe('no network requests', () => { - it('does not have configurationRequestParameters (no polling)', () => { const client = offlineInit({ - flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + flagsConfiguration: createFlagsConfigJson({}), }); - // Access the internal configurationRequestParameters - should be undefined for offline mode - const configurationRequestParameters = client['configurationRequestParameters']; - expect(configurationRequestParameters).toBeUndefined(); + const assignment = client.getStringAssignment(flagKey, 'subject-1', {}, 'default-value'); + expect(assignment).toEqual('default-value'); }); }); From 49e3f9c72e3dfa5e11fdd408c10a67cddebf9756 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 25 Jan 2026 21:37:57 -0500 Subject: [PATCH 4/5] Add validation for offlineInit() configuration - Add validateFlagsConfiguration() to ensure all required fields exist - Add validateBanditsConfiguration() to validate bandit config structure - Update offlineInit() to validate configs before loading: - If validation fails and throwOnFailedInitialization=true: throw error - If validation fails and throwOnFailedInitialization=false: log warning and use empty config (all assignments return defaults) - Update test helper to include banditReferences field This ensures invalid configurations are caught early and provides clear error messages about what fields are missing or invalid. Co-Authored-By: Claude Opus 4.5 --- src/index.spec.ts | 10 ++- src/index.ts | 163 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 122 insertions(+), 51 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 3f7b90f..2d8bace 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -983,13 +983,21 @@ describe('offlineInit', () => { // Helper to create a full configuration JSON string const createFlagsConfigJson = ( flags: Record, - options: { createdAt?: string; format?: string } = {}, + options: { + createdAt?: string; + format?: string; + banditReferences?: Record< + string, + { modelVersion: string; flagVariations: BanditVariation[] } + >; + } = {}, ): string => { return JSON.stringify({ createdAt: options.createdAt ?? '2024-04-17T19:40:53.716Z', format: options.format ?? 'SERVER', environment: { name: 'Test' }, flags, + banditReferences: options.banditReferences ?? {}, }); }; diff --git a/src/index.ts b/src/index.ts index 08cf7cc..de6f8cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,62 @@ interface BanditsConfigurationResponse { */ const DEFAULT_ASSIGNMENT_CACHE_SIZE = 50_000; +/** + * Validates that the parsed flags configuration has all required fields. + * Returns an array of validation error messages, or empty array if valid. + */ +function validateFlagsConfiguration(config: unknown): string[] { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + errors.push('Configuration must be an object'); + return errors; + } + + const cfg = config as Record; + + if (typeof cfg.createdAt !== 'string') { + errors.push('Missing or invalid "createdAt" field'); + } + if (typeof cfg.format !== 'string') { + errors.push('Missing or invalid "format" field'); + } + if (!cfg.environment || typeof cfg.environment !== 'object') { + errors.push('Missing or invalid "environment" field'); + } else if (typeof (cfg.environment as Record).name !== 'string') { + errors.push('Missing or invalid "environment.name" field'); + } + if (!cfg.flags || typeof cfg.flags !== 'object') { + errors.push('Missing or invalid "flags" field'); + } + if (!cfg.banditReferences || typeof cfg.banditReferences !== 'object') { + errors.push('Missing or invalid "banditReferences" field'); + } + + return errors; +} + +/** + * Validates that the parsed bandits configuration has all required fields. + * Returns an array of validation error messages, or empty array if valid. + */ +function validateBanditsConfiguration(config: unknown): string[] { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + errors.push('Configuration must be an object'); + return errors; + } + + const cfg = config as Record; + + if (!cfg.bandits || typeof cfg.bandits !== 'object') { + errors.push('Missing or invalid "bandits" field'); + } + + return errors; +} + /** * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. */ @@ -299,53 +355,49 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { throwOnFailedInitialization = true, } = config; + // Create memory-only configuration stores + flagConfigurationStore = new MemoryOnlyConfigurationStore(); + banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); + banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + try { - // Parse the flags configuration JSON - const flagsConfigResponse = JSON.parse(flagsConfiguration) as { - createdAt?: string; - format?: string; - environment?: { name: string }; - flags: Record; - banditReferences?: Record< - string, - { - modelVersion: string; - flagVariations: BanditVariation[]; - } - >; - }; - - // Create memory-only configuration stores - flagConfigurationStore = new MemoryOnlyConfigurationStore(); - banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); - banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); - - // Set format from the configuration (default to SERVER) - const format = (flagsConfigResponse.format as FormatEnum) ?? FormatEnum.SERVER; - flagConfigurationStore.setFormat(format); - - // Load flag configurations into store - // Note: setEntries is async but MemoryOnlyConfigurationStore performs synchronous operations internally, - // so there's no race condition. We add .catch() for defensive error handling, matching JS client SDK pattern. - flagConfigurationStore - .setEntries(flagsConfigResponse.flags ?? {}) - .catch((err) => - applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`), + // Parse and validate the flags configuration JSON + const parsedFlagsConfig = JSON.parse(flagsConfiguration); + const flagsValidationErrors = validateFlagsConfiguration(parsedFlagsConfig); + + if (flagsValidationErrors.length > 0) { + const errorMessage = `Invalid flags configuration: ${flagsValidationErrors.join(', ')}`; + if (throwOnFailedInitialization) { + throw new Error(errorMessage); + } + applicationLogger.warn( + `${errorMessage}. Using empty configuration - all assignments will return default values.`, ); + // Skip loading flags config, stores remain empty + } else { + // Cast to typed response after validation + const flagsConfigResponse = parsedFlagsConfig as FlagsConfigurationResponse; + + // Set format from the configuration + flagConfigurationStore.setFormat(flagsConfigResponse.format); + + // Load flag configurations into store + // Note: setEntries is async but MemoryOnlyConfigurationStore performs synchronous operations internally, + // so there's no race condition. We add .catch() for defensive error handling, matching JS client SDK pattern. + flagConfigurationStore + .setEntries(flagsConfigResponse.flags) + .catch((err) => + applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`), + ); - // Set configuration timestamp if available - if (flagsConfigResponse.createdAt) { + // Set configuration timestamp flagConfigurationStore.setConfigPublishedAt(flagsConfigResponse.createdAt); - } - // Set environment if available - if (flagsConfigResponse.environment) { + // Set environment flagConfigurationStore.setEnvironment(flagsConfigResponse.environment); - } - // Process bandit references from the flags configuration - // Index by flag key for quick lookup (instead of by bandit key) - if (flagsConfigResponse.banditReferences) { + // Process bandit references from the flags configuration + // Index by flag key for quick lookup (instead of by bandit key) const banditVariationsByFlagKey: Record = {}; for (const banditReference of Object.values(flagsConfigResponse.banditReferences)) { for (const flagVariation of banditReference.flagVariations) { @@ -367,17 +419,28 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { // Parse and load bandit models if provided if (banditsConfiguration) { - const banditsConfigResponse = JSON.parse(banditsConfiguration) as { - updatedAt?: string; - bandits: Record; - }; - banditModelConfigurationStore - .setEntries(banditsConfigResponse.bandits ?? {}) - .catch((err) => - applicationLogger.warn( - `Error setting bandit models for memory-only configuration store: ${err}`, - ), + const parsedBanditsConfig = JSON.parse(banditsConfiguration); + const banditsValidationErrors = validateBanditsConfiguration(parsedBanditsConfig); + + if (banditsValidationErrors.length > 0) { + const errorMessage = `Invalid bandits configuration: ${banditsValidationErrors.join(', ')}`; + if (throwOnFailedInitialization) { + throw new Error(errorMessage); + } + applicationLogger.warn( + `${errorMessage}. Skipping bandit configuration - bandit assignments will not work.`, ); + // Skip loading bandits config, store remains empty + } else { + const banditsConfigResponse = parsedBanditsConfig as BanditsConfigurationResponse; + banditModelConfigurationStore + .setEntries(banditsConfigResponse.bandits) + .catch((err) => + applicationLogger.warn( + `Error setting bandit models for memory-only configuration store: ${err}`, + ), + ); + } } // Create client without request parameters (offline mode - no polling) From 61119734147c8d02eaa1a4fa560430bce7ad59ad Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Sun, 25 Jan 2026 22:13:01 -0500 Subject: [PATCH 5/5] Move validation functions after offlineInit, fix error logging - Reorder validation functions to follow clean code principles (high-level methods first) - Fix ${err} string interpolation to properly extract error messages Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 126 +++++++++++++++++++++++++++------------------------ 1 file changed, 67 insertions(+), 59 deletions(-) diff --git a/src/index.ts b/src/index.ts index de6f8cc..b44f28e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,62 +96,6 @@ interface BanditsConfigurationResponse { */ const DEFAULT_ASSIGNMENT_CACHE_SIZE = 50_000; -/** - * Validates that the parsed flags configuration has all required fields. - * Returns an array of validation error messages, or empty array if valid. - */ -function validateFlagsConfiguration(config: unknown): string[] { - const errors: string[] = []; - - if (!config || typeof config !== 'object') { - errors.push('Configuration must be an object'); - return errors; - } - - const cfg = config as Record; - - if (typeof cfg.createdAt !== 'string') { - errors.push('Missing or invalid "createdAt" field'); - } - if (typeof cfg.format !== 'string') { - errors.push('Missing or invalid "format" field'); - } - if (!cfg.environment || typeof cfg.environment !== 'object') { - errors.push('Missing or invalid "environment" field'); - } else if (typeof (cfg.environment as Record).name !== 'string') { - errors.push('Missing or invalid "environment.name" field'); - } - if (!cfg.flags || typeof cfg.flags !== 'object') { - errors.push('Missing or invalid "flags" field'); - } - if (!cfg.banditReferences || typeof cfg.banditReferences !== 'object') { - errors.push('Missing or invalid "banditReferences" field'); - } - - return errors; -} - -/** - * Validates that the parsed bandits configuration has all required fields. - * Returns an array of validation error messages, or empty array if valid. - */ -function validateBanditsConfiguration(config: unknown): string[] { - const errors: string[] = []; - - if (!config || typeof config !== 'object') { - errors.push('Configuration must be an object'); - return errors; - } - - const cfg = config as Record; - - if (!cfg.bandits || typeof cfg.bandits !== 'object') { - errors.push('Missing or invalid "bandits" field'); - } - - return errors; -} - /** * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. */ @@ -387,7 +331,11 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { flagConfigurationStore .setEntries(flagsConfigResponse.flags) .catch((err) => - applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`), + applicationLogger.warn( + `Error setting flags for memory-only configuration store: ${ + err instanceof Error ? err.message : err + }`, + ), ); // Set configuration timestamp @@ -412,7 +360,9 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { .setEntries(banditVariationsByFlagKey) .catch((err) => applicationLogger.warn( - `Error setting bandit variations for memory-only configuration store: ${err}`, + `Error setting bandit variations for memory-only configuration store: ${ + err instanceof Error ? err.message : err + }`, ), ); } @@ -437,7 +387,9 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { .setEntries(banditsConfigResponse.bandits) .catch((err) => applicationLogger.warn( - `Error setting bandit models for memory-only configuration store: ${err}`, + `Error setting bandit models for memory-only configuration store: ${ + err instanceof Error ? err.message : err + }`, ), ); } @@ -476,6 +428,62 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient { } } +/** + * Validates that the parsed flags configuration has all required fields. + * Returns an array of validation error messages, or empty array if valid. + */ +function validateFlagsConfiguration(config: unknown): string[] { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + errors.push('Configuration must be an object'); + return errors; + } + + const cfg = config as Record; + + if (typeof cfg.createdAt !== 'string') { + errors.push('Missing or invalid "createdAt" field'); + } + if (typeof cfg.format !== 'string') { + errors.push('Missing or invalid "format" field'); + } + if (!cfg.environment || typeof cfg.environment !== 'object') { + errors.push('Missing or invalid "environment" field'); + } else if (typeof (cfg.environment as Record).name !== 'string') { + errors.push('Missing or invalid "environment.name" field'); + } + if (!cfg.flags || typeof cfg.flags !== 'object') { + errors.push('Missing or invalid "flags" field'); + } + if (!cfg.banditReferences || typeof cfg.banditReferences !== 'object') { + errors.push('Missing or invalid "banditReferences" field'); + } + + return errors; +} + +/** + * Validates that the parsed bandits configuration has all required fields. + * Returns an array of validation error messages, or empty array if valid. + */ +function validateBanditsConfiguration(config: unknown): string[] { + const errors: string[] = []; + + if (!config || typeof config !== 'object') { + errors.push('Configuration must be an object'); + return errors; + } + + const cfg = config as Record; + + if (!cfg.bandits || typeof cfg.bandits !== 'object') { + errors.push('Missing or invalid "bandits" field'); + } + + return errors; +} + /** * @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs. */