From a77d59f6933bfa19f8acec3da97f5cb17d460467 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 17 Jan 2025 14:03:07 -0800 Subject: [PATCH 1/2] feat: Add singleton support for browser-telemetry. --- .../singleton/singletonInstance.test.ts | 35 ++++ .../singleton/singletonMethods.test.ts | 150 ++++++++++++++++++ .../telemetry/browser-telemetry/src/index.ts | 15 +- .../browser-telemetry/src/singleton/index.ts | 2 + .../src/singleton/singletonInstance.ts | 100 ++++++++++++ .../src/singleton/singletonMethods.ts | 98 ++++++++++++ 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/singleton/index.ts create mode 100644 packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts create mode 100644 packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts diff --git a/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts new file mode 100644 index 000000000..01fb77104 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts @@ -0,0 +1,35 @@ +import { fallbackLogger } from '../../src/logging'; +import { getTelemetryInstance, initTelemetry, resetTelemetryInstance } from '../../src/singleton'; + +beforeEach(() => { + resetTelemetryInstance(); + jest.resetAllMocks(); +}); + +it('warns and keeps existing instance when initialized multiple times', () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + initTelemetry({ logger: mockLogger }); + const instanceA = getTelemetryInstance(); + initTelemetry({ logger: mockLogger }); + const instanceB = getTelemetryInstance(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringMatching(/Telemetry has already been initialized/), + ); + + expect(instanceA).toBe(instanceB); +}); + +it('warns when getting telemetry instance before initialization', () => { + const spy = jest.spyOn(fallbackLogger, 'warn'); + + getTelemetryInstance(); + + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/Telemetry has not been initialized/)); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts new file mode 100644 index 000000000..dc4c50256 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts @@ -0,0 +1,150 @@ +import { LDInspection } from '@launchdarkly/js-client-sdk'; + +import { Breadcrumb, LDClientTracking } from '../../src/api'; +import { BrowserTelemetry } from '../../src/api/BrowserTelemetry'; +import { getTelemetryInstance } from '../../src/singleton/singletonInstance'; +import { + addBreadcrumb, + captureError, + captureErrorEvent, + close, + inspectors, + register, +} from '../../src/singleton/singletonMethods'; + +jest.mock('../../src/singleton/singletonInstance'); + +const mockTelemetry: jest.Mocked = { + inspectors: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + addBreadcrumb: jest.fn(), + register: jest.fn(), + close: jest.fn(), +}; + +const mockGetTelemetryInstance = getTelemetryInstance as jest.Mock; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('returns empty array when telemetry is not initialized for inspectors', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + expect(() => inspectors()).not.toThrow(); + expect(inspectors()).toEqual([]); +}); + +it('returns inspectors when telemetry is initialized', () => { + const mockInspectors: LDInspection[] = [ + { name: 'test-inspector', type: 'flag-used', method: () => {} }, + ]; + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + mockTelemetry.inspectors.mockReturnValue(mockInspectors); + + expect(inspectors()).toBe(mockInspectors); +}); + +it('does not crash when calling captureError with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const error = new Error('test error'); + + expect(() => captureError(error)).not.toThrow(); + + expect(mockTelemetry.captureError).not.toHaveBeenCalled(); +}); + +it('captures errors when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const error = new Error('test error'); + + captureError(error); + + expect(mockTelemetry.captureError).toHaveBeenCalledWith(error); +}); + +it('it does not crash when calling captureErrorEvent with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const errorEvent = new ErrorEvent('error', { error: new Error('test error') }); + + expect(() => captureErrorEvent(errorEvent)).not.toThrow(); + + expect(mockTelemetry.captureErrorEvent).not.toHaveBeenCalled(); +}); + +it('captures error event when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const errorEvent = new ErrorEvent('error', { error: new Error('test error') }); + + captureErrorEvent(errorEvent); + + expect(mockTelemetry.captureErrorEvent).toHaveBeenCalledWith(errorEvent); +}); + +it('does not crash when calling addBreadcrumb with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const breadcrumb: Breadcrumb = { + type: 'custom', + data: { test: 'data' }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }; + + expect(() => addBreadcrumb(breadcrumb)).not.toThrow(); + + expect(mockTelemetry.addBreadcrumb).not.toHaveBeenCalled(); +}); + +it('adds breadcrumb when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const breadcrumb: Breadcrumb = { + type: 'custom', + data: { test: 'data' }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }; + + addBreadcrumb(breadcrumb); + + expect(mockTelemetry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb); +}); + +it('does not crash when calling register with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const mockClient: jest.Mocked = { + track: jest.fn(), + }; + + expect(() => register(mockClient)).not.toThrow(); + + expect(mockTelemetry.register).not.toHaveBeenCalled(); +}); + +it('registers client when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const mockClient: jest.Mocked = { + track: jest.fn(), + }; + + register(mockClient); + + expect(mockTelemetry.register).toHaveBeenCalledWith(mockClient); +}); + +it('does not crash when calling close with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + + expect(() => close()).not.toThrow(); + + expect(mockTelemetry.close).not.toHaveBeenCalled(); +}); + +it('closes when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + + close(); + + expect(mockTelemetry.close).toHaveBeenCalled(); +}); diff --git a/packages/telemetry/browser-telemetry/src/index.ts b/packages/telemetry/browser-telemetry/src/index.ts index e52ba601c..2cd820acb 100644 --- a/packages/telemetry/browser-telemetry/src/index.ts +++ b/packages/telemetry/browser-telemetry/src/index.ts @@ -6,7 +6,20 @@ import parse from './options'; export * from './api'; -export function initializeTelemetry(options?: Options): BrowserTelemetry { +export * from './singleton'; + +/** + * Initialize a new telemetry instance. + * + * This instance is not global. Generally developers should use {@link initializeTelemetry} instead. + * + * If for some reason multiple telemetry instances are needed, this method can be used to create a new instance. + * Instances are not aware of each other and may send duplicate data from automatically captured events. + * + * @param options The options to use for the telemetry instance. + * @returns A telemetry instance. + */ +export function initializeTelemetryInstance(options?: Options): BrowserTelemetry { const parsedOptions = parse(options || {}, safeMinLogger(options?.logger)); return new BrowserTelemetryImpl(parsedOptions); } diff --git a/packages/telemetry/browser-telemetry/src/singleton/index.ts b/packages/telemetry/browser-telemetry/src/singleton/index.ts new file mode 100644 index 000000000..00fe46e85 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/index.ts @@ -0,0 +1,2 @@ +export * from './singletonInstance'; +export * from './singletonMethods'; diff --git a/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts new file mode 100644 index 000000000..32664dd8d --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts @@ -0,0 +1,100 @@ +import { Options } from '../api'; +import { BrowserTelemetry } from '../api/BrowserTelemetry'; +import BrowserTelemetryImpl from '../BrowserTelemetryImpl'; +import { fallbackLogger, prefixLog, safeMinLogger } from '../logging'; +import parse from '../options'; + +let telemetryInstance: BrowserTelemetry | undefined; +let warnedClientNotInitialized: boolean = false; + +/** + * Initialize the LaunchDarkly telemetry client + * + * This method should be called one time as early as possible in the application lifecycle. + * + * @example + * ``` + * import { initTelemetry } from '@launchdarkly/browser-telemetry'; + * + * initTelemetry(); + * ``` + * + * After initialization the telemetry client must be registered with the LaunchDarkly SDK client. + * + * @example + * ``` + * import { initTelemetry, register } from '@launchdarkly/browser-telemetry'; + * + * initTelemetry(); + * + * // Create your LaunchDarkly client following the LaunchDarkly SDK documentation. + * + * register(ldClient); + * ``` + * + * If using the 3.x version of the LaunchDarkly SDK, then you must also add inspectors when intializing your LaunchDarkly client. + * This allows for integration with feature flag data. + * + * @example + * ``` + * import { initTelemetry, register, inspectors } from '@launchdarkly/browser-telemetry'; + * import { init } from 'launchdarkly-js-client-sdk'; + * + * initTelemetry(); + * + * const ldClient = init('YOUR_CLIENT_SIDE_ID', { + * inspectors: inspectors() + * }); + * + * register(ldClient); + * ``` + * + * @param options The options to use for the telemetry instance. Refer to {@link Options} for more information. + */ +export function initTelemetry(options?: Options) { + const logger = safeMinLogger(options?.logger); + + if (telemetryInstance) { + logger.warn(prefixLog('Telemetry has already been initialized. Ignoring new options.')); + return; + } + + const parsedOptions = parse(options || {}, logger); + telemetryInstance = new BrowserTelemetryImpl(parsedOptions); +} + +/** + * Get the telemetry instance. + * + * In typical operation this method doesn't need to be called. Instead the functions exported by this package directly + * use the telemetry instance. + * + * This function can be used when the telemetry instance needs to be injected into code instead of accessed globally. + * + * @returns The telemetry instance, or undefined if it has not been initialized. + */ +export function getTelemetryInstance(): BrowserTelemetry | undefined { + if (!telemetryInstance) { + if (warnedClientNotInitialized) { + return undefined; + } + + fallbackLogger.warn(prefixLog('Telemetry has not been initialized')); + warnedClientNotInitialized = true; + return undefined; + } + + return telemetryInstance; +} + +/** + * Reset the telemetry instance to its initial state. + * + * This method is intended to be used in tests. + * + * @internal + */ +export function resetTelemetryInstance() { + telemetryInstance = undefined; + warnedClientNotInitialized = false; +} diff --git a/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts b/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts new file mode 100644 index 000000000..e7d0391a9 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts @@ -0,0 +1,98 @@ +import type { LDInspection } from '@launchdarkly/js-client-sdk'; + +import { LDClientTracking } from '../api'; +import { Breadcrumb } from '../api/Breadcrumb'; +import { getTelemetryInstance } from './singletonInstance'; + +/** + * Returns an array of active SDK inspectors to use with SDK versions that do + * not support hooks. + * + * Telemetry must be initialized, using {@link initializeTelemetry} before calling this method. + * If telemetry is not initialized, this method will return an empty array. + * + * @returns An array of {@link LDInspection} objects. + */ +export function inspectors(): LDInspection[] { + return getTelemetryInstance()?.inspectors() || []; +} + +/** + * Captures an Error object for telemetry purposes. + * + * Use this method to manually capture errors during application operation. + * Unhandled errors are automatically captured, but this method can be used + * to capture errors which were handled, but are still useful for telemetry. + * + * Telemetry must be initialized, using {@link initializeTelemetry} before calling this method. + * If telemetry is not initialized, then the exception will be discarded. + * + * @param exception The Error object to capture + */ +export function captureError(exception: Error): void { + getTelemetryInstance()?.captureError(exception); +} + +/** + * Captures a browser ErrorEvent for telemetry purposes. + * + * This method can be used to capture a manually created error event. Use this + * function to represent application specific errors which cannot be captured + * automatically or are not `Error` types. + * + * For most errors {@link captureError} should be used. + * + * Telemetry must be initialized, using {@link initializeTelemetry} before calling this method. + * If telemetry is not initialized, then the error event will be discarded. + * + * @param errorEvent The ErrorEvent to capture + */ +export function captureErrorEvent(errorEvent: ErrorEvent): void { + getTelemetryInstance()?.captureErrorEvent(errorEvent); +} + +/** + * Add a breadcrumb which will be included with telemetry events. + * + * Many breadcrumbs can be automatically captured, but this method can be + * used for capturing manual breadcrumbs. For application specific breadcrumbs + * the {@link CustomBreadcrumb} type can be used. + * + * Telemetry must be initialized, using {@link initializeTelemetry} before calling this method. + * If telemetry is not initialized, then the breadcrumb will be discarded. + * + * @param breadcrumb The breadcrumb to add. + */ +export function addBreadcrumb(breadcrumb: Breadcrumb): void { + getTelemetryInstance()?.addBreadcrumb(breadcrumb); +} + +/** + * Registers a LaunchDarkly client instance for telemetry tracking. + * + * This method connects the telemetry system to the specific LaunchDarkly + * client instance. The client instance will be used to report telemetry + * to LaunchDarkly and also for collecting flag and context data. + * + * Telemetry must be initialized, using {@link initializeTelemetry} before calling this method. + * If telemetry is not initialized, then the client will not be registered, and no events will be sent to LaunchDarkly. + * + * @param client The {@link LDClientTracking} instance to register for + * telemetry. + */ +export function register(client: LDClientTracking): void { + getTelemetryInstance()?.register(client); +} + +/** + * Closes the telemetry system and stops data collection. + * + * In general usage this method is not required, but it can be used in cases + * where collection needs to be stopped independent of application + * lifecycle. + * + * If telemetry is not initialized, using {@link initializeTelemetry}, then this method will do nothing. + */ +export function close(): void { + getTelemetryInstance()?.close(); +} From e3fade61c08939cb0caf35ee7f799e6260b0c5d0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:04:59 -0800 Subject: [PATCH 2/2] Update packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts Co-authored-by: Stacy Harrison <11901347+stasquatch@users.noreply.github.com> --- .../browser-telemetry/src/singleton/singletonInstance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts index 32664dd8d..b04d26e9d 100644 --- a/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts +++ b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts @@ -32,7 +32,7 @@ let warnedClientNotInitialized: boolean = false; * register(ldClient); * ``` * - * If using the 3.x version of the LaunchDarkly SDK, then you must also add inspectors when intializing your LaunchDarkly client. + * If using the 3.x version of the LaunchDarkly SDK, then you must also add inspectors when initializing your LaunchDarkly client. * This allows for integration with feature flag data. * * @example