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`; +}