Skip to content
Closed
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 @@ -4,6 +4,7 @@ const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation';
const BEFORE_IDENTIFY_STAGE_NAME = 'beforeIdentify';
const AFTER_IDENTIFY_STAGE_NAME = 'afterIdentify';
const AFTER_TRACK_STAGE_NAME = 'afterTrack';
const AFTER_ENQUEUE_STAGE_NAME = 'afterEventEnqueue';

/**
* Safely executes a hook stage function, logging any errors.
Expand Down Expand Up @@ -148,6 +149,28 @@ function executeAfterTrack(logger, hooks, hookContext) {
}
}

/**
* Executes the 'afterEventEnqueue' stage for all registered hooks in reverse order.
* @param {{ error: (message: string) => void }} logger The logger instance.
* @param {Array<{ afterEventEnqueue?: (hookContext: object) => void }>} hooks The array of hook instances.
* @param {object} hookContext The full event object that was enqueued.
* @returns {void}
*/
function executeAfterEventEnqueue(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_ENQUEUE_STAGE_NAME,
getHookName(logger, hook),
() => hook?.afterEventEnqueue?.(hookContext),
undefined
);
}
}

/**
* Factory function to create a HookRunner instance.
* Manages the execution of hooks for flag evaluations and identify operations.
Expand Down Expand Up @@ -239,11 +262,25 @@ function createHookRunner(logger, initialHooks) {
executeAfterTrack(logger, hooks, hookContext);
}

/**
* Executes the 'afterEventEnqueue' stage for all registered hooks in reverse order.
* @param {object} hookContext The full event object that was enqueued.
* @returns {void}
*/
function afterEventEnqueue(hookContext) {
if (hooksInternal.length === 0) {
return;
}
const hooks = [...hooksInternal];
executeAfterEventEnqueue(logger, hooks, hookContext);
}

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

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

describe('Given a logger, runner, and hook', () => {
Expand Down Expand Up @@ -379,12 +380,114 @@ describe('Given a logger, runner, and hook', () => {
expect(logger.error).not.toHaveBeenCalled();
});

it('should execute afterEventEnqueue hooks', () => {
const context = { kind: 'user', key: 'user-123' };
const event = {
kind: 'feature',
key: 'test-flag',
context,
value: true,
variation: 1,
default: false,
creationDate: new Date().getTime(),
version: 42,
trackEvents: true,
};

hookRunner.afterEventEnqueue(event);

expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(event);
});

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

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

errorHookRunner.afterEventEnqueue({
kind: 'custom',
key: 'test-event',
context: { kind: 'user', key: 'user-123' },
creationDate: new Date().getTime(),
});

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

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

emptyHookRunner.afterEventEnqueue({
kind: 'identify',
context: { kind: 'user', key: 'user-123' },
creationDate: new Date().getTime(),
});

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

it('should execute afterEventEnqueue hooks for different event types', () => {
const context = { kind: 'user', key: 'user-123' };
const creationDate = new Date().getTime();

// Test feature event
const featureEvent = {
kind: 'feature',
key: 'test-flag',
context,
value: true,
variation: 1,
default: false,
creationDate,
version: 42,
};

hookRunner.afterEventEnqueue(featureEvent);
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(featureEvent);

// Test custom event
const customEvent = {
kind: 'custom',
key: 'test-event',
context,
data: { custom: 'data' },
metricValue: 123,
creationDate,
url: 'https://example.com',
};

hookRunner.afterEventEnqueue(customEvent);
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(customEvent);

// Test identify event
const identifyEvent = {
kind: 'identify',
context,
creationDate,
};

hookRunner.afterEventEnqueue(identifyEvent);
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(identifyEvent);

expect(testHook.afterEventEnqueue).toHaveBeenCalledTimes(3);
});

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

const createMockHook = id => ({
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
Expand All @@ -407,6 +510,9 @@ describe('Given a logger, runner, and hook', () => {
afterTrack: jest.fn().mockImplementation(() => {
afterTrackOrder.push(id);
}),
afterEventEnqueue: jest.fn().mockImplementation(() => {
afterEventEnqueueOrder.push(id);
}),
});

const hookA = createMockHook('a');
Expand Down Expand Up @@ -435,6 +541,14 @@ describe('Given a logger, runner, and hook', () => {
metricValue: 42,
});

// Test event enqueue order
runner.afterEventEnqueue({
kind: 'custom',
key: 'test-event',
context: { kind: 'user', key: 'bob' },
creationDate: new Date().getTime(),
});

// Verify evaluation hooks order
expect(beforeEvalOrder).toEqual(['a', 'b', 'c']);
expect(afterEvalOrder).toEqual(['c', 'b', 'a']);
Expand All @@ -445,5 +559,8 @@ describe('Given a logger, runner, and hook', () => {

// Verify track hooks order
expect(afterTrackOrder).toEqual(['c', 'b', 'a']);

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

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

await withClient(initialContext, { sendEvents: true }, [testHook], async client => {
// Track a custom event which should trigger afterEventEnqueue
client.track('test-event', { test: 'data' }, 42);

// Evaluate a flag which should trigger afterEventEnqueue for the feature event
client.variation('test-flag', false);

// Check that afterEventEnqueue was called for both events
expect(testHook.afterEventEnqueue).toHaveBeenCalledTimes(3); // identify + custom + feature events

// Verify the custom event
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'custom',
key: 'test-event',
context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }),
data: { test: 'data' },
metricValue: 42,
})
);

// Verify the feature event
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'feature',
key: 'test-flag',
context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }),
value: false,
default: false,
})
);

// Verify the identify event (from initialization)
expect(testHook.afterEventEnqueue).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'identify',
context: expect.objectContaining({ kind: 'user', key: 'user-key-initial' }),
})
);
});
});
});
1 change: 1 addition & 0 deletions src/__tests__/LDClient-plugins-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const createTestHook = (name = 'Test Hook') => ({
beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data),
afterIdentify: jest.fn().mockImplementation((_ctx, data) => data),
afterTrack: jest.fn().mockImplementation((_ctx, data) => data),
afterEventEnqueue: jest.fn(),
});

// Define a basic Plugin structure for tests
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const highTimeoutThreshold = 5;
// For definitions of the API in the platform object, see stubPlatform.js in the test code.

function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
console.log('🟢 LOCAL JS COMMON SDK 5.7.1 - LINKED VERSION ACTIVE 🟢');
const logger = createLogger();
const emitter = EventEmitter(logger);
const initializationStateTracker = InitializationStateTracker(emitter);
Expand Down Expand Up @@ -171,6 +172,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) {
if (shouldEnqueueEvent()) {
logger.debug(messages.debugEnqueueingEvent(event.kind));
events.enqueue(event);
hookRunner.afterEventEnqueue(event);
}
}

Expand Down
Loading