From c8352b21b678bb8f1063bb0c9df2e795c6cec8d5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Nov 2024 08:48:33 -0800 Subject: [PATCH 1/3] feat: Add browser telemetry options. (#675) --- .../__tests__/options.test.ts | 422 ++++++++++++++++++ .../browser-telemetry/src/MinLogger.ts | 9 + .../browser-telemetry/src/options.ts | 285 ++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 packages/telemetry/browser-telemetry/__tests__/options.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/MinLogger.ts create mode 100644 packages/telemetry/browser-telemetry/src/options.ts 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 000000000..1d5004ba0 --- /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 000000000..dced72e95 --- /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 000000000..a801f5ed4 --- /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[]; +} From ca1dd49e596c73e807388cefcae36e956b3477a0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Nov 2024 14:35:40 -0800 Subject: [PATCH 2/3] feat: Add stack trace parsing. (#676) Review after: https://github.com/launchdarkly/js-core/pull/675 --------- Co-authored-by: Casey Waldren --- .../__tests__/stack/StackParser.test.ts | 121 ++++++++++ .../src/stack/StackParser.ts | 209 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/stack/StackParser.ts diff --git a/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts b/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts new file mode 100644 index 000000000..0fdcbd445 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts @@ -0,0 +1,121 @@ +import { + getLines, + getSrcLines, + processUrlToFileName, + TrimOptions, + trimSourceLine, +} from '../../src/stack/StackParser'; + +it.each([ + ['http://www.launchdarkly.com', 'http://www.launchdarkly.com/', '(index)'], + ['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test/(index)', 'test/(index)'], + ['http://www.launchdarkly.com', 'http://www.launchdarkly.com/test.js', 'test.js'], + ['http://localhost:8080', 'http://localhost:8080/dist/main.js', 'dist/main.js'], +])('handles URL parsing to file names', (origin: string, url: string, expected: string) => { + expect(processUrlToFileName(url, origin)).toEqual(expected); +}); + +it.each([ + ['this is the source line', 5, { maxLength: 10, beforeColumnCharacters: 2 }, 's is the s'], + ['this is the source line', 0, { maxLength: 10, beforeColumnCharacters: 2 }, 'this is th'], + ['this is the source line', 2, { maxLength: 10, beforeColumnCharacters: 0 }, 'is is the '], + ['12345', 0, { maxLength: 5, beforeColumnCharacters: 2 }, '12345'], + ['this is the source line', 21, { maxLength: 10, beforeColumnCharacters: 2 }, 'line'], +])( + 'trims source lines', + (source: string, column: number, options: TrimOptions, expected: string) => { + expect(trimSourceLine(options, source, column)).toEqual(expected); + }, +); + +describe('given source lines', () => { + const lines = ['1234567890', 'ABCDEFGHIJ', '0987654321', 'abcdefghij']; + + it('can get a range which would underflow the lines', () => { + expect(getLines(-1, 2, lines, (input) => input)).toStrictEqual(['1234567890', 'ABCDEFGHIJ']); + }); + + it('can get a range which would overflow the lines', () => { + expect(getLines(2, 4, lines, (input) => input)).toStrictEqual(['0987654321', 'abcdefghij']); + }); + + it('can get a range which is satisfied by the lines', () => { + expect(getLines(0, 4, lines, (input) => input)).toStrictEqual([ + '1234567890', + 'ABCDEFGHIJ', + '0987654321', + 'abcdefghij', + ]); + }); +}); + +describe('given an input stack frame', () => { + const inputFrame = { + context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'], + column: 0, + }; + + it('can produce a full stack source in the output frame', () => { + expect( + getSrcLines(inputFrame, { + source: { + beforeLines: 2, + afterLines: 2, + maxLineLength: 280, + }, + }), + ).toMatchObject({ + srcBefore: ['1234567890', 'ABCDEFGHIJ'], + srcLine: 'the src line', + srcAfter: ['0987654321', 'abcdefghij'], + }); + }); + + it('can trim all the lines', () => { + expect( + getSrcLines(inputFrame, { + source: { + beforeLines: 2, + afterLines: 2, + maxLineLength: 1, + }, + }), + ).toMatchObject({ + srcBefore: ['1', 'A'], + srcLine: 't', + srcAfter: ['0', 'a'], + }); + }); + + it('can handle fewer input lines than the expected context', () => { + expect( + getSrcLines(inputFrame, { + source: { + beforeLines: 3, + afterLines: 3, + maxLineLength: 280, + }, + }), + ).toMatchObject({ + srcBefore: ['1234567890', 'ABCDEFGHIJ'], + srcLine: 'the src line', + srcAfter: ['0987654321', 'abcdefghij'], + }); + }); + + it('can handle more input lines than the expected context', () => { + expect( + getSrcLines(inputFrame, { + source: { + beforeLines: 1, + afterLines: 1, + maxLineLength: 280, + }, + }), + ).toMatchObject({ + srcBefore: ['ABCDEFGHIJ'], + srcLine: 'the src line', + srcAfter: ['0987654321'], + }); + }); +}); diff --git a/packages/telemetry/browser-telemetry/src/stack/StackParser.ts b/packages/telemetry/browser-telemetry/src/stack/StackParser.ts new file mode 100644 index 000000000..89b88ab86 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/stack/StackParser.ts @@ -0,0 +1,209 @@ +import { computeStackTrace } from 'tracekit'; + +import { StackFrame } from '../api/stack/StackFrame'; +import { StackTrace } from '../api/stack/StackTrace'; +import { ParsedStackOptions } from '../options'; + +/** + * In the browser we will not always be able to determine the source file that code originates + * from. When you access a route it may just return HTML with embedded source, or just source, + * in which case there may not be a file name. + * + * There will also be cases where there is no source file, such as when running with various + * dev servers. + * + * In these situations we use this constant in place of the file name. + */ +const INDEX_SPECIFIER = '(index)'; + +/** + * For files hosted on the origin attempt to reduce to just a filename. + * If the origin matches the source file, then the special identifier `(index)` will + * be used. + * + * @param input The input URL. + * @returns The output file name. + */ +export function processUrlToFileName(input: string, origin: string): string { + let cleaned = input; + if (input.startsWith(origin)) { + cleaned = input.slice(origin.length); + // If the input is a single `/` then it would get removed and we would + // be left with an empty string. That empty string would get replaced with + // the INDEX_SPECIFIER. In cases where a `/` remains, either singular + // or at the end of a path, then we will append the index specifier. + // For instance the route `/test/` would ultimately be `test/(index)`. + if (cleaned.startsWith('/')) { + cleaned = cleaned.slice(1); + } + + if (cleaned === '') { + return INDEX_SPECIFIER; + } + + if (cleaned.endsWith('/')) { + cleaned += INDEX_SPECIFIER; + } + } + return cleaned; +} + +export interface TrimOptions { + /** + * The maximum length of the trimmed line. + */ + maxLength: number; + + /** + * If the line needs to be trimmed, then this is the number of character to retain before the + * originating character of the frame. + */ + beforeColumnCharacters: number; +} + +/** + * Trim a source string to a reasonable size. + * + * @param options Configuration which affects trimming. + * @param line The source code line to trim. + * @param column The column which the stack frame originates from. + * @returns A trimmed source string. + */ +export function trimSourceLine(options: TrimOptions, line: string, column: number): string { + if (line.length <= options.maxLength) { + return line; + } + const captureStart = Math.max(0, column - options.beforeColumnCharacters); + const captureEnd = Math.min(line.length, captureStart + options.maxLength); + return line.slice(captureStart, captureEnd); +} + +/** + * Given a context get trimmed source lines within the specified range. + * + * The context is a list of source code lines, this function returns a subset of + * lines which have been trimmed. + * + * If an error is on a specific line of source code we want to be able to get + * lines before and after that line. This is done relative to the originating + * line of source. + * + * If you wanted to get 3 lines before the origin line, then this function would + * need to be called with `start: originLine - 3, end: originLine`. + * + * If the `start` would underflow the context, then the start is set to 0. + * If the `end` would overflow the context, then the end is set to the context + * length. + * + * Exported for testing. + * + * @param start The inclusive start index. + * @param end The exclusive end index. + * @param trimmer Method which will trim individual lines. + */ +export function getLines( + start: number, + end: number, + context: string[], + trimmer: (val: string) => string, +): string[] { + const adjustedStart = start < 0 ? 0 : start; + const adjustedEnd = end > context.length ? context.length : end; + if (adjustedStart < adjustedEnd) { + return context.slice(adjustedStart, adjustedEnd).map(trimmer); + } + return []; +} + +/** + * Given a stack frame produce source context about that stack frame. + * + * The source context includes the source line of the stack frame, some number + * of lines before the line of the stack frame, and some number of lines + * after the stack frame. The amount of context can be controlled by the + * provided options. + * + * Exported for testing. + */ +export function getSrcLines( + inFrame: { + // Tracekit returns null potentially. We accept undefined as well to be as lenient here + // as we can. + context?: string[] | null; + column?: number | null; + }, + options: ParsedStackOptions, +): { + srcBefore?: string[]; + srcLine?: string; + srcAfter?: string[]; +} { + const { context } = inFrame; + // It should be present, but we don't want to trust that it is. + if (!context) { + return {}; + } + const { maxLineLength } = options.source; + const beforeColumnCharacters = Math.floor(maxLineLength / 2); + + // The before and after lines will not be precise while we use TraceKit. + // By forking it we should be able to achieve a more optimal result. + // We only need to do this if we are not getting sufficient quality using this + // method. + + // Trimmer for non-origin lines. Starts at column 0. + // Non-origin lines are lines which are not the line for a specific stack + // frame, but instead the lines before or after that frame. + // ``` + // console.log("before origin"); // non-origin line + // throw new Error("this is the origin"); // origin line + // console.log("after origin); // non-origin line + // ``` + const trimmer = (input: string) => + trimSourceLine( + { + maxLength: options.source.maxLineLength, + beforeColumnCharacters, + }, + input, + 0, + ); + + const origin = Math.floor(context.length / 2); + return { + // The lines immediately preceeding the origin line. + srcBefore: getLines(origin - options.source.beforeLines, origin, context, trimmer), + srcLine: trimSourceLine( + { + maxLength: maxLineLength, + beforeColumnCharacters, + }, + context[origin], + inFrame.column || 0, + ), + // The lines immediately following the origin line. + srcAfter: getLines(origin + 1, origin + 1 + options.source.afterLines, context, trimmer), + }; +} + +/** + * Parse the browser stack trace into a StackTrace which contains frames with specific fields parsed + * from the free-form stack. Browser stack traces are not standardized, so implementations handling + * the output should be resilient to missing fields. + * + * @param error The error to generate a StackTrace for. + * @returns The stack trace for the given error. + */ +export default function parse(error: Error, options: ParsedStackOptions): StackTrace { + const parsed = computeStackTrace(error); + const frames: StackFrame[] = parsed.stack.reverse().map((inFrame) => ({ + fileName: processUrlToFileName(inFrame.url, window.location.origin), + function: inFrame.func, + line: inFrame.line, + col: inFrame.column, + ...getSrcLines(inFrame, options), + })); + return { + frames, + }; +} From aafd1a580fde219478199978523a7e882f34ba3a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Nov 2024 14:35:53 -0800 Subject: [PATCH 3/3] chore: Global lint. (#685) Various readme and config files are not linted by individual projects and as a result they end up missed. This PR is just from a global lint. I've created SDK-892 to address this deficit. --- README.md | 4 +- packages/sdk/akamai-base/example/package.json | 2 +- .../sdk/akamai-edgekv/example/package.json | 2 +- packages/sdk/browser/README.md | 2 - packages/sdk/cloudflare/example/package.json | 2 +- packages/sdk/cloudflare/jsr.json | 12 +---- packages/sdk/server-ai/README.md | 13 +++--- .../sdk/server-ai/examples/openai/README.md | 1 - packages/shared/common/rollup.config.js | 6 ++- packages/shared/sdk-client/rollup.config.js | 7 ++- .../telemetry/browser-telemetry/setup-jest.js | 3 +- .../jest/example/react-native-example/App.tsx | 6 ++- .../react-native-example/babel.config.js | 1 - .../react-native-example/src/welcome.test.tsx | 31 +++++++------ .../react-native-example/src/welcome.tsx | 2 +- .../react-native-example/tsconfig.eslint.json | 11 ++--- release-please-config.json | 44 ++++++++++++++----- 17 files changed, 82 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 441ba6ff9..42eddb660 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/node-server-sdk](packages/sdk/server-node/README.md) | [![NPM][sdk-server-node-npm-badge]][sdk-server-node-npm-link] | [Node.js Server][package-sdk-server-node-issues] | [![Actions Status][sdk-server-node-ci-badge]][sdk-server-node-ci] | | [@launchdarkly/vercel-server-sdk](packages/sdk/vercel/README.md) | [![NPM][sdk-vercel-npm-badge]][sdk-vercel-npm-link] | [Vercel][package-sdk-vercel-issues] | [![Actions Status][sdk-vercel-ci-badge]][sdk-vercel-ci] | | [@launchdarkly/react-native-client-sdk](packages/sdk/react-native/README.md) | [![NPM][sdk-react-native-npm-badge]][sdk-react-native-npm-link] | [React-Native][package-sdk-react-native-issues] | [![Actions Status][sdk-react-native-ci-badge]][sdk-react-native-ci] | -| [@launchdarkly/js-client-sdk](packages/sdk/browser/README.md) | [![NPM][sdk-browser-npm-badge]][sdk-browser-npm-link] | [Browser][package-sdk-browser-issues] | [![Actions Status][sdk-browser-ci-badge]][sdk-browser-ci] | -| [@launchdarkly/server-sdk-ai](packages/sdk/server-ai/README.md) | [![NPM][sdk-server-ai-npm-badge]][sdk-server-ai-npm-link] | [server-ai][package-sdk-server-ai-issues] | [![Actions Status][sdk-server-ai-ci-badge]][sdk-server-ai-ci] | +| [@launchdarkly/js-client-sdk](packages/sdk/browser/README.md) | [![NPM][sdk-browser-npm-badge]][sdk-browser-npm-link] | [Browser][package-sdk-browser-issues] | [![Actions Status][sdk-browser-ci-badge]][sdk-browser-ci] | +| [@launchdarkly/server-sdk-ai](packages/sdk/server-ai/README.md) | [![NPM][sdk-server-ai-npm-badge]][sdk-server-ai-npm-link] | [server-ai][package-sdk-server-ai-issues] | [![Actions Status][sdk-server-ai-ci-badge]][sdk-server-ai-ci] | | Shared packages | npm | issues | tests | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------- | diff --git a/packages/sdk/akamai-base/example/package.json b/packages/sdk/akamai-base/example/package.json index 3b32e538a..01fe04e0d 100644 --- a/packages/sdk/akamai-base/example/package.json +++ b/packages/sdk/akamai-base/example/package.json @@ -32,6 +32,6 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-server-base-sdk": "^1.0.0" + "@launchdarkly/akamai-server-base-sdk": "2.1.19" } } diff --git a/packages/sdk/akamai-edgekv/example/package.json b/packages/sdk/akamai-edgekv/example/package.json index d73791719..e745dce2b 100644 --- a/packages/sdk/akamai-edgekv/example/package.json +++ b/packages/sdk/akamai-edgekv/example/package.json @@ -31,6 +31,6 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-server-edgekv-sdk": "^1.0.0" + "@launchdarkly/akamai-server-edgekv-sdk": "1.2.1" } } diff --git a/packages/sdk/browser/README.md b/packages/sdk/browser/README.md index b31b0a300..b26aac523 100644 --- a/packages/sdk/browser/README.md +++ b/packages/sdk/browser/README.md @@ -1,13 +1,11 @@ # LaunchDarkly JavaScript SDK for Browsers - [![NPM][browser-sdk-npm-badge]][browser-sdk-npm-link] [![Actions Status][browser-sdk-ci-badge]][browser-sdk-ci] [![Documentation][browser-sdk-ghp-badge]][browser-sdk-ghp-link] [![NPM][browser-sdk-dm-badge]][browser-sdk-npm-link] [![NPM][browser-sdk-dt-badge]][browser-sdk-npm-link] - # ⛔️⛔️⛔️⛔️ > [!CAUTION] diff --git a/packages/sdk/cloudflare/example/package.json b/packages/sdk/cloudflare/example/package.json index 94f5ea7bb..b37c8f6ac 100644 --- a/packages/sdk/cloudflare/example/package.json +++ b/packages/sdk/cloudflare/example/package.json @@ -5,7 +5,7 @@ "module": "./dist/index.mjs", "packageManager": "yarn@3.4.1", "dependencies": { - "@launchdarkly/cloudflare-server-sdk": "2.2.3" + "@launchdarkly/cloudflare-server-sdk": "2.6.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20230321.0", diff --git a/packages/sdk/cloudflare/jsr.json b/packages/sdk/cloudflare/jsr.json index b092c83ee..4b0c8164d 100644 --- a/packages/sdk/cloudflare/jsr.json +++ b/packages/sdk/cloudflare/jsr.json @@ -3,15 +3,7 @@ "version": "2.6.1", "exports": "./src/index.ts", "publish": { - "include": [ - "LICENSE", - "README.md", - "package.json", - "jsr.json", - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.test.ts" - ] + "include": ["LICENSE", "README.md", "package.json", "jsr.json", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } } diff --git a/packages/sdk/server-ai/README.md b/packages/sdk/server-ai/README.md index 40ee29090..27468467f 100644 --- a/packages/sdk/server-ai/README.md +++ b/packages/sdk/server-ai/README.md @@ -38,13 +38,14 @@ const aiClient = initAi(ldClient); ``` 3. Evaluate a model configuration: + ```typescript - const config = await aiClient.modelConfig( - aiConfigKey!, - context, - { enabled: false }, - { myVariable: 'My User Defined Variable' }, - ); +const config = await aiClient.modelConfig( + aiConfigKey!, + context, + { enabled: false }, + { myVariable: 'My User Defined Variable' }, +); ``` For an example of how to use the config please refer to the examples folder. diff --git a/packages/sdk/server-ai/examples/openai/README.md b/packages/sdk/server-ai/examples/openai/README.md index 0126c332a..ca285018d 100644 --- a/packages/sdk/server-ai/examples/openai/README.md +++ b/packages/sdk/server-ai/examples/openai/README.md @@ -4,7 +4,6 @@ This package demonstrates the integration of LaunchDarkly's AI SDK with OpenAI, ## Installation and Build - When running as part of the js-core mono-repo the project will use local dependencies. As such those dependencies need built. diff --git a/packages/shared/common/rollup.config.js b/packages/shared/common/rollup.config.js index 5514151c2..d3eb05b64 100644 --- a/packages/shared/common/rollup.config.js +++ b/packages/shared/common/rollup.config.js @@ -36,6 +36,10 @@ export default [ }, { ...getSharedConfig('cjs', 'dist/cjs/index.cjs'), - plugins: [typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true, }), common(), json()], + plugins: [ + typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true }), + common(), + json(), + ], }, ]; diff --git a/packages/shared/sdk-client/rollup.config.js b/packages/shared/sdk-client/rollup.config.js index 81b9cde6e..093c7fe4b 100644 --- a/packages/shared/sdk-client/rollup.config.js +++ b/packages/shared/sdk-client/rollup.config.js @@ -38,6 +38,11 @@ export default [ }, { ...getSharedConfig('cjs', 'dist/cjs/index.cjs'), - plugins: [typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true, }), common(), resolve(), json()], + plugins: [ + typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true }), + common(), + resolve(), + json(), + ], }, ]; diff --git a/packages/telemetry/browser-telemetry/setup-jest.js b/packages/telemetry/browser-telemetry/setup-jest.js index 14fd78a6b..d09f7399e 100644 --- a/packages/telemetry/browser-telemetry/setup-jest.js +++ b/packages/telemetry/browser-telemetry/setup-jest.js @@ -64,7 +64,7 @@ Object.defineProperty(global, 'Request', { cache: this.cache, redirect: this.redirect, referrer: this.referrer, - integrity: this.integrity + integrity: this.integrity, }); } }, @@ -72,7 +72,6 @@ Object.defineProperty(global, 'Request', { configurable: true, }); - // Based on: // https://stackoverflow.com/a/71750830 diff --git a/packages/tooling/jest/example/react-native-example/App.tsx b/packages/tooling/jest/example/react-native-example/App.tsx index 8521ffd2c..a5473cb51 100644 --- a/packages/tooling/jest/example/react-native-example/App.tsx +++ b/packages/tooling/jest/example/react-native-example/App.tsx @@ -1,15 +1,17 @@ import { StyleSheet } from 'react-native'; + import { AutoEnvAttributes, + LDOptions, LDProvider, ReactNativeLDClient, - LDOptions, } from '@launchdarkly/react-native-client-sdk'; + import Welcome from './src/welcome'; const options: LDOptions = { debug: true, -} +}; //TODO Set MOBILE_KEY in .env file to a mobile key in your project/environment. const MOBILE_KEY = 'YOUR_MOBILE_KEY'; const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, options); diff --git a/packages/tooling/jest/example/react-native-example/babel.config.js b/packages/tooling/jest/example/react-native-example/babel.config.js index 28dcb83ba..2fa5a5274 100644 --- a/packages/tooling/jest/example/react-native-example/babel.config.js +++ b/packages/tooling/jest/example/react-native-example/babel.config.js @@ -2,6 +2,5 @@ module.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo', '@babel/preset-typescript'], - }; }; diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx index 8bd9f4e21..790ebead2 100644 --- a/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx +++ b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx @@ -1,29 +1,28 @@ /** * @jest-environment jsdom */ +import { render, screen } from '@testing-library/react-native'; import { mockFlags, resetLDMocks } from '@launchdarkly/jest/react-native'; -import { screen, render } from '@testing-library/react-native'; import { useLDClient } from '@launchdarkly/react-native-client-sdk'; + import Welcome from './welcome'; describe('Welcome component test', () => { + afterEach(() => { + resetLDMocks(); + }); - afterEach(() => { - resetLDMocks(); - }); - - test('mock boolean flag correctly', () => { - mockFlags({ 'my-boolean-flag': true }); - render(); - expect(screen.getByText('Flag value is true')).toBeTruthy(); - }); + test('mock boolean flag correctly', () => { + mockFlags({ 'my-boolean-flag': true }); + render(); + expect(screen.getByText('Flag value is true')).toBeTruthy(); + }); - test('mock ldClient correctly', () => { - const current = useLDClient(); - - current?.track('event'); - expect(current.track).toHaveBeenCalledTimes(1); - }); + test('mock ldClient correctly', () => { + const current = useLDClient(); + current?.track('event'); + expect(current.track).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.tsx index f167b11fc..0b28a7260 100644 --- a/packages/tooling/jest/example/react-native-example/src/welcome.tsx +++ b/packages/tooling/jest/example/react-native-example/src/welcome.tsx @@ -1,8 +1,8 @@ import { StyleSheet, Text, View } from 'react-native'; + import { useLDClient } from '@launchdarkly/react-native-client-sdk'; export default function Welcome() { - const ldClient = useLDClient(); const flagValue = ldClient.boolVariation('my-boolean-flag', false); diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json index 9101efe40..18ba58fe8 100644 --- a/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json +++ b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json @@ -1,10 +1,5 @@ { - "extends": "./tsconfig.json", - "include": [ - "/**/*.ts", - "/**/*.tsx", - "/*.js", - "/*.tsx" - ], - "exclude": ["node_modules"] + "extends": "./tsconfig.json", + "include": ["/**/*.ts", "/**/*.tsx", "/*.js", "/*.tsx"], + "exclude": ["node_modules"] } diff --git a/release-please-config.json b/release-please-config.json index 10d0a8a92..b25cbcf8e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,6 +12,11 @@ "path": "jsr.json", "jsonpath": "$.version" }, + { + "type": "json", + "path": "example/package.json", + "jsonpath": "$.dependencies['@launchdarkly/cloudflare-server-sdk']" + }, "src/createPlatformInfo.ts" ] }, @@ -21,10 +26,24 @@ "extra-files": ["src/createPlatformInfo.ts"] }, "packages/sdk/akamai-base": { - "extra-files": ["src/index.ts"] + "extra-files": [ + "src/index.ts", + { + "type": "json", + "path": "example/package.json", + "jsonpath": "$.dependencies['@launchdarkly/akamai-server-base-sdk']" + } + ] }, "packages/sdk/akamai-edgekv": { - "extra-files": ["src/index.ts"] + "extra-files": [ + "src/index.ts", + { + "type": "json", + "path": "example/package.json", + "jsonpath": "$.dependencies['@launchdarkly/akamai-server-edgekv-sdk']" + } + ] }, "packages/store/node-server-sdk-dynamodb": {}, "packages/store/node-server-sdk-redis": {}, @@ -34,15 +53,18 @@ }, "packages/sdk/server-ai": { "bump-minor-pre-major": true, - "extra-files": [{ - "type": "json", - "path": "examples/bedrock/package.json", - "jsonpath": "$.dependencies['@launchdarkly/server-sdk-ai']" - },{ - "type": "json", - "path": "examples/openai/package.json", - "jsonpath": "$.dependencies['@launchdarkly/server-sdk-ai']" - }] + "extra-files": [ + { + "type": "json", + "path": "examples/bedrock/package.json", + "jsonpath": "$.dependencies['@launchdarkly/server-sdk-ai']" + }, + { + "type": "json", + "path": "examples/openai/package.json", + "jsonpath": "$.dependencies['@launchdarkly/server-sdk-ai']" + } + ] } }, "plugins": [