Skip to content

Commit 1519cb9

Browse files
authored
feat: Add hook support for the track series. (#827)
Adds support for the track series to the client-side hook implementation.
1 parent 2d3fa50 commit 1519cb9

File tree

5 files changed

+234
-2
lines changed

5 files changed

+234
-2
lines changed

packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts

+46
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,49 @@ it('should not execute hooks for prerequisite evaluations', async () => {
308308
},
309309
);
310310
});
311+
312+
it('should execute afterTrack hooks when tracking events', async () => {
313+
const testHook: Hook = {
314+
beforeEvaluation: jest.fn(),
315+
afterEvaluation: jest.fn(),
316+
beforeIdentify: jest.fn(),
317+
afterIdentify: jest.fn(),
318+
afterTrack: jest.fn(),
319+
getMetadata(): HookMetadata {
320+
return {
321+
name: 'test hook',
322+
};
323+
},
324+
};
325+
326+
const platform = createBasicPlatform();
327+
const factory = makeTestDataManagerFactory('sdk-key', platform, {
328+
disableNetwork: true,
329+
});
330+
const client = new LDClientImpl(
331+
'sdk-key',
332+
AutoEnvAttributes.Disabled,
333+
platform,
334+
{
335+
sendEvents: false,
336+
hooks: [testHook],
337+
logger: {
338+
debug: jest.fn(),
339+
info: jest.fn(),
340+
warn: jest.fn(),
341+
error: jest.fn(),
342+
},
343+
},
344+
factory,
345+
);
346+
347+
await client.identify({ kind: 'user', key: 'user-key' });
348+
client.track('test', { test: 'data' }, 42);
349+
350+
expect(testHook.afterTrack).toHaveBeenCalledWith({
351+
key: 'test',
352+
context: { kind: 'user', key: 'user-key' },
353+
data: { test: 'data' },
354+
metricValue: 42,
355+
});
356+
});

packages/shared/sdk-client/__tests__/HookRunner.test.ts renamed to packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts

+124-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common';
22

3-
import { Hook, IdentifySeriesResult } from '../src/api/integrations/Hooks';
4-
import HookRunner from '../src/HookRunner';
3+
import { Hook, IdentifySeriesResult } from '../../src/api/integrations/Hooks';
4+
import HookRunner from '../../src/HookRunner';
55

66
describe('given a hook runner and test hook', () => {
77
let logger: LDLogger;
@@ -22,6 +22,7 @@ describe('given a hook runner and test hook', () => {
2222
afterEvaluation: jest.fn(),
2323
beforeIdentify: jest.fn(),
2424
afterIdentify: jest.fn(),
25+
afterTrack: jest.fn(),
2526
};
2627

2728
hookRunner = new HookRunner(logger, [testHook]);
@@ -301,4 +302,125 @@ describe('given a hook runner and test hook', () => {
301302
),
302303
);
303304
});
305+
306+
it('should execute afterTrack hooks', () => {
307+
const context: LDContext = { kind: 'user', key: 'user-123' };
308+
const key = 'test';
309+
const data = { test: 'data' };
310+
const metricValue = 42;
311+
312+
const trackContext = {
313+
key,
314+
context,
315+
data,
316+
metricValue,
317+
};
318+
319+
testHook.afterTrack = jest.fn();
320+
321+
hookRunner.afterTrack(trackContext);
322+
323+
expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext);
324+
});
325+
326+
it('should handle errors in afterTrack hooks', () => {
327+
const errorHook: Hook = {
328+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
329+
afterTrack: jest.fn().mockImplementation(() => {
330+
throw new Error('Hook error');
331+
}),
332+
};
333+
334+
const errorHookRunner = new HookRunner(logger, [errorHook]);
335+
336+
errorHookRunner.afterTrack({
337+
key: 'test',
338+
context: { kind: 'user', key: 'user-123' },
339+
});
340+
341+
expect(logger.error).toHaveBeenCalledWith(
342+
expect.stringContaining(
343+
'An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error',
344+
),
345+
);
346+
});
347+
348+
it('should skip afterTrack execution if there are no hooks', () => {
349+
const emptyHookRunner = new HookRunner(logger, []);
350+
351+
emptyHookRunner.afterTrack({
352+
key: 'test',
353+
context: { kind: 'user', key: 'user-123' },
354+
});
355+
356+
expect(logger.error).not.toHaveBeenCalled();
357+
});
358+
359+
it('executes hook stages in the specified order', () => {
360+
const beforeEvalOrder: string[] = [];
361+
const afterEvalOrder: string[] = [];
362+
const beforeIdentifyOrder: string[] = [];
363+
const afterIdentifyOrder: string[] = [];
364+
const afterTrackOrder: string[] = [];
365+
366+
const createMockHook = (id: string): Hook => ({
367+
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
368+
beforeEvaluation: jest.fn().mockImplementation((_context, data) => {
369+
beforeEvalOrder.push(id);
370+
return data;
371+
}),
372+
afterEvaluation: jest.fn().mockImplementation((_context, data, _detail) => {
373+
afterEvalOrder.push(id);
374+
return data;
375+
}),
376+
beforeIdentify: jest.fn().mockImplementation((_context, data) => {
377+
beforeIdentifyOrder.push(id);
378+
return data;
379+
}),
380+
afterIdentify: jest.fn().mockImplementation((_context, data, _result) => {
381+
afterIdentifyOrder.push(id);
382+
return data;
383+
}),
384+
afterTrack: jest.fn().mockImplementation(() => {
385+
afterTrackOrder.push(id);
386+
}),
387+
});
388+
389+
const hookA = createMockHook('a');
390+
const hookB = createMockHook('b');
391+
const hookC = createMockHook('c');
392+
393+
const runner = new HookRunner(logger, [hookA, hookB]);
394+
runner.addHook(hookC);
395+
396+
// Test evaluation order
397+
runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({
398+
value: false,
399+
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
400+
variationIndex: null,
401+
}));
402+
403+
// Test identify order
404+
const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000);
405+
identifyCallback({ status: 'completed' });
406+
407+
// Test track order
408+
runner.afterTrack({
409+
key: 'test',
410+
context: { kind: 'user', key: 'bob' },
411+
data: { test: 'data' },
412+
metricValue: 42,
413+
});
414+
415+
// Verify evaluation hooks order
416+
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
417+
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);
418+
419+
// Verify identify hooks order
420+
expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']);
421+
expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']);
422+
423+
// Verify track hooks order
424+
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);
425+
});
304426
});

