Skip to content

Commit 12f2e95

Browse files
committed
feat: Add hook support for the track series.
1 parent 2d3fa50 commit 12f2e95

File tree

6 files changed

+393
-6
lines changed

6 files changed

+393
-6
lines changed

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

Lines changed: 46 additions & 0 deletions
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

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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';
5+
import { TestHook } from './TestHook';
56

67
describe('given a hook runner and test hook', () => {
78
let logger: LDLogger;
@@ -22,6 +23,7 @@ describe('given a hook runner and test hook', () => {
2223
afterEvaluation: jest.fn(),
2324
beforeIdentify: jest.fn(),
2425
afterIdentify: jest.fn(),
26+
afterTrack: jest.fn(),
2527
};
2628

2729
hookRunner = new HookRunner(logger, [testHook]);
@@ -301,4 +303,161 @@ describe('given a hook runner and test hook', () => {
301303
),
302304
);
303305
});
306+
307+
it('should execute afterTrack hooks', () => {
308+
const context: LDContext = { kind: 'user', key: 'user-123' };
309+
const key = 'test';
310+
const data = { test: 'data' };
311+
const metricValue = 42;
312+
313+
const trackContext = {
314+
key,
315+
context,
316+
data,
317+
metricValue,
318+
};
319+
320+
testHook.afterTrack = jest.fn();
321+
322+
hookRunner.afterTrack(trackContext);
323+
324+
expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext);
325+
});
326+
327+
it('should handle errors in afterTrack hooks', () => {
328+
const errorHook: Hook = {
329+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
330+
afterTrack: jest.fn().mockImplementation(() => {
331+
throw new Error('Hook error');
332+
}),
333+
};
334+
335+
const errorHookRunner = new HookRunner(logger, [errorHook]);
336+
337+
errorHookRunner.afterTrack({
338+
key: 'test',
339+
context: { kind: 'user', key: 'user-123' },
340+
});
341+
342+
expect(logger.error).toHaveBeenCalledWith(
343+
expect.stringContaining(
344+
'An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error',
345+
),
346+
);
347+
});
348+
349+
it('should skip afterTrack execution if there are no hooks', () => {
350+
const emptyHookRunner = new HookRunner(logger, []);
351+
352+
emptyHookRunner.afterTrack({
353+
key: 'test',
354+
context: { kind: 'user', key: 'user-123' },
355+
});
356+
357+
expect(logger.error).not.toHaveBeenCalled();
358+
});
359+
360+
it('executes hook stages in the specified order', () => {
361+
const beforeEvalOrder: string[] = [];
362+
const afterEvalOrder: string[] = [];
363+
const beforeIdentifyOrder: string[] = [];
364+
const afterIdentifyOrder: string[] = [];
365+
const afterTrackOrder: string[] = [];
366+
367+
const hookA = new TestHook();
368+
hookA.beforeEvalImpl = (_context, data) => {
369+
beforeEvalOrder.push('a');
370+
return data;
371+
};
372+
hookA.afterEvalImpl = (_context, data, _detail) => {
373+
afterEvalOrder.push('a');
374+
return data;
375+
};
376+
hookA.beforeIdentifyImpl = (_context, data) => {
377+
beforeIdentifyOrder.push('a');
378+
return data;
379+
};
380+
hookA.afterIdentifyImpl = (_context, data, _result) => {
381+
afterIdentifyOrder.push('a');
382+
return data;
383+
};
384+
hookA.afterTrackImpl = () => {
385+
afterTrackOrder.push('a');
386+
};
387+
388+
const hookB = new TestHook();
389+
hookB.beforeEvalImpl = (_context, data) => {
390+
beforeEvalOrder.push('b');
391+
return data;
392+
};
393+
hookB.afterEvalImpl = (_context, data, _detail) => {
394+
afterEvalOrder.push('b');
395+
return data;
396+
};
397+
hookB.beforeIdentifyImpl = (_context, data) => {
398+
beforeIdentifyOrder.push('b');
399+
return data;
400+
};
401+
hookB.afterIdentifyImpl = (_context, data, _result) => {
402+
afterIdentifyOrder.push('b');
403+
return data;
404+
};
405+
hookB.afterTrackImpl = () => {
406+
afterTrackOrder.push('b');
407+
};
408+
409+
const hookC = new TestHook();
410+
hookC.beforeEvalImpl = (_context, data) => {
411+
beforeEvalOrder.push('c');
412+
return data;
413+
};
414+
hookC.afterEvalImpl = (_context, data, _detail) => {
415+
afterEvalOrder.push('c');
416+
return data;
417+
};
418+
hookC.beforeIdentifyImpl = (_context, data) => {
419+
beforeIdentifyOrder.push('c');
420+
return data;
421+
};
422+
hookC.afterIdentifyImpl = (_context, data, _result) => {
423+
afterIdentifyOrder.push('c');
424+
return data;
425+
};
426+
hookC.afterTrackImpl = () => {
427+
afterTrackOrder.push('c');
428+
};
429+
430+
const runner = new HookRunner(logger, [hookA, hookB]);
431+
runner.addHook(hookC);
432+
433+
// Test evaluation order
434+
runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({
435+
value: false,
436+
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
437+
variationIndex: null,
438+
}));
439+
440+
// Test identify order
441+
const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000);
442+
identifyCallback({ status: 'completed' });
443+
444+
// Test track order
445+
runner.afterTrack({
446+
key: 'test',
447+
context: { kind: 'user', key: 'bob' },
448+
data: { test: 'data' },
449+
metricValue: 42,
450+
});
451+
452+
// Verify evaluation hooks order
453+
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
454+
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);
455+
456+
// Verify identify hooks order
457+
expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']);
458+
expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']);
459+
460+
// Verify track hooks order
461+
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);
462+
});
304463
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { LDEvaluationDetail } from '@launchdarkly/js-sdk-common';
2+
3+
import {
4+
EvaluationSeriesContext,
5+
EvaluationSeriesData,
6+
Hook,
7+
HookMetadata,
8+
IdentifySeriesContext,
9+
IdentifySeriesData,
10+
IdentifySeriesResult,
11+
TrackSeriesContext,
12+
} from '../../src/api/integrations/Hooks';
13+
14+
export type EvalCapture = {
15+
method: string;
16+
hookContext: EvaluationSeriesContext;
17+
hookData: EvaluationSeriesData;
18+
detail?: LDEvaluationDetail;
19+
};
20+
21+
export type IdentifyCapture = {
22+
method: string;
23+
hookContext: IdentifySeriesContext;
24+
hookData: IdentifySeriesData;
25+
result?: IdentifySeriesResult;
26+
};
27+
28+
export type TrackCapture = {
29+
method: string;
30+
hookContext: TrackSeriesContext;
31+
};
32+
33+
export class TestHook implements Hook {
34+
captureBefore: EvalCapture[] = [];
35+
captureAfter: EvalCapture[] = [];
36+
captureIdentifyBefore: IdentifyCapture[] = [];
37+
captureIdentifyAfter: IdentifyCapture[] = [];
38+
captureTrack: TrackCapture[] = [];
39+
40+
getMetadataImpl: () => HookMetadata = () => ({ name: 'LaunchDarkly Test Hook' });
41+
42+
getMetadata(): HookMetadata {
43+
return this.getMetadataImpl();
44+
}
45+
46+
beforeEvalImpl: (
47+
hookContext: EvaluationSeriesContext,
48+
data: EvaluationSeriesData,
49+
) => EvaluationSeriesData = (_hookContext, data) => data;
50+
51+
afterEvalImpl: (
52+
hookContext: EvaluationSeriesContext,
53+
data: EvaluationSeriesData,
54+
detail: LDEvaluationDetail,
55+
) => EvaluationSeriesData = (_hookContext, data, _detail) => data;
56+
57+
beforeEvaluation?(
58+
hookContext: EvaluationSeriesContext,
59+
data: EvaluationSeriesData,
60+
): EvaluationSeriesData {
61+
this.captureBefore.push({ method: 'beforeEvaluation', hookContext, hookData: data });
62+
return this.beforeEvalImpl(hookContext, data);
63+
}
64+
65+
afterEvaluation?(
66+
hookContext: EvaluationSeriesContext,
67+
data: EvaluationSeriesData,
68+
detail: LDEvaluationDetail,
69+
): EvaluationSeriesData {
70+
this.captureAfter.push({ method: 'afterEvaluation', hookContext, hookData: data, detail });
71+
return this.afterEvalImpl(hookContext, data, detail);
72+
}
73+
74+
beforeIdentifyImpl: (
75+
hookContext: IdentifySeriesContext,
76+
data: IdentifySeriesData,
77+
) => IdentifySeriesData = (_hookContext, data) => data;
78+
79+
afterIdentifyImpl: (
80+
hookContext: IdentifySeriesContext,
81+
data: IdentifySeriesData,
82+
result: IdentifySeriesResult,
83+
) => IdentifySeriesData = (_hookContext, data, _result) => data;
84+
85+
afterTrackImpl: (hookContext: TrackSeriesContext) => void = () => {};
86+
87+
beforeIdentify?(
88+
hookContext: IdentifySeriesContext,
89+
data: IdentifySeriesData,
90+
): IdentifySeriesData {
91+
this.captureIdentifyBefore.push({ method: 'beforeIdentify', hookContext, hookData: data });
92+
return this.beforeIdentifyImpl(hookContext, data);
93+
}
94+
95+
afterIdentify?(
96+
hookContext: IdentifySeriesContext,
97+
data: IdentifySeriesData,
98+
result: IdentifySeriesResult,
99+
): IdentifySeriesData {
100+
this.captureIdentifyAfter.push({
101+
method: 'afterIdentify',
102+
hookContext,
103+
hookData: data,
104+
result,
105+
});
106+
return this.afterIdentifyImpl(hookContext, data, result);
107+
}
108+
109+
afterTrack?(hookContext: TrackSeriesContext): void {
110+
this.captureTrack.push({ method: 'afterTrack', hookContext });
111+
this.afterTrackImpl(hookContext);
112+
}
113+
}

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

Lines changed: 25 additions & 0 deletions
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
}

0 commit comments

Comments
 (0)