From a986478ed8e39d0f529ca6adec0a09b484421390 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:36:23 -0700 Subject: [PATCH] feat: Add support for inspectors. (#625) The flag usage and identify were able to be implemented in terms of hooks. The flag configuration related inspectors had to be implemented directly. It would be ideal to figure out how to gauge demand for this functionality and simply remove it when we deprecated hooks if demand is low. --- .../__tests__/BrowserDataManager.test.ts | 1 + .../__tests__/MobileDataManager.test.ts | 1 + .../LDCLientImpl.inspections.test.ts | 191 +++++++++++++++++ .../inspection/InspectionManager.test.ts | 200 ++++++++++++++++++ .../inspection/createSafeInspector.test.ts | 82 +++++++ .../shared/sdk-client/src/LDClientImpl.ts | 33 ++- .../shared/sdk-client/src/api/LDClient.ts | 2 +- .../shared/sdk-client/src/api/LDInspection.ts | 126 +++++++++++ .../shared/sdk-client/src/api/LDOptions.ts | 15 +- .../src/configuration/Configuration.ts | 4 + .../src/configuration/validators.ts | 1 + .../sdk-client/src/context/addAutoEnv.ts | 6 +- .../sdk-client/src/context/ensureKey.ts | 2 +- .../src/flag-manager/FlagUpdater.ts | 12 +- .../src/inspection/InspectorManager.ts | 106 ++++++++++ .../src/inspection/createSafeInspector.ts | 43 ++++ .../src/inspection/getInspectorHook.ts | 24 +++ .../sdk-client/src/inspection/messages.ts | 7 + 18 files changed, 847 insertions(+), 9 deletions(-) create mode 100644 packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts create mode 100644 packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts create mode 100644 packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts create mode 100644 packages/shared/sdk-client/src/api/LDInspection.ts create mode 100644 packages/shared/sdk-client/src/inspection/InspectorManager.ts create mode 100644 packages/shared/sdk-client/src/inspection/createSafeInspector.ts create mode 100644 packages/shared/sdk-client/src/inspection/getInspectorHook.ts create mode 100644 packages/shared/sdk-client/src/inspection/messages.ts diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 4a50480c4..8266e600e 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -94,6 +94,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { userAgentHeaderName: 'user-agent', trackEventModifier: (event) => event, hooks: [], + inspectors: [], }; const mockedFetch = mockFetch('{"flagA": true}', 200); platform = { diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index 5c651cb3c..9fdb9fa02 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -82,6 +82,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { userAgentHeaderName: 'user-agent', trackEventModifier: (event) => event, hooks: [], + inspectors: [], }; const mockedFetch = mockFetch('{"flagA": true}', 200); platform = { diff --git a/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts b/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts new file mode 100644 index 000000000..487299d6c --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts @@ -0,0 +1,191 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { AutoEnvAttributes, clone } from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../src/api/LDInspection'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags, PatchFlag } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +it('calls flag-used inspectors', async () => { + const flagUsedInspector: LDInspection = { + type: 'flag-used', + name: 'test flag used inspector', + method: jest.fn(), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagUsedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + await client.variation('flag-key', false); + + expect(flagUsedInspector.method).toHaveBeenCalledWith( + 'flag-key', + { + value: false, + variationIndex: null, + reason: { + kind: 'ERROR', + errorKind: 'FLAG_NOT_FOUND', + }, + }, + { key: 'user-key' }, + ); +}); + +it('calls client-identity-changed inspectors', async () => { + const identifyInspector: LDInspection = { + type: 'client-identity-changed', + name: 'test client identity inspector', + method: jest.fn(), + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [identifyInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + + expect(identifyInspector.method).toHaveBeenCalledWith({ key: 'user-key' }); +}); + +it('calls flag-detail-changed inspector for individial flag changes on patch', async () => { + const eventQueue = new AsyncQueue(); + const flagDetailChangedInspector: LDInspection = { + type: 'flag-detail-changed', + name: 'test flag detail changed inspector', + method: jest.fn(() => eventQueue.add({})), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagDetailChangedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + let mockEventSource: MockEventSource; + + const putResponse = clone(mockResponseJson); + const putEvents = [{ data: JSON.stringify(putResponse) }]; + platform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + const patchResponse = clone(putResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version += 1; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + + // @ts-ignore + mockEventSource.simulateEvents('patch', patchEvents); + mockEventSource.simulateEvents('put', putEvents); + return mockEventSource; + }, + ); + + await client.identify({ key: 'user-key' }, { waitForNetworkResults: true }); + + await eventQueue.take(); + expect(flagDetailChangedInspector.method).toHaveBeenCalledWith('dev-test-flag', { + reason: null, + value: false, + variationIndex: 0, + }); +}); + +it('calls flag-details-changed inspectors when all flag values change', async () => { + const flagDetailsChangedInspector: LDInspection = { + type: 'flag-details-changed', + name: 'test flag details changed inspector', + method: jest.fn(), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagDetailsChangedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + let mockEventSource: MockEventSource; + + platform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + const simulatedEvents = [{ data: JSON.stringify(mockResponseJson) }]; + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + await client.identify({ key: 'user-key' }, { waitForNetworkResults: true }); + expect(flagDetailsChangedInspector.method).toHaveBeenCalledWith({ + 'dev-test-flag': { reason: null, value: true, variationIndex: 0 }, + 'easter-i-tunes-special': { reason: null, value: false, variationIndex: 1 }, + 'easter-specials': { reason: null, value: 'no specials', variationIndex: 3 }, + fdsafdsafdsafdsa: { reason: null, value: true, variationIndex: 0 }, + 'log-level': { reason: null, value: 'warn', variationIndex: 3 }, + 'moonshot-demo': { reason: null, value: true, variationIndex: 0 }, + test1: { reason: null, value: 's1', variationIndex: 0 }, + 'this-is-a-test': { reason: null, value: true, variationIndex: 0 }, + }); +}); diff --git a/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts b/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts new file mode 100644 index 000000000..8871db609 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts @@ -0,0 +1,200 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import InspectorManager from '../../src/inspection/InspectorManager'; + +describe('given an inspector manager with no registered inspectors', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const manager = new InspectorManager([], logger); + + it('does not cause errors and does not produce any logs', () => { + manager.onIdentityChanged({ kind: 'user', key: 'key' }); + manager.onFlagUsed( + 'flag-key', + { + value: null, + reason: null, + }, + { key: 'key' }, + ); + manager.onFlagsChanged({}); + manager.onFlagChanged('flag-key', { + value: null, + reason: null, + }); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('reports that it has no inspectors', () => { + expect(manager.hasInspectors()).toBeFalsy(); + }); +}); + +describe('given an inspector with callbacks of every type', () => { + /** + * @type {AsyncQueue} + */ + const eventQueue = new AsyncQueue(); + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const manager = new InspectorManager( + [ + { + type: 'flag-used', + name: 'my-flag-used-inspector', + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); + }, + }, + // 'flag-used registered twice. + { + type: 'flag-used', + name: 'my-other-flag-used-inspector', + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); + }, + }, + { + type: 'flag-details-changed', + name: 'my-flag-details-inspector', + method: (details) => { + eventQueue.add({ + type: 'flag-details-changed', + details, + }); + }, + }, + { + type: 'flag-detail-changed', + name: 'my-flag-detail-inspector', + method: (flagKey, flagDetail) => { + eventQueue.add({ + type: 'flag-detail-changed', + flagKey, + flagDetail, + }); + }, + }, + { + type: 'client-identity-changed', + name: 'my-identity-inspector', + method: (context) => { + eventQueue.add({ + type: 'client-identity-changed', + context, + }); + }, + }, + // Invalid inspector shouldn't have an effect. + { + // @ts-ignore + type: 'potato', + name: 'my-potato-inspector', + method: () => {}, + }, + ], + logger, + ); + + afterEach(() => { + expect(eventQueue.length()).toEqual(0); + }); + + afterAll(() => { + eventQueue.close(); + }); + + it('logged that there was a bad inspector', () => { + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "my-potato-inspector" of an invalid type (potato) was configured', + ); + }); + + it('executes `onFlagUsed` handlers', async () => { + manager.onFlagUsed( + 'flag-key', + { + value: 'test', + variationIndex: 1, + reason: { + kind: 'OFF', + }, + }, + { key: 'test-key' }, + ); + + const expectedEvent = { + type: 'flag-used', + flagKey: 'flag-key', + flagDetail: { + value: 'test', + variationIndex: 1, + reason: { + kind: 'OFF', + }, + }, + context: { key: 'test-key' }, + }; + const event1 = await eventQueue.take(); + expect(event1).toMatchObject(expectedEvent); + + // There are two handlers, so there should be another event. + const event2 = await eventQueue.take(); + expect(event2).toMatchObject(expectedEvent); + }); + + it('executes `onFlags` handler', async () => { + manager.onFlagsChanged({ + example: { value: 'a-value', reason: null }, + }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'flag-details-changed', + details: { + example: { value: 'a-value' }, + }, + }); + }); + + it('executes `onFlagChanged` handler', async () => { + manager.onFlagChanged('the-flag', { value: 'a-value', reason: null }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'flag-detail-changed', + flagKey: 'the-flag', + flagDetail: { + value: 'a-value', + }, + }); + }); + + it('executes `onIdentityChanged` handler', async () => { + manager.onIdentityChanged({ key: 'the-key' }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'client-identity-changed', + context: { key: 'the-key' }, + }); + }); + + it('reports that it has inspectors', () => { + expect(manager.hasInspectors()).toBeTruthy(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts b/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts new file mode 100644 index 000000000..d85dd1612 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts @@ -0,0 +1,82 @@ +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDInspectionFlagUsedHandler } from '../../src/api/LDInspection'; +import createSafeInspector from '../../src/inspection/createSafeInspector'; + +describe('given a safe inspector', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const mockInspector: LDInspectionFlagUsedHandler = { + type: 'flag-used', + name: 'the-inspector-name', + method: () => { + throw new Error('evil inspector'); + }, + }; + const safeInspector = createSafeInspector(mockInspector, logger); + + it('has the correct type', () => { + expect(safeInspector.type).toEqual('flag-used'); + }); + + it('does not allow exceptions to propagate', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + }); + + it('only logs one error', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "the-inspector-name" of type: "flag-used" generated an exception', + ); + }); +}); + +// Type and name are required by the schema, but it should operate fine if they are not specified. +describe('given a safe inspector with no name or type', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockInspector = { + method: () => { + throw new Error('evil inspector'); + }, + }; + // @ts-ignore Allow registering the invalid inspector. + const safeInspector = createSafeInspector(mockInspector, logger); + + it('has undefined type', () => { + expect(safeInspector.type).toBeUndefined(); + }); + + it('has undefined name', () => { + expect(safeInspector.name).toBeUndefined(); + }); + + it('does not allow exceptions to propagate', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + }); + + it('only logs one error', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "undefined" of type: "undefined" generated an exception', + ); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 106a5ed6d..e62081624 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -31,7 +31,10 @@ import { import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager'; +import { FlagChangeType } from './flag-manager/FlagUpdater'; import HookRunner from './HookRunner'; +import { getInspectorHook } from './inspection/getInspectorHook'; +import InspectorManager from './inspection/InspectorManager'; import LDEmitter, { EventName } from './LDEmitter'; const { ClientMessages, ErrorKinds } = internal; @@ -57,6 +60,7 @@ export default class LDClientImpl implements LDClient { private _baseHeaders: LDHeaders; protected dataManager: DataManager; private _hookRunner: HookRunner; + private _inspectorManager: InspectorManager; /** * Creates the client object synchronously. No async, no network calls. @@ -107,7 +111,8 @@ export default class LDClientImpl implements LDClient { this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`); }); - this._flagManager.on((context, flagKeys) => { + this._flagManager.on((context, flagKeys, type) => { + this._handleInspectionChanged(flagKeys, type); const ldContext = Context.toLDContext(context); this.emitter.emit('change', ldContext, flagKeys); flagKeys.forEach((it) => { @@ -124,6 +129,10 @@ export default class LDClientImpl implements LDClient { ); this._hookRunner = new HookRunner(this.logger, this._config.hooks); + this._inspectorManager = new InspectorManager(this._config.inspectors, this.logger); + if (this._inspectorManager.hasInspectors()) { + this._hookRunner.addHook(getInspectorHook(this._inspectorManager)); + } } allFlags(): LDFlagSet { @@ -474,4 +483,26 @@ export default class LDClientImpl implements LDClient { protected sendEvent(event: internal.InputEvent): void { this._eventProcessor?.sendEvent(event); } + + private _handleInspectionChanged(flagKeys: Array, type: FlagChangeType) { + if (!this._inspectorManager.hasInspectors()) { + return; + } + + const details: Record = {}; + flagKeys.forEach((flagKey) => { + const item = this._flagManager.get(flagKey); + if (item?.flag && !item.flag.deleted) { + const { reason, value, variation } = item.flag; + details[flagKey] = createSuccessEvaluationDetail(value, variation, reason); + } + }); + if (type === 'init') { + this._inspectorManager.onFlagsChanged(details); + } else if (type === 'patch') { + Object.entries(details).forEach(([flagKey, detail]) => { + this._inspectorManager.onFlagChanged(flagKey, detail); + }); + } + } } diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index cec06b37f..cac279bc2 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -328,5 +328,5 @@ export interface LDClient { * * @param Hook The hook to add. */ - addHook?(hook: Hook): void; + addHook(hook: Hook): void; } diff --git a/packages/shared/sdk-client/src/api/LDInspection.ts b/packages/shared/sdk-client/src/api/LDInspection.ts new file mode 100644 index 000000000..bf73dea37 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDInspection.ts @@ -0,0 +1,126 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; + +import { LDEvaluationDetail } from './LDEvaluationDetail'; + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag usage. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagUsedHandler { + type: 'flag-used'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when a flag is accessed via a variation method, or it can be called based on actions in + * wrapper SDKs which have different methods of tracking when a flag was accessed. It is not called when a call is made + * to allFlags. + */ + method: (flagKey: string, flagDetail: LDEvaluationDetail, context?: LDContext) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag data. In order to understand the + * current flag state it should be combined with {@link LDInspectionFlagValueChangedHandler}. + * This interface will get the initial flag information, and + * {@link LDInspectionFlagValueChangedHandler} will provide changes to individual flags. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailsChangedHandler { + type: 'flag-details-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when the flags in the store are replaced with new flags. It will contain all flags + * regardless of if they have been evaluated. + */ + method: (details: Record) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect changes to flag data, but does not provide the initial + * data. It can be combined with {@link LDInspectionFlagValuesChangedHandler} to track the + * entire flag state. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailChangedHandler { + type: 'flag-detail-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when a flag is updated. It will not be called + * when all flags are updated. + */ + method: (flagKey: string, detail: LDEvaluationDetail) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to track current identity state of the SDK. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionIdentifyHandler { + type: 'client-identity-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method will be called when an identify operation completes. + */ + method: (context: LDContext) => void; +} + +export type LDInspection = + | LDInspectionFlagUsedHandler + | LDInspectionFlagDetailsChangedHandler + | LDInspectionFlagDetailChangedHandler + | LDInspectionIdentifyHandler; diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 6c752a72f..27627011a 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,6 +1,7 @@ import type { LDLogger } from '@launchdarkly/js-sdk-common'; import { Hook } from './integrations/Hooks'; +import { LDInspection } from './LDInspection'; export interface LDOptions { /** @@ -251,10 +252,20 @@ export interface LDOptions { * Example: * ```typescript * import { init } from '@launchdarkly/node-server-sdk'; - * import { TracingHook } from '@launchdarkly/node-server-sdk-otel'; + * import { TheHook } from '@launchdarkly/some-hook'; * - * const client = init('my-sdk-key', { hooks: [new TracingHook()] }); + * const client = init('my-sdk-key', { hooks: [new TheHook()] }); * ``` */ hooks?: Hook[]; + + /** + * Inspectors can be used for collecting information for monitoring, analytics, and debugging. + * + * + * @deprecated Hooks should be used instead of inspectors and inspectors will be removed in + * a future version. If you need functionality that is not exposed using hooks, then please + * let us know through a github issue or support. + */ + inspectors?: LDInspection[]; } diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 4a7cf34d5..a055ffa32 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -12,6 +12,7 @@ import { } from '@launchdarkly/js-sdk-common'; import { Hook, type LDOptions } from '../api'; +import { LDInspection } from '../api/LDInspection'; import validators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; @@ -53,6 +54,7 @@ export interface Configuration { readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; readonly trackEventModifier: (event: internal.InputCustomEvent) => internal.InputCustomEvent; readonly hooks: Hook[]; + readonly inspectors: LDInspection[]; } const DEFAULT_POLLING: string = 'https://clientsdk.launchdarkly.com'; @@ -122,6 +124,8 @@ export default class ConfigurationImpl implements Configuration { public readonly hooks: Hook[] = []; + public readonly inspectors: LDInspection[] = []; + public readonly trackEventModifier: ( event: internal.InputCustomEvent, ) => internal.InputCustomEvent; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index be637d90d..fba69b438 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -33,6 +33,7 @@ const validators: Record = { wrapperVersion: TypeValidators.String, payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), hooks: TypeValidators.createTypeArray('Hook[]', {}), + inspectors: TypeValidators.createTypeArray('LDInspection', {}), }; export default validators; diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts index 682a44071..0a2a2417d 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -103,7 +103,11 @@ export const addDeviceInfo = async (platform: Platform) => { return undefined; }; -export const addAutoEnv = async (context: LDContext, platform: Platform, config: Configuration) => { +export const addAutoEnv = async ( + context: LDContext, + platform: Platform, + config: Configuration, +): Promise => { // LDUser is not supported for auto env reporting if (isLegacyUser(context)) { return context as LDUser; diff --git a/packages/shared/sdk-client/src/context/ensureKey.ts b/packages/shared/sdk-client/src/context/ensureKey.ts index 5ba0f2309..877ef7dd8 100644 --- a/packages/shared/sdk-client/src/context/ensureKey.ts +++ b/packages/shared/sdk-client/src/context/ensureKey.ts @@ -63,7 +63,7 @@ const ensureKeyLegacy = async (c: LDUser, platform: Platform) => { * @param context * @param platform */ -export const ensureKey = async (context: LDContext, platform: Platform) => { +export const ensureKey = async (context: LDContext, platform: Platform): Promise => { const cloned = clone(context); if (isSingleKind(cloned)) { diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index f556094ca..c7b1a130f 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -4,6 +4,8 @@ import calculateChangedKeys from './calculateChangedKeys'; import FlagStore from './FlagStore'; import { ItemDescriptor } from './ItemDescriptor'; +export type FlagChangeType = 'init' | 'patch'; + /** * This callback indicates that the details associated with one or more flags * have changed. @@ -17,7 +19,11 @@ import { ItemDescriptor } from './ItemDescriptor'; * This event does not include the value of the flag. It is expected that you * will call a variation method for flag values which you require. */ -export type FlagsChangeCallback = (context: Context, flagKeys: Array) => void; +export type FlagsChangeCallback = ( + context: Context, + flagKeys: Array, + type: FlagChangeType, +) => void; /** * The flag updater handles logic required during the flag update process. @@ -43,7 +49,7 @@ export default class FlagUpdater { if (changed.length > 0) { this._changeCallbacks.forEach((callback) => { try { - callback(context, changed); + callback(context, changed, 'init'); } catch (err) { /* intentionally empty */ } @@ -74,7 +80,7 @@ export default class FlagUpdater { this._flagStore.insertOrUpdate(key, item); this._changeCallbacks.forEach((callback) => { try { - callback(context, [key]); + callback(context, [key], 'patch'); } catch (err) { /* intentionally empty */ } diff --git a/packages/shared/sdk-client/src/inspection/InspectorManager.ts b/packages/shared/sdk-client/src/inspection/InspectorManager.ts new file mode 100644 index 000000000..779f80249 --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/InspectorManager.ts @@ -0,0 +1,106 @@ +import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDEvaluationDetail } from '../api'; +import { LDInspection } from '../api/LDInspection'; +import createSafeInspector from './createSafeInspector'; +import { invalidInspector } from './messages'; + +const FLAG_USED_TYPE = 'flag-used'; +const FLAG_DETAILS_CHANGED_TYPE = 'flag-details-changed'; +const FLAG_DETAIL_CHANGED_TYPE = 'flag-detail-changed'; +const IDENTITY_CHANGED_TYPE = 'client-identity-changed'; + +const VALID__TYPES = [ + FLAG_USED_TYPE, + FLAG_DETAILS_CHANGED_TYPE, + FLAG_DETAIL_CHANGED_TYPE, + IDENTITY_CHANGED_TYPE, +]; + +function validateInspector(inspector: LDInspection, logger: LDLogger): boolean { + const valid = + VALID__TYPES.includes(inspector.type) && + inspector.method && + typeof inspector.method === 'function'; + + if (!valid) { + logger.warn(invalidInspector(inspector.type, inspector.name)); + } + + return valid; +} + +/** + * Manages dispatching of inspection data to registered inspectors. + */ +export default class InspectorManager { + private _safeInspectors: LDInspection[] = []; + + constructor(inspectors: LDInspection[], logger: LDLogger) { + const validInspectors = inspectors.filter((inspector) => validateInspector(inspector, logger)); + this._safeInspectors = validInspectors.map((inspector) => + createSafeInspector(inspector, logger), + ); + } + + hasInspectors(): boolean { + return this._safeInspectors.length !== 0; + } + + /** + * Notify registered inspectors of a flag being used. + * + * @param flagKey The key for the flag. + * @param detail The LDEvaluationDetail for the flag. + * @param context The LDContext for the flag. + */ + onFlagUsed(flagKey: string, detail: LDEvaluationDetail, context?: LDContext) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_USED_TYPE) { + inspector.method(flagKey, detail, context); + } + }); + } + + /** + * Notify registered inspectors that the flags have been replaced. + * + * @param flags The current flags as a Record. + */ + onFlagsChanged(flags: Record) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_DETAILS_CHANGED_TYPE) { + inspector.method(flags); + } + }); + } + + /** + * Notify registered inspectors that a flag value has changed. + * + * @param flagKey The key for the flag that changed. + * @param flag An `LDEvaluationDetail` for the flag. + */ + onFlagChanged(flagKey: string, flag: LDEvaluationDetail) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_DETAIL_CHANGED_TYPE) { + inspector.method(flagKey, flag); + } + }); + } + + /** + * Notify the registered inspectors that the context identity has changed. + * + * The notification itself will be dispatched asynchronously. + * + * @param context The `LDContext` which is now identified. + */ + onIdentityChanged(context: LDContext) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === IDENTITY_CHANGED_TYPE) { + inspector.method(context); + } + }); + } +} diff --git a/packages/shared/sdk-client/src/inspection/createSafeInspector.ts b/packages/shared/sdk-client/src/inspection/createSafeInspector.ts new file mode 100644 index 000000000..02f95ac1f --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/createSafeInspector.ts @@ -0,0 +1,43 @@ +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../api/LDInspection'; +import { inspectorMethodError } from './messages'; + +/** + * Wrap an inspector ensuring that calling its methods are safe. + * @param inspector Inspector to wrap. + */ +export default function createSafeInspector( + inspector: LDInspection, + logger: LDLogger, +): LDInspection { + let errorLogged = false; + const wrapper: LDInspection = { + method: (...args: any[]) => { + try { + // We are proxying arguments here to the underlying method. Typescript doesn't care + // for this as it cannot validate the parameters are correct, but we are also the caller + // in this case and will dispatch things with the correct arguments. The dispatch to this + // will itself happen with a type guard. + // @ts-ignore + inspector.method(...args); + } catch { + // If something goes wrong in an inspector we want to log that something + // went wrong. We don't want to flood the logs, so we only log something + // the first time that something goes wrong. + // We do not include the exception in the log, because we do not know what + // kind of data it may contain. + if (!errorLogged) { + errorLogged = true; + logger.warn(inspectorMethodError(wrapper.type, wrapper.name)); + } + // Prevent errors. + } + }, + type: inspector.type, + name: inspector.name, + synchronous: inspector.synchronous, + }; + + return wrapper; +} diff --git a/packages/shared/sdk-client/src/inspection/getInspectorHook.ts b/packages/shared/sdk-client/src/inspection/getInspectorHook.ts new file mode 100644 index 000000000..14e33ce6d --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/getInspectorHook.ts @@ -0,0 +1,24 @@ +import { EvaluationSeriesContext, EvaluationSeriesData, Hook, LDEvaluationDetail } from '../api'; +import InspectorManager from './InspectorManager'; + +export function getInspectorHook(inspectorManager: InspectorManager): Hook { + return { + getMetadata() { + return { + name: 'LaunchDarkly-Inspector-Adapter', + }; + }, + afterEvaluation: ( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + detail: LDEvaluationDetail, + ) => { + inspectorManager.onFlagUsed(hookContext.flagKey, detail, hookContext.context); + return data; + }, + afterIdentify(hookContext, data, _result) { + inspectorManager.onIdentityChanged(hookContext.context); + return data; + }, + }; +} diff --git a/packages/shared/sdk-client/src/inspection/messages.ts b/packages/shared/sdk-client/src/inspection/messages.ts new file mode 100644 index 000000000..7a7094f4b --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/messages.ts @@ -0,0 +1,7 @@ +export function invalidInspector(type: string, name: string) { + return `an inspector: "${name}" of an invalid type (${type}) was configured`; +} + +export function inspectorMethodError(type: string, name: string) { + return `an inspector: "${name}" of type: "${type}" generated an exception`; +}