diff --git a/src/HookRunner.js b/src/HookRunner.js index 571cf6e..7121ddb 100644 --- a/src/HookRunner.js +++ b/src/HookRunner.js @@ -4,6 +4,7 @@ const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify'; const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify'; const AFTER_TRACK_STAGE_NAME = 'afterTrack'; +const AFTER_ENQUEUE_STAGE_NAME = 'afterEventEnqueue'; /** * Safely executes a hook stage function, logging any errors. @@ -148,6 +149,28 @@ function executeAfterTrack(logger, hooks, hookContext) { } } +/** + * Executes the 'afterEventEnqueue' stage for all registered hooks in reverse order. + * @param {{ error: (message: string) => void }} logger The logger instance. + * @param {Array<{ afterEventEnqueue?: (hookContext: object) => void }>} hooks The array of hook instances. + * @param {object} hookContext The full event object that was enqueued. + * @returns {void} + */ +function executeAfterEventEnqueue(logger, hooks, hookContext) { + // This iterates in reverse, versus reversing a shallow copy of the hooks, + // for efficiency. + for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { + const hook = hooks[hookIndex]; + tryExecuteStage( + logger, + AFTER_ENQUEUE_STAGE_NAME, + getHookName(logger, hook), + () => hook?.afterEventEnqueue?.(hookContext), + undefined + ); + } +} + /** * Factory function to create a HookRunner instance. * Manages the execution of hooks for flag evaluations and identify operations. @@ -239,11 +262,25 @@ function createHookRunner(logger, initialHooks) { executeAfterTrack(logger, hooks, hookContext); } + /** + * Executes the 'afterEventEnqueue' stage for all registered hooks in reverse order. + * @param {object} hookContext The full event object that was enqueued. + * @returns {void} + */ + function afterEventEnqueue(hookContext) { + if (hooksInternal.length === 0) { + return; + } + const hooks = [...hooksInternal]; + executeAfterEventEnqueue(logger, hooks, hookContext); + } + return { withEvaluation, identify, addHook, afterTrack, + afterEventEnqueue, }; } diff --git a/src/__tests__/HookRunner-test.js b/src/__tests__/HookRunner-test.js index b68d06d..3771f54 100644 --- a/src/__tests__/HookRunner-test.js +++ b/src/__tests__/HookRunner-test.js @@ -17,6 +17,7 @@ const createTestHook = (name = 'Test Hook') => ({ beforeIdentify: jest.fn(), afterIdentify: jest.fn(), afterTrack: jest.fn(), + afterEventEnqueue: jest.fn(), }); describe('Given a logger, runner, and hook', () => { @@ -379,12 +380,114 @@ describe('Given a logger, runner, and hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should execute afterEventEnqueue hooks', () => { + const context = { kind: 'user', key: 'user-123' }; + const event = { + kind: 'feature', + key: 'test-flag', + context, + value: true, + variation: 1, + default: false, + creationDate: new Date().getTime(), + version: 42, + trackEvents: true, + }; + + hookRunner.afterEventEnqueue(event); + + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(event); + }); + + it('should handle errors in afterEventEnqueue hooks', () => { + const errorHook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + afterEventEnqueue: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + }; + + const errorHookRunner = createHookRunner(logger, [errorHook]); + + errorHookRunner.afterEventEnqueue({ + kind: 'custom', + key: 'test-event', + context: { kind: 'user', key: 'user-123' }, + creationDate: new Date().getTime(), + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "afterEventEnqueue" of the "Error Hook" hook: Error: Hook error' + ) + ); + }); + + it('should skip afterEventEnqueue execution if there are no hooks', () => { + const emptyHookRunner = createHookRunner(logger, []); + + emptyHookRunner.afterEventEnqueue({ + kind: 'identify', + context: { kind: 'user', key: 'user-123' }, + creationDate: new Date().getTime(), + }); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should execute afterEventEnqueue hooks for different event types', () => { + const context = { kind: 'user', key: 'user-123' }; + const creationDate = new Date().getTime(); + + // Test feature event + const featureEvent = { + kind: 'feature', + key: 'test-flag', + context, + value: true, + variation: 1, + default: false, + creationDate, + version: 42, + }; + + hookRunner.afterEventEnqueue(featureEvent); + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(featureEvent); + + // Test custom event + const customEvent = { + kind: 'custom', + key: 'test-event', + context, + data: { custom: 'data' }, + metricValue: 123, + creationDate, + url: 'https://example.com', + }; + + hookRunner.afterEventEnqueue(customEvent); + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(customEvent); + + // Test identify event + const identifyEvent = { + kind: 'identify', + context, + creationDate, + }; + + hookRunner.afterEventEnqueue(identifyEvent); + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(identifyEvent); + + expect(testHook.afterEventEnqueue).toHaveBeenCalledTimes(3); + }); + it('executes hook stages in the specified order', () => { const beforeEvalOrder = []; const afterEvalOrder = []; const beforeIdentifyOrder = []; const afterIdentifyOrder = []; const afterTrackOrder = []; + const afterEventEnqueueOrder = []; const createMockHook = id => ({ getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }), @@ -407,6 +510,9 @@ describe('Given a logger, runner, and hook', () => { afterTrack: jest.fn().mockImplementation(() => { afterTrackOrder.push(id); }), + afterEventEnqueue: jest.fn().mockImplementation(() => { + afterEventEnqueueOrder.push(id); + }), }); const hookA = createMockHook('a'); @@ -435,6 +541,14 @@ describe('Given a logger, runner, and hook', () => { metricValue: 42, }); + // Test event enqueue order + runner.afterEventEnqueue({ + kind: 'custom', + key: 'test-event', + context: { kind: 'user', key: 'bob' }, + creationDate: new Date().getTime(), + }); + // Verify evaluation hooks order expect(beforeEvalOrder).toEqual(['a', 'b', 'c']); expect(afterEvalOrder).toEqual(['c', 'b', 'a']); @@ -445,5 +559,8 @@ describe('Given a logger, runner, and hook', () => { // Verify track hooks order expect(afterTrackOrder).toEqual(['c', 'b', 'a']); + + // Verify event enqueue hooks order + expect(afterEventEnqueueOrder).toEqual(['c', 'b', 'a']); }); }); diff --git a/src/__tests__/LDClient-hooks-test.js b/src/__tests__/LDClient-hooks-test.js index c707e75..d578e53 100644 --- a/src/__tests__/LDClient-hooks-test.js +++ b/src/__tests__/LDClient-hooks-test.js @@ -251,4 +251,61 @@ describe('LDClient Hooks Integration', () => { }); }); }); + + it('should execute afterEventEnqueue hooks when events are enqueued', async () => { + const testHook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + afterTrack: jest.fn(), + afterEventEnqueue: jest.fn(), + getMetadata() { + return { + name: 'test hook', + }; + }, + }; + + await withClient(initialContext, { sendEvents: true }, [testHook], async client => { + // Track a custom event which should trigger afterEventEnqueue + client.track('test-event', { test: 'data' }, 42); + + // Evaluate a flag which should trigger afterEventEnqueue for the feature event + client.variation('test-flag', false); + + // Check that afterEventEnqueue was called for both events + expect(testHook.afterEventEnqueue).toHaveBeenCalledTimes(3); // identify + custom + feature events + + // Verify the custom event + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'custom', + key: 'test-event', + context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }), + data: { test: 'data' }, + metricValue: 42, + }) + ); + + // Verify the feature event + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'feature', + key: 'test-flag', + context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }), + value: false, + default: false, + }) + ); + + // Verify the identify event (from initialization) + expect(testHook.afterEventEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'identify', + context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }), + }) + ); + }); + }); }); diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js index 411405a..af2652a 100644 --- a/src/__tests__/LDClient-plugins-test.js +++ b/src/__tests__/LDClient-plugins-test.js @@ -18,6 +18,7 @@ const createTestHook = (name = 'Test Hook') => ({ beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data), afterIdentify: jest.fn().mockImplementation((_ctx, data) => data), afterTrack: jest.fn().mockImplementation((_ctx, data) => data), + afterEventEnqueue: jest.fn(), }); // Define a basic Plugin structure for tests diff --git a/src/index.js b/src/index.js index e05fec6..68c37a4 100644 --- a/src/index.js +++ b/src/index.js @@ -39,6 +39,7 @@ const highTimeoutThreshold = 5; // For definitions of the API in the platform object, see stubPlatform.js in the test code. function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { + console.log('🟢 LOCAL JS COMMON SDK 5.7.1 - LINKED VERSION ACTIVE 🟢'); const logger = createLogger(); const emitter = EventEmitter(logger); const initializationStateTracker = InitializationStateTracker(emitter); @@ -171,6 +172,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { if (shouldEnqueueEvent()) { logger.debug(messages.debugEnqueueingEvent(event.kind)); events.enqueue(event); + hookRunner.afterEventEnqueue(event); } } diff --git a/typings.d.ts b/typings.d.ts index 3aae737..ba077b7 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -149,6 +149,75 @@ declare module 'launchdarkly-js-sdk-common' { readonly metricValue?: number; } + /** + * Contextual information provided to event enqueue stages. + * + * This represents the full event object that was enqueued and can be + * one of several event types (identify, feature, custom, debug). + */ + export interface EventEnqueueContext { + /** + * The kind of event being enqueued. + */ + readonly kind: 'identify' | 'feature' | 'custom' | 'debug'; + /** + * The context associated with the event. + */ + readonly context: LDContext; + /** + * The timestamp when the event was created. + */ + readonly creationDate: number; + /** + * For feature and custom events, the key identifying the flag or custom event. + */ + readonly key?: string; + /** + * For feature events, the flag value. + */ + readonly value?: LDFlagValue; + /** + * For feature events, the variation index. + */ + readonly variation?: number | null; + /** + * For feature events, the flag version. + */ + readonly version?: number; + /** + * For feature events, the default value. + */ + readonly default?: LDFlagValue; + /** + * For feature events, the evaluation reason. + */ + readonly reason?: LDEvaluationReason; + /** + * For feature events, whether the flag is configured to track events. + */ + readonly trackEvents?: boolean; + /** + * For feature events, the debug events expiration date. + */ + readonly debugEventsUntilDate?: number; + /** + * For custom events, the event data. + */ + readonly data?: unknown; + /** + * For custom events, the metric value. + */ + readonly metricValue?: number; + /** + * For custom events, the URL where the event occurred. + */ + readonly url?: string; + /** + * For custom events with anonymous contexts, the context kind. + */ + readonly contextKind?: string; + } + /** * Interface for extending SDK functionality via hooks. */ @@ -173,10 +242,7 @@ declare module 'launchdarkly-js-sdk-common' { * return {...data, "my-new-field": /*my data/*} * ``` */ - beforeEvaluation?( - hookContext: EvaluationSeriesContext, - data: EvaluationSeriesData, - ): EvaluationSeriesData; + beforeEvaluation?(hookContext: EvaluationSeriesContext, data: EvaluationSeriesData): EvaluationSeriesData; /** * This method is called during the execution of the variation method @@ -198,7 +264,7 @@ declare module 'launchdarkly-js-sdk-common' { afterEvaluation?( hookContext: EvaluationSeriesContext, data: EvaluationSeriesData, - detail: LDEvaluationDetail, + detail: LDEvaluationDetail ): EvaluationSeriesData; /** @@ -236,7 +302,7 @@ declare module 'launchdarkly-js-sdk-common' { afterIdentify?( hookContext: IdentifySeriesContext, data: IdentifySeriesData, - result: IdentifySeriesResult, + result: IdentifySeriesResult ): IdentifySeriesData; /** @@ -247,6 +313,14 @@ declare module 'launchdarkly-js-sdk-common' { * mutable. */ afterTrack?(hookContext: TrackSeriesContext): void; + + /** + * This method is called after an event has been enqueued for processing. + * + * @param hookContext Contains the full event object that was enqueued. This is not + * mutable. + */ + afterEventEnqueue?(hookContext: EventEnqueueContext): void; } /** @@ -262,8 +336,8 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Metadata about the SDK that is running the plugin. - */ + * Metadata about the SDK that is running the plugin. + */ export interface LDPluginSdkMetadata { /** * The name of the SDK. @@ -287,8 +361,8 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Metadata about the application where the LaunchDarkly SDK is running. - */ + * Metadata about the application where the LaunchDarkly SDK is running. + */ export interface LDPluginApplicationMetadata { /** * A unique identifier representing the application where the LaunchDarkly SDK is running. @@ -312,8 +386,8 @@ declare module 'launchdarkly-js-sdk-common' { } /** - * Metadata about the environment where the plugin is running. - */ + * Metadata about the environment where the plugin is running. + */ export interface LDPluginEnvironmentMetadata { /** * Metadata about the SDK that is running the plugin. @@ -333,89 +407,89 @@ declare module 'launchdarkly-js-sdk-common' { readonly clientSideId: string; } -/** - * Interface for plugins to the LaunchDarkly SDK. - */ -export interface LDPlugin { /** - * Get metadata about the plugin. + * Interface for plugins to the LaunchDarkly SDK. */ - getMetadata(): LDPluginMetadata; + export interface LDPlugin { + /** + * Get metadata about the plugin. + */ + getMetadata(): LDPluginMetadata; - /** - * Registers the plugin with the SDK. Called once during SDK initialization. - * - * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate - * actions to ensure the SDK is ready before sending track events or evaluating flags. - * - * @param client The SDK client instance. - * @param environmentMetadata Information about the environment where the plugin is running. - */ - register(client: LDClientBase, environmentMetadata: LDPluginEnvironmentMetadata): void; + /** + * Registers the plugin with the SDK. Called once during SDK initialization. + * + * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate + * actions to ensure the SDK is ready before sending track events or evaluating flags. + * + * @param client The SDK client instance. + * @param environmentMetadata Information about the environment where the plugin is running. + */ + register(client: LDClientBase, environmentMetadata: LDPluginEnvironmentMetadata): void; - /** - * Gets a list of hooks that the plugin wants to register. - * - * This method will be called once during SDK initialization before the register method is called. - * - * If the plugin does not need to register any hooks, this method doesn't need to be implemented. - * @param metadata - */ - getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; + /** + * Gets a list of hooks that the plugin wants to register. + * + * This method will be called once during SDK initialization before the register method is called. + * + * If the plugin does not need to register any hooks, this method doesn't need to be implemented. + * @param metadata + */ + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; + + /** + * An optional function called if the plugin wants to register debug capabilities. + * This method allows plugins to receive a debug override interface for + * temporarily overriding flag values during development and testing. + * + * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. + * The API may change in future versions. + * + * @param debugOverride The debug override interface instance + */ + registerDebug?(debugOverride: LDDebugOverride): void; + } /** - * An optional function called if the plugin wants to register debug capabilities. - * This method allows plugins to receive a debug override interface for - * temporarily overriding flag values during development and testing. + * Debug interface for plugins that need to override flag values during development. + * This interface provides methods to temporarily override flag values that take + * precedence over the actual flag values from LaunchDarkly. These overrides are + * useful for testing, development, and debugging scenarios. * * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. * The API may change in future versions. - * - * @param debugOverride The debug override interface instance */ - registerDebug?(debugOverride: LDDebugOverride): void; -} - -/** - * Debug interface for plugins that need to override flag values during development. - * This interface provides methods to temporarily override flag values that take - * precedence over the actual flag values from LaunchDarkly. These overrides are - * useful for testing, development, and debugging scenarios. - * - * @experimental This interface is experimental and intended for use by LaunchDarkly tools at this time. - * The API may change in future versions. - */ -export interface LDDebugOverride { - /** - * Set an override value for a flag that takes precedence over the real flag value. - * - * @param flagKey The flag key. - * @param value The override value. - */ - setOverride(flagKey: string, value: LDFlagValue): void; + export interface LDDebugOverride { + /** + * Set an override value for a flag that takes precedence over the real flag value. + * + * @param flagKey The flag key. + * @param value The override value. + */ + setOverride(flagKey: string, value: LDFlagValue): void; - /** - * Remove an override value for a flag, reverting to the real flag value. - * - * @param flagKey The flag key. - */ - removeOverride(flagKey: string): void; + /** + * Remove an override value for a flag, reverting to the real flag value. + * + * @param flagKey The flag key. + */ + removeOverride(flagKey: string): void; - /** - * Clear all override values, reverting all flags to their real values. - */ - clearAllOverrides(): void; + /** + * Clear all override values, reverting all flags to their real values. + */ + clearAllOverrides(): void; - /** - * Get all currently active flag overrides. - * - * @returns - * An object containing all active overrides as key-value pairs, - * where keys are flag keys and values are the overridden flag values. - * Returns an empty object if no overrides are active. - */ - getAllOverrides(): LDFlagSet; -} + /** + * Get all currently active flag overrides. + * + * @returns + * An object containing all active overrides as key-value pairs, + * where keys are flag keys and values are the overridden flag values. + * Returns an empty object if no overrides are active. + */ + getAllOverrides(): LDFlagSet; + } /** * LaunchDarkly initialization options that are supported by all variants of the JS client. @@ -1123,13 +1197,13 @@ export interface LDDebugOverride { * Changing the current context also causes all feature flag values to be reloaded. Until that has * finished, calls to {@link variation} will still return flag values for the previous context. You can * use a callback or a Promise to determine when the new flag values are available. - * - * It is possible that the identify call will fail. In that case, when using a callback, the callback will receive - * an error value. While the SDK will continue to function, the developer will need to be aware that - * calls to {@link variation} will still return flag values for the previous context. - * - * When using a promise, it is important that you handle the rejection case; - * otherwise it will become an unhandled Promise rejection, which is a serious error on some platforms. + * + * It is possible that the identify call will fail. In that case, when using a callback, the callback will receive + * an error value. While the SDK will continue to function, the developer will need to be aware that + * calls to {@link variation} will still return flag values for the previous context. + * + * When using a promise, it is important that you handle the rejection case; + * otherwise it will become an unhandled Promise rejection, which is a serious error on some platforms. * * @param context * The context properties. Must contain at least the `key` property. @@ -1446,7 +1520,7 @@ export interface LDDebugOverride { * Synchronous inspectors execute inline with evaluation and care should be taken to ensure * they have minimal performance overhead. */ - synchronous?: boolean, + synchronous?: boolean; /** * This method is called when a flag is accessed via a variation method, or it can be called based on actions in @@ -1478,7 +1552,7 @@ export interface LDDebugOverride { /** * If `true`, then the inspector will be ran synchronously with flag updates. */ - synchronous?: boolean, + synchronous?: boolean; /** * This method is called when the flags in the store are replaced with new flags. It will contain all flags @@ -1508,7 +1582,7 @@ export interface LDDebugOverride { /** * If `true`, then the inspector will be ran synchronously with flag updates. */ - synchronous?: boolean, + synchronous?: boolean; /** * This method is called when a flag is updated. It will not be called @@ -1536,7 +1610,7 @@ export interface LDDebugOverride { /** * If `true`, then the inspector will be ran synchronously with identification. */ - synchronous?: boolean, + synchronous?: boolean; /** * This method will be called when an identify operation completes.