diff --git a/packages/telemetry/browser-telemetry/__tests__/options.test.ts b/packages/telemetry/browser-telemetry/__tests__/options.test.ts new file mode 100644 index 0000000000..1d5004ba08 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/options.test.ts @@ -0,0 +1,422 @@ +import ErrorCollector from '../src/collectors/error'; +import parse, { defaultOptions } from '../src/options'; + +const mockLogger = { + warn: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('handles an empty configuration', () => { + const outOptions = parse({}); + expect(outOptions).toEqual(defaultOptions()); +}); + +it('can set all options at once', () => { + const outOptions = parse({ + maxPendingEvents: 1, + breadcrumbs: { + maxBreadcrumbs: 1, + click: false, + evaluations: false, + flagChange: false, + }, + collectors: [new ErrorCollector(), new ErrorCollector()], + }); + expect(outOptions).toEqual({ + maxPendingEvents: 1, + breadcrumbs: { + keyboardInput: true, + maxBreadcrumbs: 1, + click: false, + evaluations: false, + flagChange: false, + http: { + customUrlFilter: undefined, + instrumentFetch: true, + instrumentXhr: true, + }, + }, + stack: { + source: { + beforeLines: 3, + afterLines: 3, + maxLineLength: 280, + }, + }, + collectors: [new ErrorCollector(), new ErrorCollector()], + }); +}); + +it('warns when maxPendingEvents is not a number', () => { + const outOptions = parse( + { + // @ts-ignore + maxPendingEvents: 'not a number', + }, + mockLogger, + ); + + expect(outOptions.maxPendingEvents).toEqual(defaultOptions().maxPendingEvents); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "maxPendingEvents" should be of type number, got string, using default value', + ); +}); + +it('accepts valid maxPendingEvents number', () => { + const outOptions = parse( + { + maxPendingEvents: 100, + }, + mockLogger, + ); + + expect(outOptions.maxPendingEvents).toEqual(100); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('warns when breadcrumbs config is not an object', () => { + const outOptions = parse( + { + // @ts-ignore + breadcrumbs: 'not an object', + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs).toEqual(defaultOptions().breadcrumbs); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs" should be of type object, got string, using default value', + ); +}); + +it('warns when collectors is not an array', () => { + const outOptions = parse( + { + // @ts-ignore + collectors: 'not an array', + }, + mockLogger, + ); + + expect(outOptions.collectors).toEqual(defaultOptions().collectors); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "collectors" should be of type Collector[], got string, using default value', + ); +}); + +it('accepts valid collectors array', () => { + const collectors = [new ErrorCollector()]; + const outOptions = parse( + { + collectors, + }, + mockLogger, + ); + + expect(outOptions.collectors).toEqual(collectors); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('warns when stack config is not an object', () => { + const outOptions = parse( + { + // @ts-ignore + stack: 'not an object', + }, + mockLogger, + ); + + expect(outOptions.stack).toEqual(defaultOptions().stack); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "stack" should be of type object, got string, using default value', + ); +}); + +it('warns when breadcrumbs.maxBreadcrumbs is not a number', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + maxBreadcrumbs: 'not a number', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.maxBreadcrumbs).toEqual( + defaultOptions().breadcrumbs.maxBreadcrumbs, + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.maxBreadcrumbs" should be of type number, got string, using default value', + ); +}); + +it('accepts valid breadcrumbs.maxBreadcrumbs number', () => { + const outOptions = parse( + { + breadcrumbs: { + maxBreadcrumbs: 50, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.maxBreadcrumbs).toEqual(50); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('warns when breadcrumbs.click is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + click: 'not a boolean', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.click).toEqual(defaultOptions().breadcrumbs.click); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.click" should be of type boolean, got string, using default value', + ); +}); + +it('warns when breadcrumbs.evaluations is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + evaluations: 'not a boolean', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.evaluations).toEqual(defaultOptions().breadcrumbs.evaluations); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.evaluations" should be of type boolean, got string, using default value', + ); +}); + +it('warns when breadcrumbs.flagChange is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + flagChange: 'not a boolean', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.flagChange).toEqual(defaultOptions().breadcrumbs.flagChange); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.flagChange" should be of type boolean, got string, using default value', + ); +}); + +it('warns when breadcrumbs.keyboardInput is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + keyboardInput: 'not a boolean', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.keyboardInput).toEqual(defaultOptions().breadcrumbs.keyboardInput); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.keyboardInput" should be of type boolean, got string, using default value', + ); +}); + +it('accepts valid breadcrumbs.click boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + click: false, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.click).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('accepts valid breadcrumbs.evaluations boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + evaluations: false, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.evaluations).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('accepts valid breadcrumbs.flagChange boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + flagChange: false, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.flagChange).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('accepts valid breadcrumbs.keyboardInput boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + keyboardInput: false, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.keyboardInput).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('warns when breadcrumbs.http is not an object', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + http: 'not an object', + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http).toEqual(defaultOptions().breadcrumbs.http); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.http" should be of type HttpBreadCrumbOptions | false, got string, using default value', + ); +}); + +it('warns when breadcrumbs.http.instrumentFetch is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + // @ts-ignore + instrumentFetch: 'not a boolean', + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.instrumentFetch).toEqual( + defaultOptions().breadcrumbs.http.instrumentFetch, + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.http.instrumentFetch" should be of type boolean, got string, using default value', + ); +}); + +it('warns when breadcrumbs.http.instrumentXhr is not boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + // @ts-ignore + instrumentXhr: 'not a boolean', + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.instrumentXhr).toEqual( + defaultOptions().breadcrumbs.http.instrumentXhr, + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Config option "breadcrumbs.http.instrumentXhr" should be of type boolean, got string, using default value', + ); +}); + +it('accepts valid breadcrumbs.http.instrumentFetch boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + instrumentFetch: false, + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.instrumentFetch).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('accepts valid breadcrumbs.http.instrumentXhr boolean', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + instrumentXhr: false, + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.instrumentXhr).toEqual(false); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('accepts valid breadcrumbs.http.customUrlFilter function', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + customUrlFilter: (url: string) => url.replace('secret', 'redacted'), + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.customUrlFilter).toBeDefined(); + expect(outOptions.breadcrumbs.http.customUrlFilter?.('test-secret-123')).toBe( + 'test-redacted-123', + ); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('warns when breadcrumbs.http.customUrlFilter is not a function', () => { + const outOptions = parse( + { + breadcrumbs: { + http: { + // @ts-ignore + customUrlFilter: 'not a function', + }, + }, + }, + mockLogger, + ); + + expect(outOptions.breadcrumbs.http.customUrlFilter).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'The "breadcrumbs.http.customUrlFilter" must be a function. Received string', + ); +}); diff --git a/packages/telemetry/browser-telemetry/src/MinLogger.ts b/packages/telemetry/browser-telemetry/src/MinLogger.ts new file mode 100644 index 0000000000..dced72e958 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/MinLogger.ts @@ -0,0 +1,9 @@ +/** + * Minimal logging implementation. Compatible with an LDLogger. + * + * implementation node: Does not use a logging implementation exported by the SDK. + * This allows usage with multiple SDK versions. + */ +export interface MinLogger { + warn(...args: any[]): void; +} diff --git a/packages/telemetry/browser-telemetry/src/options.ts b/packages/telemetry/browser-telemetry/src/options.ts new file mode 100644 index 0000000000..a801f5ed47 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/options.ts @@ -0,0 +1,285 @@ +import { Collector } from './api/Collector'; +import { HttpBreadCrumbOptions, Options, StackOptions, UrlFilter } from './api/Options'; +import { MinLogger } from './MinLogger'; + +export function defaultOptions(): ParsedOptions { + return { + breadcrumbs: { + maxBreadcrumbs: 50, + evaluations: true, + flagChange: true, + click: true, + keyboardInput: true, + http: { + instrumentFetch: true, + instrumentXhr: true, + }, + }, + stack: { + source: { + beforeLines: 3, + afterLines: 3, + maxLineLength: 280, + }, + }, + maxPendingEvents: 100, + collectors: [], + }; +} + +function wrongOptionType(name: string, expectedType: string, actualType: string): string { + return `Config option "${name}" should be of type ${expectedType}, got ${actualType}, using default value`; +} + +function checkBasic(type: string, name: string, logger?: MinLogger): (item: T) => boolean { + return (item: T) => { + const actualType = typeof item; + if (actualType === type) { + return true; + } + logger?.warn(wrongOptionType(name, type, actualType)); + return false; + }; +} + +function itemOrDefault(item: T | undefined, defaultValue: T, checker?: (item: T) => boolean): T { + if (item !== undefined && item !== null) { + if (!checker) { + return item; + } + if (checker(item)) { + return item; + } + } + return defaultValue; +} + +function parseHttp( + options: HttpBreadCrumbOptions | false | undefined, + defaults: ParsedHttpOptions, + logger?: MinLogger, +): ParsedHttpOptions { + if (options !== undefined && options !== false && typeof options !== 'object') { + logger?.warn( + wrongOptionType('breadcrumbs.http', 'HttpBreadCrumbOptions | false', typeof options), + ); + return defaults; + } + + if (options === false) { + return { + instrumentFetch: false, + instrumentXhr: false, + }; + } + + // Make sure that the custom filter is at least a function. + if (options?.customUrlFilter) { + if (typeof options.customUrlFilter !== 'function') { + logger?.warn( + `The "breadcrumbs.http.customUrlFilter" must be a function. Received ${typeof options.customUrlFilter}`, + ); + } + } + const customUrlFilter = + options?.customUrlFilter && typeof options?.customUrlFilter === 'function' + ? options.customUrlFilter + : undefined; + + return { + instrumentFetch: itemOrDefault( + options?.instrumentFetch, + defaults.instrumentFetch, + checkBasic('boolean', 'breadcrumbs.http.instrumentFetch', logger), + ), + instrumentXhr: itemOrDefault( + options?.instrumentXhr, + defaults.instrumentXhr, + checkBasic('boolean', 'breadcrumbs.http.instrumentXhr', logger), + ), + customUrlFilter, + }; +} + +function parseStack( + options: StackOptions | undefined, + defaults: ParsedStackOptions, + logger?: MinLogger, +): ParsedStackOptions { + return { + source: { + beforeLines: itemOrDefault( + options?.source?.beforeLines, + defaults.source.beforeLines, + checkBasic('number', 'stack.beforeLines', logger), + ), + afterLines: itemOrDefault( + options?.source?.afterLines, + defaults.source.afterLines, + checkBasic('number', 'stack.afterLines', logger), + ), + maxLineLength: itemOrDefault( + options?.source?.maxLineLength, + defaults.source.maxLineLength, + checkBasic('number', 'stack.maxLineLength', logger), + ), + }, + }; +} + +export default function parse(options: Options, logger?: MinLogger): ParsedOptions { + const defaults = defaultOptions(); + if (options.breadcrumbs) { + checkBasic('object', 'breadcrumbs', logger)(options.breadcrumbs); + } + if (options.stack) { + checkBasic('object', 'stack', logger)(options.stack); + } + return { + breadcrumbs: { + maxBreadcrumbs: itemOrDefault( + options.breadcrumbs?.maxBreadcrumbs, + defaults.breadcrumbs.maxBreadcrumbs, + checkBasic('number', 'breadcrumbs.maxBreadcrumbs', logger), + ), + evaluations: itemOrDefault( + options.breadcrumbs?.evaluations, + defaults.breadcrumbs.evaluations, + checkBasic('boolean', 'breadcrumbs.evaluations', logger), + ), + flagChange: itemOrDefault( + options.breadcrumbs?.flagChange, + defaults.breadcrumbs.flagChange, + checkBasic('boolean', 'breadcrumbs.flagChange', logger), + ), + click: itemOrDefault( + options.breadcrumbs?.click, + defaults.breadcrumbs.click, + checkBasic('boolean', 'breadcrumbs.click', logger), + ), + keyboardInput: itemOrDefault( + options.breadcrumbs?.keyboardInput, + defaults.breadcrumbs.keyboardInput, + checkBasic('boolean', 'breadcrumbs.keyboardInput', logger), + ), + http: parseHttp(options.breadcrumbs?.http, defaults.breadcrumbs.http, logger), + }, + stack: parseStack(options.stack, defaults.stack), + maxPendingEvents: itemOrDefault( + options.maxPendingEvents, + defaults.maxPendingEvents, + checkBasic('number', 'maxPendingEvents', logger), + ), + collectors: [ + ...itemOrDefault(options.collectors, defaults.collectors, (item) => { + if (Array.isArray(item)) { + return true; + } + logger?.warn(logger?.warn(wrongOptionType('collectors', 'Collector[]', typeof item))); + return false; + }), + ], + }; +} + +/** + * Internal type for parsed http options. + * @internal + */ +export interface ParsedHttpOptions { + /** + * True to instrument fetch and enable fetch breadcrumbs. + */ + instrumentFetch: boolean; + + /** + * True to instrument XMLHttpRequests and enable XMLHttpRequests breadcrumbs. + */ + instrumentXhr: boolean; + + /** + * Optional custom URL filter. + */ + customUrlFilter?: UrlFilter; +} + +/** + * Internal type for parsed stack options. + * @internal + */ +export interface ParsedStackOptions { + source: { + /** + * The number of lines captured before the originating line. + */ + beforeLines: number; + + /** + * The number of lines captured after the originating line. + */ + afterLines: number; + + /** + * The maximum length of source line to include. Lines longer than this will be + * trimmed. + */ + maxLineLength: number; + }; +} + +/** + * Internal type for parsed options. + * @internal + */ +export interface ParsedOptions { + /** + * The maximum number of pending events. Events may be captured before the LaunchDarkly + * SDK is initialized and these are stored until they can be sent. This only affects the + * events captured during initialization. + */ + maxPendingEvents: number; + /** + * Properties related to automatic breadcrumb collection. + */ + breadcrumbs: { + /** + * Set the maximum number of breadcrumbs. Defaults to 50. + */ + maxBreadcrumbs: number; + + /** + * True to enable automatic evaluation breadcrumbs. Defaults to true. + */ + evaluations: boolean; + + /** + * True to enable flag change breadcrumbs. Defaults to true. + */ + flagChange: boolean; + + /** + * True to enable click breadcrumbs. Defaults to true. + */ + click: boolean; + + /** + * True to enable input breadcrumbs for keypresses. Defaults to true. + */ + keyboardInput?: boolean; + + /** + * Settings for http instrumentation and breadcrumbs. + */ + http: ParsedHttpOptions; + }; + + /** + * Settings which affect call stack capture. + */ + stack: ParsedStackOptions; + + /** + * Additional, or custom, collectors. + */ + collectors: Collector[]; +}