diff --git a/src/HookRunner.js b/src/HookRunner.js index 3e314ad..571cf6e 100644 --- a/src/HookRunner.js +++ b/src/HookRunner.js @@ -3,6 +3,7 @@ const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify'; const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify'; +const AFTER_TRACK_STAGE_NAME = 'afterTrack'; /** * Safely executes a hook stage function, logging any errors. @@ -125,6 +126,28 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) { } } +/** + * Executes the 'afterTrack' stage for all registered hooks in reverse order. + * @param {{ error: (message: string) => void }} logger The logger instance. + * @param {Array<{ afterTrack?: (hookContext: { context: object, data: object, metricValue: number }) => void }>} hooks The array of hook instances. + * @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation. + * @returns {void} + */ +function executeAfterTrack(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_TRACK_STAGE_NAME, + getHookName(logger, hook), + () => hook?.afterTrack?.(hookContext), + undefined + ); + } +} + /** * Factory function to create a HookRunner instance. * Manages the execution of hooks for flag evaluations and identify operations. @@ -203,10 +226,24 @@ function createHookRunner(logger, initialHooks) { hooksInternal.push(hook); } + /** + * Executes the 'afterTrack' stage for all registered hooks in reverse order. + * @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation. + * @returns {void} + */ + function afterTrack(hookContext) { + if (hooksInternal.length === 0) { + return; + } + const hooks = [...hooksInternal]; + executeAfterTrack(logger, hooks, hookContext); + } + return { withEvaluation, identify, addHook, + afterTrack, }; } diff --git a/src/__tests__/HookRunner-test.js b/src/__tests__/HookRunner-test.js index fd1a099..b68d06d 100644 --- a/src/__tests__/HookRunner-test.js +++ b/src/__tests__/HookRunner-test.js @@ -16,6 +16,7 @@ const createTestHook = (name = 'Test Hook') => ({ afterEvaluation: jest.fn(), beforeIdentify: jest.fn(), afterIdentify: jest.fn(), + afterTrack: jest.fn(), }); describe('Given a logger, runner, and hook', () => { @@ -328,4 +329,121 @@ describe('Given a logger, runner, and hook', () => { `An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error` ); }); + + it('should execute afterTrack hooks', () => { + const context = { kind: 'user', key: 'user-123' }; + const key = 'test'; + const data = { test: 'data' }; + const metricValue = 42; + + const trackContext = { + key, + context, + data, + metricValue, + }; + + hookRunner.afterTrack(trackContext); + + expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext); + }); + + it('should handle errors in afterTrack hooks', () => { + const errorHook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + afterTrack: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + }; + + const errorHookRunner = createHookRunner(logger, [errorHook]); + + errorHookRunner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'user-123' }, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error') + ); + }); + + it('should skip afterTrack execution if there are no hooks', () => { + const emptyHookRunner = createHookRunner(logger, []); + + emptyHookRunner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'user-123' }, + }); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('executes hook stages in the specified order', () => { + const beforeEvalOrder = []; + const afterEvalOrder = []; + const beforeIdentifyOrder = []; + const afterIdentifyOrder = []; + const afterTrackOrder = []; + + const createMockHook = id => ({ + getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }), + beforeEvaluation: jest.fn().mockImplementation((_context, data) => { + beforeEvalOrder.push(id); + return data; + }), + afterEvaluation: jest.fn().mockImplementation((_context, data) => { + afterEvalOrder.push(id); + return data; + }), + beforeIdentify: jest.fn().mockImplementation((_context, data) => { + beforeIdentifyOrder.push(id); + return data; + }), + afterIdentify: jest.fn().mockImplementation((_context, data) => { + afterIdentifyOrder.push(id); + return data; + }), + afterTrack: jest.fn().mockImplementation(() => { + afterTrackOrder.push(id); + }), + }); + + const hookA = createMockHook('a'); + const hookB = createMockHook('b'); + const hookC = createMockHook('c'); + + const runner = createHookRunner(logger, [hookA, hookB]); + runner.addHook(hookC); + + // Test evaluation order + runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + })); + + // Test identify order + const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000); + identifyCallback({ status: 'completed' }); + + // Test track order + runner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'bob' }, + data: { test: 'data' }, + metricValue: 42, + }); + + // Verify evaluation hooks order + expect(beforeEvalOrder).toEqual(['a', 'b', 'c']); + expect(afterEvalOrder).toEqual(['c', 'b', 'a']); + + // Verify identify hooks order + expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']); + expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']); + + // Verify track hooks order + expect(afterTrackOrder).toEqual(['c', 'b', 'a']); + }); }); diff --git a/src/__tests__/LDClient-hooks-test.js b/src/__tests__/LDClient-hooks-test.js index 43c3713..c707e75 100644 --- a/src/__tests__/LDClient-hooks-test.js +++ b/src/__tests__/LDClient-hooks-test.js @@ -225,4 +225,30 @@ describe('LDClient Hooks Integration', () => { }); }); }); + + it('should execute afterTrack hooks when tracking events', async () => { + const testHook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + afterTrack: jest.fn(), + getMetadata() { + return { + name: 'test hook', + }; + }, + }; + + await withClient(initialContext, {}, [testHook], async client => { + client.track('test', { test: 'data' }, 42); + + expect(testHook.afterTrack).toHaveBeenCalledWith({ + key: 'test', + context: { kind: 'user', key: 'user-key-initial' }, + data: { test: 'data' }, + metricValue: 42, + }); + }); + }); }); diff --git a/src/index.js b/src/index.js index 26327f9..e02c7ee 100644 --- a/src/index.js +++ b/src/index.js @@ -428,6 +428,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { e.metricValue = metricValue; } enqueueEvent(e); + hookRunner.afterTrack({ context, key, data, metricValue }); } function connectStream() { diff --git a/typings.d.ts b/typings.d.ts index 35a17d0..536fe5a 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -127,6 +127,29 @@ declare module 'launchdarkly-js-sdk-common' { status: IdentifySeriesStatus; } + /** + * Contextual information provided to track stages. + */ + export interface TrackSeriesContext { + /** + * The key for the event being tracked. + */ + readonly key: string; + /** + * The context associated with the track operation. + */ + readonly context: LDContext; + /** + * The data associated with the track operation. + */ + readonly data?: unknown; + /** + * The metric value associated with the track operation. + */ + readonly metricValue?: number; + } + + /** * Interface for extending SDK functionality via hooks. */ @@ -216,6 +239,15 @@ declare module 'launchdarkly-js-sdk-common' { data: IdentifySeriesData, result: IdentifySeriesResult, ): IdentifySeriesData; + + /** + * This method is called during the execution of the track process after the event + * has been enqueued. + * + * @param hookContext Contains information about the track operation being performed. This is not + * mutable. + */ + afterTrack?(hookContext: TrackSeriesContext): void; } /**