Skip to content

Commit

Permalink
feat: Add support for hooks.
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion committed Oct 2, 2024
1 parent 8cd0cdc commit 160e7c7
Show file tree
Hide file tree
Showing 10 changed files with 617 additions and 6 deletions.
227 changes: 227 additions & 0 deletions packages/shared/sdk-client/__tests__/HookRunner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common';

import { Hook, IdentifyResult } from '../src/api/integrations/Hooks';
import HookRunner from '../src/HookRunner';

describe('given a hook runner and test hook', () => {
let logger: LDLogger;
let testHook: Hook;
let hookRunner: HookRunner;

beforeEach(() => {
logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

testHook = {
getMetadata: jest.fn().mockReturnValue({ name: 'Test Hook' }),
beforeEvaluation: jest.fn(),
afterEvaluation: jest.fn(),
beforeIdentify: jest.fn(),
afterIdentify: jest.fn(),
};

hookRunner = new HookRunner(logger, [testHook]);
});

describe('when evaluating flags', () => {
it('should execute hooks and return the evaluation result', () => {
const key = 'test-flag';
const context: LDContext = { kind: 'user', key: 'user-123' };
const defaultValue = false;
const evaluationResult: LDEvaluationDetail = {
value: true,
variationIndex: 1,
reason: { kind: 'OFF' },
};

const method = jest.fn().mockReturnValue(evaluationResult);

const result = hookRunner.withEvaluation(key, context, defaultValue, method);

expect(testHook.beforeEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
flagKey: key,
context,
defaultValue,
}),
{},
);

expect(method).toHaveBeenCalled();

expect(testHook.afterEvaluation).toHaveBeenCalledWith(
expect.objectContaining({
flagKey: key,
context,
defaultValue,
}),
{},
evaluationResult,
);

expect(result).toEqual(evaluationResult);
});

it('should handle errors in hooks', () => {
const errorHook: Hook = {
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
beforeEvaluation: jest.fn().mockImplementation(() => {
throw new Error('Hook error');
}),
afterEvaluation: jest.fn(),
};

const errorHookRunner = new HookRunner(logger, [errorHook]);

const method = jest
.fn()
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });

errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);

expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
),
);
});

it('should skip hook execution if there are no hooks', () => {
const emptyHookRunner = new HookRunner(logger, []);
const method = jest
.fn()
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });

emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);

expect(method).toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
});

describe('when handling an identifcation', () => {
it('should execute identify hooks', () => {
const context: LDContext = { kind: 'user', key: 'user-123' };
const timeout = 10;
const identifyResult: IdentifyResult = 'completed';

const identifyCallback = hookRunner.identify(context, timeout);
identifyCallback(identifyResult);

expect(testHook.beforeIdentify).toHaveBeenCalledWith(
expect.objectContaining({
context,
timeout,
}),
{},
);

expect(testHook.afterIdentify).toHaveBeenCalledWith(
expect.objectContaining({
context,
timeout,
}),
{},
identifyResult,
);
});

it('should handle errors in identify hooks', () => {
const errorHook: Hook = {
getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }),
beforeIdentify: jest.fn().mockImplementation(() => {
throw new Error('Hook error');
}),
afterIdentify: jest.fn(),
};

const errorHookRunner = new HookRunner(logger, [errorHook]);

const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-123' }, 1000);
identifyCallback('error');

expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error',
),
);
});
});

it('should use the added hook in future invocations', () => {
const newHook: Hook = {
getMetadata: jest.fn().mockReturnValue({ name: 'New Hook' }),
beforeEvaluation: jest.fn(),
afterEvaluation: jest.fn(),
};

hookRunner.addHook(newHook);

const method = jest
.fn()
.mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } });

hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method);

expect(newHook.beforeEvaluation).toHaveBeenCalled();
expect(newHook.afterEvaluation).toHaveBeenCalled();
});

it('should log "unknown hook" when getMetadata throws an error', () => {
const errorHook: Hook = {
getMetadata: jest.fn().mockImplementation(() => {
throw new Error('Metadata error');
}),
beforeEvaluation: jest.fn().mockImplementation(() => {
throw new Error('Test error in beforeEvaluation');
}),
afterEvaluation: jest.fn(),
};

const errorHookRunner = new HookRunner(logger, [errorHook]);

errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
value: true,
variationIndex: 1,
reason: { kind: 'OFF' },
}));

expect(logger.error).toHaveBeenCalledWith(
'Exception thrown getting metadata for hook. Unable to get hook name.',
);

