Skip to content

Commit f7bebeb

Browse files
authored
feat: Add support for the afterTrack stage for hooks. (#123)
1 parent 9a7dd27 commit f7bebeb

File tree

5 files changed

+214
-0
lines changed

5 files changed

+214
-0
lines changed

src/HookRunner.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
33
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
44
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
55
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
6+
const AFTER_TRACK_STAGE_NAME = 'afterTrack';
67

78
/**
89
* Safely executes a hook stage function, logging any errors.
@@ -125,6 +126,28 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
125126
}
126127
}
127128

129+
/**
130+
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
131+
* @param {{ error: (message: string) => void }} logger The logger instance.
132+
* @param {Array<{ afterTrack?: (hookContext: { context: object, data: object, metricValue: number }) => void }>} hooks The array of hook instances.
133+
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
134+
* @returns {void}
135+
*/
136+
function executeAfterTrack(logger, hooks, hookContext) {
137+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
138+
// for efficiency.
139+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
140+
const hook = hooks[hookIndex];
141+
tryExecuteStage(
142+
logger,
143+
AFTER_TRACK_STAGE_NAME,
144+
getHookName(logger, hook),
145+
() => hook?.afterTrack?.(hookContext),
146+
undefined
147+
);
148+
}
149+
}
150+
128151
/**
129152
* Factory function to create a HookRunner instance.
130153
* Manages the execution of hooks for flag evaluations and identify operations.
@@ -203,10 +226,24 @@ function createHookRunner(logger, initialHooks) {
203226
hooksInternal.push(hook);
204227
}
205228

229+
/**
230+
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
231+
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
232+
* @returns {void}
233+
*/
234+
function afterTrack(hookContext) {
235+
if (hooksInternal.length === 0) {
236+
return;
237+
}
238+
const hooks = [...hooksInternal];
239+
executeAfterTrack(logger, hooks, hookContext);
240+
}
241+
206242
return {
207243
withEvaluation,
208244
identify,
209245
addHook,
246+
afterTrack,
210247
};
211248
}
212249

src/__tests__/HookRunner-test.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const createTestHook = (name = 'Test Hook') => ({
1616
afterEvaluation: jest.fn(),
1717
beforeIdentify: jest.fn(),
1818
afterIdentify: jest.fn(),
19+
afterTrack: jest.fn(),
1920
});
2021

2122
describe('Given a logger, runner, and hook', () => {
@@ -328,4 +329,121 @@ describe('Given a logger, runner, and hook', () => {
328329
`An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error`
329330
);
330331
});
332+
333+
it('should execute afterTrack hooks', () => {
334+
const context = { kind: 'user', key: 'user-123' };
335+
const key = 'test';
336+
const data = { test: 'data' };
337+
const metricValue = 42;
338+
339+
const trackContext = {
340+
key,
341+
context,
342+
data,
343+
metricValue,
344+
};
345+
346+
hookRunner.afterTrack(trackContext);
347+
348+
expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext);
349+
});
350+
351+
it('should handle errors in afterTrack hooks', () => {
352+
const errorHook = {
353+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
354+
afterTrack: jest.fn().mockImplementation(() => {
355+
throw new Error('Hook error');
356+
}),
357+
};
358+
359+
const errorHookRunner = createHookRunner(logger, [errorHook]);
360+
361+
errorHookRunner.afterTrack({
362+
key: 'test',
363+
context: { kind: 'user', key: 'user-123' },
364+
});
365+
366+
expect(logger.error).toHaveBeenCalledWith(
367+
expect.stringContaining('An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error')
368+
);
369+
});
370+
371+
it('should skip afterTrack execution if there are no hooks', () => {
372+
const emptyHookRunner = createHookRunner(logger, []);
373+
374+
emptyHookRunner.afterTrack({
375+
key: 'test',
376+
context: { kind: 'user', key: 'user-123' },
377+
});
378+
379+
expect(logger.error).not.toHaveBeenCalled();
380+
});
381+
382+
it('executes hook stages in the specified order', () => {
383+
const beforeEvalOrder = [];
384+
const afterEvalOrder = [];
385+
const beforeIdentifyOrder = [];
386+
const afterIdentifyOrder = [];
387+
const afterTrackOrder = [];
388+
389+
const createMockHook = id => ({
390+
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
391+
beforeEvaluation: jest.fn().mockImplementation((_context, data) => {
392+
beforeEvalOrder.push(id);
393+
return data;
394+
}),
395+
afterEvaluation: jest.fn().mockImplementation((_context, data) => {
396+
afterEvalOrder.push(id);
397+
return data;
398+
}),
399+
beforeIdentify: jest.fn().mockImplementation((_context, data) => {
400+
beforeIdentifyOrder.push(id);
401+
return data;
402+
}),
403+
afterIdentify: jest.fn().mockImplementation((_context, data) => {
404+
afterIdentifyOrder.push(id);
405+
return data;
406+
}),
407+
afterTrack: jest.fn().mockImplementation(() => {
408+
afterTrackOrder.push(id);
409+
}),
410+
});
411+
412+
const hookA = createMockHook('a');
413+
const hookB = createMockHook('b');
414+
const hookC = createMockHook('c');
415+
416+
const runner = createHookRunner(logger, [hookA, hookB]);
417+
runner.addHook(hookC);
418+
419+
// Test evaluation order
420+
runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({
421+
value: false,
422+
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
423+
variationIndex: null,
424+
}));
425+
426+
// Test identify order
427+
const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000);
428+
identifyCallback({ status: 'completed' });
429+
430+
// Test track order
431+
runner.afterTrack({
432+
key: 'test',
433+
context: { kind: 'user', key: 'bob' },
434+
data: { test: 'data' },
435+
metricValue: 42,
436+
});
437+
438+
// Verify evaluation hooks order
439+
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
440+
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);
441+
442+
// Verify identify hooks order
443+
expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']);
444+
expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']);
445+
446+
// Verify track hooks order
447+
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);
448+
});
331449
});