packages/shared/sdk-client/src/HookRunner.ts

+25
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import {
77
IdentifySeriesContext,
88
IdentifySeriesData,
99
IdentifySeriesResult,
10+
TrackSeriesContext,
1011
} from './api/integrations/Hooks';
1112
import { LDEvaluationDetail } from './api/LDEvaluationDetail';
1213

1314
const UNKNOWN_HOOK_NAME = 'unknown hook';
1415
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
1516
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
17+
const AFTER_TRACK_STAGE_NAME = 'afterTrack';
1618

1719
function tryExecuteStage<TData>(
1820
logger: LDLogger,
@@ -114,6 +116,21 @@ function executeAfterIdentify(
114116
}
115117
}
116118

119+
function executeAfterTrack(logger: LDLogger, hooks: Hook[], hookContext: TrackSeriesContext) {
120+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
121+
// for efficiency.
122+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
123+
const hook = hooks[hookIndex];
124+
tryExecuteStage(
125+
logger,
126+
AFTER_TRACK_STAGE_NAME,
127+
getHookName(logger, hook),
128+
() => hook?.afterTrack?.(hookContext),
129+
undefined,
130+
);
131+
}
132+
}
133+
117134
export default class HookRunner {
118135
private readonly _hooks: Hook[] = [];
119136

@@ -164,4 +181,12 @@ export default class HookRunner {
164181
addHook(hook: Hook): void {
165182
this._hooks.push(hook);
166183
}
184+
185+
afterTrack(hookContext: TrackSeriesContext): void {
186+
if (this._hooks.length === 0) {
187+
return;
188+
}
189+
const hooks: Hook[] = [...this._hooks];
190+
executeAfterTrack(this._logger, hooks, hookContext);
191+
}
167192
}

packages/shared/sdk-client/src/LDClientImpl.ts

+8
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,14 @@ export default class LDClientImpl implements LDClient {
307307
this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue),
308308
),
309309
);
310+
311+
this._hookRunner.afterTrack({
312+
key,
313+
// The context is pre-checked above, so we know it can be unwrapped.
314+
context: this._uncheckedContext!,
315+
data,
316+
metricValue,
317+
});
310318
}
311319

312320
private _variationInternal(

packages/shared/sdk-client/src/api/integrations/Hooks.ts

+31
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ export interface IdentifySeriesResult {
8989
status: IdentifySeriesStatus;
9090
}
9191

92+
/**
93+
* Contextual information provided to track stages.
94+
*/
95+
export interface TrackSeriesContext {
96+
/**
97+
* The key for the event being tracked.
98+
*/
99+
readonly key: string;
100+
/**
101+
* The context associated with the track operation.
102+
*/
103+
readonly context: LDContext;
104+
/**
105+
* The data associated with the track operation.
106+
*/
107+
readonly data?: unknown;
108+
/**
109+
* The metric value associated with the track operation.
110+
*/
111+
readonly metricValue?: number;
112+
}
113+
92114
/**
93115
* Interface for extending SDK functionality via hooks.
94116
*/
@@ -178,4 +200,13 @@ export interface Hook {
178200
data: IdentifySeriesData,
179201
result: IdentifySeriesResult,
180202
): IdentifySeriesData;
203+
204+
/**
205+
* This method is called during the execution of the track process after the event
206+
* has been enqueued.
207+
*
208+
* @param hookContext Contains information about the track operation being performed. This is not
209+
* mutable.
210+
*/
211+
afterTrack?(hookContext: TrackSeriesContext): void;
181212
}

0 commit comments

Comments
 (0)