diff --git a/package.json b/package.json index 87d3c96..87e563a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.4.0", + "version": "4.5.0", "description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)", "main": "dist/index.js", "files": [ diff --git a/src/api-endpoints.ts b/src/api-endpoints.ts index ce2f1e1..6d45c0c 100644 --- a/src/api-endpoints.ts +++ b/src/api-endpoints.ts @@ -1,8 +1,13 @@ -import { BASE_URL as DEFAULT_BASE_URL, UFC_ENDPOINT, BANDIT_ENDPOINT } from './constants'; -import { IQueryParams } from './http-client'; +import { + BASE_URL as DEFAULT_BASE_URL, + UFC_ENDPOINT, + BANDIT_ENDPOINT, + PRECOMPUTED_FLAGS_ENDPOINT, +} from './constants'; +import { IQueryParams, IQueryParamsWithSubject } from './http-client'; interface IApiEndpointsParams { - queryParams?: IQueryParams; + queryParams?: IQueryParams | IQueryParamsWithSubject; baseUrl?: string; } @@ -27,4 +32,8 @@ export default class ApiEndpoints { banditParametersEndpoint(): URL { return this.endpoint(BANDIT_ENDPOINT); } + + precomputedFlagsEndpoint(): URL { + return this.endpoint(PRECOMPUTED_FLAGS_ENDPOINT); + } } diff --git a/src/assignment-logger.spec.ts b/src/assignment-logger.spec.ts index 84f86db..31b2c3c 100644 --- a/src/assignment-logger.spec.ts +++ b/src/assignment-logger.spec.ts @@ -1,5 +1,6 @@ import { IAssignmentEvent } from './assignment-logger'; import { AllocationEvaluationCode } from './flag-evaluation-details-builder'; +import { FormatEnum } from './interfaces'; describe('IAssignmentEvent', () => { it('should allow adding arbitrary fields', () => { @@ -11,6 +12,7 @@ describe('IAssignmentEvent', () => { subject: 'subject_123', timestamp: new Date().toISOString(), subjectAttributes: { age: 25, country: 'USA' }, + format: FormatEnum.SERVER, holdoutKey: 'holdout_key_123', evaluationDetails: { environmentName: 'Test', diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index e126a1b..662fe6c 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -50,9 +50,14 @@ export interface IAssignmentEvent { metaData?: Record; /** - * The flag evaluation details + * The format of the flag. */ - evaluationDetails: IFlagEvaluationDetails; + format: string; + + /** + * The flag evaluation details. Null if the flag was precomputed. + */ + evaluationDetails: IFlagEvaluationDetails | null; } /** diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 3ae2b2f..276824d 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -871,7 +871,13 @@ export default class EppoClient { 'FLAG_UNRECOGNIZED_OR_DISABLED', `Unrecognized or disabled flag: ${flagKey}`, ); - return noneResult(flagKey, subjectKey, subjectAttributes, flagEvaluationDetails); + return noneResult( + flagKey, + subjectKey, + subjectAttributes, + flagEvaluationDetails, + configDetails.configFormat, + ); } if (!checkTypeMatch(expectedVariationType, flag.variationType)) { @@ -881,7 +887,13 @@ export default class EppoClient { 'TYPE_MISMATCH', errorMessage, ); - return noneResult(flagKey, subjectKey, subjectAttributes, flagEvaluationDetails); + return noneResult( + flagKey, + subjectKey, + subjectAttributes, + flagEvaluationDetails, + configDetails.configFormat, + ); } throw new TypeError(errorMessage); } @@ -893,7 +905,13 @@ export default class EppoClient { 'FLAG_UNRECOGNIZED_OR_DISABLED', `Unrecognized or disabled flag: ${flagKey}`, ); - return noneResult(flagKey, subjectKey, subjectAttributes, flagEvaluationDetails); + return noneResult( + flagKey, + subjectKey, + subjectAttributes, + flagEvaluationDetails, + configDetails.configFormat, + ); } const result = this.evaluator.evaluateFlag( @@ -941,9 +959,8 @@ export default class EppoClient { return { configFetchedAt: this.flagConfigurationStore.getConfigFetchedAt() ?? '', configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', - configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { - name: '', - }, + configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { name: '' }, + configFormat: this.flagConfigurationStore.getFormat() ?? '', }; } @@ -1062,12 +1079,13 @@ export default class EppoClient { } private maybeLogAssignment(result: FlagEvaluation) { - const { flagKey, subjectKey, allocationKey, subjectAttributes, variation } = result; + const { flagKey, format, subjectKey, allocationKey, subjectAttributes, variation } = 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(), diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts new file mode 100644 index 0000000..ad24824 --- /dev/null +++ b/src/client/eppo-precomputed-client.spec.ts @@ -0,0 +1,708 @@ +import * as td from 'testdouble'; + +import ApiEndpoints from '../api-endpoints'; +import { IAssignmentLogger } from '../assignment-logger'; +import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; +import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; +import FetchHttpClient from '../http-client'; +import { FormatEnum, PrecomputedFlag, VariationType } from '../interfaces'; +import { encodeBase64, getMD5Hash } from '../obfuscation'; +import PrecomputedRequestor from '../precomputed-requestor'; + +import EppoPrecomputedClient, { + PrecomputedFlagsRequestParameters, +} from './eppo-precomputed-client'; + +describe('EppoPrecomputedClient E2E test', () => { + const precomputedFlags = { + createdAt: '2024-11-18T14:23:39.456Z', + format: 'PRECOMPUTED', + environment: { + name: 'Test', + }, + flags: { + 'string-flag': { + allocationKey: 'allocation-123', + variationKey: 'variation-123', + variationType: 'STRING', + variationValue: 'red', + extraLogging: {}, + doLog: true, + }, + 'boolean-flag': { + allocationKey: 'allocation-124', + variationKey: 'variation-124', + variationType: 'BOOLEAN', + variationValue: true, + extraLogging: {}, + doLog: true, + }, + 'integer-flag': { + allocationKey: 'allocation-125', + variationKey: 'variation-125', + variationType: 'INTEGER', + variationValue: 42, + extraLogging: {}, + doLog: true, + }, + 'numeric-flag': { + allocationKey: 'allocation-126', + variationKey: 'variation-126', + variationType: 'NUMERIC', + variationValue: 3.14, + extraLogging: {}, + doLog: true, + }, + 'json-flag': { + allocationKey: 'allocation-127', + variationKey: 'variation-127', + variationType: 'JSON', + variationValue: '{"key": "value", "number": 123}', + extraLogging: {}, + doLog: true, + }, + }, + }; // TODO: readMockPrecomputedFlagsResponse(MOCK_PRECOMPUTED_FLAGS_RESPONSE_FILE); + + global.fetch = jest.fn(() => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(precomputedFlags), + }); + }) as jest.Mock; + const storage = new MemoryOnlyConfigurationStore(); + + 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', { + 'attribute-key': 'attribute-value', + }); + await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); + }); + + const precomputedFlagKey = 'mock-flag'; + const mockPrecomputedFlag: PrecomputedFlag = { + variationKey: 'a', + variationValue: 'variation-a', + allocationKey: 'allocation-a', + doLog: true, + variationType: VariationType.STRING, + extraLogging: {}, + }; + + describe('error encountered', () => { + let client: EppoPrecomputedClient; + + beforeAll(() => { + storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + client = new EppoPrecomputedClient(storage); + }); + + afterAll(() => { + td.reset(); + }); + + it('returns default value when flag not found', () => { + expect(client.getStringAssignment('non-existent-flag', 'default')).toBe('default'); + }); + }); + + describe('setLogger', () => { + beforeAll(() => { + storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + }); + + it('Invokes logger for queued events', () => { + const mockLogger = td.object(); + + const client = new EppoPrecomputedClient(storage); + client.getStringAssignment(precomputedFlagKey, 'default-value'); + client.setAssignmentLogger(mockLogger); + + expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); + // Subject not available because PrecomputedFlagsRequestParameters were not provided + expect(td.explain(mockLogger.logAssignment).calls[0].args[0].subject).toEqual(''); + }); + + it('Does not log same queued event twice', () => { + const mockLogger = td.object(); + + const client = new EppoPrecomputedClient(storage); + + 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(storage); + + 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(new MemoryOnlyConfigurationStore()); + 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(storage); + 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({ [precomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient(storage); + 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(mockPrecomputedFlag.variationKey); + expect(loggedEvent.allocation).toEqual(mockPrecomputedFlag.allocationKey); + expect(loggedEvent.experiment).toEqual( + `${precomputedFlagKey}-${mockPrecomputedFlag.allocationKey}`, + ); + }); + + it('handles logging exception', () => { + const mockLogger = td.object(); + td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); + + storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient(storage); + 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({ [precomputedFlagKey]: mockPrecomputedFlag }); + client = new EppoPrecomputedClient(storage); + 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({ + [precomputedFlagKey]: mockPrecomputedFlag, + 'flag-2': { + ...mockPrecomputedFlag, + variationKey: 'b', + }, + 'flag-3': { + ...mockPrecomputedFlag, + variationKey: '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({ + [precomputedFlagKey]: mockPrecomputedFlag, + 'flag-2': mockPrecomputedFlag, + 'flag-3': 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({ + [precomputedFlagKey]: { + ...mockPrecomputedFlag, + variationKey: 'a', + variationValue: 'variation-a', + }, + }); + client.getStringAssignment(precomputedFlagKey, 'default'); + + storage.setEntries({ + [precomputedFlagKey]: { + ...mockPrecomputedFlag, + variationKey: 'b', + variationValue: '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({ [precomputedFlagKey]: mockPrecomputedFlag }); + + client.getStringAssignment(precomputedFlagKey, 'default'); // log this assignment + client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log + + // change the variation + storage.setEntries({ + [precomputedFlagKey]: { + ...mockPrecomputedFlag, + allocationKey: 'allocation-a', // same allocation key + variationKey: 'b', // but different variation + variationValue: '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({ [precomputedFlagKey]: mockPrecomputedFlag }); + + client.getStringAssignment(precomputedFlagKey, 'default'); // important: log this assignment + client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log + + // change the allocation + storage.setEntries({ + [precomputedFlagKey]: { + ...mockPrecomputedFlag, + allocationKey: 'allocation-b', // different allocation key + variationKey: 'b', // but same variation + variationValue: '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 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(precomputedFlags), + }); + }) as jest.Mock; + }); + + beforeEach(async () => { + requestParameters = { + apiKey: 'dummy-key', + sdkName: 'js-client-sdk-common', + sdkVersion: '1.0.0', + precompute: { + 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, 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); + client.setPrecomputedFlagsRequestParameters(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(new MockStore(), { + ...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(new MockStore(), 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(storage, 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 precomputedFlags; + }, + }); + } + }) as jest.Mock; + + const { pollAfterSuccessfulInitialization } = configModification; + requestParameters = { + ...requestParameters, + pollAfterSuccessfulInitialization, + }; + client = new EppoPrecomputedClient(precomputedFlagStore, requestParameters); + // 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(precomputedFlags), + } 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, 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('Obfuscated precomputed flags', () => { + let client: EppoPrecomputedClient; + + beforeAll(() => { + storage.setEntries({ + [getMD5Hash(precomputedFlagKey)]: { + ...mockPrecomputedFlag, + allocationKey: encodeBase64(mockPrecomputedFlag.allocationKey), + variationKey: encodeBase64(mockPrecomputedFlag.variationKey), + variationValue: encodeBase64(mockPrecomputedFlag.variationValue), + extraLogging: {}, + }, + }); + client = new EppoPrecomputedClient(storage, undefined, true); + }); + + afterAll(() => { + td.reset(); + }); + + it('returns decoded variation value', () => { + expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe( + mockPrecomputedFlag.variationValue, + ); + }); + }); + + it('logs variation assignment with format from precomputed flags response', () => { + const mockLogger = td.object(); + storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient(storage); + 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); + }); +}); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts new file mode 100644 index 0000000..fb96107 --- /dev/null +++ b/src/client/eppo-precomputed-client.ts @@ -0,0 +1,369 @@ +import ApiEndpoints from '../api-endpoints'; +import { logger } from '../application-logger'; +import { IAssignmentEvent, IAssignmentLogger } from '../assignment-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 } 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 { decodePrecomputedFlag } from '../decoding'; +import { FlagEvaluationWithoutDetails } from '../evaluator'; +import FetchHttpClient from '../http-client'; +import { PrecomputedFlag, VariationType } from '../interfaces'; +import { getMD5Hash } from '../obfuscation'; +import initPoller, { IPoller } from '../poller'; +import PrecomputedRequestor from '../precomputed-requestor'; +import { Attributes } from '../types'; +import { validateNotBlank } from '../validation'; +import { LIB_VERSION } from '../version'; + +export type PrecomputedFlagsRequestParameters = { + apiKey: string; + sdkVersion: string; + sdkName: string; + baseUrl?: string; + precompute: { + subjectKey: string; + subjectAttributes: Attributes; + }; + requestTimeoutMs?: number; + pollingIntervalMs?: number; + numInitialRequestRetries?: number; + numPollRequestRetries?: number; + pollAfterSuccessfulInitialization?: boolean; + pollAfterFailedInitialization?: boolean; + throwOnFailedInitialization?: boolean; + skipInitialPoll?: boolean; +}; + +export default class EppoPrecomputedClient { + private readonly queuedAssignmentEvents: IAssignmentEvent[] = []; + private assignmentLogger?: IAssignmentLogger; + private assignmentCache?: AssignmentCache; + private requestPoller?: IPoller; + + constructor( + private precomputedFlagStore: IConfigurationStore, + private precomputedFlagsRequestParameters?: PrecomputedFlagsRequestParameters, + private isObfuscated = false, + ) {} + + public setPrecomputedFlagsRequestParameters( + precomputedFlagsRequestParameters: PrecomputedFlagsRequestParameters, + ) { + this.precomputedFlagsRequestParameters = precomputedFlagsRequestParameters; + } + + public setPrecomputedFlagStore(precomputedFlagStore: IConfigurationStore) { + this.precomputedFlagStore = precomputedFlagStore; + } + + public setIsObfuscated(isObfuscated: boolean) { + this.isObfuscated = isObfuscated; + } + + public async fetchPrecomputedFlags() { + if (!this.precomputedFlagsRequestParameters) { + 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 + precompute: { subjectKey, subjectAttributes }, + 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.precomputedFlagsRequestParameters; + + let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.precomputedFlagsRequestParameters; + 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({ + baseUrl: baseUrl ?? PRECOMPUTED_BASE_URL, + queryParams: { apiKey, sdkName, sdkVersion }, + }); + const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); + const precomputedRequestor = new PrecomputedRequestor( + httpClient, + this.precomputedFlagStore, + subjectKey, + subjectAttributes, + ); + + 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 preComputedFlag = this.getPrecomputedFlag(flagKey); + + if (preComputedFlag == null) { + logger.warn(`[Eppo SDK] No assigned variation. Flag not found: ${flagKey}`); + return defaultValue; + } + + // Check variation type + if (preComputedFlag.variationType !== expectedType) { + logger.error( + `[Eppo SDK] Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${preComputedFlag.variationType}`, + ); + return defaultValue; + } + + const result: FlagEvaluationWithoutDetails = { + flagKey, + format: this.precomputedFlagStore.getFormat() ?? '', + subjectKey: this.precomputedFlagsRequestParameters?.precompute.subjectKey ?? '', + subjectAttributes: this.precomputedFlagsRequestParameters?.precompute.subjectAttributes ?? {}, + variation: { + key: preComputedFlag.variationKey, + value: preComputedFlag.variationValue, + }, + allocationKey: preComputedFlag.allocationKey, + extraLogging: preComputedFlag.extraLogging, + doLog: preComputedFlag.doLog, + }; + + try { + if (result?.doLog) { + this.logAssignment(result); + } + } catch (error) { + logger.error(`[Eppo SDK] Error logging assignment event: ${error}`); + } + + try { + return result.variation?.value !== undefined + ? valueTransformer(result.variation.value) + : defaultValue; + } catch (error) { + logger.error(`[Eppo SDK] 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, + ); + } + + private getPrecomputedFlag(flagKey: string): PrecomputedFlag | null { + return this.isObfuscated + ? this.getObfuscatedFlag(flagKey) + : this.precomputedFlagStore.get(flagKey); + } + + private getObfuscatedFlag(flagKey: string): PrecomputedFlag | null { + const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get( + getMD5Hash(flagKey), + ) as PrecomputedFlag; + return precomputedFlag ? decodePrecomputedFlag(precomputedFlag) : 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); + } + + /** + * 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(`[Eppo SDK] 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(`[Eppo SDK] Error logging assignment event: ${error.message}`); + } + } + + private buildLoggerMetadata(): Record { + return { + obfuscated: this.isObfuscated, + sdkLanguage: 'javascript', + sdkLibVersion: LIB_VERSION, + }; + } +} diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 5d8be09..763cb06 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,8 +1,7 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; +import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; import { IHttpClient } from './http-client'; -import { BanditVariation, BanditParameters, Flag, Environment } from './interfaces'; - -type Entry = Flag | BanditVariation[] | BanditParameters; +import { BanditVariation, BanditParameters, Flag } from './interfaces'; // Requests AND stores flag configurations export default class ConfigurationRequestor { @@ -21,10 +20,11 @@ export default class ConfigurationRequestor { return; } - await this.hydrateConfigurationStore(this.flagConfigurationStore, { + await hydrateConfigurationStore(this.flagConfigurationStore, { entries: configResponse.flags, environment: configResponse.environment, createdAt: configResponse.createdAt, + format: configResponse.format, }); const flagsHaveBandits = Object.keys(configResponse.bandits ?? {}).length > 0; @@ -35,10 +35,11 @@ export default class ConfigurationRequestor { // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.bandits); - await this.hydrateConfigurationStore(this.banditVariationConfigurationStore, { + await hydrateConfigurationStore(this.banditVariationConfigurationStore, { entries: banditVariations, environment: configResponse.environment, createdAt: configResponse.createdAt, + format: configResponse.format, }); // TODO: different polling intervals for bandit parameters @@ -48,33 +49,16 @@ export default class ConfigurationRequestor { throw new Error('Bandit parameters fetched but no bandit configuration store provided'); } - await this.hydrateConfigurationStore(this.banditModelConfigurationStore, { + await hydrateConfigurationStore(this.banditModelConfigurationStore, { entries: banditResponse.bandits, environment: configResponse.environment, createdAt: configResponse.createdAt, + format: configResponse.format, }); } } } - private async hydrateConfigurationStore( - configurationStore: IConfigurationStore | null, - response: { - entries: Record; - environment: Environment; - createdAt: 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); - } - } - } - private indexBanditVariationsByFlagKey( banditVariationsByBanditKey: Record, ): Record { diff --git a/src/configuration-store/configuration-store-utils.ts b/src/configuration-store/configuration-store-utils.ts new file mode 100644 index 0000000..a6307f9 --- /dev/null +++ b/src/configuration-store/configuration-store-utils.ts @@ -0,0 +1,31 @@ +import { + BanditParameters, + BanditVariation, + Environment, + Flag, + PrecomputedFlag, +} from '../interfaces'; + +import { IConfigurationStore } from './configuration-store'; + +type Entry = Flag | BanditVariation[] | BanditParameters | PrecomputedFlag; + +export async function hydrateConfigurationStore( + configurationStore: IConfigurationStore | null, + response: { + entries: Record; + environment: Environment; + createdAt: string; + format: 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); + } + } +} diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index 0b2240d..9b3fe2c 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -36,6 +36,8 @@ export interface IConfigurationStore { setConfigFetchedAt(configFetchedAt: string): void; getConfigPublishedAt(): string | null; setConfigPublishedAt(configPublishedAt: string): void; + getFormat(): string | null; + setFormat(format: string): void; } export interface ISyncStore { diff --git a/src/configuration-store/hybrid.store.ts b/src/configuration-store/hybrid.store.ts index 825c2e2..a3dff70 100644 --- a/src/configuration-store/hybrid.store.ts +++ b/src/configuration-store/hybrid.store.ts @@ -1,5 +1,5 @@ import { logger, loggerPrefix } from '../application-logger'; -import { Environment } from '../interfaces'; +import { Environment, FormatEnum } from '../interfaces'; import { IAsyncStore, IConfigurationStore, ISyncStore } from './configuration-store'; @@ -11,6 +11,7 @@ export class HybridConfigurationStore implements IConfigurationStore { 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. @@ -93,4 +94,12 @@ export class HybridConfigurationStore implements IConfigurationStore { 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.ts b/src/configuration-store/memory.store.ts index ffd80b8..af1d3a6 100644 --- a/src/configuration-store/memory.store.ts +++ b/src/configuration-store/memory.store.ts @@ -1,4 +1,4 @@ -import { Environment } from '../interfaces'; +import { Environment, FormatEnum } from '../interfaces'; import { IConfigurationStore, ISyncStore } from './configuration-store'; @@ -34,7 +34,7 @@ export class MemoryOnlyConfigurationStore implements IConfigurationStore { private configFetchedAt: string | null = null; private configPublishedAt: string | null = null; private environment: Environment | null = null; - + private format: FormatEnum | null = null; init(): Promise { this.initialized = true; return Promise.resolve(); @@ -89,4 +89,12 @@ export class MemoryOnlyConfigurationStore implements IConfigurationStore { 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/constants.ts b/src/constants.ts index f26a13b..cac895f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,8 @@ export const DEFAULT_POLL_CONFIG_REQUEST_RETRIES = 7; 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'; +export const PRECOMPUTED_BASE_URL = 'https://fs-edge-assignment.eppo.cloud'; +export const PRECOMPUTED_FLAGS_ENDPOINT = '/assignments'; export const SESSION_ASSIGNMENT_CONFIG_LOADED = 'eppo-session-assignment-config-loaded'; export const NULL_SENTINEL = 'EPPO_NULL'; // number of logging events that may be queued while waiting for initialization diff --git a/src/decoding.ts b/src/decoding.ts index 5247704..4725122 100644 --- a/src/decoding.ts +++ b/src/decoding.ts @@ -9,6 +9,7 @@ import { Split, Shard, ObfuscatedSplit, + PrecomputedFlag, } from './interfaces'; import { decodeBase64 } from './obfuscation'; @@ -76,3 +77,13 @@ export function decodeObject(obj: Record): Record [decodeBase64(key), decodeBase64(value)]), ); } + +export function decodePrecomputedFlag(precomputedFlag: PrecomputedFlag): PrecomputedFlag { + return { + ...precomputedFlag, + allocationKey: decodeBase64(precomputedFlag.allocationKey), + variationKey: decodeBase64(precomputedFlag.variationKey), + variationValue: decodeBase64(precomputedFlag.variationValue), + extraLogging: decodeObject(precomputedFlag.extraLogging), + }; +} diff --git a/src/evaluator.spec.ts b/src/evaluator.spec.ts index 10b9e90..d51f1af 100644 --- a/src/evaluator.spec.ts +++ b/src/evaluator.spec.ts @@ -1,5 +1,5 @@ import { Evaluator, hashKey, isInShardRange, matchesRules } from './evaluator'; -import { Flag, Variation, Shard, VariationType, ConfigDetails } from './interfaces'; +import { Flag, Variation, Shard, VariationType, ConfigDetails, FormatEnum } from './interfaces'; import { getMD5Hash } from './obfuscation'; import { ObfuscatedOperatorType, OperatorType, Rule } from './rules'; import { DeterministicSharder } from './sharders'; @@ -20,6 +20,7 @@ describe('Evaluator', () => { }, configFetchedAt: new Date().toISOString(), configPublishedAt: new Date().toISOString(), + configFormat: FormatEnum.CLIENT, }; }); diff --git a/src/evaluator.ts b/src/evaluator.ts index f685ef8..f9f4f11 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -20,8 +20,9 @@ import { Rule, matchesRule } from './rules'; import { MD5Sharder, Sharder } from './sharders'; import { Attributes } from './types'; -export interface FlagEvaluation { +export interface FlagEvaluationWithoutDetails { flagKey: string; + format: string; subjectKey: string; subjectAttributes: Attributes; allocationKey: string | null; @@ -29,6 +30,9 @@ export interface FlagEvaluation { extraLogging: Record; // whether to log assignment event doLog: boolean; +} + +export interface FlagEvaluation extends FlagEvaluationWithoutDetails { flagEvaluationDetails: IFlagEvaluationDetails; } @@ -63,6 +67,7 @@ export class Evaluator { 'FLAG_UNRECOGNIZED_OR_DISABLED', `Unrecognized or disabled flag: ${flag.key}`, ), + configDetails.configFormat, ); } @@ -109,6 +114,7 @@ export class Evaluator { .build(flagEvaluationCode, flagEvaluationDescription); return { flagKey: flag.key, + format: configDetails.configFormat, subjectKey, subjectAttributes, allocationKey: allocation.key, @@ -133,6 +139,7 @@ export class Evaluator { 'DEFAULT_ALLOCATION_NULL', 'No allocations matched. Falling back to "Default Allocation", serving NULL', ), + configDetails.configFormat, ); } catch (err: any) { const flagEvaluationDetails = flagEvaluationDetailsBuilder.gracefulBuild( @@ -207,9 +214,11 @@ export function noneResult( subjectKey: string, subjectAttributes: Attributes, flagEvaluationDetails: IFlagEvaluationDetails, + format: string, ): FlagEvaluation { return { flagKey, + format, subjectKey, subjectAttributes, allocationKey: null, diff --git a/src/http-client.ts b/src/http-client.ts index b6ded5c..35f8c97 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -1,5 +1,14 @@ import ApiEndpoints from './api-endpoints'; -import { BanditParameters, BanditVariation, Environment, Flag } from './interfaces'; +import { + BanditParameters, + BanditVariation, + Environment, + Flag, + FormatEnum, + PrecomputedFlag, + PrecomputedFlagsPayload, +} from './interfaces'; +import { Attributes } from './types'; export interface IQueryParams { apiKey: string; @@ -7,6 +16,11 @@ export interface IQueryParams { sdkName: string; } +export interface IQueryParamsWithSubject extends IQueryParams { + subjectKey: string; + subjectAttributes: Attributes; +} + export class HttpRequestError extends Error { constructor(public message: string, public status: number, public cause?: Error) { super(message); @@ -18,6 +32,7 @@ export class HttpRequestError extends Error { export interface IUniversalFlagConfigResponse { createdAt: string; // ISO formatted string + format: FormatEnum; environment: Environment; flags: Record; bandits: Record; @@ -27,10 +42,21 @@ export interface IBanditParametersResponse { bandits: Record; } +export interface IPrecomputedFlagsResponse { + createdAt: string; + format: FormatEnum; + environment: Environment; + flags: Record; +} + export interface IHttpClient { getUniversalFlagConfiguration(): Promise; getBanditParameters(): Promise; + getPrecomputedFlags( + payload: PrecomputedFlagsPayload, + ): Promise; rawGet(url: URL): Promise; + rawPost(url: URL, payload: P): Promise; } export default class FetchHttpClient implements IHttpClient { @@ -46,6 +72,13 @@ export default class FetchHttpClient implements IHttpClient { return await this.rawGet(url); } + async getPrecomputedFlags( + payload: PrecomputedFlagsPayload, + ): Promise { + const url = this.apiEndpoints.precomputedFlagsEndpoint(); + return await this.rawPost(url, payload); + } + async rawGet(url: URL): Promise { try { // Canonical implementation of abortable fetch for interrupting when request takes longer than desired. @@ -71,4 +104,37 @@ export default class FetchHttpClient implements IHttpClient { throw new HttpRequestError('Network error', 0, error); } } + + async rawPost(url: URL, payload: P): Promise { + try { + const controller = new AbortController(); + const signal = controller.signal; + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal, + }); + + clearTimeout(timeoutId); + + if (!response?.ok) { + const errorBody = await response.text(); + throw new HttpRequestError(errorBody || 'Failed to post data', response?.status); + } + return await response.json(); + } catch (error: any) { + if (error.name === 'AbortError') { + throw new HttpRequestError('Request timed out', 408, error); + } else if (error instanceof HttpRequestError) { + throw error; + } + + throw new HttpRequestError('Network error', 0, error); + } + } } diff --git a/src/index.ts b/src/index.ts index 98a05a7..66a0076 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,9 @@ import EppoClient, { IAssignmentDetails, IContainerExperiment, } from './client/eppo-client'; +import EppoPrecomputedClient, { + PrecomputedFlagsRequestParameters, +} from './client/eppo-precomputed-client'; import FlagConfigRequestor from './configuration-requestor'; import { IConfigurationStore, @@ -36,7 +39,7 @@ 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 { Flag, ObfuscatedFlag, VariationType } from './interfaces'; +import { PrecomputedFlag, Flag, ObfuscatedFlag, VariationType } from './interfaces'; import { AttributeType, Attributes, @@ -56,12 +59,14 @@ export { IBanditLogger, IBanditEvent, IContainerExperiment, + PrecomputedFlagsRequestParameters, EppoClient, constants, ApiEndpoints, FlagConfigRequestor, HttpClient, validation, + EppoPrecomputedClient, // Configuration store IConfigurationStore, @@ -86,6 +91,7 @@ export { FlagConfigurationRequestParameters, Flag, ObfuscatedFlag, + PrecomputedFlag, VariationType, AttributeType, Attributes, diff --git a/src/interfaces.ts b/src/interfaces.ts index 0d89ebc..3afcb21 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,5 @@ import { Rule } from './rules'; +import { Attributes } from './types'; export enum VariationType { STRING = 'STRING', @@ -46,6 +47,7 @@ export interface ConfigDetails { configFetchedAt: string; configPublishedAt: string; configEnvironment: Environment; + configFormat: string; } export interface Flag { @@ -133,3 +135,29 @@ export interface BanditCategoricalAttributeCoefficients { valueCoefficients: Record; missingValueCoefficient: number; } + +export enum FormatEnum { + SERVER = 'SERVER', + CLIENT = 'CLIENT', + PRECOMPUTED = 'PRECOMPUTED', +} + +export interface PrecomputedFlag { + allocationKey: string; + variationKey: string; + variationType: VariationType; + variationValue: string; + extraLogging: Record; + doLog: boolean; +} + +export interface PrecomputedFlagsDetails { + precomputedFlagsFetchedAt: string; + precomputedFlagsPublishedAt: string; + precomputedFlagsEnvironment: Environment; +} + +export interface PrecomputedFlagsPayload { + subject_key: string; + subject_attributes: Attributes; +} diff --git a/src/precomputed-requestor.spec.ts b/src/precomputed-requestor.spec.ts new file mode 100644 index 0000000..1f258a7 --- /dev/null +++ b/src/precomputed-requestor.spec.ts @@ -0,0 +1,126 @@ +import ApiEndpoints from './api-endpoints'; +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', + { + '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 new file mode 100644 index 0000000..7e349be --- /dev/null +++ b/src/precomputed-requestor.ts @@ -0,0 +1,33 @@ +import { IConfigurationStore } from './configuration-store/configuration-store'; +import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; +import { IHttpClient } from './http-client'; +import { PrecomputedFlag } from './interfaces'; +import { Attributes } 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: Attributes, + ) {} + + async fetchAndStorePrecomputedFlags(): Promise { + const precomputedResponse = await this.httpClient.getPrecomputedFlags({ + subject_key: this.subjectKey, + subject_attributes: this.subjectAttributes, + }); + + if (!precomputedResponse?.flags) { + return; + } + + await hydrateConfigurationStore(this.precomputedFlagStore, { + entries: precomputedResponse.flags, + environment: precomputedResponse.environment, + createdAt: precomputedResponse.createdAt, + format: precomputedResponse.format, + }); + } +}