diff --git a/src/application-logger.ts b/src/application-logger.ts index 7e5861d..03185b2 100644 --- a/src/application-logger.ts +++ b/src/application-logger.ts @@ -4,7 +4,7 @@ export const loggerPrefix = '[Eppo SDK]'; // Create a Pino logger instance export const logger = pino({ - level: process.env.NODE_ENV === 'production' ? 'warn' : 'info', + level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'warn' : 'info'), // https://getpino.io/#/docs/browser browser: { disabled: true }, }); diff --git a/src/client/eppo-client-assignment-details.spec.ts b/src/client/eppo-client-assignment-details.spec.ts index 6797778..b0eb09b 100644 --- a/src/client/eppo-client-assignment-details.spec.ts +++ b/src/client/eppo-client-assignment-details.spec.ts @@ -33,7 +33,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should set the details for a matched rule', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); const subjectAttributes = { email: 'alice@mycompany.com', country: 'US' }; const result = client.getIntegerAssignmentDetails( @@ -85,7 +85,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should set the details for a matched split', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); const subjectAttributes = { email: 'alice@mycompany.com', country: 'Brazil' }; const result = client.getIntegerAssignmentDetails( @@ -128,7 +128,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle matching a split allocation with a matched rule', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); const subjectAttributes = { id: 'alice', email: 'alice@external.com', country: 'Brazil' }; const result = client.getStringAssignmentDetails( @@ -190,7 +190,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle unrecognized flags', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); const result = client.getIntegerAssignmentDetails('asdf', 'alice', {}, 0); expect(result).toEqual({ @@ -215,7 +215,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should handle type mismatches with graceful failure mode enabled', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(true); const result = client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true); expect(result).toEqual({ @@ -252,7 +252,7 @@ describe('EppoClient get*AssignmentDetails', () => { }); it('should throw an error for type mismatches with graceful failure mode disabled', () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); expect(() => client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true)).toThrow(); }); @@ -302,7 +302,7 @@ describe('EppoClient get*AssignmentDetails', () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const subject = subjects.find((subject) => subject.subjectKey === subjectKey)!; - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); const focusOn = { diff --git a/src/client/eppo-client-experiment-container.spec.ts b/src/client/eppo-client-experiment-container.spec.ts index 733df7c..9eb4fda 100644 --- a/src/client/eppo-client-experiment-container.spec.ts +++ b/src/client/eppo-client-experiment-container.spec.ts @@ -31,7 +31,7 @@ describe('getExperimentContainerEntry', () => { beforeEach(async () => { const storage = new MemoryOnlyConfigurationStore(); await initConfiguration(storage); - client = new EppoClient(storage); + client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(true); flagExperiment = { flagKey: 'my-key', diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 9a8ccc8..5c71d33 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -66,7 +66,12 @@ describe('EppoClient Bandits E2E test', () => { }); beforeEach(() => { - client = new EppoClient(flagStore, banditVariationStore, banditModelStore, undefined, false); + client = new EppoClient({ + flagConfigurationStore: flagStore, + banditVariationConfigurationStore: banditVariationStore, + banditModelConfigurationStore: banditModelStore, + isObfuscated: false, + }); client.setIsGracefulFailureMode(false); client.setAssignmentLogger({ logAssignment: mockLogAssignment }); client.setBanditLogger({ logBanditAction: mockLogBanditAction }); diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index f16a3f8..e2ca1bd 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -76,7 +76,7 @@ describe('EppoClient E2E test', () => { beforeAll(() => { storage.setEntries({ [flagKey]: mockFlag }); - client = new EppoClient(storage); + client = new EppoClient({ flagConfigurationStore: storage }); td.replace(EppoClient.prototype, 'getAssignmentDetail', function () { throw new Error('Mock test error'); @@ -137,7 +137,7 @@ describe('EppoClient E2E test', () => { it('Invokes logger for queued events', () => { const mockLogger = td.object(); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setAssignmentLogger(mockLogger); @@ -150,7 +150,7 @@ describe('EppoClient E2E test', () => { it('Does not log same queued event twice', () => { const mockLogger = td.object(); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.getStringAssignment(flagKey, 'subject-to-be-logged', {}, 'default-value'); client.setAssignmentLogger(mockLogger); @@ -161,7 +161,7 @@ describe('EppoClient E2E test', () => { it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object(); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); times(MAX_EVENT_QUEUE_SIZE + 100, (i) => client.getStringAssignment(flagKey, `subject-to-be-logged-${i}`, {}, 'default-value'), @@ -199,7 +199,7 @@ describe('EppoClient E2E test', () => { it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setIsGracefulFailureMode(false); let assignments: { @@ -253,7 +253,7 @@ describe('EppoClient E2E test', () => { it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => { const { flag, variationType, defaultValue, subjects } = testCases[fileName]; - const client = new EppoClient(storage, undefined, undefined, undefined, true); + const client = new EppoClient({ flagConfigurationStore: storage, isObfuscated: true }); client.setIsGracefulFailureMode(false); const typeAssignmentFunctions = { @@ -285,7 +285,9 @@ describe('EppoClient E2E test', () => { }); it('returns null if getStringAssignment was called for the subject before any UFC was loaded', () => { - const localClient = new EppoClient(new MemoryOnlyConfigurationStore()); + const localClient = new EppoClient({ + flagConfigurationStore: new MemoryOnlyConfigurationStore(), + }); expect(localClient.getStringAssignment(flagKey, 'subject-1', {}, 'hello world')).toEqual( 'hello world', ); @@ -293,7 +295,7 @@ describe('EppoClient E2E test', () => { }); it('returns default value when key does not exist', async () => { - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); const nonExistentFlag = 'non-existent-flag'; @@ -310,7 +312,7 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object(); storage.setEntries({ [flagKey]: mockFlag }); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setAssignmentLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -336,7 +338,7 @@ describe('EppoClient E2E test', () => { td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); storage.setEntries({ [flagKey]: mockFlag }); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); client.setAssignmentLogger(mockLogger); const subjectAttributes = { foo: 3 }; @@ -352,7 +354,7 @@ describe('EppoClient E2E test', () => { it('exports flag configuration', () => { storage.setEntries({ [flagKey]: mockFlag }); - const client = new EppoClient(storage); + const client = new EppoClient({ flagConfigurationStore: storage }); expect(client.getFlagConfigurations()).toEqual({ [flagKey]: mockFlag }); }); @@ -364,7 +366,7 @@ describe('EppoClient E2E test', () => { mockLogger = td.object(); storage.setEntries({ [flagKey]: mockFlag }); - client = new EppoClient(storage); + client = new EppoClient({ flagConfigurationStore: storage }); client.setAssignmentLogger(mockLogger); }); @@ -617,7 +619,10 @@ describe('EppoClient E2E test', () => { }); it('Fetches initial configuration with parameters in constructor', async () => { - client = new EppoClient(thisFlagStorage, undefined, undefined, requestConfiguration); + client = new EppoClient({ + flagConfigurationStore: thisFlagStorage, + configurationRequestParameters: requestConfiguration, + }); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(flagKey, subject, {}, 123.4); @@ -629,7 +634,7 @@ describe('EppoClient E2E test', () => { }); it('Fetches initial configuration with parameters provided later', async () => { - client = new EppoClient(thisFlagStorage); + client = new EppoClient({ flagConfigurationStore: thisFlagStorage }); client.setIsGracefulFailureMode(false); client.setConfigurationRequestParameters(requestConfiguration); // no configuration loaded @@ -651,9 +656,12 @@ describe('EppoClient E2E test', () => { } } - client = new EppoClient(new MockStore(), undefined, undefined, { - ...requestConfiguration, - pollAfterSuccessfulInitialization: true, + client = new EppoClient({ + flagConfigurationStore: new MockStore(), + configurationRequestParameters: { + ...requestConfiguration, + pollAfterSuccessfulInitialization: true, + }, }); client.setIsGracefulFailureMode(false); // no configuration loaded @@ -680,7 +688,10 @@ describe('EppoClient E2E test', () => { } } - client = new EppoClient(new MockStore(), undefined, undefined, requestConfiguration); + client = new EppoClient({ + flagConfigurationStore: new MockStore(), + configurationRequestParameters: requestConfiguration, + }); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); @@ -722,7 +733,10 @@ describe('EppoClient E2E test', () => { ...requestConfiguration, pollAfterSuccessfulInitialization, }; - client = new EppoClient(thisFlagStorage, undefined, undefined, requestConfiguration); + client = new EppoClient({ + flagConfigurationStore: thisFlagStorage, + configurationRequestParameters: requestConfiguration, + }); client.setIsGracefulFailureMode(false); // no configuration loaded let variation = client.getNumericAssignment(flagKey, subject, {}, 0.0); @@ -787,7 +801,10 @@ describe('EppoClient E2E test', () => { throwOnFailedInitialization, pollAfterFailedInitialization, }; - client = new EppoClient(thisFlagStorage, undefined, undefined, requestConfiguration); + client = new EppoClient({ + flagConfigurationStore: thisFlagStorage, + configurationRequestParameters: requestConfiguration, + }); client.setIsGracefulFailureMode(false); // no configuration loaded expect(client.getNumericAssignment(flagKey, subject, {}, 0.0)).toBe(0.0); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 899fcb8..5c6e326 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -13,13 +13,15 @@ import { IConfigurationStore } from '../configuration-store/configuration-store' import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, - DEFAULT_REQUEST_TIMEOUT_MS, - MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, + DEFAULT_REQUEST_TIMEOUT_MS, } from '../constants'; import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; +import ArrayBackedNamedEventQueue from '../events/array-backed-named-event-queue'; +import { BoundedEventQueue } from '../events/bounded-event-queue'; +import NamedEventQueue from '../events/named-event-queue'; import { FlagEvaluationDetailsBuilder, IFlagEvaluationDetails, @@ -38,8 +40,8 @@ import { import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; import { - AttributeType, Attributes, + AttributeType, BanditActions, BanditSubjectAttributes, ContextAttributes, @@ -76,54 +78,82 @@ export interface IContainerExperiment { } export default class EppoClient { - private readonly queuedAssignmentEvents: IAssignmentEvent[] = []; - private assignmentLogger?: IAssignmentLogger; - private readonly queuedBanditEvents: IBanditEvent[] = []; + private readonly eventQueue: NamedEventQueue; + private readonly assignmentEventsQueue: BoundedEventQueue = + newBoundedArrayEventQueue('assignments'); + private readonly banditEventsQueue: BoundedEventQueue = + newBoundedArrayEventQueue('bandit'); + private readonly banditEvaluator = new BanditEvaluator(); private banditLogger?: IBanditLogger; - private isGracefulFailureMode = true; - private assignmentCache?: AssignmentCache; private banditAssignmentCache?: AssignmentCache; + private configurationRequestParameters?: FlagConfigurationRequestParameters; + private banditModelConfigurationStore?: IConfigurationStore; + private banditVariationConfigurationStore?: IConfigurationStore; + private flagConfigurationStore: IConfigurationStore; + private assignmentLogger?: IAssignmentLogger; + private assignmentCache?: AssignmentCache; + // whether to suppress any errors and return default values instead + private isGracefulFailureMode = true; + private isObfuscated: boolean; private requestPoller?: IPoller; private readonly evaluator = new Evaluator(); - private readonly banditEvaluator = new BanditEvaluator(); - constructor( - private flagConfigurationStore: IConfigurationStore, - private banditVariationConfigurationStore?: IConfigurationStore, - private banditModelConfigurationStore?: IConfigurationStore, - private configurationRequestParameters?: FlagConfigurationRequestParameters, - private isObfuscated = false, - ) {} + constructor({ + eventQueue = new ArrayBackedNamedEventQueue('events'), + isObfuscated = false, + flagConfigurationStore, + banditVariationConfigurationStore, + banditModelConfigurationStore, + configurationRequestParameters, + }: { + // Queue for arbitrary, application-level events (not to be confused with Eppo specific assignment + // or bandit events). These events are application-specific and captures by EppoClient#track API. + eventQueue?: NamedEventQueue; + flagConfigurationStore: IConfigurationStore; + banditVariationConfigurationStore?: IConfigurationStore; + banditModelConfigurationStore?: IConfigurationStore; + configurationRequestParameters?: FlagConfigurationRequestParameters; + isObfuscated?: boolean; + }) { + this.eventQueue = eventQueue; + this.flagConfigurationStore = flagConfigurationStore; + this.banditVariationConfigurationStore = banditVariationConfigurationStore; + this.banditModelConfigurationStore = banditModelConfigurationStore; + this.configurationRequestParameters = configurationRequestParameters; + this.isObfuscated = isObfuscated; + } - public setConfigurationRequestParameters( + setConfigurationRequestParameters( configurationRequestParameters: FlagConfigurationRequestParameters, ) { this.configurationRequestParameters = configurationRequestParameters; } - public setFlagConfigurationStore( - flagConfigurationStore: IConfigurationStore, - ) { + // noinspection JSUnusedGlobalSymbols + setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore) { this.flagConfigurationStore = flagConfigurationStore; } - public setBanditVariationConfigurationStore( + // noinspection JSUnusedGlobalSymbols + setBanditVariationConfigurationStore( banditVariationConfigurationStore: IConfigurationStore, ) { this.banditVariationConfigurationStore = banditVariationConfigurationStore; } - public setBanditModelConfigurationStore( + // noinspection JSUnusedGlobalSymbols + setBanditModelConfigurationStore( banditModelConfigurationStore: IConfigurationStore, ) { this.banditModelConfigurationStore = banditModelConfigurationStore; } - public setIsObfuscated(isObfuscated: boolean) { + // noinspection JSUnusedGlobalSymbols + setIsObfuscated(isObfuscated: boolean) { this.isObfuscated = isObfuscated; } - public async fetchFlagConfigurations() { + async fetchFlagConfigurations() { if (!this.configurationRequestParameters) { throw new Error( 'Eppo SDK unable to fetch flag configurations without configuration request parameters', @@ -183,7 +213,8 @@ export default class EppoClient { await this.requestPoller.start(); } - public stopPolling() { + // noinspection JSUnusedGlobalSymbols + stopPolling() { if (this.requestPoller) { this.requestPoller.stop(); } @@ -200,7 +231,7 @@ export default class EppoClient { * @returns a variation value if the subject is part of the experiment sample, otherwise the default value * @public */ - public getStringAssignment( + getStringAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -222,7 +253,7 @@ export default class EppoClient { * @returns an object that includes the variation value along with additional metadata about the assignment * @public */ - public getStringAssignmentDetails( + getStringAssignmentDetails( flagKey: string, subjectKey: string, subjectAttributes: Record, @@ -245,7 +276,7 @@ export default class EppoClient { /** * @deprecated use getBooleanAssignment instead. */ - public getBoolAssignment( + getBoolAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -263,7 +294,7 @@ export default class EppoClient { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns a boolean variation value if the subject is part of the experiment sample, otherwise the default value */ - public getBooleanAssignment( + getBooleanAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -285,7 +316,7 @@ export default class EppoClient { * @returns an object that includes the variation value along with additional metadata about the assignment * @public */ - public getBooleanAssignmentDetails( + getBooleanAssignmentDetails( flagKey: string, subjectKey: string, subjectAttributes: Record, @@ -314,7 +345,7 @@ export default class EppoClient { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns an integer variation value if the subject is part of the experiment sample, otherwise the default value */ - public getIntegerAssignment( + getIntegerAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -336,7 +367,7 @@ export default class EppoClient { * @returns an object that includes the variation value along with additional metadata about the assignment * @public */ - public getIntegerAssignmentDetails( + getIntegerAssignmentDetails( flagKey: string, subjectKey: string, subjectAttributes: Record, @@ -365,7 +396,7 @@ export default class EppoClient { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns a number variation value if the subject is part of the experiment sample, otherwise the default value */ - public getNumericAssignment( + getNumericAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -387,7 +418,7 @@ export default class EppoClient { * @returns an object that includes the variation value along with additional metadata about the assignment * @public */ - public getNumericAssignmentDetails( + getNumericAssignmentDetails( flagKey: string, subjectKey: string, subjectAttributes: Record, @@ -416,7 +447,7 @@ export default class EppoClient { * @param defaultValue default value to return if the subject is not part of the experiment sample * @returns a JSON object variation value if the subject is part of the experiment sample, otherwise the default value */ - public getJSONAssignment( + getJSONAssignment( flagKey: string, subjectKey: string, subjectAttributes: Attributes, @@ -426,7 +457,7 @@ export default class EppoClient { .variation; } - public getJSONAssignmentDetails( + getJSONAssignmentDetails( flagKey: string, subjectKey: string, subjectAttributes: Record, @@ -446,7 +477,7 @@ export default class EppoClient { }; } - public getBanditAction( + getBanditAction( flagKey: string, subjectKey: string, subjectAttributes: BanditSubjectAttributes, @@ -463,7 +494,7 @@ export default class EppoClient { return { variation, action }; } - public getBanditActionDetails( + getBanditActionDetails( flagKey: string, subjectKey: string, subjectAttributes: BanditSubjectAttributes, @@ -474,7 +505,7 @@ export default class EppoClient { let action: string | null = null; // Initialize with a generic evaluation details. This will mutate as the function progresses. - let evaluationDetails: IFlagEvaluationDetails = this.flagEvaluationDetailsBuilder( + let evaluationDetails: IFlagEvaluationDetails = this.newFlagEvaluationDetailsBuilder( flagKey, ).buildForNoneResult( 'ASSIGNMENT_ERROR', @@ -544,7 +575,7 @@ export default class EppoClient { * @param subjectAttributes optional attributes associated with the subject, for example name and email. * @returns The container entry associated with the experiment. */ - public getExperimentContainerEntry( + getExperimentContainerEntry( flagExperiment: IContainerExperiment, subjectKey: string, subjectAttributes: Attributes, @@ -648,13 +679,11 @@ export default class EppoClient { private ensureContextualSubjectAttributes( subjectAttributes: BanditSubjectAttributes, ): ContextAttributes { - let result: ContextAttributes; if (this.isInstanceOfContextualAttributes(subjectAttributes)) { - result = subjectAttributes as ContextAttributes; + return subjectAttributes as ContextAttributes; } else { - result = this.deduceAttributeContext(subjectAttributes as Attributes); + return this.deduceAttributeContext(subjectAttributes as Attributes); } - return result; } private ensureActionsWithContextualAttributes( @@ -726,9 +755,9 @@ export default class EppoClient { try { if (this.banditLogger) { this.banditLogger.logBanditAction(banditEvent); - } else if (this.queuedBanditEvents.length < MAX_EVENT_QUEUE_SIZE) { + } else { // If no logger defined, queue up the events (up to a max) to flush if a logger is later defined - this.queuedBanditEvents.push(banditEvent); + this.banditEventsQueue.push(banditEvent); } // Record in the assignment cache, if active, to deduplicate subsequent repeat assignments this.banditAssignmentCache?.set(banditAssignmentCacheProperties); @@ -775,27 +804,19 @@ export default class EppoClient { } private parseVariationWithDetails( - result: FlagEvaluation, + { flagEvaluationDetails, variation }: FlagEvaluation, defaultValue: EppoValue, expectedVariationType: VariationType, ): { eppoValue: EppoValue; flagEvaluationDetails: IFlagEvaluationDetails } { try { - if (!result.variation || result.flagEvaluationDetails.flagEvaluationCode !== 'MATCH') { - return { - eppoValue: defaultValue, - flagEvaluationDetails: result.flagEvaluationDetails, - }; + if (!variation || flagEvaluationDetails.flagEvaluationCode !== 'MATCH') { + return { eppoValue: defaultValue, flagEvaluationDetails }; } - return { - eppoValue: EppoValue.valueOf(result.variation.value, expectedVariationType), - flagEvaluationDetails: result.flagEvaluationDetails, - }; + const eppoValue = EppoValue.valueOf(variation.value, expectedVariationType); + return { eppoValue, flagEvaluationDetails }; } catch (error: any) { const eppoValue = this.rethrowIfNotGraceful(error, defaultValue); - return { - eppoValue, - flagEvaluationDetails: result.flagEvaluationDetails, - }; + return { eppoValue, flagEvaluationDetails }; } } @@ -819,7 +840,7 @@ export default class EppoClient { * @param expectedVariationType The expected variation type * @returns A detailed return of assignment for a particular subject and flag */ - public getAssignmentDetail( + getAssignmentDetail( flagKey: string, subjectKey: string, subjectAttributes: Attributes = {}, @@ -828,7 +849,7 @@ export default class EppoClient { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - const flagEvaluationDetailsBuilder = this.flagEvaluationDetailsBuilder(flagKey); + const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(flagKey); const configDetails = this.getConfigDetails(); const flag = this.getFlag(flagKey); @@ -879,7 +900,7 @@ export default class EppoClient { try { if (result?.doLog) { - this.logAssignment(result); + this.maybeLogAssignment(result); } } catch (error) { logger.error(`[Eppo SDK] Error logging assignment event: ${error}`); @@ -888,7 +909,12 @@ export default class EppoClient { return result; } - private flagEvaluationDetailsBuilder(flagKey: string): FlagEvaluationDetailsBuilder { + // noinspection JSUnusedGlobalSymbols + track(event: unknown, params: Record) { + this.eventQueue.push({ event, params }); + } + + private newFlagEvaluationDetailsBuilder(flagKey: string): FlagEvaluationDetailsBuilder { const flag = this.getFlag(flagKey); const configDetails = this.getConfigDetails(); return new FlagEvaluationDetailsBuilder( @@ -920,7 +946,8 @@ export default class EppoClient { return flag ? decodeFlag(flag) : null; } - public getFlagKeys() { + // noinspection JSUnusedGlobalSymbols + getFlagKeys() { /** * Returns a list of all flag keys that have been initialized. * This can be useful to debug the initialization process. @@ -930,7 +957,7 @@ export default class EppoClient { return this.flagConfigurationStore.getKeys(); } - public isInitialized() { + isInitialized() { return ( this.flagConfigurationStore.isInitialized() && (!this.banditVariationConfigurationStore || @@ -939,70 +966,70 @@ export default class EppoClient { ); } - /** @deprecated Renamed to setAssignmentLogger */ - public setLogger(logger: IAssignmentLogger) { + /** @deprecated Use `setAssignmentLogger` */ + setLogger(logger: IAssignmentLogger) { this.setAssignmentLogger(logger); } - public setAssignmentLogger(logger: IAssignmentLogger) { + setAssignmentLogger(logger: IAssignmentLogger) { this.assignmentLogger = logger; // log any assignment events that may have been queued while initializing - this.flushQueuedEvents(this.queuedAssignmentEvents, this.assignmentLogger?.logAssignment); + this.flushQueuedEvents(this.assignmentEventsQueue, this.assignmentLogger?.logAssignment); } - public setBanditLogger(logger: IBanditLogger) { + setBanditLogger(logger: IBanditLogger) { this.banditLogger = logger; // log any bandit events that may have been queued while initializing - this.flushQueuedEvents(this.queuedBanditEvents, this.banditLogger?.logBanditAction); + this.flushQueuedEvents(this.banditEventsQueue, this.banditLogger?.logBanditAction); } /** * Assignment cache methods. */ - public disableAssignmentCache() { + disableAssignmentCache() { this.assignmentCache = undefined; } - public useNonExpiringInMemoryAssignmentCache() { + useNonExpiringInMemoryAssignmentCache() { this.assignmentCache = new NonExpiringInMemoryAssignmentCache(); } - public useLRUInMemoryAssignmentCache(maxSize: number) { + useLRUInMemoryAssignmentCache(maxSize: number) { this.assignmentCache = new LRUInMemoryAssignmentCache(maxSize); } - public useCustomAssignmentCache(cache: AssignmentCache) { + // noinspection JSUnusedGlobalSymbols + useCustomAssignmentCache(cache: AssignmentCache) { this.assignmentCache = cache; } - public disableBanditAssignmentCache() { + disableBanditAssignmentCache() { this.banditAssignmentCache = undefined; } - public useNonExpiringInMemoryBanditAssignmentCache() { + useNonExpiringInMemoryBanditAssignmentCache() { this.banditAssignmentCache = new NonExpiringInMemoryAssignmentCache(); } - public useLRUInMemoryBanditAssignmentCache(maxSize: number) { + useLRUInMemoryBanditAssignmentCache(maxSize: number) { this.banditAssignmentCache = new LRUInMemoryAssignmentCache(maxSize); } - public useCustomBanditAssignmentCache(cache: AssignmentCache) { + // noinspection JSUnusedGlobalSymbols + useCustomBanditAssignmentCache(cache: AssignmentCache) { this.banditAssignmentCache = cache; } - public setIsGracefulFailureMode(gracefulFailureMode: boolean) { + setIsGracefulFailureMode(gracefulFailureMode: boolean) { this.isGracefulFailureMode = gracefulFailureMode; } - public getFlagConfigurations(): Record { + getFlagConfigurations(): Record { return this.flagConfigurationStore.entries(); } - private flushQueuedEvents(eventQueue: T[], logFunction?: (event: T) => void) { - const eventsToFlush = [...eventQueue]; // defensive copy - eventQueue.length = 0; // Truncate the array - + private flushQueuedEvents(eventQueue: BoundedEventQueue, logFunction?: (event: T) => void) { + const eventsToFlush = eventQueue.flush(); if (!logFunction) { return; } @@ -1016,7 +1043,7 @@ export default class EppoClient { }); } - private logAssignment(result: FlagEvaluation) { + private maybeLogAssignment(result: FlagEvaluation) { const { flagKey, subjectKey, allocationKey, subjectAttributes, variation } = result; const event: IAssignmentEvent = { ...(result.extraLogging ?? {}), @@ -1032,6 +1059,7 @@ export default class EppoClient { }; if (variation && allocationKey) { + // If already logged, don't log again const hasLoggedAssignment = this.assignmentCache?.has({ flagKey, subjectKey, @@ -1046,10 +1074,10 @@ export default class EppoClient { try { if (this.assignmentLogger) { this.assignmentLogger.logAssignment(event); - } else if (this.queuedAssignmentEvents.length < MAX_EVENT_QUEUE_SIZE) { + } else { // 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.assignmentEventsQueue.push(event); } this.assignmentCache?.set({ flagKey, @@ -1099,3 +1127,7 @@ export function checkValueTypeMatch( return false; } } + +export function newBoundedArrayEventQueue(name: string): BoundedEventQueue { + return new BoundedEventQueue(new ArrayBackedNamedEventQueue(name)); +} diff --git a/src/evaluator.ts b/src/evaluator.ts index d0d4282..54ee80a 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -27,6 +27,7 @@ export interface FlagEvaluation { allocationKey: string | null; variation: Variation | null; extraLogging: Record; + // whether to log assignment event doLog: boolean; flagEvaluationDetails: IFlagEvaluationDetails; } diff --git a/src/events/array-backed-named-event-queue.ts b/src/events/array-backed-named-event-queue.ts new file mode 100644 index 0000000..9a0dec3 --- /dev/null +++ b/src/events/array-backed-named-event-queue.ts @@ -0,0 +1,28 @@ +import NamedEventQueue from './named-event-queue'; + +/** A named event queue backed by an array. */ +export default class ArrayBackedNamedEventQueue implements NamedEventQueue { + private readonly events: T[] = []; + + constructor(public readonly name: string) {} + + get length(): number { + return this.events.length; + } + + set length(value: number) { + this.events.length = value; + } + + push(event: T): void { + this.events.push(event); + } + + [Symbol.iterator](): IterableIterator { + return this.events[Symbol.iterator](); + } + + shift(): T | undefined { + return this.events.shift(); + } +} diff --git a/src/events/bounded-event-queue.ts b/src/events/bounded-event-queue.ts new file mode 100644 index 0000000..39bfad6 --- /dev/null +++ b/src/events/bounded-event-queue.ts @@ -0,0 +1,32 @@ +import { logger } from '../application-logger'; +import { MAX_EVENT_QUEUE_SIZE } from '../constants'; + +import NamedEventQueue from './named-event-queue'; + +/** A bounded event queue that drops events when it reaches its maximum size. */ +export class BoundedEventQueue { + constructor( + private readonly queue: NamedEventQueue, + private readonly maxSize = MAX_EVENT_QUEUE_SIZE, + ) {} + + push(event: T) { + if (this.queue.length < this.maxSize) { + this.queue.push(event); + } else { + logger.warn(`Dropping event for queue ${this.queue.name} since the queue is full`); + } + } + + /** Clears all events in the queue and returns them in insertion order. */ + flush(): T[] { + const events = [...this.queue]; + this.queue.length = 0; + return events; + } + + /** Returns the first event in the queue and removes it. */ + shift(): T | undefined { + return this.queue.shift(); + } +} diff --git a/src/events/named-event-queue.ts b/src/events/named-event-queue.ts new file mode 100644 index 0000000..1c98056 --- /dev/null +++ b/src/events/named-event-queue.ts @@ -0,0 +1,12 @@ +/** A queue of events that can be named. */ +export default interface NamedEventQueue { + length: number; + + name: string; + + push(event: T): void; + + [Symbol.iterator](): IterableIterator; + + shift(): T | undefined; +} diff --git a/src/events/sdk-key-decoder.spec.ts b/src/events/sdk-key-decoder.spec.ts new file mode 100644 index 0000000..d2a3ca6 --- /dev/null +++ b/src/events/sdk-key-decoder.spec.ts @@ -0,0 +1,17 @@ +import SdkKeyDecoder from './sdk-key-decoder'; + +describe('SdkKeyDecoder', () => { + it('should decode the event ingestion hostname from the SDK key', () => { + const decoder = new SdkKeyDecoder(); + const hostname = decoder.decodeEventIngestionHostName( + 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk', + ); + expect(hostname).toEqual('123456.e.testing.eppo.cloud'); + }); + + it("should return null if the SDK key doesn't contain the event ingestion hostname", () => { + const decoder = new SdkKeyDecoder(); + expect(decoder.decodeEventIngestionHostName('zCsQuoHJxVPp895')).toBeNull(); + expect(decoder.decodeEventIngestionHostName('zCsQuoHJxVPp895.xxxxxx')).toBeNull(); + }); +}); diff --git a/src/events/sdk-key-decoder.ts b/src/events/sdk-key-decoder.ts new file mode 100644 index 0000000..8f01ac7 --- /dev/null +++ b/src/events/sdk-key-decoder.ts @@ -0,0 +1,14 @@ +export default class SdkKeyDecoder { + /** + * Decodes and returns the event ingestion hostname from the provided Eppo SDK key string. + * If the SDK key doesn't contain the event ingestion hostname, or it's invalid, it returns null. + */ + decodeEventIngestionHostName(sdkKey: string): string | null { + const encodedPayload = sdkKey.split('.')[1]; + if (!encodedPayload) return null; + + const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf-8'); + const params = new URLSearchParams(decodedPayload); + return params.get('eh'); + } +} diff --git a/src/flag-evaluation-details-builder.ts b/src/flag-evaluation-details-builder.ts index 31c6b30..a30dd0d 100644 --- a/src/flag-evaluation-details-builder.ts +++ b/src/flag-evaluation-details-builder.ts @@ -50,7 +50,6 @@ export class FlagEvaluationDetailsBuilder { private matchedRule: IFlagEvaluationDetails['matchedRule'] = null; private matchedAllocation: IFlagEvaluationDetails['matchedAllocation'] = null; private readonly unmatchedAllocations: IFlagEvaluationDetails['unmatchedAllocations'] = []; - private readonly unevaluatedAllocations: IFlagEvaluationDetails['unevaluatedAllocations'] = []; constructor( private readonly environmentName: string,