Skip to content

Commit 160e7c7

Browse files
committed
feat: Add support for hooks.
1 parent 8cd0cdc commit 160e7c7

File tree

10 files changed

+617
-6
lines changed

10 files changed

+617
-6
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common';
2+
3+
import { Hook, IdentifyResult } from '../src/api/integrations/Hooks';
4+
import HookRunner from '../src/HookRunner';
5+
6+
describe('given a hook runner and test hook', () => {
7+
let logger: LDLogger;
8+
let testHook: Hook;
9+
let hookRunner: HookRunner;
10+
11+
beforeEach(() => {
12+
logger = {
13+
error: jest.fn(),
14+
warn: jest.fn(),
15+
info: jest.fn(),
16+
debug: jest.fn(),
17+
};
18+
19+
testHook = {
20+
getMetadata: jest.fn().mockReturnValue({ name: 'Test Hook' }),
21+
beforeEvaluation: jest.fn(),
22+
afterEvaluation: jest.fn(),
23+
beforeIdentify: jest.fn(),
24+
afterIdentify: jest.fn(),
25+
};
26+
27+
hookRunner = new HookRunner(logger, [testHook]);
28+
});
29+
30+
describe('when evaluating flags', () => {
31+
it('should execute hooks and return the evaluation result', () => {
32+
const key = 'test-flag';
33+
const context: LDContext = { kind: 'user', key: 'user-123' };
34+
const defaultValue = false;
35+
const evaluationResult: LDEvaluationDetail = {
36+
value: true,
37+
variationIndex: 1,
38+
reason: { kind: 'OFF' },
39+
};
40+
41+
const method = jest.fn().mockReturnValue(evaluationResult);
42+
43+
const result = hookRunner.withEvaluation(key, context, defaultValue, method);
44+
45+
expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
46+
expect.objectContaining({
47+
flagKey: key,
48+
context,
49+
defaultValue,
50+
}),
51+
{},
52+
);
53+
54+
expect(method).toHaveBeenCalled();
55+
56+
expect(testHook.afterEvaluation).toHaveBeenCalledWith(
57+
expect.objectContaining({
58+
flagKey: key,
59+
context,
60+
defaultValue,
61+
}),
62+
{},
63+
evaluationResult,
64+
);
65+
66+
expect(result).toEqual(evaluationResult);
67+
});
68+
69+
it('should handle errors in hooks', () => {
70+
const errorHook: Hook = {
71+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
72+
beforeEvaluation: jest.fn().mockImplementation(() => {
73+
throw new Error('Hook error');
74+
}),
75+
afterEvaluation: jest.fn(),
76+
};
77+
78+
const errorHookRunner = new HookRunner(logger, [errorHook]);
79+
80+
const method = jest
81+
.fn()
82+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
83+
84+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
85+
86+
expect(logger.error).toHaveBeenCalledWith(
87+
expect.stringContaining(
88+
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
89+
),
90+
);
91+
});
92+
93+
it('should skip hook execution if there are no hooks', () => {
94+
const emptyHookRunner = new HookRunner(logger, []);
95+
const method = jest
96+
.fn()
97+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
98+
99+
emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
100+
101+
expect(method).toHaveBeenCalled();
102+
expect(logger.error).not.toHaveBeenCalled();
103+
});
104+
});
105+
106+
describe('when handling an identifcation', () => {
107+
it('should execute identify hooks', () => {
108+
const context: LDContext = { kind: 'user', key: 'user-123' };
109+
const timeout = 10;
110+
const identifyResult: IdentifyResult = 'completed';
111+
112+
const identifyCallback = hookRunner.identify(context, timeout);
113+
identifyCallback(identifyResult);
114+
115+
expect(testHook.beforeIdentify).toHaveBeenCalledWith(
116+
expect.objectContaining({
117+
context,
118+
timeout,
119+
}),
120+
{},
121+
);
122+
123+
expect(testHook.afterIdentify).toHaveBeenCalledWith(
124+
expect.objectContaining({
125+
context,
126+
timeout,
127+
}),
128+
{},
129+
identifyResult,
130+
);
131+
});
132+
133+
it('should handle errors in identify hooks', () => {
134+
const errorHook: Hook = {
135+
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
136+
beforeIdentify: jest.fn().mockImplementation(() => {
137+
throw new Error('Hook error');
138+
}),
139+
afterIdentify: jest.fn(),
140+
};
141+
142+
const errorHookRunner = new HookRunner(logger, [errorHook]);
143+
144+
const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-123' }, 1000);
145+
identifyCallback('error');
146+
147+
expect(logger.error).toHaveBeenCalledWith(
148+
expect.stringContaining(
149+
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
150+
),
151+
);
152+
});
153+
});
154+
155+
it('should use the added hook in future invocations', () => {
156+
const newHook: Hook = {
157+
getMetadata: jest.fn().mockReturnValue({ name: 'New Hook' }),
158+
beforeEvaluation: jest.fn(),
159+
afterEvaluation: jest.fn(),
160+
};
161+
162+
hookRunner.addHook(newHook);
163+
164+
const method = jest
165+
.fn()
166+
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });
167+
168+
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);
169+
170+
expect(newHook.beforeEvaluation).toHaveBeenCalled();
171+
expect(newHook.afterEvaluation).toHaveBeenCalled();
172+
});
173+
174+
it('should log "unknown hook" when getMetadata throws an error', () => {
175+
const errorHook: Hook = {
176+
getMetadata: jest.fn().mockImplementation(() => {
177+
throw new Error('Metadata error');
178+
}),
179+
beforeEvaluation: jest.fn().mockImplementation(() => {
180+
throw new Error('Test error in beforeEvaluation');
181+
}),
182+
afterEvaluation: jest.fn(),
183+
};
184+
185+
const errorHookRunner = new HookRunner(logger, [errorHook]);
186+
187+
errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
188+
value: true,
189+
variationIndex: 1,
190+
reason: { kind: 'OFF' },
191+
}));
192+
193+
expect(logger.error).toHaveBeenCalledWith(
194+
'Exception thrown getting metadata for hook. Unable to get hook name.',
195+
);
196+
197+
// Verify that the error was logged with the correct hook name
198+
expect(logger.error).toHaveBeenCalledWith(
199+
expect.stringContaining(
200+
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation',
201+
),
202+
);
203+
});
204+
205+
it('should log the correct hook name when an error occurs', () => {
206+
// Modify the testHook to throw an error in beforeEvaluation
207+
testHook.beforeEvaluation = jest.fn().mockImplementation(() => {
208+
throw new Error('Test error in beforeEvaluation');
209+
});
210+
211+
hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
212+
value: true,
213+
variationIndex: 1,
214+
reason: { kind: 'OFF' },
215+
}));
216+
217+
// Verify that getMetadata was called to get the hook name
218+
expect(testHook.getMetadata).toHaveBeenCalled();
219+
220+
// Verify that the error was logged with the correct hook name
221+
expect(logger.error).toHaveBeenCalledWith(
222+
expect.stringContaining(
223+
'An error was encountered in "beforeEvaluation" of the "Test Hook" hook: Error: Test error in beforeEvaluation',
224+
),
225+
);
226+
});
227+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common';
2+
3+
import {
4+
EvaluationSeriesContext,
5+
EvaluationSeriesData,
6+
Hook,
7+
IdentifyResult,
8+
IdentifySeriesContext,
9+
IdentifySeriesData,
10+
} from './api/integrations/Hooks';
11+
import { LDEvaluationDetail } from './api/LDEvaluationDetail';
12+
13+
const UNKNOWN_HOOK_NAME = 'unknown hook';
14+
const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
15+
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
16+
17+
function tryExecuteStage<TData>(
18+
logger: LDLogger,
19+
method: string,
20+
hookName: string,
21+
stage: () => TData,
22+
def: TData,
23+
): TData {
24+
try {
25+
return stage();
26+
} catch (err) {
27+
logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`);
28+
return def;
29+
}
30+
}
31+
32+
function getHookName(logger: LDLogger, hook?: Hook): string {
33+
try {
34+
return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME;
35+
} catch {
36+
logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`);
37+
return UNKNOWN_HOOK_NAME;
38+
}
39+
}
40+
41+
function executeBeforeEvaluation(
42+
logger: LDLogger,
43+
hooks: Hook[],
44+
hookContext: EvaluationSeriesContext,
45+
): EvaluationSeriesData[] {
46+
return hooks.map((hook) =>
47+
tryExecuteStage(
48+
logger,
49+
BEFORE_EVALUATION_STAGE_NAME,
50+
getHookName(logger, hook),
51+
() => hook?.beforeEvaluation?.(hookContext, {}) ?? {},
52+
{},
53+
),
54+
);
55+
}
56+
57+
function executeAfterEvaluation(
58+
logger: LDLogger,
59+
hooks: Hook[],
60+
hookContext: EvaluationSeriesContext,
61+
updatedData: (EvaluationSeriesData | undefined)[],
62+
result: LDEvaluationDetail,
63+
) {
64+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
65+
// for efficiency.
66+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
67+
const hook = hooks[hookIndex];
68+
const data = updatedData[hookIndex] ?? {};
69+
tryExecuteStage(
70+
logger,
71+
AFTER_EVALUATION_STAGE_NAME,
72+
getHookName(logger, hook),
73+
() => hook?.afterEvaluation?.(hookContext, data, result) ?? {},
74+
{},
75+
);
76+
}
77+
}
78+
79+
function executeBeforeIdentify(
80+
logger: LDLogger,
81+
hooks: Hook[],
82+
hookContext: IdentifySeriesContext,
83+
): IdentifySeriesData[] {
84+
return hooks.map((hook) =>
85+
tryExecuteStage(
86+
logger,
87+
BEFORE_EVALUATION_STAGE_NAME,
88+
getHookName(logger, hook),
89+
() => hook?.beforeIdentify?.(hookContext, {}) ?? {},
90+
{},
91+
),
92+
);
93+
}
94+
95+
function executeAfterIdentify(
96+
logger: LDLogger,
97+
hooks: Hook[],
98+
hookContext: IdentifySeriesContext,
99+
updatedData: (IdentifySeriesData | undefined)[],
100+
result: IdentifyResult,
101+
) {
102+
// This iterates in reverse, versus reversing a shallow copy of the hooks,
103+
// for efficiency.
104+
for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) {
105+
const hook = hooks[hookIndex];
106+
const data = updatedData[hookIndex] ?? {};
107+
tryExecuteStage(
108+
logger,
109+
AFTER_EVALUATION_STAGE_NAME,
110+
getHookName(logger, hook),
111+
() => hook?.afterIdentify?.(hookContext, data, result) ?? {},
112+
{},
113+
);
114+
}
115+
}
116+
117+
export default class HookRunner {
118+
private readonly hooks: Hook[] = [];
119+
120+
constructor(
121+
private readonly logger: LDLogger,
122+
initialHooks: Hook[],
123+
) {
124+
this.hooks.push(...initialHooks);
125+
}
126+
127+
withEvaluation(
128+
key: string,
129+
context: LDContext | undefined,
130+
defaultValue: unknown,
131+
method: () => LDEvaluationDetail,
132+
): LDEvaluationDetail {
133+
if (this.hooks.length === 0) {
134+
return method();
135+
}
136+
const hooks: Hook[] = [...this.hooks];
137+
const hookContext: EvaluationSeriesContext = {
138+
flagKey: key,
139+
context,
140+
defaultValue,
141+
};
142+
143+
const hookData = executeBeforeEvaluation(this.logger, hooks, hookContext);
144+
const result = method();
145+
executeAfterEvaluation(this.logger, hooks, hookContext, hookData, result);
146+
return result;
147+
}
148+
149+
identify(context: LDContext, timeout: number | undefined): (result: IdentifyResult) => void {
150+
const hooks: Hook[] = [...this.hooks];
151+
const hookContext: IdentifySeriesContext = {
152+
context,
153+
timeout,
154+
};
155+
const hookData = executeBeforeIdentify(this.logger, hooks, hookContext);
156+
return (result) => {
157+
executeAfterIdentify(this.logger, hooks, hookContext, hookData, result);
158+
};
159+
}
160+
161+
addHook(hook: Hook): void {
162+
this.hooks.push(hook);
163+
}
164+
}

0 commit comments

Comments
 (0)