From dced0b2713112e6c925dc748bf721b45d2fa8118 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:18:04 -0700 Subject: [PATCH 1/9] feat: Add support for afterTrack in hooks. --- src/HookRunner.js | 37 ++++++++++ src/__tests__/HookRunner-test.js | 118 +++++++++++++++++++++++++++++++ src/index.js | 1 + typings.d.ts | 32 +++++++++ 4 files changed, 188 insertions(+) diff --git a/src/HookRunner.js b/src/HookRunner.js index 3e314ad..571cf6e 100644 --- a/src/HookRunner.js +++ b/src/HookRunner.js @@ -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. @@ -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. @@ -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, }; } diff --git a/src/__tests__/HookRunner-test.js b/src/__tests__/HookRunner-test.js index fd1a099..b68d06d 100644 --- a/src/__tests__/HookRunner-test.js +++ b/src/__tests__/HookRunner-test.js @@ -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', () => { @@ -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']); + }); }); diff --git a/src/index.js b/src/index.js index 26327f9..339c55d 100644 --- a/src/index.js +++ b/src/index.js @@ -428,6 +428,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { e.metricValue = metricValue; } enqueueEvent(e); + hookRunner.afterTrack({ context, data, metricValue }); } function connectStream() { diff --git a/typings.d.ts b/typings.d.ts index 35a17d0..536fe5a 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -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. */ @@ -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; } /** From a0106d942f3bbc6161316c946fc0b2efc43f3f4c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:52:59 -0700 Subject: [PATCH 2/9] feat: Add support for plugins. --- src/__tests__/LDClient-plugins-test.js | 136 +++++++++++++++++++++++ src/configuration.js | 1 + src/index.js | 10 +- src/plugins.js | 62 +++++++++++ typings.d.ts | 146 +++++++++++++++++++++++++ 5 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/LDClient-plugins-test.js create mode 100644 src/plugins.js diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js new file mode 100644 index 0000000..48ec220 --- /dev/null +++ b/src/__tests__/LDClient-plugins-test.js @@ -0,0 +1,136 @@ +const { initialize } = require('../index'); +const stubPlatform = require('./stubPlatform'); +const { respondJson } = require('./mockHttp'); + +// Mock the logger functions +const mockLogger = () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}); + +// Define a basic Hook structure for tests +const createTestHook = (name = 'Test Hook') => ({ + getMetadata: jest.fn().mockReturnValue({ name }), + beforeEvaluation: jest.fn().mockImplementation((_ctx, data) => data), + afterEvaluation: jest.fn().mockImplementation((_ctx, data) => data), + beforeIdentify: jest.fn().mockImplementation((_ctx, data) => data), + afterIdentify: jest.fn().mockImplementation((_ctx, data) => data), + afterTrack: jest.fn().mockImplementation((_ctx, data) => data), +}); + +// Define a basic Plugin structure for tests +const createTestPlugin = (name = 'Test Plugin', hooks = []) => ({ + getMetadata: jest.fn().mockReturnValue({ name }), + register: jest.fn(), + getHooks: jest.fn().mockReturnValue(hooks), +}); + +// Helper to initialize the client for tests +async function withClient(initialContext, configOverrides = {}, plugins = [], testFn) { + const platform = stubPlatform.defaults(); + const server = platform.testing.http.newServer(); + const logger = mockLogger(); + + // Disable streaming and event sending unless overridden + const defaults = { + baseUrl: server.url, + streaming: false, + sendEvents: false, + useLdd: false, + logger: logger, + plugins: plugins, + }; + const config = { ...defaults, ...configOverrides }; + const { client, start } = initialize('env', initialContext, config, platform); + + server.byDefault(respondJson({})); + start(); + + try { + await client.waitForInitialization(10); + await testFn(client, logger, platform); + } finally { + await client.close(); + server.close(); + } +} + +it('registers plugins and executes hooks during initialization', async () => { + const mockHook = createTestHook('test-hook'); + const mockPlugin = createTestPlugin('test-plugin', [mockHook]); + + await withClient( + { key: 'user-key', kind: 'user' }, + {}, + [mockPlugin], + async (client) => { + // Verify the plugin was registered + expect(mockPlugin.register).toHaveBeenCalled(); + + // Test identify hook + await client.identify({ key: 'user-key', kind: 'user' }); + expect(mockHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {} + ); + expect(mockHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {}, + { status: 'completed' } + ); + + // Test variation hook + client.variation('flag-key', false); + expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( + { + context: { key: 'user-key', kind: 'user' }, + defaultValue: false, + flagKey: 'flag-key', + }, + {} + ); + expect(mockHook.afterEvaluation).toHaveBeenCalled(); + + // Test track hook + client.track('event-key', { data: true }, 42); + expect(mockHook.afterTrack).toHaveBeenCalledWith({ + context: { key: 'user-key', kind: 'user' }, + key: 'event-key', + data: { data: true }, + metricValue: 42, + }); + } + ); +}); + +it('registers multiple plugins and executes all hooks', async () => { + const mockHook1 = createTestHook('test-hook-1'); + const mockHook2 = createTestHook('test-hook-2'); + const mockPlugin1 = createTestPlugin('test-plugin-1', [mockHook1]); + const mockPlugin2 = createTestPlugin('test-plugin-2', [mockHook2]); + + await withClient( + { key: 'user-key', kind: 'user' }, + {}, + [mockPlugin1, mockPlugin2], + async (client) => { + // Verify plugins were registered + expect(mockPlugin1.register).toHaveBeenCalled(); + expect(mockPlugin2.register).toHaveBeenCalled(); + + // Test that both hooks work + await client.identify({ key: 'user-key', kind: 'user' }); + client.variation('flag-key', false); + client.track('event-key', { data: true }, 42); + + expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterEvaluation).toHaveBeenCalled(); + expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook2.afterEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterTrack).toHaveBeenCalled(); + expect(mockHook2.afterTrack).toHaveBeenCalled(); + } + ); +}); diff --git a/src/configuration.js b/src/configuration.js index 7907ddf..617f528 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -38,6 +38,7 @@ const baseOptionDefs = { application: { validator: applicationConfigValidator }, inspectors: { default: [] }, hooks: { default: [] }, + plugins: { default: [] }, }; /** diff --git a/src/index.js b/src/index.js index 26327f9..09438f2 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context'); const { InspectorTypes, InspectorManager } = require('./InspectorManager'); const timedPromise = require('./timedPromise'); const createHookRunner = require('./HookRunner'); - +const { getPluginHooks, registerPlugins } = require('./plugins'); const changeEvent = 'change'; const internalChangeEvent = 'internal-change'; const highTimeoutThreshold = 5; @@ -41,7 +41,11 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const sendEvents = options.sendEvents; let environment = env; let hash = options.hash; - const hookRunner = createHookRunner(logger, options.hooks); + const plugins = [...options.plugins]; + + const pluginHooks = getPluginHooks(logger, environment, plugins); + + const hookRunner = createHookRunner(logger, [...options.hooks, ...pluginHooks]); const persistentStorage = PersistentStorage(platform.localStorage, logger); @@ -871,6 +875,8 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { addHook: addHook, }; + registerPlugins(logger, environment, client, plugins); + return { client: client, // The client object containing all public methods. options: options, // The validated configuration object, including all defaults. diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 0000000..f665b5f --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,62 @@ +const UNKNOWN_PLUGIN_NAME = 'unknown plugin'; + +/** + * Safely gets the name of a plugin with error handling + * @param {{ error: (message: string) => void }} logger - The logger instance + * @param {{getMetadata: () => {name: string}}} plugin - Plugin object that may have a name property + * @returns {string} The plugin name or 'unknown' if not available + */ +function getPluginName(logger, plugin) { + try { + return plugin.getMetadata().name || UNKNOWN_PLUGIN_NAME; + } catch (error) { + logger.error(`Exception thrown getting metadata for plugin. Unable to get plugin name.`); + return UNKNOWN_PLUGIN_NAME; + } +} + +/** + * Safely retrieves hooks from plugins with error handling + * @param {Object} logger - The logger instance + * @param {Object} environmentMetadata - Metadata about the environment for plugin initialization + * @param {Array<{getHooks: (environmentMetadata: object) => Hook[]}>} plugins - Array of plugin objects that may implement getHooks + * @returns {Array} Array of hook objects collected from all plugins + */ +function getPluginHooks(logger, environmentMetadata, plugins) { + const hooks = []; + plugins.forEach(plugin => { + try { + const pluginHooks = plugin.getHooks?.(environmentMetadata); + if (pluginHooks === undefined) { + logger.error(`Plugin ${getPluginName(logger, plugin)} returned undefined from getHooks.`); + } else if (pluginHooks && pluginHooks.length > 0) { + hooks.push(...pluginHooks); + } + } catch (error) { + logger.error(`Exception thrown getting hooks for plugin ${getPluginName(logger, plugin)}. Unable to get hooks.`); + } + }); + return hooks; +} + +/** + * Registers plugins with the SDK + * @param {{ error: (message: string) => void }} logger - The logger instance + * @param {Object} environmentMetadata - Metadata about the environment for plugin initialization + * @param {Object} client - The SDK client instance + * @param {Array<{register: (client: object, environmentMetadata: object) => void}>} plugins - Array of plugin objects that implement register + */ +function registerPlugins(logger, environmentMetadata, client, plugins) { + plugins.forEach(plugin => { + try { + plugin.register(client, environmentMetadata); + } catch (error) { + logger.error(`Exception thrown registering plugin ${getPluginName(logger, plugin)}.`); + } + }); +} + +module.exports = { + getPluginHooks, + registerPlugins, +}; diff --git a/typings.d.ts b/typings.d.ts index 35a17d0..055d9e5 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -218,6 +218,147 @@ declare module 'launchdarkly-js-sdk-common' { ): IdentifySeriesData; } + /** + * Meta-data about a plugin implementation. + * + * May be used in logs and analytics to identify the plugin. + */ + export interface LDPluginMetadata { + /** + * The name of the plugin. + */ + readonly name: string; + } + + /** + * Metadata about the SDK that is running the plugin. + */ + export interface LDPluginSdkMetadata { + /** + * The name of the SDK. + */ + name: string; + + /** + * The version of the SDK. + */ + version: string; + + /** + * If this is a wrapper SDK, then this is the name of the wrapper. + */ + wrapperName?: string; + + /** + * If this is a wrapper SDK, then this is the version of the wrapper. + */ + wrapperVersion?: string; + } + + /** + * Metadata about the application where the LaunchDarkly SDK is running. + */ + export interface LDPluginApplicationMetadata { + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `authentication-service` + */ + id?: string; + + /** + * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) + */ + version?: string; + + /** + * A human-friendly application name representing the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + */ + name?: string; + + /** + * A human-friendly name representing the version of the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + */ + versionName?: string; + } + + /** + * Metadata about the environment where the plugin is running. + */ + export interface LDPluginEnvironmentMetadata { + /** + * Metadata about the SDK that is running the plugin. + */ + sdk: LDPluginSdkMetadata; + + /** + * Metadata about the application where the LaunchDarkly SDK is running. + * + * Only present if any application information is available. + */ + application?: LDPluginApplicationMetadata; + + /** + * Present if the SDK is a client-side SDK running in a web environment. + */ + clientSideId?: string; + + /** + * Present if the SDK is a client-side SDK running in a mobile environment. + */ + mobileKey?: string; + + /** + * Present if the SDK is a server-side SDK. + */ + sdkKey?: string; + } + +/** + * Interface for plugins to the LaunchDarkly SDK. + */ +export interface LDPlugin { + /** + * Get metadata about the plugin. + */ + getMetadata(): LDPluginMetadata; + + /** + * Registers the plugin with the SDK. Called once during SDK initialization. + * + * The SDK initialization will typically not have been completed at this point, so the plugin should take appropriate + * actions to ensure the SDK is ready before sending track events or evaluating flags. + * + * @param client The SDK client instance. + * @param environmentMetadata Information about the environment where the plugin is running. + */ + register(client: LDClientBase, environmentMetadata: LDPluginEnvironmentMetadata): void; + + /** + * Gets a list of hooks that the plugin wants to register. + * + * This method will be called once during SDK initialization before the register method is called. + * + * If the plugin does not need to register any hooks, this method doesn't need to be implemented. + * @param metadata + */ + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; +} + /** * LaunchDarkly initialization options that are supported by all variants of the JS client. * The browser SDK and Electron SDK may support additional options. @@ -474,6 +615,11 @@ declare module 'launchdarkly-js-sdk-common' { * ``` */ hooks?: Hook[]; + + /** + * A list of plugins to be used with the SDK. + */ + plugins?: LDPlugin[]; } /** From 0e282ff72f0a769138a53ade5cd86bcb37596f2c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:57:05 -0700 Subject: [PATCH 3/9] Test update and minor fix. --- src/__tests__/LDClient-hooks-test.js | 26 ++++++++++++++++++++++++++ src/index.js | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/__tests__/LDClient-hooks-test.js b/src/__tests__/LDClient-hooks-test.js index 43c3713..7f48238 100644 --- a/src/__tests__/LDClient-hooks-test.js +++ b/src/__tests__/LDClient-hooks-test.js @@ -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, + }); + }); + }); }); diff --git a/src/index.js b/src/index.js index 339c55d..e02c7ee 100644 --- a/src/index.js +++ b/src/index.js @@ -428,7 +428,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { e.metricValue = metricValue; } enqueueEvent(e); - hookRunner.afterTrack({ context, data, metricValue }); + hookRunner.afterTrack({ context, key, data, metricValue }); } function connectStream() { From 216fdd801b269323027ea272653c9af48817c882 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:59:18 -0700 Subject: [PATCH 4/9] Lint fix --- src/__tests__/LDClient-hooks-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/LDClient-hooks-test.js b/src/__tests__/LDClient-hooks-test.js index 7f48238..c707e75 100644 --- a/src/__tests__/LDClient-hooks-test.js +++ b/src/__tests__/LDClient-hooks-test.js @@ -242,7 +242,7 @@ describe('LDClient Hooks Integration', () => { 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' }, From 222892bf3b7df67feada1bb32ac5f25f93d0f7a7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 11:00:00 -0700 Subject: [PATCH 5/9] Lint --- src/__tests__/LDClient-plugins-test.js | 116 +++++++++++-------------- 1 file changed, 53 insertions(+), 63 deletions(-) diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js index 48ec220..5652b71 100644 --- a/src/__tests__/LDClient-plugins-test.js +++ b/src/__tests__/LDClient-plugins-test.js @@ -61,48 +61,43 @@ it('registers plugins and executes hooks during initialization', async () => { const mockHook = createTestHook('test-hook'); const mockPlugin = createTestPlugin('test-plugin', [mockHook]); - await withClient( - { key: 'user-key', kind: 'user' }, - {}, - [mockPlugin], - async (client) => { - // Verify the plugin was registered - expect(mockPlugin.register).toHaveBeenCalled(); - - // Test identify hook - await client.identify({ key: 'user-key', kind: 'user' }); - expect(mockHook.beforeIdentify).toHaveBeenCalledWith( - { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, - {} - ); - expect(mockHook.afterIdentify).toHaveBeenCalledWith( - { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, - {}, - { status: 'completed' } - ); - - // Test variation hook - client.variation('flag-key', false); - expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( - { - context: { key: 'user-key', kind: 'user' }, - defaultValue: false, - flagKey: 'flag-key', - }, - {} - ); - expect(mockHook.afterEvaluation).toHaveBeenCalled(); - - // Test track hook - client.track('event-key', { data: true }, 42); - expect(mockHook.afterTrack).toHaveBeenCalledWith({ + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin], async client => { + // Verify the plugin was registered + expect(mockPlugin.register).toHaveBeenCalled(); + + // Test identify hook + await client.identify({ key: 'user-key', kind: 'user' }); + expect(mockHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {} + ); + expect(mockHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, timeout: undefined }, + {}, + { status: 'completed' } + ); + + // Test variation hook + client.variation('flag-key', false); + expect(mockHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key', kind: 'user' }, - key: 'event-key', - data: { data: true }, - metricValue: 42, - }); - } - ); + defaultValue: false, + flagKey: 'flag-key', + }, + {} + ); + expect(mockHook.afterEvaluation).toHaveBeenCalled(); + + // Test track hook + client.track('event-key', { data: true }, 42); + expect(mockHook.afterTrack).toHaveBeenCalledWith({ + context: { key: 'user-key', kind: 'user' }, + key: 'event-key', + data: { data: true }, + metricValue: 42, + }); + }); }); it('registers multiple plugins and executes all hooks', async () => { @@ -111,26 +106,21 @@ it('registers multiple plugins and executes all hooks', async () => { const mockPlugin1 = createTestPlugin('test-plugin-1', [mockHook1]); const mockPlugin2 = createTestPlugin('test-plugin-2', [mockHook2]); - await withClient( - { key: 'user-key', kind: 'user' }, - {}, - [mockPlugin1, mockPlugin2], - async (client) => { - // Verify plugins were registered - expect(mockPlugin1.register).toHaveBeenCalled(); - expect(mockPlugin2.register).toHaveBeenCalled(); - - // Test that both hooks work - await client.identify({ key: 'user-key', kind: 'user' }); - client.variation('flag-key', false); - client.track('event-key', { data: true }, 42); - - expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); - expect(mockHook1.afterEvaluation).toHaveBeenCalled(); - expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); - expect(mockHook2.afterEvaluation).toHaveBeenCalled(); - expect(mockHook1.afterTrack).toHaveBeenCalled(); - expect(mockHook2.afterTrack).toHaveBeenCalled(); - } - ); + await withClient({ key: 'user-key', kind: 'user' }, {}, [mockPlugin1, mockPlugin2], async client => { + // Verify plugins were registered + expect(mockPlugin1.register).toHaveBeenCalled(); + expect(mockPlugin2.register).toHaveBeenCalled(); + + // Test that both hooks work + await client.identify({ key: 'user-key', kind: 'user' }); + client.variation('flag-key', false); + client.track('event-key', { data: true }, 42); + + expect(mockHook1.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterEvaluation).toHaveBeenCalled(); + expect(mockHook2.beforeEvaluation).toHaveBeenCalled(); + expect(mockHook2.afterEvaluation).toHaveBeenCalled(); + expect(mockHook1.afterTrack).toHaveBeenCalled(); + expect(mockHook2.afterTrack).toHaveBeenCalled(); + }); }); From 55b6f3de93243f171d2e4381c37dbd6698de8f65 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:30:06 -0700 Subject: [PATCH 6/9] Add environment data and test. --- src/__tests__/LDClient-plugins-test.js | 88 ++++++++++++++++++++++++++ src/index.js | 8 ++- src/plugins.js | 47 ++++++++++++++ typings.d.ts | 30 +-------- 4 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/__tests__/LDClient-plugins-test.js b/src/__tests__/LDClient-plugins-test.js index 5652b71..fcc690b 100644 --- a/src/__tests__/LDClient-plugins-test.js +++ b/src/__tests__/LDClient-plugins-test.js @@ -124,3 +124,91 @@ it('registers multiple plugins and executes all hooks', async () => { expect(mockHook2.afterTrack).toHaveBeenCalled(); }); }); + +it('passes correct environmentMetadata to plugin getHooks and register functions', async () => { + const mockPlugin = createTestPlugin('test-plugin'); + const options = { + wrapperName: 'test-wrapper', + wrapperVersion: '2.0.0', + application: { + name: 'test-app', + version: '3.0.0', + }, + }; + + await withClient( + { key: 'user-key', kind: 'user' }, + { ...options, plugins: [mockPlugin] }, + [mockPlugin], + async (client, logger, testPlatform) => { + expect(testPlatform.userAgent).toBeDefined(); + expect(testPlatform.version).toBeDefined(); + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application.id, + version: options.application.version, + }, + clientSideId: 'env', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + wrapperName: options.wrapperName, + wrapperVersion: options.wrapperVersion, + }, + application: { + id: options.application.id, + version: options.application.version, + }, + clientSideId: 'env', + } + ); + } + ); +}); + +it('passes correct environmentMetadata without optional fields', async () => { + const mockPlugin = createTestPlugin('test-plugin'); + + await withClient( + { key: 'user-key', kind: 'user' }, + { plugins: [mockPlugin] }, + [mockPlugin], + async (client, logger, testPlatform) => { + expect(testPlatform.userAgent).toBeDefined(); + expect(testPlatform.version).toBeDefined(); + // Verify getHooks was called with correct environmentMetadata + expect(mockPlugin.getHooks).toHaveBeenCalledWith({ + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + }, + clientSideId: 'env', + }); + + // Verify register was called with correct environmentMetadata + expect(mockPlugin.register).toHaveBeenCalledWith( + expect.any(Object), // client + { + sdk: { + name: testPlatform.userAgent, + version: testPlatform.version, + }, + clientSideId: 'env', + } + ); + } + ); +}); diff --git a/src/index.js b/src/index.js index b59d9f3..86d6752 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,7 @@ const { checkContext, getContextKeys } = require('./context'); const { InspectorTypes, InspectorManager } = require('./InspectorManager'); const timedPromise = require('./timedPromise'); const createHookRunner = require('./HookRunner'); -const { getPluginHooks, registerPlugins } = require('./plugins'); +const { getPluginHooks, registerPlugins, createPluginEnvironment } = require('./plugins'); const changeEvent = 'change'; const internalChangeEvent = 'internal-change'; const highTimeoutThreshold = 5; @@ -43,7 +43,9 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { let hash = options.hash; const plugins = [...options.plugins]; - const pluginHooks = getPluginHooks(logger, environment, plugins); + const pluginEnvironment = createPluginEnvironment(platform, env, options); + + const pluginHooks = getPluginHooks(logger, pluginEnvironment, plugins); const hookRunner = createHookRunner(logger, [...options.hooks, ...pluginHooks]); @@ -876,7 +878,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { addHook: addHook, }; - registerPlugins(logger, environment, client, plugins); + registerPlugins(logger, pluginEnvironment, client, plugins); return { client: client, // The client object containing all public methods. diff --git a/src/plugins.js b/src/plugins.js index f665b5f..561635b 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -56,7 +56,54 @@ function registerPlugins(logger, environmentMetadata, client, plugins) { }); } +/** + * Creates a plugin environment object + * @param {{userAgent: string, version: string}} platform - The platform object + * @param {string} env - The environment + * @param {{application: {name: string, version: string}, wrapperName: string, wrapperVersion: string}} options - The options + * @returns {{sdk: {name: string, version: string, wrapperName: string, wrapperVersion: string}, application: {name: string, version: string}, clientSideId: string}} The plugin environment + */ +function createPluginEnvironment(platform, env, options) { + const pluginSdkMetadata = {}; + + if (platform.userAgent) { + pluginSdkMetadata.name = platform.userAgent; + } + if (platform.version) { + pluginSdkMetadata.version = platform.version; + } + if (options.wrapperName) { + pluginSdkMetadata.wrapperName = options.wrapperName; + } + if (options.wrapperVersion) { + pluginSdkMetadata.wrapperVersion = options.wrapperVersion; + } + + const pluginApplicationMetadata = {}; + + if (options.application) { + if (options.application.name) { + pluginApplicationMetadata.name = options.application.name; + } + if (options.application.version) { + pluginApplicationMetadata.version = options.application.version; + } + } + + const pluginEnvironment = { + sdk: pluginSdkMetadata, + clientSideId: env, + }; + + if (Object.keys(pluginApplicationMetadata).length > 0) { + pluginEnvironment.application = pluginApplicationMetadata; + } + + return pluginEnvironment; +} + module.exports = { getPluginHooks, registerPlugins, + createPluginEnvironment, }; diff --git a/typings.d.ts b/typings.d.ts index b579647..4f784dd 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -310,22 +310,6 @@ declare module 'launchdarkly-js-sdk-common' { * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) */ version?: string; - - /** - * A human-friendly application name representing the application where the LaunchDarkly SDK is running. - * - * This can be specified as any string value as long as it only uses the following characters: ASCII letters, - * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. - */ - name?: string; - - /** - * A human-friendly name representing the version of the application where the LaunchDarkly SDK is running. - * - * This can be specified as any string value as long as it only uses the following characters: ASCII letters, - * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. - */ - versionName?: string; } /** @@ -345,19 +329,9 @@ declare module 'launchdarkly-js-sdk-common' { application?: LDPluginApplicationMetadata; /** - * Present if the SDK is a client-side SDK running in a web environment. - */ - clientSideId?: string; - - /** - * Present if the SDK is a client-side SDK running in a mobile environment. - */ - mobileKey?: string; - - /** - * Present if the SDK is a server-side SDK. + * The client-side ID used to initialize the SDK. */ - sdkKey?: string; + clientSideId: string; } /** From 867406564ad79df176a43c5de3aaf7719a9c3262 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:02:34 -0700 Subject: [PATCH 7/9] Annotate types with readonly. --- typings.d.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/typings.d.ts b/typings.d.ts index 4f784dd..97826a7 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -269,22 +269,22 @@ declare module 'launchdarkly-js-sdk-common' { /** * The name of the SDK. */ - name: string; + readonly name: string; /** * The version of the SDK. */ - version: string; + readonly version: string; /** * If this is a wrapper SDK, then this is the name of the wrapper. */ - wrapperName?: string; + readonly wrapperName?: string; /** * If this is a wrapper SDK, then this is the version of the wrapper. */ - wrapperVersion?: string; + readonly wrapperVersion?: string; } /** @@ -299,7 +299,7 @@ declare module 'launchdarkly-js-sdk-common' { * * Example: `authentication-service` */ - id?: string; + readonly id?: string; /** * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. @@ -309,7 +309,7 @@ declare module 'launchdarkly-js-sdk-common' { * * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) */ - version?: string; + readonly version?: string; } /** @@ -319,19 +319,19 @@ declare module 'launchdarkly-js-sdk-common' { /** * Metadata about the SDK that is running the plugin. */ - sdk: LDPluginSdkMetadata; + readonly sdk: LDPluginSdkMetadata; /** * Metadata about the application where the LaunchDarkly SDK is running. * * Only present if any application information is available. */ - application?: LDPluginApplicationMetadata; + readonly application?: LDPluginApplicationMetadata; /** * The client-side ID used to initialize the SDK. */ - clientSideId: string; + readonly clientSideId: string; } /** From 02a570b1159181a73c049ccf32082feb06f01390 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:06:58 -0700 Subject: [PATCH 8/9] Add typescript tests. --- test-types.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test-types.ts b/test-types.ts index 956fb52..b27906f 100644 --- a/test-types.ts +++ b/test-types.ts @@ -27,6 +27,40 @@ var user: ld.LDContext = { }, privateAttributeNames: [ 'name', 'email' ] }; +const hook: ld.Hook = { + getMetadata: () => ({ + name: 'hook', + }), + + beforeEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData): ld.EvaluationSeriesData { + return data; + }, + afterEvaluation(hookContext: ld.EvaluationSeriesContext, data: ld.EvaluationSeriesData, detail: ld.LDEvaluationDetail): ld.EvaluationSeriesData { + return data; + }, + beforeIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData): ld.IdentifySeriesData { + return data; + }, + afterIdentify(hookContext: ld.IdentifySeriesContext, data: ld.IdentifySeriesData, result: ld.IdentifySeriesResult): ld.IdentifySeriesData { + return data; + }, + + afterTrack(hookContext: ld.TrackSeriesContext): void { + } +}; + +const plugin: ld.LDPlugin = { + getMetadata: () => ({ + name: 'plugin', + }), + register(client: ld.LDClientBase, environmentMetadata: ld.LDPluginEnvironmentMetadata): void { + }, + + getHooks(metadata: ld.LDPluginEnvironmentMetadata): ld.Hook[] { + return []; + }, +}; + var logger: ld.LDLogger = ld.commonBasicLogger({ level: 'info' }); var allBaseOptions: ld.LDOptionsBase = { bootstrap: { }, @@ -48,7 +82,9 @@ var allBaseOptions: ld.LDOptionsBase = { application: { version: 'version', id: 'id' - } + }, + hooks: [ hook ], + plugins: [ plugin ] }; var client: ld.LDClientBase = {} as ld.LDClientBase; // wouldn't do this in real life, it's just so the following statements will compile From 963f23ac3b3b9889e501eec6d8733b694f0c5e86 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:36:47 -0700 Subject: [PATCH 9/9] Add experimental notice. --- typings.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typings.d.ts b/typings.d.ts index 97826a7..ba882f1 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -624,6 +624,8 @@ export interface LDPlugin { /** * A list of plugins to be used with the SDK. + * + * Plugin support is currently experimental and subject to change. */ plugins?: LDPlugin[]; }