From 6bf4603afe92d1728949bcb6fd4913e2c94d9555 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:51:21 -0700 Subject: [PATCH 1/6] feat: Implement support for hooks. (#400) Adds hook support. More than 50% of the line count is unit testing and extending contract test support. I could potentially re-organize to simplify the review if needed. --- contract-tests/TestHook.js | 44 ++ contract-tests/index.js | 1 + contract-tests/sdkClientEntity.js | 7 + .../__tests__/LDClient.hooks.test.ts | 615 ++++++++++++++++++ .../__tests__/options/Configuration.test.ts | 15 + .../shared/sdk-server/src/LDClientImpl.ts | 453 +++++++++---- .../shared/sdk-server/src/api/LDClient.ts | 11 + packages/shared/sdk-server/src/api/index.ts | 3 + .../sdk-server/src/api/integrations/Hook.ts | 80 +++ .../sdk-server/src/api/integrations/index.ts | 1 + .../sdk-server/src/api/options/LDOptions.ts | 20 + .../sdk-server/src/integrations/index.ts | 3 + .../sdk-server/src/options/Configuration.ts | 6 + .../src/options/ValidatedOptions.ts | 2 + 14 files changed, 1147 insertions(+), 114 deletions(-) create mode 100644 contract-tests/TestHook.js create mode 100644 packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts create mode 100644 packages/shared/sdk-server/src/api/integrations/Hook.ts diff --git a/contract-tests/TestHook.js b/contract-tests/TestHook.js new file mode 100644 index 000000000..9cb2274f3 --- /dev/null +++ b/contract-tests/TestHook.js @@ -0,0 +1,44 @@ +import got from 'got'; + +export default class TestHook { + constructor(name, endpoint, data) { + this._name = name; + this._endpoint = endpoint; + this._data = data; + } + + async _safePost(body) { + try { + await got.post(this._endpoint, { json: body }); + } catch { + // The test could move on before the post, so we are ignoring + // failed posts. + } + } + + getMetadata() { + return { + name: 'LaunchDarkly Tracing Hook', + }; + } + + beforeEvaluation(hookContext, data) { + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'beforeEvaluation', + }); + return { ...data, ...(this._data?.['beforeEvaluation'] || {}) }; + } + + afterEvaluation(hookContext, data, detail) { + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'afterEvaluation', + evaluationDetail: detail, + }); + + return { ...data, ...(this._data?.['afterEvaluation'] || {}) }; + } +} diff --git a/contract-tests/index.js b/contract-tests/index.js index 4891ff393..5df159619 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -34,6 +34,7 @@ app.get('/', (req, res) => { 'polling-gzip', 'inline-context', 'anonymous-redaction', + 'evaluation-hooks', ], }); }); diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index c9a1556f3..c3891fad5 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -10,6 +10,7 @@ import ld, { import BigSegmentTestStore from './BigSegmentTestStore.js'; import { Log, sdkLogger } from './log.js'; +import TestHook from './TestHook.js'; const badCommandError = new Error('unsupported command'); export { badCommandError }; @@ -19,6 +20,7 @@ export function makeSdkConfig(options, tag) { logger: sdkLogger(tag), diagnosticOptOut: true, }; + const maybeTime = (seconds) => seconds === undefined || seconds === null ? undefined : seconds / 1000; if (options.streaming) { @@ -60,6 +62,11 @@ export function makeSdkConfig(options, tag) { : undefined, }; } + if (options.hooks) { + cf.hooks = options.hooks.hooks.map( + (hook) => new TestHook(hook.name, hook.callbackUri, hook.data), + ); + } return cf; } diff --git a/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts new file mode 100644 index 000000000..b69de7083 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts @@ -0,0 +1,615 @@ +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import { integrations, LDClientImpl, LDEvaluationDetail, LDMigrationStage } from '../src'; +import Reasons from '../src/evaluation/Reasons'; +import TestData from '../src/integrations/test_data/TestData'; +import TestLogger, { LogLevel } from './Logger'; +import makeCallbacks from './makeCallbacks'; + +const defaultUser = { kind: 'user', key: 'user-key' }; + +type EvalCapture = { + method: string; + hookContext: integrations.EvaluationSeriesContext; + hookData: integrations.EvaluationSeriesData; + detail?: LDEvaluationDetail; +}; + +class TestHook implements integrations.Hook { + captureBefore: EvalCapture[] = []; + captureAfter: EvalCapture[] = []; + + getMetadataImpl: () => integrations.HookMetadata = () => ({ name: 'LaunchDarkly Test Hook' }); + + getMetadata(): integrations.HookMetadata { + return this.getMetadataImpl(); + } + + verifyBefore( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) { + expect(this.captureBefore).toHaveLength(1); + expect(this.captureBefore[0].hookContext).toEqual(hookContext); + expect(this.captureBefore[0].hookData).toEqual(data); + } + + verifyAfter( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ) { + expect(this.captureAfter).toHaveLength(1); + expect(this.captureAfter[0].hookContext).toEqual(hookContext); + expect(this.captureAfter[0].hookData).toEqual(data); + expect(this.captureAfter[0].detail).toEqual(detail); + } + + beforeEvalImpl: ( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) => integrations.EvaluationSeriesData = (_hookContext, data) => data; + + afterEvalImpl: ( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ) => integrations.EvaluationSeriesData = (_hookContext, data, _detail) => data; + + beforeEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + this.captureBefore.push({ method: 'beforeEvaluation', hookContext, hookData: data }); + return this.beforeEvalImpl(hookContext, data); + } + afterEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + this.captureAfter.push({ method: 'afterEvaluation', hookContext, hookData: data, detail }); + return this.afterEvalImpl(hookContext, data, detail); + } +} + +describe('given an LDClient with test data', () => { + let client: LDClientImpl; + let td: TestData; + let testHook: TestHook; + let logger: TestLogger; + + beforeEach(async () => { + logger = new TestLogger(); + testHook = new TestHook(); + td = new TestData(); + client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + hooks: [testHook], + logger, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + }); + + afterEach(() => { + client.close(); + }); + + it('variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').booleanFlag().on(true)); + await client.variation('flagKey', defaultUser, false); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('variation detail triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').booleanFlag().on(true)); + await client.variationDetail('flagKey', defaultUser, false); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variationDetail', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variationDetail', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('boolean variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').booleanFlag().on(true)); + await client.boolVariation('flagKey', defaultUser, false); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.boolVariation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.boolVariation', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('boolean variation detail triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').booleanFlag().on(true)); + await client.boolVariationDetail('flagKey', defaultUser, false); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.boolVariationDetail', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.boolVariationDetail', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('number variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll(42).on(true)); + await client.numberVariation('flagKey', defaultUser, 21); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 21, + method: 'LDClient.numberVariation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 21, + method: 'LDClient.numberVariation', + }, + {}, + { + value: 42, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('number variation detail triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll(42).on(true)); + await client.numberVariationDetail('flagKey', defaultUser, 21); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 21, + method: 'LDClient.numberVariationDetail', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 21, + method: 'LDClient.numberVariationDetail', + }, + {}, + { + value: 42, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('string variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll('strValue').on(true)); + await client.stringVariation('flagKey', defaultUser, 'default'); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'default', + method: 'LDClient.stringVariation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'default', + method: 'LDClient.stringVariation', + }, + {}, + { + value: 'strValue', + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('string variation detail triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll('strValue').on(true)); + await client.stringVariationDetail('flagKey', defaultUser, 'default'); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'default', + method: 'LDClient.stringVariationDetail', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'default', + method: 'LDClient.stringVariationDetail', + }, + {}, + { + value: 'strValue', + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('json variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll({ the: 'value' }).on(true)); + await client.jsonVariation('flagKey', defaultUser, { default: 'value' }); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: { default: 'value' }, + method: 'LDClient.jsonVariation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: { default: 'value' }, + method: 'LDClient.jsonVariation', + }, + {}, + { + value: { the: 'value' }, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('json variation detail triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll({ the: 'value' }).on(true)); + await client.jsonVariationDetail('flagKey', defaultUser, { default: 'value' }); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: { default: 'value' }, + method: 'LDClient.jsonVariationDetail', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: { default: 'value' }, + method: 'LDClient.jsonVariationDetail', + }, + {}, + { + value: { the: 'value' }, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('migration variation triggers before/after evaluation hooks', async () => { + td.update(td.flag('flagKey').valueForAll('live')); + await client.migrationVariation('flagKey', defaultUser, LDMigrationStage.Off); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'off', + method: 'LDClient.migrationVariation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: 'off', + method: 'LDClient.migrationVariation', + }, + {}, + { + value: 'live', + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); + }); + + it('propagates data between stages', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) => ({ + ...data, + added: 'added data', + }); + await client.variation('flagKey', defaultUser, false); + + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + { added: 'added data' }, + { + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }, + ); + }); + + it('handles an exception thrown in beforeEvaluation', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + _data: integrations.EvaluationSeriesData, + ) => { + throw new Error('bad hook'); + }; + await client.variation('flagKey', defaultUser, false); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "beforeEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, + }, + ]); + }); + + it('handles an exception thrown in afterEvaluation', async () => { + testHook.afterEvalImpl = () => { + throw new Error('bad hook'); + }; + await client.variation('flagKey', defaultUser, false); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "afterEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, + }, + ]); + }); + + it('handles exception getting the hook metadata', async () => { + testHook.getMetadataImpl = () => { + throw new Error('bad hook'); + }; + await client.variation('flagKey', defaultUser, false); + + logger.expectMessages([ + { + level: LogLevel.Error, + matches: /Exception thrown getting metadata for hook. Unable to get hook name./, + }, + ]); + }); + + it('uses unknown name when the name cannot be accessed', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + _data: integrations.EvaluationSeriesData, + ) => { + throw new Error('bad hook'); + }; + testHook.getMetadataImpl = () => { + throw new Error('bad hook'); + }; + testHook.afterEvalImpl = () => { + throw new Error('bad hook'); + }; + await client.variation('flagKey', defaultUser, false); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "afterEvaluation" of the "unknown hook" hook: Error: bad hook/, + }, + { + level: LogLevel.Error, + matches: + /An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: bad hook/, + }, + ]); + }); +}); + +it('can add a hook after initialization', async () => { + const logger = new TestLogger(); + const td = new TestData(); + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + logger, + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + + td.update(td.flag('flagKey').booleanFlag().on(true)); + const testHook = new TestHook(); + client.addHook(testHook); + + await client.variation('flagKey', defaultUser, false); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); +}); + +it('executes hook stages in the specified order', async () => { + const beforeCalledOrder: string[] = []; + const afterCalledOrder: string[] = []; + + const hookA = new TestHook(); + hookA.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('a'); + return data; + }; + + hookA.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('a'); + return data; + }; + + const hookB = new TestHook(); + hookB.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('b'); + return data; + }; + hookB.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('b'); + return data; + }; + + const hookC = new TestHook(); + hookC.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('c'); + return data; + }; + + hookC.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('c'); + return data; + }; + + const logger = new TestLogger(); + const td = new TestData(); + const client = new LDClientImpl( + 'sdk-key', + basicPlatform, + { + updateProcessor: td.getFactory(), + sendEvents: false, + logger, + hooks: [hookA, hookB], + }, + makeCallbacks(true), + ); + + await client.waitForInitialization(); + client.addHook(hookC); + await client.variation('flagKey', defaultUser, false); + + expect(beforeCalledOrder).toEqual(['a', 'b', 'c']); + expect(afterCalledOrder).toEqual(['c', 'b', 'a']); +}); diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index 9f1561538..03b6fa27d 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -40,6 +40,7 @@ describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without opti expect(config.useLdd).toBe(false); expect(config.wrapperName).toBeUndefined(); expect(config.wrapperVersion).toBeUndefined(); + expect(config.hooks).toBeUndefined(); }); }); @@ -364,4 +365,18 @@ describe('when setting different options', () => { const config = new Configuration(withLogger({ ...values })); expect(logger(config).getCount()).toEqual(warnings); }); + + // Valid usage is covered in LDClient.hooks.test.ts + test('non-array hooks should use default', () => { + // @ts-ignore + const config = new Configuration(withLogger({ hooks: 'hook!' })); + expect(config.hooks).toBeUndefined(); + logger(config).expectMessages([ + { + level: LogLevel.Warn, + matches: + /Config option "hooks" should be of type Hook\[\], got string, using default value/, + }, + ]); + }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index c0dbc6b07..024e79237 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -24,6 +24,7 @@ import { LDMigrationVariation, LDOptions, } from './api'; +import { EvaluationSeriesContext, EvaluationSeriesData, Hook } from './api/integrations/Hook'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -45,7 +46,6 @@ import FlagsStateBuilder from './FlagsStateBuilder'; import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; -import AsyncStoreFacade from './store/AsyncStoreFacade'; import VersionedDataKinds from './store/VersionedDataKinds'; const { ClientMessages, ErrorKinds, NullEventProcessor } = internal; @@ -66,6 +66,23 @@ export interface LDClientCallbacks { hasEventListeners: () => boolean; } +const BOOL_VARIATION_METHOD_NAME = 'LDClient.boolVariation'; +const NUMBER_VARIATION_METHOD_NAME = 'LDClient.numberVariation'; +const STRING_VARIATION_METHOD_NAME = 'LDClient.stringVariation'; +const JSON_VARIATION_METHOD_NAME = 'LDClient.jsonVariation'; +const VARIATION_METHOD_NAME = 'LDClient.variation'; +const MIGRATION_VARIATION_METHOD_NAME = 'LDClient.migrationVariation'; + +const BOOL_VARIATION_DETAIL_METHOD_NAME = 'LDClient.boolVariationDetail'; +const NUMBER_VARIATION_DETAIL_METHOD_NAME = 'LDClient.numberVariationDetail'; +const STRING_VARIATION_DETAIL_METHOD_NAME = 'LDClient.stringVariationDetail'; +const JSON_VARIATION_DETAIL_METHOD_NAME = 'LDClient.jsonVariationDetail'; +const VARIATION_METHOD_DETAIL_NAME = 'LDClient.variationDetail'; + +const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; +const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; +const UNKNOWN_HOOK_NAME = 'unknown hook'; + /** * @ignore */ @@ -74,8 +91,6 @@ export default class LDClientImpl implements LDClient { private featureStore: LDFeatureStore; - private asyncFeatureStore: AsyncStoreFacade; - private updateProcessor?: subsystem.LDStreamProcessor; private eventFactoryDefault = new EventFactory(false); @@ -108,6 +123,8 @@ export default class LDClientImpl implements LDClient { private diagnosticsManager?: internal.DiagnosticsManager; + private hooks: Hook[]; + /** * Intended for use by platform specific client implementations. * @@ -131,6 +148,8 @@ export default class LDClientImpl implements LDClient { const { onUpdate, hasEventListeners } = callbacks; const config = new Configuration(options, internalOptions); + this.hooks = config.hooks || []; + if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); } @@ -139,7 +158,7 @@ export default class LDClientImpl implements LDClient { const clientContext = new ClientContext(sdkKey, config, platform); const featureStore = config.featureStoreFactory(clientContext); - this.asyncFeatureStore = new AsyncStoreFacade(featureStore); + const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { @@ -272,11 +291,20 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: any) => void, ): Promise { - return new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { - resolve(res.detail.value); - callback?.(null, res.detail.value); - }); + return this.withHooks( + key, + context, + defaultValue, + VARIATION_METHOD_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { + resolve(res.detail); + }); + }), + ).then((detail) => { + callback?.(null, detail.value); + return detail.value; }); } @@ -286,12 +314,25 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - return new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryWithReasons, (res) => { - resolve(res.detail); - callback?.(null, res.detail); - }); - }); + return this.withHooks( + key, + context, + defaultValue, + VARIATION_METHOD_DETAIL_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + (res) => { + resolve(res.detail); + callback?.(null, res.detail); + }, + ); + }), + ); } private typedEval( @@ -299,56 +340,87 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: TResult, eventFactory: EventFactory, + methodName: string, typeChecker: (value: unknown) => [boolean, string], ): Promise { - return new Promise>((resolve) => { - this.evaluateIfPossible( - key, - context, - defaultValue, - eventFactory, - (res) => { - const typedRes: LDEvaluationDetailTyped = { - value: res.detail.value as TResult, - reason: res.detail.reason, - variationIndex: res.detail.variationIndex, - }; - resolve(typedRes); - }, - typeChecker, - ); - }); + return this.withHooks( + key, + context, + defaultValue, + methodName, + () => + new Promise>((resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + eventFactory, + (res) => { + const typedRes: LDEvaluationDetailTyped = { + value: res.detail.value as TResult, + reason: res.detail.reason, + variationIndex: res.detail.variationIndex, + }; + resolve(typedRes); + }, + typeChecker, + ); + }), + ); } async boolVariation(key: string, context: LDContext, defaultValue: boolean): Promise { return ( - await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ - TypeValidators.Boolean.is(value), - TypeValidators.Boolean.getType(), - ]) + await this.typedEval( + key, + context, + defaultValue, + this.eventFactoryDefault, + BOOL_VARIATION_METHOD_NAME, + (value) => [TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType()], + ) ).value; } async numberVariation(key: string, context: LDContext, defaultValue: number): Promise { return ( - await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ - TypeValidators.Number.is(value), - TypeValidators.Number.getType(), - ]) + await this.typedEval( + key, + context, + defaultValue, + this.eventFactoryDefault, + NUMBER_VARIATION_METHOD_NAME, + (value) => [TypeValidators.Number.is(value), TypeValidators.Number.getType()], + ) ).value; } async stringVariation(key: string, context: LDContext, defaultValue: string): Promise { return ( - await this.typedEval(key, context, defaultValue, this.eventFactoryDefault, (value) => [ - TypeValidators.String.is(value), - TypeValidators.String.getType(), - ]) + await this.typedEval( + key, + context, + defaultValue, + this.eventFactoryDefault, + STRING_VARIATION_METHOD_NAME, + (value) => [TypeValidators.String.is(value), TypeValidators.String.getType()], + ) ).value; } jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise { - return this.variation(key, context, defaultValue); + return this.withHooks( + key, + context, + defaultValue, + JSON_VARIATION_METHOD_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { + resolve(res.detail); + }); + }), + ).then((detail) => detail.value); } boolVariationDetail( @@ -356,10 +428,14 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: boolean, ): Promise> { - return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ - TypeValidators.Boolean.is(value), - TypeValidators.Boolean.getType(), - ]); + return this.typedEval( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + BOOL_VARIATION_DETAIL_METHOD_NAME, + (value) => [TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType()], + ); } numberVariationDetail( @@ -367,10 +443,14 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: number, ): Promise> { - return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ - TypeValidators.Number.is(value), - TypeValidators.Number.getType(), - ]); + return this.typedEval( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + NUMBER_VARIATION_DETAIL_METHOD_NAME, + (value) => [TypeValidators.Number.is(value), TypeValidators.Number.getType()], + ); } stringVariationDetail( @@ -378,10 +458,14 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: string, ): Promise> { - return this.typedEval(key, context, defaultValue, this.eventFactoryWithReasons, (value) => [ - TypeValidators.String.is(value), - TypeValidators.String.getType(), - ]); + return this.typedEval( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + STRING_VARIATION_DETAIL_METHOD_NAME, + (value) => [TypeValidators.String.is(value), TypeValidators.String.getType()], + ); } jsonVariationDetail( @@ -389,71 +473,112 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: unknown, ): Promise> { - return this.variationDetail(key, context, defaultValue); + return this.withHooks( + key, + context, + defaultValue, + JSON_VARIATION_DETAIL_METHOD_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + (res) => { + resolve(res.detail); + }, + ); + }), + ); } - async migrationVariation( + private async migrationVariationInternal( key: string, context: LDContext, defaultValue: LDMigrationStage, - ): Promise { + ): Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }> { const convertedContext = Context.fromLDContext(context); - return new Promise((resolve) => { - this.evaluateIfPossible( - key, - context, - defaultValue, - this.eventFactoryWithReasons, - ({ detail }, flag) => { - const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; - const checkRatio = flag?.migration?.checkRatio; - const samplingRatio = flag?.samplingRatio; - - if (!IsMigrationStage(detail.value)) { - const error = new Error( - `Unrecognized MigrationState for "${key}"; returning default value.`, - ); - this.onError(error); - const reason = { - kind: 'ERROR', - errorKind: ErrorKinds.WrongType, - }; + return new Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }>( + (resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + ({ detail }, flag) => { + const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; + const checkRatio = flag?.migration?.checkRatio; + const samplingRatio = flag?.samplingRatio; + + if (!IsMigrationStage(detail.value)) { + const error = new Error( + `Unrecognized MigrationState for "${key}"; returning default value.`, + ); + this.onError(error); + const reason = { + kind: 'ERROR', + errorKind: ErrorKinds.WrongType, + }; + resolve({ + detail: { + value: defaultValue, + reason, + }, + migration: { + value: defaultValue, + tracker: new MigrationOpTracker( + key, + contextKeys, + defaultValue, + defaultValue, + reason, + checkRatio, + undefined, + flag?.version, + samplingRatio, + this.logger, + ), + }, + }); + return; + } resolve({ - value: defaultValue, - tracker: new MigrationOpTracker( - key, - contextKeys, - defaultValue, - defaultValue, - reason, - checkRatio, - undefined, - flag?.version, - samplingRatio, - this.logger, - ), + detail, + migration: { + value: detail.value as LDMigrationStage, + tracker: new MigrationOpTracker( + key, + contextKeys, + defaultValue, + detail.value, + detail.reason, + checkRatio, + // Can be null for compatibility reasons. + detail.variationIndex === null ? undefined : detail.variationIndex, + flag?.version, + samplingRatio, + this.logger, + ), + }, }); - return; - } - resolve({ - value: detail.value as LDMigrationStage, - tracker: new MigrationOpTracker( - key, - contextKeys, - defaultValue, - detail.value, - detail.reason, - checkRatio, - // Can be null for compatibility reasons. - detail.variationIndex === null ? undefined : detail.variationIndex, - flag?.version, - samplingRatio, - this.logger, - ), - }); - }, - ); - }); + }, + ); + }, + ); + } + + async migrationVariation( + key: string, + context: LDContext, + defaultValue: LDMigrationStage, + ): Promise { + const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = + this.prepareHooks(key, context, defaultValue, MIGRATION_VARIATION_METHOD_NAME); + const hookData = this.executeBeforeEvaluation(hooks, hookContext); + const res = await this.migrationVariationInternal(key, context, defaultValue); + this.executeAfterEvaluation(hooks, hookContext, hookData, res.detail); + return res.migration; } allFlagsState( @@ -601,6 +726,10 @@ export default class LDClientImpl implements LDClient { callback?.(null, true); } + addHook(hook: Hook): void { + this.hooks.push(hook); + } + private variationInternal( flagKey: string, context: LDContext, @@ -738,4 +867,100 @@ export default class LDClientImpl implements LDClient { this.onReady(); } } + + private async withHooks( + key: string, + context: LDContext, + defaultValue: unknown, + methodName: string, + method: () => Promise, + ): Promise { + if (this.hooks.length === 0) { + return method(); + } + const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = + this.prepareHooks(key, context, defaultValue, methodName); + const hookData = this.executeBeforeEvaluation(hooks, hookContext); + const result = await method(); + this.executeAfterEvaluation(hooks, hookContext, hookData, result); + return result; + } + + private tryExecuteStage( + method: string, + hookName: string, + stage: () => EvaluationSeriesData, + ): EvaluationSeriesData { + try { + return stage(); + } catch (err) { + this.logger?.error( + `An error was encountered in "${method}" of the "${hookName}" hook: ${err}`, + ); + return {}; + } + } + + private hookName(hook?: Hook): string { + try { + return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME; + } catch { + this.logger?.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); + return UNKNOWN_HOOK_NAME; + } + } + + private executeAfterEvaluation( + 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] ?? {}; + this.tryExecuteStage( + AFTER_EVALUATION_STAGE_NAME, + this.hookName(hook), + () => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, + ); + } + } + + private executeBeforeEvaluation( + hooks: Hook[], + hookContext: EvaluationSeriesContext, + ): EvaluationSeriesData[] { + return hooks.map((hook) => + this.tryExecuteStage( + BEFORE_EVALUATION_STAGE_NAME, + this.hookName(hook), + () => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, + ), + ); + } + + private prepareHooks( + key: string, + context: LDContext, + defaultValue: unknown, + methodName: string, + ): { + hooks: Hook[]; + hookContext: EvaluationSeriesContext; + } { + // Copy the hooks to use a consistent set during evaluation. Hooks could be added and we want + // to ensure all correct stages for any give hook execute. Not for instance the afterEvaluation + // stage without beforeEvaluation having been called on that hook. + const hooks: Hook[] = [...this.hooks]; + const hookContext: EvaluationSeriesContext = { + flagKey: key, + context, + defaultValue, + method: methodName, + }; + return { hooks, hookContext }; + } } diff --git a/packages/shared/sdk-server/src/api/LDClient.ts b/packages/shared/sdk-server/src/api/LDClient.ts index 177727c44..2b15d51f7 100644 --- a/packages/shared/sdk-server/src/api/LDClient.ts +++ b/packages/shared/sdk-server/src/api/LDClient.ts @@ -9,6 +9,7 @@ import { LDMigrationOpEvent, LDMigrationVariation } from './data'; import { LDFlagsState } from './data/LDFlagsState'; import { LDFlagsStateOptions } from './data/LDFlagsStateOptions'; import { LDMigrationStage } from './data/LDMigrationStage'; +import { Hook } from './integrations/Hook'; /** * The LaunchDarkly SDK client object. @@ -441,4 +442,14 @@ export interface LDClient { * fails, so be sure to attach a rejection handler to it. */ flush(callback?: (err: Error | null, res: boolean) => void): Promise; + + /** + * Add a hook to the client. In order to register a hook before the client + * starts, please use the `hooks` property of {@link LDOptions}. + * + * Hooks provide entrypoints which allow for observation of SDK functions. + * + * @param Hook The hook to add. + */ + addHook?(hook: Hook): void; } diff --git a/packages/shared/sdk-server/src/api/index.ts b/packages/shared/sdk-server/src/api/index.ts index 41e35f8bb..8a99cc362 100644 --- a/packages/shared/sdk-server/src/api/index.ts +++ b/packages/shared/sdk-server/src/api/index.ts @@ -7,6 +7,9 @@ export * from './subsystems/LDFeatureStore'; // These are items that should be less frequently used, and therefore they // are namespaced to reduce clutter amongst the top level exports. + +// Integrations was overwritten by the exports of index.ts. On a major version +// we should consider removing this and exporting integrations differently. export * as integrations from './integrations'; export * as interfaces from './interfaces'; export * as subsystems from './subsystems'; diff --git a/packages/shared/sdk-server/src/api/integrations/Hook.ts b/packages/shared/sdk-server/src/api/integrations/Hook.ts new file mode 100644 index 000000000..52e763986 --- /dev/null +++ b/packages/shared/sdk-server/src/api/integrations/Hook.ts @@ -0,0 +1,80 @@ +import { LDContext, LDEvaluationDetail } from '@launchdarkly/js-sdk-common'; + +/** + * Contextual information provided to evaluation stages. + */ +export interface EvaluationSeriesContext { + readonly flagKey: string; + readonly context: LDContext; + readonly defaultValue: unknown; + readonly method: string; +} + +/** + * Implementation specific hook data for evaluation stages. + * + * Hook implementations can use this to store data needed between stages. + */ +export interface EvaluationSeriesData { + readonly [index: string]: unknown; +} + +/** + * Meta-data about a hook implementation. + */ +export interface HookMetadata { + readonly name: string; +} + +/** + * Interface for extending SDK functionality via hooks. + */ +export interface Hook { + /** + * Get metadata about the hook implementation. + */ + getMetadata(): HookMetadata; + + /** + * The before method is called during the execution of a variation method + * before the flag value has been determined. The method is executed synchronously. + * + * @param hookContext Contains information about the evaluation being performed. This is not + * mutable. + * @param data A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + beforeEvaluation?( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + ): EvaluationSeriesData; + + /** + * The after method is called during the execution of the variation method + * after the flag value has been determined. The method is executed synchronously. + * + * @param hookContext Contains read-only information about the evaluation + * being performed. + * @param data A record associated with each stage of hook invocations. Each + * stage is called with the data of the previous stage for a series. + * @param detail The result of the evaluation. This value should not be + * modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + afterEvaluation?( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + detail: LDEvaluationDetail, + ): EvaluationSeriesData; +} diff --git a/packages/shared/sdk-server/src/api/integrations/index.ts b/packages/shared/sdk-server/src/api/integrations/index.ts index fdf3c3574..19c0a7522 100644 --- a/packages/shared/sdk-server/src/api/integrations/index.ts +++ b/packages/shared/sdk-server/src/api/integrations/index.ts @@ -1 +1,2 @@ export * from './FileDataSourceOptions'; +export * from './Hook'; diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index 3954563c7..a0d38b0fa 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -1,5 +1,6 @@ import { LDClientContext, LDLogger, subsystem, VoidFunction } from '@launchdarkly/js-sdk-common'; +import { Hook } from '../integrations/Hook'; import { LDDataSourceUpdates, LDFeatureStore } from '../subsystems'; import { LDBigSegmentsOptions } from './LDBigSegmentsOptions'; import { LDProxyOptions } from './LDProxyOptions'; @@ -268,4 +269,23 @@ export interface LDOptions { */ versionName?: string; }; + + /** + * Initial set of hooks for the client. + * + * Hooks provide entrypoints which allow for observation of SDK functions. + * + * LaunchDarkly provides integration packages, and most applications will not + * need to implement their own hooks. Refer to the `@launchdarkly/node-server-sdk-otel` + * for instrumentation for the `@launchdarkly/node-server-sdk`. + * + * Example: + * ```typescript + * import { init } from '@launchdarkly/node-server-sdk'; + * import { TracingHook } from '@launchdarkly/node-server-sdk-otel'; + * + * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * ``` + */ + hooks?: Hook[]; } diff --git a/packages/shared/sdk-server/src/integrations/index.ts b/packages/shared/sdk-server/src/integrations/index.ts index 2c162e822..0bbbcdf0a 100644 --- a/packages/shared/sdk-server/src/integrations/index.ts +++ b/packages/shared/sdk-server/src/integrations/index.ts @@ -1,4 +1,7 @@ import FileDataSourceFactory from './FileDataSourceFactory'; export * from './test_data'; +// Api exported integrations, but it was overwritten by the more specific +// integrations from index.ts. +export * from '../api/integrations'; export { FileDataSourceFactory }; diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 709216d18..9e68a4f20 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -13,6 +13,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; +import { Hook } from '../api/integrations'; import { LDDataSourceUpdates, LDFeatureStore } from '../api/subsystems'; import InMemoryFeatureStore from '../store/InMemoryFeatureStore'; import { ValidatedOptions } from './ValidatedOptions'; @@ -54,6 +55,7 @@ const validations: Record = { wrapperName: TypeValidators.String, wrapperVersion: TypeValidators.String, application: TypeValidators.Object, + hooks: TypeValidators.createTypeArray('Hook[]', {}), }; /** @@ -208,6 +210,8 @@ export default class Configuration { public readonly bigSegments?: LDBigSegmentsOptions; + public readonly hooks?: Hook[]; + constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { // The default will handle undefined, but not null. // Because we can be called from JS we need to be extra defensive. @@ -272,5 +276,7 @@ export default class Configuration { // @ts-ignore this.featureStoreFactory = () => validatedOptions.featureStore; } + + this.hooks = validatedOptions.hooks; } } diff --git a/packages/shared/sdk-server/src/options/ValidatedOptions.ts b/packages/shared/sdk-server/src/options/ValidatedOptions.ts index 45484e8e1..ce9a58de9 100644 --- a/packages/shared/sdk-server/src/options/ValidatedOptions.ts +++ b/packages/shared/sdk-server/src/options/ValidatedOptions.ts @@ -1,6 +1,7 @@ import { LDLogger, subsystem } from '@launchdarkly/js-sdk-common'; import { LDBigSegmentsOptions, LDOptions, LDProxyOptions, LDTLSOptions } from '../api'; +import { Hook } from '../api/integrations'; import { LDFeatureStore } from '../api/subsystems'; /** @@ -39,4 +40,5 @@ export interface ValidatedOptions { // Allow indexing this by a string for the validation step. [index: string]: any; bigSegments?: LDBigSegmentsOptions; + hooks?: Hook[]; } From 7416e6381dac8c5b630fdcb2c46ee4d2993dfc84 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:00:34 -0700 Subject: [PATCH 2/6] chore: Refactor hook execution. (#417) This code lifts the hook running code out of the client. --- .../__tests__/LDClient.hooks.test.ts | 227 +------------ .../__tests__/hooks/HookRunner.test.ts | 291 +++++++++++++++++ .../sdk-server/__tests__/hooks/TestHook.ts | 67 ++++ .../shared/sdk-server/src/LDClientImpl.ts | 299 ++++++------------ .../shared/sdk-server/src/hooks/HookRunner.ts | 147 +++++++++ 5 files changed, 605 insertions(+), 426 deletions(-) create mode 100644 packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts create mode 100644 packages/shared/sdk-server/__tests__/hooks/TestHook.ts create mode 100644 packages/shared/sdk-server/src/hooks/HookRunner.ts diff --git a/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts index b69de7083..efee536ab 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts @@ -1,78 +1,14 @@ import { basicPlatform } from '@launchdarkly/private-js-mocks'; -import { integrations, LDClientImpl, LDEvaluationDetail, LDMigrationStage } from '../src'; +import { LDClientImpl, LDMigrationStage } from '../src'; import Reasons from '../src/evaluation/Reasons'; import TestData from '../src/integrations/test_data/TestData'; -import TestLogger, { LogLevel } from './Logger'; +import { TestHook } from './hooks/TestHook'; +import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; const defaultUser = { kind: 'user', key: 'user-key' }; -type EvalCapture = { - method: string; - hookContext: integrations.EvaluationSeriesContext; - hookData: integrations.EvaluationSeriesData; - detail?: LDEvaluationDetail; -}; - -class TestHook implements integrations.Hook { - captureBefore: EvalCapture[] = []; - captureAfter: EvalCapture[] = []; - - getMetadataImpl: () => integrations.HookMetadata = () => ({ name: 'LaunchDarkly Test Hook' }); - - getMetadata(): integrations.HookMetadata { - return this.getMetadataImpl(); - } - - verifyBefore( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - ) { - expect(this.captureBefore).toHaveLength(1); - expect(this.captureBefore[0].hookContext).toEqual(hookContext); - expect(this.captureBefore[0].hookData).toEqual(data); - } - - verifyAfter( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - detail: LDEvaluationDetail, - ) { - expect(this.captureAfter).toHaveLength(1); - expect(this.captureAfter[0].hookContext).toEqual(hookContext); - expect(this.captureAfter[0].hookData).toEqual(data); - expect(this.captureAfter[0].detail).toEqual(detail); - } - - beforeEvalImpl: ( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - ) => integrations.EvaluationSeriesData = (_hookContext, data) => data; - - afterEvalImpl: ( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - detail: LDEvaluationDetail, - ) => integrations.EvaluationSeriesData = (_hookContext, data, _detail) => data; - - beforeEvaluation?( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - ): integrations.EvaluationSeriesData { - this.captureBefore.push({ method: 'beforeEvaluation', hookContext, hookData: data }); - return this.beforeEvalImpl(hookContext, data); - } - afterEvaluation?( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - detail: LDEvaluationDetail, - ): integrations.EvaluationSeriesData { - this.captureAfter.push({ method: 'afterEvaluation', hookContext, hookData: data, detail }); - return this.afterEvalImpl(hookContext, data, detail); - } -} - describe('given an LDClient with test data', () => { let client: LDClientImpl; let td: TestData; @@ -409,105 +345,6 @@ describe('given an LDClient with test data', () => { }, ); }); - - it('propagates data between stages', async () => { - testHook.beforeEvalImpl = ( - _hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - ) => ({ - ...data, - added: 'added data', - }); - await client.variation('flagKey', defaultUser, false); - - testHook.verifyAfter( - { - flagKey: 'flagKey', - context: { ...defaultUser }, - defaultValue: false, - method: 'LDClient.variation', - }, - { added: 'added data' }, - { - value: false, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, - variationIndex: null, - }, - ); - }); - - it('handles an exception thrown in beforeEvaluation', async () => { - testHook.beforeEvalImpl = ( - _hookContext: integrations.EvaluationSeriesContext, - _data: integrations.EvaluationSeriesData, - ) => { - throw new Error('bad hook'); - }; - await client.variation('flagKey', defaultUser, false); - logger.expectMessages([ - { - level: LogLevel.Error, - matches: - /An error was encountered in "beforeEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, - }, - ]); - }); - - it('handles an exception thrown in afterEvaluation', async () => { - testHook.afterEvalImpl = () => { - throw new Error('bad hook'); - }; - await client.variation('flagKey', defaultUser, false); - logger.expectMessages([ - { - level: LogLevel.Error, - matches: - /An error was encountered in "afterEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, - }, - ]); - }); - - it('handles exception getting the hook metadata', async () => { - testHook.getMetadataImpl = () => { - throw new Error('bad hook'); - }; - await client.variation('flagKey', defaultUser, false); - - logger.expectMessages([ - { - level: LogLevel.Error, - matches: /Exception thrown getting metadata for hook. Unable to get hook name./, - }, - ]); - }); - - it('uses unknown name when the name cannot be accessed', async () => { - testHook.beforeEvalImpl = ( - _hookContext: integrations.EvaluationSeriesContext, - _data: integrations.EvaluationSeriesData, - ) => { - throw new Error('bad hook'); - }; - testHook.getMetadataImpl = () => { - throw new Error('bad hook'); - }; - testHook.afterEvalImpl = () => { - throw new Error('bad hook'); - }; - await client.variation('flagKey', defaultUser, false); - logger.expectMessages([ - { - level: LogLevel.Error, - matches: - /An error was encountered in "afterEvaluation" of the "unknown hook" hook: Error: bad hook/, - }, - { - level: LogLevel.Error, - matches: - /An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: bad hook/, - }, - ]); - }); }); it('can add a hook after initialization', async () => { @@ -555,61 +392,3 @@ it('can add a hook after initialization', async () => { }, ); }); - -it('executes hook stages in the specified order', async () => { - const beforeCalledOrder: string[] = []; - const afterCalledOrder: string[] = []; - - const hookA = new TestHook(); - hookA.beforeEvalImpl = (_context, data) => { - beforeCalledOrder.push('a'); - return data; - }; - - hookA.afterEvalImpl = (_context, data, _detail) => { - afterCalledOrder.push('a'); - return data; - }; - - const hookB = new TestHook(); - hookB.beforeEvalImpl = (_context, data) => { - beforeCalledOrder.push('b'); - return data; - }; - hookB.afterEvalImpl = (_context, data, _detail) => { - afterCalledOrder.push('b'); - return data; - }; - - const hookC = new TestHook(); - hookC.beforeEvalImpl = (_context, data) => { - beforeCalledOrder.push('c'); - return data; - }; - - hookC.afterEvalImpl = (_context, data, _detail) => { - afterCalledOrder.push('c'); - return data; - }; - - const logger = new TestLogger(); - const td = new TestData(); - const client = new LDClientImpl( - 'sdk-key', - basicPlatform, - { - updateProcessor: td.getFactory(), - sendEvents: false, - logger, - hooks: [hookA, hookB], - }, - makeCallbacks(true), - ); - - await client.waitForInitialization(); - client.addHook(hookC); - await client.variation('flagKey', defaultUser, false); - - expect(beforeCalledOrder).toEqual(['a', 'b', 'c']); - expect(afterCalledOrder).toEqual(['c', 'b', 'a']); -}); diff --git a/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts new file mode 100644 index 000000000..b72a184f8 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts @@ -0,0 +1,291 @@ +import { integrations } from '../../src'; +import Reasons from '../../src/evaluation/Reasons'; +import HookRunner from '../../src/hooks/HookRunner'; +import TestLogger, { LogLevel } from '../Logger'; +import { TestHook } from './TestHook'; + +const defaultUser = { kind: 'user', key: 'user-key' }; + +describe('given a HookRunner', () => { + let testHook: TestHook; + let logger: TestLogger; + let runner: HookRunner; + + beforeEach(async () => { + logger = new TestLogger(); + testHook = new TestHook(); + runner = new HookRunner(logger, [testHook]); + }); + + it('propagates data between stages', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) => ({ + ...data, + added: 'added data', + }); + + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + { added: 'added data' }, + { + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }, + ); + }); + + it('handles an exception thrown in beforeEvaluation', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + _data: integrations.EvaluationSeriesData, + ) => { + throw new Error('bad hook'); + }; + + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "beforeEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, + }, + ]); + }); + + it('handles an exception thrown in afterEvaluation', async () => { + testHook.afterEvalImpl = () => { + throw new Error('bad hook'); + }; + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "afterEvaluation" of the "LaunchDarkly Test Hook" hook: Error: bad hook/, + }, + ]); + }); + + it('handles exception getting the hook metadata', async () => { + testHook.getMetadataImpl = () => { + throw new Error('bad hook'); + }; + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + + logger.expectMessages([ + { + level: LogLevel.Error, + matches: /Exception thrown getting metadata for hook. Unable to get hook name./, + }, + ]); + }); + + it('uses unknown name when the name cannot be accessed', async () => { + testHook.beforeEvalImpl = ( + _hookContext: integrations.EvaluationSeriesContext, + _data: integrations.EvaluationSeriesData, + ) => { + throw new Error('bad hook'); + }; + testHook.getMetadataImpl = () => { + throw new Error('bad hook'); + }; + testHook.afterEvalImpl = () => { + throw new Error('bad hook'); + }; + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + logger.expectMessages([ + { + level: LogLevel.Error, + matches: + /An error was encountered in "afterEvaluation" of the "unknown hook" hook: Error: bad hook/, + }, + { + level: LogLevel.Error, + matches: + /An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: bad hook/, + }, + ]); + }); +}); + +it('can add a hook after initialization', async () => { + const logger = new TestLogger(); + const runner = new HookRunner(logger, []); + + const testHook = new TestHook(); + runner.addHook(testHook); + + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: true, + reason: { kind: 'FALLTHROUGH' }, + variationIndex: 0, + }), + ); + testHook.verifyBefore( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + ); + testHook.verifyAfter( + { + flagKey: 'flagKey', + context: { ...defaultUser }, + defaultValue: false, + method: 'LDClient.variation', + }, + {}, + { + value: true, + reason: Reasons.Fallthrough, + variationIndex: 0, + }, + ); +}); + +it('executes hook stages in the specified order', async () => { + const beforeCalledOrder: string[] = []; + const afterCalledOrder: string[] = []; + + const hookA = new TestHook(); + hookA.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('a'); + return data; + }; + + hookA.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('a'); + return data; + }; + + const hookB = new TestHook(); + hookB.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('b'); + return data; + }; + hookB.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('b'); + return data; + }; + + const hookC = new TestHook(); + hookC.beforeEvalImpl = (_context, data) => { + beforeCalledOrder.push('c'); + return data; + }; + + hookC.afterEvalImpl = (_context, data, _detail) => { + afterCalledOrder.push('c'); + return data; + }; + + const logger = new TestLogger(); + const runner = new HookRunner(logger, [hookA, hookB]); + + const testHook = new TestHook(); + runner.addHook(testHook); + runner.addHook(hookC); + await runner.withEvaluationSeries( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }), + ); + + expect(beforeCalledOrder).toEqual(['a', 'b', 'c']); + expect(afterCalledOrder).toEqual(['c', 'b', 'a']); +}); + +it('can return custom data', async () => { + const runner = new HookRunner(undefined, []); + const res = await runner.withEvaluationSeriesExtraDetail( + 'flagKey', + defaultUser, + false, + 'LDClient.variation', + async () => ({ + detail: { + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + }, + test: 'test', + }), + ); + expect(res.test).toEqual('test'); +}); diff --git a/packages/shared/sdk-server/__tests__/hooks/TestHook.ts b/packages/shared/sdk-server/__tests__/hooks/TestHook.ts new file mode 100644 index 000000000..6ba2555b8 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/hooks/TestHook.ts @@ -0,0 +1,67 @@ +import { integrations, LDEvaluationDetail } from '../../src'; + +export type EvalCapture = { + method: string; + hookContext: integrations.EvaluationSeriesContext; + hookData: integrations.EvaluationSeriesData; + detail?: LDEvaluationDetail; +}; + +export class TestHook implements integrations.Hook { + captureBefore: EvalCapture[] = []; + captureAfter: EvalCapture[] = []; + + getMetadataImpl: () => integrations.HookMetadata = () => ({ name: 'LaunchDarkly Test Hook' }); + + getMetadata(): integrations.HookMetadata { + return this.getMetadataImpl(); + } + + verifyBefore( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) { + expect(this.captureBefore).toHaveLength(1); + expect(this.captureBefore[0].hookContext).toEqual(hookContext); + expect(this.captureBefore[0].hookData).toEqual(data); + } + + verifyAfter( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ) { + expect(this.captureAfter).toHaveLength(1); + expect(this.captureAfter[0].hookContext).toEqual(hookContext); + expect(this.captureAfter[0].hookData).toEqual(data); + expect(this.captureAfter[0].detail).toEqual(detail); + } + + beforeEvalImpl: ( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ) => integrations.EvaluationSeriesData = (_hookContext, data) => data; + + afterEvalImpl: ( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ) => integrations.EvaluationSeriesData = (_hookContext, data, _detail) => data; + + beforeEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + this.captureBefore.push({ method: 'beforeEvaluation', hookContext, hookData: data }); + return this.beforeEvalImpl(hookContext, data); + } + + afterEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + this.captureAfter.push({ method: 'afterEvaluation', hookContext, hookData: data, detail }); + return this.afterEvalImpl(hookContext, data, detail); + } +} diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 024e79237..fedbc171e 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -24,7 +24,7 @@ import { LDMigrationVariation, LDOptions, } from './api'; -import { EvaluationSeriesContext, EvaluationSeriesData, Hook } from './api/integrations/Hook'; +import { Hook } from './api/integrations/Hook'; import { BigSegmentStoreMembership } from './api/interfaces'; import BigSegmentsManager from './BigSegmentsManager'; import BigSegmentStoreStatusProvider from './BigSegmentStatusProviderImpl'; @@ -43,6 +43,7 @@ import ContextDeduplicator from './events/ContextDeduplicator'; import EventFactory from './events/EventFactory'; import isExperiment from './events/isExperiment'; import FlagsStateBuilder from './FlagsStateBuilder'; +import HookRunner from './hooks/HookRunner'; import MigrationOpEventToInputEvent from './MigrationOpEventConversion'; import MigrationOpTracker from './MigrationOpTracker'; import Configuration from './options/Configuration'; @@ -79,10 +80,6 @@ const STRING_VARIATION_DETAIL_METHOD_NAME = 'LDClient.stringVariationDetail'; const JSON_VARIATION_DETAIL_METHOD_NAME = 'LDClient.jsonVariationDetail'; const VARIATION_METHOD_DETAIL_NAME = 'LDClient.variationDetail'; -const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; -const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; -const UNKNOWN_HOOK_NAME = 'unknown hook'; - /** * @ignore */ @@ -123,7 +120,7 @@ export default class LDClientImpl implements LDClient { private diagnosticsManager?: internal.DiagnosticsManager; - private hooks: Hook[]; + private hookRunner: HookRunner; /** * Intended for use by platform specific client implementations. @@ -148,7 +145,7 @@ export default class LDClientImpl implements LDClient { const { onUpdate, hasEventListeners } = callbacks; const config = new Configuration(options, internalOptions); - this.hooks = config.hooks || []; + this.hookRunner = new HookRunner(config.logger, config.hooks || []); if (!sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); @@ -291,21 +288,23 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: any) => void, ): Promise { - return this.withHooks( - key, - context, - defaultValue, - VARIATION_METHOD_NAME, - () => - new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { - resolve(res.detail); - }); - }), - ).then((detail) => { - callback?.(null, detail.value); - return detail.value; - }); + return this.hookRunner + .withEvaluationSeries( + key, + context, + defaultValue, + VARIATION_METHOD_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { + resolve(res.detail); + }); + }), + ) + .then((detail) => { + callback?.(null, detail.value); + return detail.value; + }); } variationDetail( @@ -314,7 +313,7 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - return this.withHooks( + return this.hookRunner.withEvaluationSeries( key, context, defaultValue, @@ -343,7 +342,7 @@ export default class LDClientImpl implements LDClient { methodName: string, typeChecker: (value: unknown) => [boolean, string], ): Promise { - return this.withHooks( + return this.hookRunner.withEvaluationSeries( key, context, defaultValue, @@ -409,18 +408,20 @@ export default class LDClientImpl implements LDClient { } jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise { - return this.withHooks( - key, - context, - defaultValue, - JSON_VARIATION_METHOD_NAME, - () => - new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { - resolve(res.detail); - }); - }), - ).then((detail) => detail.value); + return this.hookRunner + .withEvaluationSeries( + key, + context, + defaultValue, + JSON_VARIATION_METHOD_NAME, + () => + new Promise((resolve) => { + this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { + resolve(res.detail); + }); + }), + ) + .then((detail) => detail.value); } boolVariationDetail( @@ -473,7 +474,7 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: unknown, ): Promise> { - return this.withHooks( + return this.hookRunner.withEvaluationSeries( key, context, defaultValue, @@ -499,73 +500,60 @@ export default class LDClientImpl implements LDClient { defaultValue: LDMigrationStage, ): Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }> { const convertedContext = Context.fromLDContext(context); - return new Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }>( - (resolve) => { - this.evaluateIfPossible( - key, - context, - defaultValue, - this.eventFactoryWithReasons, - ({ detail }, flag) => { - const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; - const checkRatio = flag?.migration?.checkRatio; - const samplingRatio = flag?.samplingRatio; - - if (!IsMigrationStage(detail.value)) { - const error = new Error( - `Unrecognized MigrationState for "${key}"; returning default value.`, - ); - this.onError(error); - const reason = { - kind: 'ERROR', - errorKind: ErrorKinds.WrongType, - }; - resolve({ - detail: { - value: defaultValue, - reason, - }, - migration: { - value: defaultValue, - tracker: new MigrationOpTracker( - key, - contextKeys, - defaultValue, - defaultValue, - reason, - checkRatio, - undefined, - flag?.version, - samplingRatio, - this.logger, - ), - }, - }); - return; - } + const res = await new Promise<{ detail: LDEvaluationDetail; flag?: Flag }>((resolve) => { + this.evaluateIfPossible( + key, + context, + defaultValue, + this.eventFactoryWithReasons, + ({ detail }, flag) => { + if (!IsMigrationStage(detail.value)) { + const error = new Error( + `Unrecognized MigrationState for "${key}"; returning default value.`, + ); + this.onError(error); + const reason = { + kind: 'ERROR', + errorKind: ErrorKinds.WrongType, + }; resolve({ - detail, - migration: { - value: detail.value as LDMigrationStage, - tracker: new MigrationOpTracker( - key, - contextKeys, - defaultValue, - detail.value, - detail.reason, - checkRatio, - // Can be null for compatibility reasons. - detail.variationIndex === null ? undefined : detail.variationIndex, - flag?.version, - samplingRatio, - this.logger, - ), + detail: { + value: defaultValue, + reason, }, + flag, }); - }, - ); + return; + } + resolve({ detail, flag }); + }, + ); + }); + + const { detail, flag } = res; + const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; + const checkRatio = flag?.migration?.checkRatio; + const samplingRatio = flag?.samplingRatio; + + return { + detail, + migration: { + value: detail.value as LDMigrationStage, + tracker: new MigrationOpTracker( + key, + contextKeys, + defaultValue, + detail.value, + detail.reason, + checkRatio, + // Can be null for compatibility reasons. + detail.variationIndex === null ? undefined : detail.variationIndex, + flag?.version, + samplingRatio, + this.logger, + ), }, - ); + }; } async migrationVariation( @@ -573,11 +561,14 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: LDMigrationStage, ): Promise { - const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = - this.prepareHooks(key, context, defaultValue, MIGRATION_VARIATION_METHOD_NAME); - const hookData = this.executeBeforeEvaluation(hooks, hookContext); - const res = await this.migrationVariationInternal(key, context, defaultValue); - this.executeAfterEvaluation(hooks, hookContext, hookData, res.detail); + const res = await this.hookRunner.withEvaluationSeriesExtraDetail( + key, + context, + defaultValue, + MIGRATION_VARIATION_METHOD_NAME, + () => this.migrationVariationInternal(key, context, defaultValue), + ); + return res.migration; } @@ -727,7 +718,7 @@ export default class LDClientImpl implements LDClient { } addHook(hook: Hook): void { - this.hooks.push(hook); + this.hookRunner.addHook(hook); } private variationInternal( @@ -867,100 +858,4 @@ export default class LDClientImpl implements LDClient { this.onReady(); } } - - private async withHooks( - key: string, - context: LDContext, - defaultValue: unknown, - methodName: string, - method: () => Promise, - ): Promise { - if (this.hooks.length === 0) { - return method(); - } - const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = - this.prepareHooks(key, context, defaultValue, methodName); - const hookData = this.executeBeforeEvaluation(hooks, hookContext); - const result = await method(); - this.executeAfterEvaluation(hooks, hookContext, hookData, result); - return result; - } - - private tryExecuteStage( - method: string, - hookName: string, - stage: () => EvaluationSeriesData, - ): EvaluationSeriesData { - try { - return stage(); - } catch (err) { - this.logger?.error( - `An error was encountered in "${method}" of the "${hookName}" hook: ${err}`, - ); - return {}; - } - } - - private hookName(hook?: Hook): string { - try { - return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME; - } catch { - this.logger?.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); - return UNKNOWN_HOOK_NAME; - } - } - - private executeAfterEvaluation( - 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] ?? {}; - this.tryExecuteStage( - AFTER_EVALUATION_STAGE_NAME, - this.hookName(hook), - () => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, - ); - } - } - - private executeBeforeEvaluation( - hooks: Hook[], - hookContext: EvaluationSeriesContext, - ): EvaluationSeriesData[] { - return hooks.map((hook) => - this.tryExecuteStage( - BEFORE_EVALUATION_STAGE_NAME, - this.hookName(hook), - () => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, - ), - ); - } - - private prepareHooks( - key: string, - context: LDContext, - defaultValue: unknown, - methodName: string, - ): { - hooks: Hook[]; - hookContext: EvaluationSeriesContext; - } { - // Copy the hooks to use a consistent set during evaluation. Hooks could be added and we want - // to ensure all correct stages for any give hook execute. Not for instance the afterEvaluation - // stage without beforeEvaluation having been called on that hook. - const hooks: Hook[] = [...this.hooks]; - const hookContext: EvaluationSeriesContext = { - flagKey: key, - context, - defaultValue, - method: methodName, - }; - return { hooks, hookContext }; - } } diff --git a/packages/shared/sdk-server/src/hooks/HookRunner.ts b/packages/shared/sdk-server/src/hooks/HookRunner.ts new file mode 100644 index 000000000..5e9e531ed --- /dev/null +++ b/packages/shared/sdk-server/src/hooks/HookRunner.ts @@ -0,0 +1,147 @@ +import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { EvaluationSeriesContext, EvaluationSeriesData, Hook } from '../integrations'; + +const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; +const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; +const UNKNOWN_HOOK_NAME = 'unknown hook'; + +export default class HookRunner { + private readonly hooks: Hook[] = []; + + constructor( + private readonly logger: LDLogger | undefined, + hooks: Hook[], + ) { + this.hooks.push(...hooks); + } + + public async withEvaluationSeries( + key: string, + context: LDContext, + defaultValue: unknown, + methodName: string, + method: () => Promise, + ): Promise { + // This early return is here to avoid the extra async/await associated with + // using withHooksDataWithDetail. + if (this.hooks.length === 0) { + return method(); + } + + return this.withEvaluationSeriesExtraDetail( + key, + context, + defaultValue, + methodName, + async () => { + const detail = await method(); + return { detail }; + }, + ).then(({ detail }) => detail); + } + + /** + * This function allows extra information to be returned with the detail for situations like + * migrations where a tracker is returned with the detail. + */ + public async withEvaluationSeriesExtraDetail( + key: string, + context: LDContext, + defaultValue: unknown, + methodName: string, + method: () => Promise<{ detail: LDEvaluationDetail; [index: string]: any }>, + ): Promise<{ detail: LDEvaluationDetail; [index: string]: any }> { + if (this.hooks.length === 0) { + return method(); + } + const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = + this.prepareHooks(key, context, defaultValue, methodName); + const hookData = this.executeBeforeEvaluation(hooks, hookContext); + const result = await method(); + this.executeAfterEvaluation(hooks, hookContext, hookData, result.detail); + return result; + } + + private tryExecuteStage( + method: string, + hookName: string, + stage: () => EvaluationSeriesData, + ): EvaluationSeriesData { + try { + return stage(); + } catch (err) { + this.logger?.error( + `An error was encountered in "${method}" of the "${hookName}" hook: ${err}`, + ); + return {}; + } + } + + private hookName(hook?: Hook): string { + try { + return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME; + } catch { + this.logger?.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); + return UNKNOWN_HOOK_NAME; + } + } + + private executeAfterEvaluation( + 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] ?? {}; + this.tryExecuteStage( + AFTER_EVALUATION_STAGE_NAME, + this.hookName(hook), + () => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, + ); + } + } + + private executeBeforeEvaluation( + hooks: Hook[], + hookContext: EvaluationSeriesContext, + ): EvaluationSeriesData[] { + return hooks.map((hook) => + this.tryExecuteStage( + BEFORE_EVALUATION_STAGE_NAME, + this.hookName(hook), + () => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, + ), + ); + } + + private prepareHooks( + key: string, + context: LDContext, + defaultValue: unknown, + methodName: string, + ): { + hooks: Hook[]; + hookContext: EvaluationSeriesContext; + } { + // Copy the hooks to use a consistent set during evaluation. Hooks could be added and we want + // to ensure all correct stages for any give hook execute. Not for instance the afterEvaluation + // stage without beforeEvaluation having been called on that hook. + const hooks: Hook[] = [...this.hooks]; + const hookContext: EvaluationSeriesContext = { + flagKey: key, + context, + defaultValue, + method: methodName, + }; + return { hooks, hookContext }; + } + + addHook(hook: Hook): void { + this.hooks.push(hook); + } +} From f1848085f6d0d540ab2e05e1ad26ea1bd048d808 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 3 Apr 2024 13:59:29 -0700 Subject: [PATCH 3/6] chore: Add support for hook errors contract test. (#419) --- contract-tests/TestHook.js | 10 +++++++++- contract-tests/sdkClientEntity.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/contract-tests/TestHook.js b/contract-tests/TestHook.js index 9cb2274f3..0c3457eb3 100644 --- a/contract-tests/TestHook.js +++ b/contract-tests/TestHook.js @@ -1,10 +1,11 @@ import got from 'got'; export default class TestHook { - constructor(name, endpoint, data) { + constructor(name, endpoint, data, errors) { this._name = name; this._endpoint = endpoint; this._data = data; + this._errors = errors; } async _safePost(body) { @@ -23,6 +24,9 @@ export default class TestHook { } beforeEvaluation(hookContext, data) { + if(this._errors?.beforeEvaluation) { + throw new Error(this._errors.beforeEvaluation); + } this._safePost({ evaluationSeriesContext: hookContext, evaluationSeriesData: data, @@ -32,6 +36,9 @@ export default class TestHook { } afterEvaluation(hookContext, data, detail) { + if(this._errors?.afterEvaluation) { + throw new Error(this._errors.afterEvaluation); + } this._safePost({ evaluationSeriesContext: hookContext, evaluationSeriesData: data, @@ -39,6 +46,7 @@ export default class TestHook { evaluationDetail: detail, }); + return { ...data, ...(this._data?.['afterEvaluation'] || {}) }; } } diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index c3891fad5..0734ba582 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -64,7 +64,7 @@ export function makeSdkConfig(options, tag) { } if (options.hooks) { cf.hooks = options.hooks.hooks.map( - (hook) => new TestHook(hook.name, hook.callbackUri, hook.data), + (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), ); } return cf; From eba02cf8eda815f6bbe79fa08b40b1243a101e42 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 4 Apr 2024 09:27:38 -0700 Subject: [PATCH 4/6] feat: Add OpenTelemetry support for node-server-sdk. (#401) --- ...stores-node-server-sdk-otel--bug_report.md | 36 ++++ ...s-node-server-sdk-otel--feature_request.md | 19 ++ .github/workflows/manual-publish-docs.yml | 1 + .github/workflows/manual-publish.yml | 1 + .github/workflows/node-otel.yml | 27 +++ .github/workflows/release-please.yml | 24 +++ .release-please-manifest.json | 3 +- README.md | 12 ++ package.json | 5 +- .../telemetry/node-server-sdk-otel/LICENSE | 13 ++ .../telemetry/node-server-sdk-otel/README.md | 69 +++++++ .../node-server-sdk-otel/jest.config.js | 8 + .../node-server-sdk-otel/package.json | 63 +++++++ .../src/TracingHook.test.ts | 138 ++++++++++++++ .../node-server-sdk-otel/src/TracingHook.ts | 174 ++++++++++++++++++ .../node-server-sdk-otel/src/index.ts | 2 + .../node-server-sdk-otel/tsconfig.eslint.json | 5 + .../node-server-sdk-otel/tsconfig.json | 19 ++ .../node-server-sdk-otel/tsconfig.ref.json | 7 + .../node-server-sdk-otel/typedoc.json | 5 + release-please-config.json | 5 +- tsconfig.json | 3 + 22 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md create mode 100644 .github/workflows/node-otel.yml create mode 100644 packages/telemetry/node-server-sdk-otel/LICENSE create mode 100644 packages/telemetry/node-server-sdk-otel/README.md create mode 100644 packages/telemetry/node-server-sdk-otel/jest.config.js create mode 100644 packages/telemetry/node-server-sdk-otel/package.json create mode 100644 packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts create mode 100644 packages/telemetry/node-server-sdk-otel/src/TracingHook.ts create mode 100644 packages/telemetry/node-server-sdk-otel/src/index.ts create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.json create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.ref.json create mode 100644 packages/telemetry/node-server-sdk-otel/typedoc.json diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md new file mode 100644 index 000000000..8887a360c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md @@ -0,0 +1,36 @@ +--- +name: '@launchdarkly/node-server-sdk-otel Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: telemetry/node-server-sdk-otel, bug' +assignees: '' +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the code in this library. If you're not sure whether the problem you are having is specifically related to this library, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md new file mode 100644 index 000000000..7f3383906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md @@ -0,0 +1,19 @@ +--- +name: '@launchdarkly/node-server-sdk-otel Feature Request' +about: Suggest an idea for this project +title: '' +labels: 'package: telemetry/node-server-sdk-otel, feature' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index 9380d4e17..88133c146 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -18,6 +18,7 @@ on: - packages/sdk/akamai-edgekv - packages/store/node-server-sdk-redis - packages/store/node-server-sdk-dynamodb + - packages/telemetry/node-server-sdk-otel name: Publish Documentation jobs: build-publish: diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index ca6ddc421..d75cb0319 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -21,6 +21,7 @@ on: - packages/sdk/akamai-edgekv - packages/store/node-server-sdk-redis - packages/store/node-server-sdk-dynamodb + - packages/telemetry/node-server-sdk-otel prerelease: description: 'Is this a prerelease. If so, then the latest tag will not be updated in npm.' type: boolean diff --git a/.github/workflows/node-otel.yml b/.github/workflows/node-otel.yml new file mode 100644 index 000000000..0edf497ae --- /dev/null +++ b/.github/workflows/node-otel.yml @@ -0,0 +1,27 @@ +name: telemetry/node-server-sdk-otel + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-node-server-otel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://registry.npmjs.org' + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/node-server-sdk-otel' + workspace_path: packages/telemetry/node-server-sdk-otel diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d418bd7e1..70dbaf2b5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -21,6 +21,7 @@ jobs: package-akamai-edgekv-released: ${{ steps.release.outputs['packages/sdk/akamai-edgekv--release_created'] }} package-node-server-sdk-redis-release: ${{ steps.release.outputs['packages/store/node-server-sdk-redis--release_created'] }} package-node-server-sdk-dynamodb-release: ${{ steps.release.outputs['packages/store/node-server-sdk-dynamodb--release_created'] }} + package-node-server-sdk-otel-release: ${{ steps.release.outputs['packages/telemetry/node-server-sdk-otel--release_created'] }} steps: - uses: google-github-actions/release-please-action@v4 id: release @@ -331,3 +332,26 @@ jobs: with: workspace_path: packages/store/node-server-sdk-dynamodb aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + + release-node-server-sdk-otel: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + id-token: write + contents: write + if: ${{ needs.release-please.outputs.package-node-server-sdk-otel-release == 'true' }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + registry-url: 'https://registry.npmjs.org' + - uses: ./actions/install-npm-version + with: + npm_version: 9.5.0 + - id: release-node-server-sdk-otel + name: Full release of packages/telemetry/node-server-sdk-otel + uses: ./actions/full-release + with: + workspace_path: packages/telemetry/node-server-sdk-otel + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d4f13e670..60bd448e1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,5 +11,6 @@ "packages/store/node-server-sdk-dynamodb": "6.1.7", "packages/store/node-server-sdk-redis": "4.1.7", "packages/shared/sdk-client": "1.1.1", - "packages/sdk/react-native": "10.1.1" + "packages/sdk/react-native": "10.1.1", + "packages/telemetry/node-server-sdk-otel": "0.0.1" } diff --git a/README.md b/README.md index 4ea80c2d0..c50a78700 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/node-server-sdk-redis](packages/store/node-server-sdk-redis/README.md) | [![NPM][node-redis-npm-badge]][node-redis-npm-link] | [Node Redis][node-redis-issues] | [![Actions Status][node-redis-ci-badge]][node-redis-ci] | | [@launchdarkly/node-server-sdk-dynamodb](packages/store/node-server-sdk-dynamodb/README.md) | [![NPM][node-dynamodb-npm-badge]][node-dynamodb-npm-link] | [Node DynamoDB][node-dynamodb-issues] | [![Actions Status][node-dynamodb-ci-badge]][node-dynamodb-ci] | +| Telemetry Packages | npm | issues | tests | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | +| [@launchdarkly/node-server-sdk-otel](packages/telemetry/node-server-sdk-otel/README.md) | [![NPM][node-otel-npm-badge]][node-otel-npm-link] | [Node OTel][node-otel-issues] | [![Actions Status][node-otel-ci-badge]][node-otel-ci] | + ## Organization `packages` Top level directory containing package implementations. @@ -36,6 +40,8 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. `packages/store` Persistent store packages for use with SDKs in this repository. +`packages/telemetry` Packages for adding telemetry support to SDKs. + ## LaunchDarkly overview [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! @@ -157,3 +163,9 @@ We encourage pull requests and other contributions from the community. Check out [node-dynamodb-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-dynamodb.svg?style=flat-square [node-dynamodb-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-dynamodb [node-dynamodb-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+store%2Fnode-server-sdk-dynamodb%22+ +[//]: # 'telemetry/node-server-sdk-otel' +[node-otel-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml/badge.svg +[node-otel-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml +[node-otel-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-otel.svg?style=flat-square +[node-otel-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-otel +[node-otel-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+telemetry%2Fnode-server-sdk-otel%22+ \ No newline at end of file diff --git a/package.json b/package.json index 927b215bb..7b80f9ad6 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,13 @@ "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/vercel", - "packages/sdk/vercel/examples/complete", - "packages/sdk/vercel/examples/route-handler", "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", "packages/sdk/akamai-edgekv/example", "packages/store/node-server-sdk-redis", - "packages/store/node-server-sdk-dynamodb" + "packages/store/node-server-sdk-dynamodb", + "packages/telemetry/node-server-sdk-otel" ], "private": true, "scripts": { diff --git a/packages/telemetry/node-server-sdk-otel/LICENSE b/packages/telemetry/node-server-sdk-otel/LICENSE new file mode 100644 index 000000000..a3c1557e3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/packages/telemetry/node-server-sdk-otel/README.md b/packages/telemetry/node-server-sdk-otel/README.md new file mode 100644 index 000000000..9783df499 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/README.md @@ -0,0 +1,69 @@ +# LaunchDarkly Server-Side SDK for Node.js - OpenTelemetry integration + +[![NPM][node-otel-npm-badge]][node-otel-npm-link] +[![Actions Status][node-otel-ci-badge]][node-otel-ci] +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/js-core/packages/telemetry/node-server-sdk-otel/docs/) + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Supported Node versions + +This package is compatible with Node.js versions 14 and above. + +## Quick setup + +This assumes that you have already installed the LaunchDarkly Node.js SDK. + +1. Install this package with `npm` or `yarn`: + +```shell +npm install @launchdarkly/node-server-sdk-otel --save +``` + +2. If your application does not already have its' own dependency on the `@opentelemetry/api` package, add `@opentelemetry/api` as well: + +```shell +npm install @opentelemetry/api --save +``` + +3. Import the tracing hook: + +```typescript +import { TracingHook } from '@launchdarkly/node-server-sdk-otel'; +``` + +4. When configuring your SDK client, add the `TracingHook` + +```typescript +import { init } from '@launchdarkly/node-server-sdk'; + +const client = init('YOUR SDK KEY', {hooks: [new TracingHook()]}); +``` + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). + - Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[node-otel-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml/badge.svg +[node-otel-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml +[node-otel-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-otel.svg?style=flat-square +[node-otel-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-otel diff --git a/packages/telemetry/node-server-sdk-otel/jest.config.js b/packages/telemetry/node-server-sdk-otel/jest.config.js new file mode 100644 index 000000000..bcd6a8d01 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/*.test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], + setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], +}; diff --git a/packages/telemetry/node-server-sdk-otel/package.json b/packages/telemetry/node-server-sdk-otel/package.json new file mode 100644 index 000000000..70c2534e9 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/package.json @@ -0,0 +1,63 @@ +{ + "name": "@launchdarkly/node-server-sdk-otel", + "version": "0.0.1", + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/telemetry/node-server-sdk-otel", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "description": "OpenTelemetry integration for the LaunchDarkly Server-Side SDK for Node.js", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "analytics", + "client" + ], + "scripts": { + "doc": "../../../scripts/build-doc.sh .", + "test": "npx jest --ci", + "build": "npx tsc", + "clean": "npx tsc --build --clean", + "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", + "check": "yarn && yarn prettier && yarn lint && tsc && yarn test", + "lint": "npx eslint . --ext .ts" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@launchdarkly/node-server-sdk": "9.2.2", + "@opentelemetry/api": ">=1.3.0" + }, + "devDependencies": { + "@launchdarkly/node-server-sdk": "9.2.2", + "@launchdarkly/private-js-mocks": "0.0.1", + "@opentelemetry/api": ">=1.3.0", + "@opentelemetry/sdk-node": "0.49.1", + "@opentelemetry/sdk-trace-node": "1.22.0", + "@testing-library/dom": "^9.3.1", + "@testing-library/jest-dom": "^5.16.5", + "@types/jest": "^29.5.3", + "@types/semver": "^7.5.0", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.6.1", + "jest-diff": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^3.0.0", + "ts-jest": "^29.1.1", + "typedoc": "0.25.0", + "typescript": "5.1.6" + } +} diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts new file mode 100644 index 000000000..7438fe9c3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts @@ -0,0 +1,138 @@ +import { trace } from '@opentelemetry/api'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; + +import { basicLogger, init, integrations } from '@launchdarkly/node-server-sdk'; + +import TracingHook from './TracingHook'; + +const spanExporter = new InMemorySpanExporter(); +const sdk = new NodeSDK({ + serviceName: 'ryan-test', + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); +sdk.start(); + +it('validates configuration', async () => { + const messages: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook = new TracingHook({ + // @ts-ignore + spans: 'potato', + // @ts-ignore + includeVariant: 'potato', + logger: basicLogger({ + destination: (text) => { + messages.push(text); + }, + }), + }); + + expect(messages.length).toEqual(2); + expect(messages[0]).toEqual( + 'error: [LaunchDarkly] Config option "includeVariant" should be of type boolean, got string, using default value', + ); + expect(messages[1]).toEqual( + 'error: [LaunchDarkly] Config option "spans" should be of type boolean, got string, using default value', + ); +}); + +it('instance can be created with default config', () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook = new TracingHook(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook2 = new TracingHook({}); + }).not.toThrow(); +}); + +describe('with a testing otel span collector', () => { + afterEach(async () => { + spanExporter.reset(); + }); + + it('produces span events', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.name).toEqual('feature_flag'); + expect(spanEvent.attributes!['feature_flag.key']).toEqual('test-bool'); + expect(spanEvent.attributes!['feature_flag.provider_name']).toEqual('LaunchDarkly'); + expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('user-key'); + expect(spanEvent.attributes!['feature_flag.variant']).toBeUndefined(); + }); + + it('can include variant in span events', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ includeVariant: true })], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.variant']).toEqual('false'); + }); + + it('can include variation spans', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ spans: true })], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const variationSpan = spans[0]; + expect(variationSpan.name).toEqual('LDClient.boolVariation'); + expect(variationSpan.attributes['feature_flag.context.key']).toEqual('user-key'); + }); + + it('can handle multi-context key requirements', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation( + 'test-bool', + { kind: 'multi', user: { key: 'bob' }, org: { key: 'org-key' } }, + false, + ); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('org:org-key:user:bob'); + }); +}); diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts new file mode 100644 index 000000000..82aade9ff --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts @@ -0,0 +1,174 @@ +// eslint-disable-next-line max-classes-per-file +import { Attributes, context, Span, trace } from '@opentelemetry/api'; + +import { + basicLogger, + Context, + integrations, + LDEvaluationDetail, + LDLogger, + OptionMessages, + SafeLogger, + TypeValidators, +} from '@launchdarkly/node-server-sdk'; + +const FEATURE_FLAG_SCOPE = 'feature_flag'; +const FEATURE_FLAG_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.key`; +const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider_name`; +const FEATURE_FLAG_CONTEXT_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.context.key`; +const FEATURE_FLAG_VARIANT_ATTR = `${FEATURE_FLAG_SCOPE}.variant`; + +const TRACING_HOOK_NAME = 'LaunchDarkly Tracing Hook'; + +/** + * Options which allow configuring the tracing hook. + */ +export interface TracingHookOptions { + /** + * Experimental: If set to true, then the tracing hook will add spans for each variation + * method call. Span events are always added and are unaffected by this + * setting. + * + * The default value is false. + * + * This feature is experimental and the data in the spans, or nesting of spans, + * could change in future versions. + */ + spans?: boolean; + + /** + * If set to true, then the tracing hook will add the evaluated flag value + * to span events and spans. + * + * The default is false. + */ + includeVariant?: boolean; + + /** + * Set to use a custom logging configuration, otherwise the logging will be done + * using `console`. + */ + logger?: LDLogger; +} + +interface ValidatedHookOptions { + spans: boolean; + includeVariant: boolean; + logger: LDLogger; +} + +type SpanTraceData = { + span?: Span; +}; + +const defaultOptions: ValidatedHookOptions = { + spans: false, + includeVariant: false, + logger: basicLogger({ name: TRACING_HOOK_NAME }), +}; + +function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { + const validatedOptions: ValidatedHookOptions = { ...defaultOptions }; + + if (options?.logger !== undefined) { + validatedOptions.logger = new SafeLogger(options.logger, defaultOptions.logger); + } + + if (options?.includeVariant !== undefined) { + if (TypeValidators.Boolean.is(options.includeVariant)) { + validatedOptions.includeVariant = options.includeVariant; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('includeVariant', 'boolean', typeof options?.includeVariant), + ); + } + } + + if (options?.spans !== undefined) { + if (TypeValidators.Boolean.is(options.spans)) { + validatedOptions.spans = options.spans; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('spans', 'boolean', typeof options?.spans), + ); + } + } + + return validatedOptions; +} + +/** + * The TracingHook adds OpenTelemetry support to the LaunchDarkly SDK. + * + * By default, span events will be added for each call to a "Variation" method. + * + * The span event will include the canonicalKey of the context, the provider of the evaluation + * (LaunchDarkly), and the key of the flag being evaluated. + */ +export default class TracingHook implements integrations.Hook { + private readonly options: ValidatedHookOptions; + private readonly tracer = trace.getTracer('launchdarkly-client'); + + /** + * Construct a TracingHook with the given options. + * + * @param options Options to customize tracing behavior. + */ + constructor(options?: TracingHookOptions) { + this.options = validateOptions(options); + } + + /** + * Get the meta-data for the tracing hook. + */ + getMetadata(): integrations.HookMetadata { + return { + name: TRACING_HOOK_NAME, + }; + } + + /** + * Implements the "beforeEvaluation" stage of the TracingHook. + */ + beforeEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + if (this.options.spans) { + const { canonicalKey } = Context.fromLDContext(hookContext.context); + + const span = this.tracer.startSpan(hookContext.method, undefined, context.active()); + span.setAttribute('feature_flag.context.key', canonicalKey); + span.setAttribute('feature_flag.key', hookContext.flagKey); + + return { ...data, span }; + } + return data; + } + + /** + * Implements the "afterEvaluation" stage of the TracingHook. + */ + afterEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + (data as SpanTraceData).span?.end(); + + const currentTrace = trace.getActiveSpan(); + if (currentTrace) { + const eventAttributes: Attributes = { + [FEATURE_FLAG_KEY_ATTR]: hookContext.flagKey, + [FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly', + [FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, + }; + if (this.options.includeVariant) { + eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value); + } + currentTrace.addEvent(FEATURE_FLAG_SCOPE, eventAttributes); + } + + return data; + } +} diff --git a/packages/telemetry/node-server-sdk-otel/src/index.ts b/packages/telemetry/node-server-sdk-otel/src/index.ts new file mode 100644 index 000000000..c814dffa3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/index.ts @@ -0,0 +1,2 @@ +export { default as TracingHook } from './TracingHook'; +export { TracingHookOptions } from './TracingHook'; diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json b/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.json b/packages/telemetry/node-server-sdk-otel/tsconfig.json new file mode 100644 index 000000000..dbe90f4cc --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ES2017", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "stripInternal": true, + }, + "include": ["src"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json b/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json new file mode 100644 index 000000000..0c86b2c55 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/telemetry/node-server-sdk-otel/typedoc.json b/packages/telemetry/node-server-sdk-otel/typedoc.json new file mode 100644 index 000000000..7ac616b54 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/release-please-config.json b/release-please-config.json index 9aa9c3649..b15eecceb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -18,7 +18,10 @@ "extra-files": ["src/index.ts"] }, "packages/store/node-server-sdk-dynamodb": {}, - "packages/store/node-server-sdk-redis": {} + "packages/store/node-server-sdk-redis": {}, + "packages/telemetry/node-server-sdk-otel": { + "bump-minor-pre-major": true + } }, "plugins": ["node-workspace"] } diff --git a/tsconfig.json b/tsconfig.json index 3c2f59d95..28f83728f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,6 +45,9 @@ }, { "path": "./packages/store/node-server-sdk-dynamodb/tsconfig.ref.json" + }, + { + "path": "./packages/telemetry/node-server-sdk-otel/tsconfig.ref.json" } ] } From b6e3613d8eae973d8b9c026c0b3d58d02cbba131 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 4 Apr 2024 09:47:31 -0700 Subject: [PATCH 5/6] chore: Set otel package to release as 1.0.0 (#423) --- release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config.json b/release-please-config.json index b15eecceb..04e80ca76 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -20,7 +20,7 @@ "packages/store/node-server-sdk-dynamodb": {}, "packages/store/node-server-sdk-redis": {}, "packages/telemetry/node-server-sdk-otel": { - "bump-minor-pre-major": true + "release-as": "1.0.0" } }, "plugins": ["node-workspace"] From 8d3e48417aeea55f59a6efe569e1593f2c71860e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 10 Apr 2024 10:13:19 -0700 Subject: [PATCH 6/6] chore: Change version number to match latest. --- packages/telemetry/node-server-sdk-otel/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/telemetry/node-server-sdk-otel/package.json b/packages/telemetry/node-server-sdk-otel/package.json index 70c2534e9..6db279f57 100644 --- a/packages/telemetry/node-server-sdk-otel/package.json +++ b/packages/telemetry/node-server-sdk-otel/package.json @@ -29,11 +29,11 @@ }, "license": "Apache-2.0", "peerDependencies": { - "@launchdarkly/node-server-sdk": "9.2.2", + "@launchdarkly/node-server-sdk": "9.2.4", "@opentelemetry/api": ">=1.3.0" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "9.2.2", + "@launchdarkly/node-server-sdk": "9.2.4", "@launchdarkly/private-js-mocks": "0.0.1", "@opentelemetry/api": ">=1.3.0", "@opentelemetry/sdk-node": "0.49.1",