Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/HookRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation';
const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
const AFTER_TRACK_STAGE_NAME = 'afterTrack';

/**
* Safely executes a hook stage function, logging any errors.
Expand Down Expand Up @@ -125,6 +126,28 @@ function executeAfterIdentify(logger, hooks, hookContext, updatedData, result) {
}
}

/**
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
* @param {{ error: (message: string) => void }} logger The logger instance.
* @param {Array<{ afterTrack?: (hookContext: { context: object, data: object, metricValue: number }) => void }>} hooks The array of hook instances.
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
* @returns {void}
*/
function executeAfterTrack(logger, hooks, hookContext) {
// 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];
tryExecuteStage(
logger,
AFTER_TRACK_STAGE_NAME,
getHookName(logger, hook),
() => hook?.afterTrack?.(hookContext),
undefined
);
}
}

/**
* Factory function to create a HookRunner instance.
* Manages the execution of hooks for flag evaluations and identify operations.
Expand Down Expand Up @@ -203,10 +226,24 @@ function createHookRunner(logger, initialHooks) {
hooksInternal.push(hook);
}

/**
* Executes the 'afterTrack' stage for all registered hooks in reverse order.
* @param {{ context: object, data: object, metricValue: number }} hookContext The context for the track operation.
* @returns {void}
*/
function afterTrack(hookContext) {
if (hooksInternal.length === 0) {
return;
}
const hooks = [...hooksInternal];
executeAfterTrack(logger, hooks, hookContext);
}

return {
withEvaluation,
identify,
addHook,
afterTrack,
};
}

Expand Down
118 changes: 118 additions & 0 deletions src/__tests__/HookRunner-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const createTestHook = (name = 'Test Hook') => ({
afterEvaluation: jest.fn(),
beforeIdentify: jest.fn(),
afterIdentify: jest.fn(),
afterTrack: jest.fn(),
});

describe('Given a logger, runner, and hook', () => {
Expand Down Expand Up @@ -328,4 +329,121 @@ describe('Given a logger, runner, and hook', () => {
`An error was encountered in "beforeEvaluation" of the "${hookName}" hook: Error: Specific test error`
);
});

it('should execute afterTrack hooks', () => {
const context = { kind: 'user', key: 'user-123' };
const key = 'test';
const data = { test: 'data' };
const metricValue = 42;

const trackContext = {
key,
context,
data,
metricValue,
};

hookRunner.afterTrack(trackContext);

expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext);
});

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

const errorHookRunner = createHookRunner(logger, [errorHook]);

errorHookRunner.afterTrack({
key: 'test',
context: { kind: 'user', key: 'user-123' },
});

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

it('should skip afterTrack execution if there are no hooks', () => {
const emptyHookRunner = createHookRunner(logger, []);

emptyHookRunner.afterTrack({
key: 'test',
context: { kind: 'user', key: 'user-123' },
});

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

it('executes hook stages in the specified order', () => {
const beforeEvalOrder = [];
const afterEvalOrder = [];
const beforeIdentifyOrder = [];
const afterIdentifyOrder = [];
const afterTrackOrder = [];

const createMockHook = id => ({
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
beforeEvaluation: jest.fn().mockImplementation((_context, data) => {
beforeEvalOrder.push(id);
return data;
}),
afterEvaluation: jest.fn().mockImplementation((_context, data) => {
afterEvalOrder.push(id);
return data;
}),
beforeIdentify: jest.fn().mockImplementation((_context, data) => {
beforeIdentifyOrder.push(id);
return data;
}),
afterIdentify: jest.fn().mockImplementation((_context, data) => {
afterIdentifyOrder.push(id);
return data;
}),
afterTrack: jest.fn().mockImplementation(() => {
afterTrackOrder.push(id);
}),
});

const hookA = createMockHook('a');
const hookB = createMockHook('b');
const hookC = createMockHook('c');

const runner = createHookRunner(logger, [hookA, hookB]);
runner.addHook(hookC);

// Test evaluation order
runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({
value: false,
reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' },
variationIndex: null,
}));

// Test identify order
const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000);
identifyCallback({ status: 'completed' });

// Test track order
runner.afterTrack({
key: 'test',
context: { kind: 'user', key: 'bob' },
data: { test: 'data' },
metricValue: 42,
});

// Verify evaluation hooks order
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);

// Verify identify hooks order
expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']);
expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']);

// Verify track hooks order
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);
});
});
26 changes: 26 additions & 0 deletions src/__tests__/LDClient-hooks-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,30 @@ describe('LDClient Hooks Integration', () => {
});
});
});

it('should execute afterTrack hooks when tracking events', async () => {
const testHook = {
beforeEvaluation: jest.fn(),
afterEvaluation: jest.fn(),
beforeIdentify: jest.fn(),
afterIdentify: jest.fn(),
afterTrack: jest.fn(),
getMetadata() {
return {
name: 'test hook',
};
},
};

await withClient(initialContext, {}, [testHook], async client => {
client.track('test', { test: 'data' }, 42);

expect(testHook.afterTrack).toHaveBeenCalledWith({
key: 'test',
context: { kind: 'user', key: 'user-key-initial' },
data: { test: 'data' },
metricValue: 42,
});
});
});
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
e.metricValue = metricValue;
}
enqueueEvent(e);
hookRunner.afterTrack({ context, key, data, metricValue });
}

function connectStream() {
Expand Down
32 changes: 32 additions & 0 deletions typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ declare module 'launchdarkly-js-sdk-common' {
status: IdentifySeriesStatus;
}

/**
* Contextual information provided to track stages.
*/
export interface TrackSeriesContext {
/**
* The key for the event being tracked.
*/
readonly key: string;
/**
* The context associated with the track operation.
*/
readonly context: LDContext;
/**
* The data associated with the track operation.
*/
readonly data?: unknown;
/**
* The metric value associated with the track operation.
*/
readonly metricValue?: number;
}


/**
* Interface for extending SDK functionality via hooks.
*/
Expand Down Expand Up @@ -216,6 +239,15 @@ declare module 'launchdarkly-js-sdk-common' {
data: IdentifySeriesData,
result: IdentifySeriesResult,
): IdentifySeriesData;

/**
* This method is called during the execution of the track process after the event
* has been enqueued.
*
* @param hookContext Contains information about the track operation being performed. This is not
* mutable.
*/
afterTrack?(hookContext: TrackSeriesContext): void;
}

/**
Expand Down