Skip to content

feat: Add hook support for the track series. #827

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 22, 2025
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
46 changes: 46 additions & 0 deletions packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,49 @@ it('should not execute hooks for prerequisite evaluations', async () => {
},
);
});

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

const platform = createBasicPlatform();
const factory = makeTestDataManagerFactory('sdk-key', platform, {
disableNetwork: true,
});
const client = new LDClientImpl(
'sdk-key',
AutoEnvAttributes.Disabled,
platform,
{
sendEvents: false,
hooks: [testHook],
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
},
factory,
);

await client.identify({ kind: 'user', key: 'user-key' });
client.track('test', { test: 'data' }, 42);

expect(testHook.afterTrack).toHaveBeenCalledWith({
key: 'test',
context: { kind: 'user', key: 'user-key' },
data: { test: 'data' },
metricValue: 42,
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common';

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

describe('given a hook runner and test hook', () => {
let logger: LDLogger;
Expand All @@ -22,6 +22,7 @@ describe('given a hook runner and test hook', () => {
afterEvaluation: jest.fn(),
beforeIdentify: jest.fn(),
afterIdentify: jest.fn(),
afterTrack: jest.fn(),
};

hookRunner = new HookRunner(logger, [testHook]);
Expand Down Expand Up @@ -301,4 +302,125 @@ describe('given a hook runner and test hook', () => {
),
);
});

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

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

testHook.afterTrack = jest.fn();

hookRunner.afterTrack(trackContext);

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

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

const errorHookRunner = new HookRunner(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 = new HookRunner(logger, []);

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

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

it('executes hook stages in the specified order', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was missing from the client-side implementation previously. Added it and extended it for track.

const beforeEvalOrder: string[] = [];
const afterEvalOrder: string[] = [];
const beforeIdentifyOrder: string[] = [];
const afterIdentifyOrder: string[] = [];
const afterTrackOrder: string[] = [];

const createMockHook = (id: string): Hook => ({
getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }),
beforeEvaluation: jest.fn().mockImplementation((_context, data) => {
beforeEvalOrder.push(id);
return data;
}),
afterEvaluation: jest.fn().mockImplementation((_context, data, _detail) => {
afterEvalOrder.push(id);
return data;
}),
beforeIdentify: jest.fn().mockImplementation((_context, data) => {
beforeIdentifyOrder.push(id);
return data;
}),
afterIdentify: jest.fn().mockImplementation((_context, data, _result) => {
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 = new HookRunner(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']);
});
});
25 changes: 25 additions & 0 deletions packages/shared/sdk-client/src/HookRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
IdentifySeriesContext,
IdentifySeriesData,
IdentifySeriesResult,
TrackSeriesContext,
} 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';
const AFTER_TRACK_STAGE_NAME = 'afterTrack';

function tryExecuteStage<TData>(
logger: LDLogger,
Expand Down Expand Up @@ -114,6 +116,21 @@ function executeAfterIdentify(
}
}

function executeAfterTrack(logger: LDLogger, hooks: Hook[], hookContext: TrackSeriesContext) {
// 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,
);
}
}

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

Expand Down Expand Up @@ -164,4 +181,12 @@ export default class HookRunner {
addHook(hook: Hook): void {
this._hooks.push(hook);
}

afterTrack(hookContext: TrackSeriesContext): void {
if (this._hooks.length === 0) {
return;
}
const hooks: Hook[] = [...this._hooks];
executeAfterTrack(this._logger, hooks, hookContext);
}
}
8 changes: 8 additions & 0 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ export default class LDClientImpl implements LDClient {
this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue),
),
);

this._hookRunner.afterTrack({
key,
// The context is pre-checked above, so we know it can be unwrapped.
context: this._uncheckedContext!,
data,
metricValue,
});
}

private _variationInternal(
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/sdk-client/src/api/integrations/Hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,28 @@ export interface IdentifySeriesResult {
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 @@ -178,4 +200,13 @@ export interface Hook {
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;
}