// Verify that the error was logged with the correct hook name
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation',
),
);
});

it('should log the correct hook name when an error occurs', () => {
// Modify the testHook to throw an error in beforeEvaluation
testHook.beforeEvaluation = jest.fn().mockImplementation(() => {
throw new Error('Test error in beforeEvaluation');
});

hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({
value: true,
variationIndex: 1,
reason: { kind: 'OFF' },
}));

// Verify that getMetadata was called to get the hook name
expect(testHook.getMetadata).toHaveBeenCalled();

// Verify that the error was logged with the correct hook name
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'An error was encountered in "beforeEvaluation" of the "Test Hook" hook: Error: Test error in beforeEvaluation',
),
);
});
});
164 changes: 164 additions & 0 deletions packages/shared/sdk-client/src/HookRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common';

import {
EvaluationSeriesContext,
EvaluationSeriesData,
Hook,
IdentifyResult,
IdentifySeriesContext,
IdentifySeriesData,
} 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';

function tryExecuteStage<TData>(
logger: LDLogger,
method: string,
hookName: string,
stage: () => TData,
def: TData,
): TData {
try {
return stage();
} catch (err) {
logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`);
return def;
}
}

function getHookName(logger: LDLogger, hook?: Hook): string {
try {
return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME;
} catch {
logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`);
return UNKNOWN_HOOK_NAME;
}
}

function executeBeforeEvaluation(
logger: LDLogger,
hooks: Hook[],
hookContext: EvaluationSeriesContext,
): EvaluationSeriesData[] {
return hooks.map((hook) =>
tryExecuteStage(
logger,
BEFORE_EVALUATION_STAGE_NAME,
getHookName(logger, hook),
() => hook?.beforeEvaluation?.(hookContext, {}) ?? {},
{},
),
);
}

function executeAfterEvaluation(
logger: LDLogger,
hooks: Hook[],
hookContext: EvaluationSeriesContext,
updatedData: (EvaluationSeriesData | undefined)[],
result: LDEvaluationDetail,
) {
// 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];
const data = updatedData[hookIndex] ?? {};
tryExecuteStage(
logger,
AFTER_EVALUATION_STAGE_NAME,
getHookName(logger, hook),
() => hook?.afterEvaluation?.(hookContext, data, result) ?? {},
{},
);
}
}

function executeBeforeIdentify(
logger: LDLogger,
hooks: Hook[],
hookContext: IdentifySeriesContext,
): IdentifySeriesData[] {
return hooks.map((hook) =>
tryExecuteStage(
logger,
BEFORE_EVALUATION_STAGE_NAME,
getHookName(logger, hook),
() => hook?.beforeIdentify?.(hookContext, {}) ?? {},
{},
),
);
}

function executeAfterIdentify(
logger: LDLogger,
hooks: Hook[],
hookContext: IdentifySeriesContext,
updatedData: (IdentifySeriesData | undefined)[],
result: IdentifyResult,
) {
// 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];
const data = updatedData[hookIndex] ?? {};
tryExecuteStage(
logger,
AFTER_EVALUATION_STAGE_NAME,
getHookName(logger, hook),
() => hook?.afterIdentify?.(hookContext, data, result) ?? {},
{},
);
}
}

export default class HookRunner {
private readonly hooks: Hook[] = [];

constructor(
private readonly logger: LDLogger,
initialHooks: Hook[],
) {
this.hooks.push(...initialHooks);
}

withEvaluation(
key: string,
context: LDContext | undefined,
defaultValue: unknown,
method: () => LDEvaluationDetail,
): LDEvaluationDetail {
if (this.hooks.length === 0) {
return method();
}
const hooks: Hook[] = [...this.hooks];
const hookContext: EvaluationSeriesContext = {
flagKey: key,
context,
defaultValue,
};

const hookData = executeBeforeEvaluation(this.logger, hooks, hookContext);
const result = method();
executeAfterEvaluation(this.logger, hooks, hookContext, hookData, result);
return result;
}

identify(context: LDContext, timeout: number | undefined): (result: IdentifyResult) => void {
const hooks: Hook[] = [...this.hooks];
const hookContext: IdentifySeriesContext = {
context,
timeout,
};
const hookData = executeBeforeIdentify(this.logger, hooks, hookContext);
return (result) => {
executeAfterIdentify(this.logger, hooks, hookContext, hookData, result);
};
}

addHook(hook: Hook): void {
this.hooks.push(hook);
}
}
Loading

0 comments on commit 160e7c7

Please sign in to comment.