src/__tests__/LDClient-hooks-test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,30 @@ describe('LDClient Hooks Integration', () => {
225225
});
226226
});
227227
});
228+
229+
it('should execute afterTrack hooks when tracking events', async () => {
230+
const testHook = {
231+
beforeEvaluation: jest.fn(),
232+
afterEvaluation: jest.fn(),
233+
beforeIdentify: jest.fn(),
234+
afterIdentify: jest.fn(),
235+
afterTrack: jest.fn(),
236+
getMetadata() {
237+
return {
238+
name: 'test hook',
239+
};
240+
},
241+
};
242+
243+
await withClient(initialContext, {}, [testHook], async client => {
244+
client.track('test', { test: 'data' }, 42);
245+
246+
expect(testHook.afterTrack).toHaveBeenCalledWith({
247+
key: 'test',
248+
context: { kind: 'user', key: 'user-key-initial' },
249+
data: { test: 'data' },
250+
metricValue: 42,
251+
});
252+
});
253+
});
228254
});

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
428428
e.metricValue = metricValue;
429429
}
430430
enqueueEvent(e);
431+
hookRunner.afterTrack({ context, key, data, metricValue });
431432
}
432433

433434
function connectStream() {

typings.d.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,29 @@ declare module 'launchdarkly-js-sdk-common' {
127127
status: IdentifySeriesStatus;
128128
}
129129

130+
/**
131+
* Contextual information provided to track stages.
132+
*/
133+
export interface TrackSeriesContext {
134+
/**
135+
* The key for the event being tracked.
136+
*/
137+
readonly key: string;
138+
/**
139+
* The context associated with the track operation.
140+
*/
141+
readonly context: LDContext;
142+
/**
143+
* The data associated with the track operation.
144+
*/
145+
readonly data?: unknown;
146+
/**
147+
* The metric value associated with the track operation.
148+
*/
149+
readonly metricValue?: number;
150+
}
151+
152+
130153
/**
131154
* Interface for extending SDK functionality via hooks.
132155
*/
@@ -216,6 +239,15 @@ declare module 'launchdarkly-js-sdk-common' {
216239
data: IdentifySeriesData,
217240
result: IdentifySeriesResult,
218241
): IdentifySeriesData;
242+
243+
/**
244+
* This method is called during the execution of the track process after the event
245+
* has been enqueued.
246+
*
247+
* @param hookContext Contains information about the track operation being performed. This is not
248+
* mutable.
249+
*/
250+
afterTrack?(hookContext: TrackSeriesContext): void;
219251
}
220252

221253
/**

0 commit comments

Comments
 (0)