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 a94c64d..5646889 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; @@ -913,3 +914,375 @@ 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('can request assignment', () => { + offlineInit({ + flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }), + }); + + const client = getInstance(); + 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', () => { + 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', () => { + const client = offlineInit({ + flagsConfiguration: createFlagsConfigJson({}), + }); + + const assignment = client.getStringAssignment(flagKey, 'subject-1', {}, 'default-value'); + expect(assignment).toEqual('default-value'); + }); + }); + + describe('bandit support', () => { + 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: { + control: { + key: 'control', + value: 'control', + }, + [banditKey]: { + key: banditKey, + value: banditKey, + }, + }, + allocations: [ + { + key: 'training', + rules: [], + splits: [ + { + variationKey: banditKey, + shards: [ + { + salt: 'traffic-split', + ranges: [{ start: 0, end: 10000 }], + }, + ], + }, + ], + doLog: true, + }, + ], + totalShards: 10000, + }; + + // Flags configuration with bandit references (matching bandit-flags-v1.json structure) + const flagsConfigJson = JSON.stringify({ + createdAt: '2024-04-17T19:40:53.716Z', + format: 'SERVER', + environment: { name: 'Test' }, + flags: { [banditFlagKey]: banditFlagConfig }, + banditReferences: { + [banditKey]: { + modelVersion: '123', + flagVariations: [ + { + key: banditKey, + flagKey: banditFlagKey, + 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 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, + '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(banditResult.variation).toEqual(banditKey); + expect(banditResult.action).toEqual('nike'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index ffbedae..0dc404f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { Flag, FlagConfigurationRequestParameters, FlagKey, + FormatEnum, MemoryOnlyConfigurationStore, NamedEventQueue, applicationLogger, @@ -18,7 +19,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'; @@ -37,7 +38,7 @@ export { EppoAssignmentLogger, } from '@eppo/js-client-sdk-common'; -export { IClientConfig }; +export { IClientConfig, IOfflineClientConfig }; let clientInstance: EppoClient; @@ -49,6 +50,15 @@ let flagConfigurationStore: MemoryOnlyConfigurationStore; let banditVariationConfigurationStore: MemoryOnlyConfigurationStore; let banditModelConfigurationStore: MemoryOnlyConfigurationStore; +/** + * 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: () => {}, @@ -111,11 +121,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(); @@ -282,6 +289,140 @@ export function getBanditsConfiguration(): string | null { 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'] = {},