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..2d8bace 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,383 @@ 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; + 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 ?? {}, + }); + }; + + 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 33c62c8..b44f28e 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,211 @@ 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; + + // Create memory-only configuration stores + flagConfigurationStore = new MemoryOnlyConfigurationStore(); + banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); + banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + + try { + // 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 instanceof Error ? err.message : err + }`, + ), + ); + + // Set configuration timestamp + flagConfigurationStore.setConfigPublishedAt(flagsConfigResponse.createdAt); + + // 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) + 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 instanceof Error ? err.message : err + }`, + ), + ); + } + + // Parse and load bandit models if provided + if (banditsConfiguration) { + 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 instanceof Error ? err.message : 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; + } +} + +/** + * 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. + */ function newEventDispatcher( sdkKey: string, config: IClientConfig['eventTracking'] = {},