diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts index 9d9c6172f7..42054630de 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts @@ -308,3 +308,49 @@ it('should not execute hooks for prerequisite evaluations', async () => { }, ); }); + +it('should execute afterTrack hooks when tracking events', async () => { + const testHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + afterTrack: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'test hook', + }; + }, + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + hooks: [testHook], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ kind: 'user', key: 'user-key' }); + client.track('test', { test: 'data' }, 42); + + expect(testHook.afterTrack).toHaveBeenCalledWith({ + key: 'test', + context: { kind: 'user', key: 'user-key' }, + data: { test: 'data' }, + metricValue: 42, + }); +}); diff --git a/packages/shared/sdk-client/__tests__/HookRunner.test.ts b/packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts similarity index 71% rename from packages/shared/sdk-client/__tests__/HookRunner.test.ts rename to packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts index 85d704df49..7ecca84ff2 100644 --- a/packages/shared/sdk-client/__tests__/HookRunner.test.ts +++ b/packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts @@ -1,7 +1,7 @@ import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common'; -import { Hook, IdentifySeriesResult } from '../src/api/integrations/Hooks'; -import HookRunner from '../src/HookRunner'; +import { Hook, IdentifySeriesResult } from '../../src/api/integrations/Hooks'; +import HookRunner from '../../src/HookRunner'; describe('given a hook runner and test hook', () => { let logger: LDLogger; @@ -22,6 +22,7 @@ describe('given a hook runner and test hook', () => { afterEvaluation: jest.fn(), beforeIdentify: jest.fn(), afterIdentify: jest.fn(), + afterTrack: jest.fn(), }; hookRunner = new HookRunner(logger, [testHook]); @@ -301,4 +302,125 @@ describe('given a hook runner and test hook', () => { ), ); }); + + it('should execute afterTrack hooks', () => { + const context: LDContext = { kind: 'user', key: 'user-123' }; + const key = 'test'; + const data = { test: 'data' }; + const metricValue = 42; + + const trackContext = { + key, + context, + data, + metricValue, + }; + + testHook.afterTrack = jest.fn(); + + hookRunner.afterTrack(trackContext); + + expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext); + }); + + it('should handle errors in afterTrack hooks', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + afterTrack: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + }; + + const errorHookRunner = new HookRunner(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 = new HookRunner(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: string[] = []; + const afterEvalOrder: string[] = []; + const beforeIdentifyOrder: string[] = []; + const afterIdentifyOrder: string[] = []; + const afterTrackOrder: string[] = []; + + const createMockHook = (id: string): Hook => ({ + getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }), + beforeEvaluation: jest.fn().mockImplementation((_context, data) => { + beforeEvalOrder.push(id); + return data; + }), + afterEvaluation: jest.fn().mockImplementation((_context, data, _detail) => { + afterEvalOrder.push(id); + return data; + }), + beforeIdentify: jest.fn().mockImplementation((_context, data) => { + beforeIdentifyOrder.push(id); + return data; + }), + afterIdentify: jest.fn().mockImplementation((_context, data, _result) => { + 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 = new HookRunner(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/packages/shared/sdk-client/src/HookRunner.ts b/packages/shared/sdk-client/src/HookRunner.ts index 8380bfba11..87bda82cdd 100644 --- a/packages/shared/sdk-client/src/HookRunner.ts +++ b/packages/shared/sdk-client/src/HookRunner.ts @@ -7,12 +7,14 @@ import { IdentifySeriesContext, IdentifySeriesData, IdentifySeriesResult, + TrackSeriesContext, } from './api/integrations/Hooks'; import { LDEvaluationDetail } from './api/LDEvaluationDetail'; const UNKNOWN_HOOK_NAME = 'unknown hook'; const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; +const AFTER_TRACK_STAGE_NAME = 'afterTrack'; function tryExecuteStage( logger: LDLogger, @@ -114,6 +116,21 @@ function executeAfterIdentify( } } +function executeAfterTrack(logger: LDLogger, hooks: Hook[], hookContext: TrackSeriesContext) { + // 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, + ); + } +} + export default class HookRunner { private readonly _hooks: Hook[] = []; @@ -164,4 +181,12 @@ export default class HookRunner { addHook(hook: Hook): void { this._hooks.push(hook); } + + afterTrack(hookContext: TrackSeriesContext): void { + if (this._hooks.length === 0) { + return; + } + const hooks: Hook[] = [...this._hooks]; + executeAfterTrack(this._logger, hooks, hookContext); + } } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index df59fb7c44..b23530087f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -307,6 +307,14 @@ export default class LDClientImpl implements LDClient { this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue), ), ); + + this._hookRunner.afterTrack({ + key, + // The context is pre-checked above, so we know it can be unwrapped. + context: this._uncheckedContext!, + data, + metricValue, + }); } private _variationInternal( diff --git a/packages/shared/sdk-client/src/api/integrations/Hooks.ts b/packages/shared/sdk-client/src/api/integrations/Hooks.ts index 5382b26516..a453775bba 100644 --- a/packages/shared/sdk-client/src/api/integrations/Hooks.ts +++ b/packages/shared/sdk-client/src/api/integrations/Hooks.ts @@ -89,6 +89,28 @@ export interface IdentifySeriesResult { 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. */ @@ -178,4 +200,13 @@ export interface Hook { 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; }