diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts new file mode 100644 index 0000000000..e386507a5d --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts @@ -0,0 +1,107 @@ +import { UiBreadcrumb } from '../../../src/api/Breadcrumb'; +import { Recorder } from '../../../src/api/Recorder'; +import ClickCollector from '../../../src/collectors/dom/ClickCollector'; + +// Mock the window object +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); + +// Mock the document object +const mockDocument = { + body: document.createElement('div'), +}; + +// Setup global mocks +Object.defineProperty(global, 'window', { + value: { + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + }, + writable: true, +}); +global.document = mockDocument as any; + +describe('given a ClickCollector with a mock recorder', () => { + let mockRecorder: Recorder; + let collector: ClickCollector; + let clickHandler: Function; + + beforeEach(() => { + // Reset mocks + mockAddEventListener.mockReset(); + mockRemoveEventListener.mockReset(); + + // Capture the click handler when addEventListener is called + mockAddEventListener.mockImplementation((event, handler) => { + clickHandler = handler; + }); + // Create mock recorder + mockRecorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + // Create collector + collector = new ClickCollector(); + }); + + it('adds a click event listener when created', () => { + expect(mockAddEventListener).toHaveBeenCalledWith('click', expect.any(Function), true); + }); + + it('registers recorder and uses it for click events', () => { + // Register the recorder + collector.register(mockRecorder, 'test-session'); + + // Simulate a click event + const mockTarget = document.createElement('button'); + mockTarget.className = 'test-button'; + document.body.appendChild(mockTarget); + const mockEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + // Call the captured click handler + clickHandler(mockEvent); + + // Verify breadcrumb was added with correct properties + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'ui', + type: 'click', + level: 'info', + timestamp: expect.any(Number), + message: 'body > button.test-button', + }), + ); + }); + + it('stops adding breadcrumbs after unregistering', () => { + // Register then unregister + collector.register(mockRecorder, 'test-session'); + collector.unregister(); + // Simulate click + const mockTarget = document.createElement('button'); + const mockEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + clickHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not add a bread crumb for a null target', () => { + collector.register(mockRecorder, 'test-session'); + + const mockEvent = { target: null } as MouseEvent; + clickHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts new file mode 100644 index 0000000000..63b0ca1456 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts @@ -0,0 +1,178 @@ +import { UiBreadcrumb } from '../../../src/api/Breadcrumb'; +import { Recorder } from '../../../src/api/Recorder'; +import KeypressCollector from '../../../src/collectors/dom/KeypressCollector'; + +// Mock the window object +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); + +// Mock the document object +const mockDocument = { + body: document.createElement('div'), +}; + +// Setup global mocks +Object.defineProperty(global, 'window', { + value: { + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + }, + writable: true, +}); +global.document = mockDocument as any; + +describe('given a KeypressCollector with a mock recorder', () => { + let mockRecorder: Recorder; + let collector: KeypressCollector; + let keypressHandler: Function; + + beforeEach(() => { + // Reset mocks + mockAddEventListener.mockReset(); + mockRemoveEventListener.mockReset(); + + // Capture the keypress handler when addEventListener is called + mockAddEventListener.mockImplementation((event, handler) => { + keypressHandler = handler; + }); + + // Create mock recorder + mockRecorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + // Create collector + collector = new KeypressCollector(); + }); + + it('adds a keypress event listener when created', () => { + expect(mockAddEventListener).toHaveBeenCalledWith('keypress', expect.any(Function), true); + }); + + it('registers recorder and uses it for keypress events on input elements', () => { + collector.register(mockRecorder, 'test-session'); + + const mockTarget = document.createElement('input'); + mockTarget.className = 'test-input'; + document.body.appendChild(mockTarget); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'ui', + type: 'input', + level: 'info', + timestamp: expect.any(Number), + message: 'body > input.test-input', + }), + ); + }); + + it('registers recorder and uses it for keypress events on textarea elements', () => { + collector.register(mockRecorder, 'test-session'); + + const mockTarget = document.createElement('textarea'); + mockTarget.className = 'test-textarea'; + document.body.appendChild(mockTarget); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'ui', + type: 'input', + level: 'info', + timestamp: expect.any(Number), + message: 'body > textarea.test-textarea', + }), + ); + }); + + it('registers recorder and uses it for keypress events on contentEditable elements', () => { + collector.register(mockRecorder, 'test-session'); + + const mockTarget = document.createElement('p'); + mockTarget.className = 'test-editable'; + mockTarget.contentEditable = 'true'; + // https://github.com/jsdom/jsdom/issues/1670 + Object.defineProperties(mockTarget, { + isContentEditable: { + value: true, + }, + }); + document.body.appendChild(mockTarget); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'ui', + type: 'input', + level: 'info', + timestamp: expect.any(Number), + message: 'body > p.test-editable', + }), + ); + }); + + it('does not add breadcrumb for non-input non-editable elements', () => { + collector.register(mockRecorder, 'test-session'); + + const mockTarget = document.createElement('div'); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('stops adding breadcrumbs after unregistering', () => { + collector.register(mockRecorder, 'test-session'); + collector.unregister(); + + const mockTarget = document.createElement('input'); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not add a breadcrumb for a null target', () => { + collector.register(mockRecorder, 'test-session'); + + const mockEvent = { target: null } as KeyboardEvent; + keypressHandler(mockEvent); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('deduplicates events within throttle time', () => { + collector.register(mockRecorder, 'test-session'); + + const mockTarget = document.createElement('input'); + mockTarget.className = 'test-input'; + document.body.appendChild(mockTarget); + const mockEvent = new KeyboardEvent('keypress'); + Object.defineProperty(mockEvent, 'target', { value: mockTarget }); + + // First event should be recorded + keypressHandler(mockEvent); + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1); + + // Second event within throttle time should be ignored + keypressHandler(mockEvent); + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/toSelector.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/toSelector.test.ts new file mode 100644 index 0000000000..80d266f4a4 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/toSelector.test.ts @@ -0,0 +1,84 @@ +import toSelector, { elementToString, getClassName } from '../../../src/collectors/dom/toSelector'; + +it.each([ + [{}, undefined], + [{ className: '' }, undefined], + [{ className: 'potato' }, '.potato'], + [{ className: 'cheese potato' }, '.cheese.potato'], +])('can format class names', (element: any, expected?: string) => { + expect(getClassName(element)).toBe(expected); +}); + +it.each([ + [{}, ''], + [{ tagName: 'DIV' }, 'div'], + [{ tagName: 'P', id: 'test' }, 'p#test'], + [{ tagName: 'P', className: 'bold' }, 'p.bold'], + [{ tagName: 'P', className: 'bold', id: 'test' }, 'p#test.bold'], +])('can format an element as a string', (element: any, expected: string) => { + expect(elementToString(element)).toBe(expected); +}); + +it.each([ + [{}, ''], + [undefined, ''], + [null, ''], + ['toaster', ''], + [ + { + tagName: 'BODY', + parentNode: { + tagName: 'HTML', + }, + }, + 'body', + ], + [ + { + tagName: 'DIV', + parentNode: { + tagName: 'BODY', + parentNode: { + tagName: 'HTML', + }, + }, + }, + 'body > div', + ], + [ + { + tagName: 'DIV', + className: 'cheese taco', + id: 'taco', + parentNode: { + tagName: 'BODY', + parentNode: { + tagName: 'HTML', + }, + }, + }, + 'body > div#taco.cheese.taco', + ], +])('can produce a CSS selector from a dom element', (element: any, expected: string) => { + expect(toSelector(element)).toBe(expected); +}); + +it('respects max depth', () => { + const element = { + tagName: 'DIV', + className: 'cheese taco', + id: 'taco', + parentNode: { + tagName: 'P', + parentNode: { + tagName: 'BODY', + parentNode: { + tagName: 'HTML', + }, + }, + }, + }; + + expect(toSelector(element, { maxDepth: 1 })).toBe('div#taco.cheese.taco'); + expect(toSelector(element, { maxDepth: 2 })).toBe('p > div#taco.cheese.taco'); +}); diff --git a/packages/telemetry/browser-telemetry/package.json b/packages/telemetry/browser-telemetry/package.json index 433acb1db0..cb59634752 100644 --- a/packages/telemetry/browser-telemetry/package.json +++ b/packages/telemetry/browser-telemetry/package.json @@ -24,7 +24,7 @@ "description": "Telemetry integration for LaunchDarkly browser SDKs.", "scripts": { "test": "npx jest --runInBand", - "build": "tsup", + "build": "tsc --noEmit && tsup", "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", "check": "yarn && yarn prettier && yarn lint && tsc && yarn test", "lint": "npx eslint . --ext .ts" diff --git a/packages/telemetry/browser-telemetry/setup-jest.js b/packages/telemetry/browser-telemetry/setup-jest.js index e17ac62cb1..14fd78a6b4 100644 --- a/packages/telemetry/browser-telemetry/setup-jest.js +++ b/packages/telemetry/browser-telemetry/setup-jest.js @@ -5,6 +5,74 @@ global.TextEncoder = TextEncoder; Object.assign(window, { TextDecoder, TextEncoder }); +// Mock fetch if not defined in the test environment +if (!window.fetch) { + Object.defineProperty(window, 'fetch', { + value: jest.fn(), + writable: true, + configurable: true, + }); +} + +// When mocking fetches we need response to be defined so we can check if a given +// value is an instance of response. +Object.defineProperty(global, 'Response', { + value: class Response { + constructor(body, init = {}) { + this.body = body; + this.status = init.status || 200; + this.ok = this.status >= 200 && this.status < 300; + this.statusText = init.statusText || ''; + this.headers = new Map(Object.entries(init.headers || {})); + } + + async json() { + return JSON.parse(this.body); + } + + async text() { + return String(this.body); + } + }, + writable: true, + configurable: true, +}); + +// We need a global request to validate the fetch argument processing. +Object.defineProperty(global, 'Request', { + value: class Request { + constructor(input, init = {}) { + this.url = typeof input === 'string' ? input : input.url; + this.method = (init.method || 'GET').toUpperCase(); + this.headers = new Map(Object.entries(init.headers || {})); + this.body = init.body || null; + this.mode = init.mode || 'cors'; + this.credentials = init.credentials || 'same-origin'; + this.cache = init.cache || 'default'; + this.redirect = init.redirect || 'follow'; + this.referrer = init.referrer || 'about:client'; + this.integrity = init.integrity || ''; + } + + clone() { + return new Request(this.url, { + method: this.method, + headers: Object.fromEntries(this.headers), + body: this.body, + mode: this.mode, + credentials: this.credentials, + cache: this.cache, + redirect: this.redirect, + referrer: this.referrer, + integrity: this.integrity + }); + } + }, + writable: true, + configurable: true, +}); + + // Based on: // https://stackoverflow.com/a/71750830 diff --git a/packages/telemetry/browser-telemetry/src/api/ErrorData.ts b/packages/telemetry/browser-telemetry/src/api/ErrorData.ts index 0ed03945a9..864f10ac66 100644 --- a/packages/telemetry/browser-telemetry/src/api/ErrorData.ts +++ b/packages/telemetry/browser-telemetry/src/api/ErrorData.ts @@ -1,5 +1,5 @@ import { Breadcrumb } from './Breadcrumb'; -import StackTrace from './stack/StackTrace'; +import { StackTrace } from './stack/StackTrace'; /** * Interface representing error data. diff --git a/packages/telemetry/browser-telemetry/src/api/index.ts b/packages/telemetry/browser-telemetry/src/api/index.ts new file mode 100644 index 0000000000..5e9233fae0 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/index.ts @@ -0,0 +1,6 @@ +export * from './Breadcrumb'; +export * from './Collector'; +export * from './ErrorData'; +export * from './Options'; +export * from './Recorder'; +export * from './stack'; diff --git a/packages/telemetry/browser-telemetry/src/api/stack/StackFrame.ts b/packages/telemetry/browser-telemetry/src/api/stack/StackFrame.ts index 9d12f3b02c..024a6d5e4a 100644 --- a/packages/telemetry/browser-telemetry/src/api/stack/StackFrame.ts +++ b/packages/telemetry/browser-telemetry/src/api/stack/StackFrame.ts @@ -1,7 +1,7 @@ /** * Represents a frame in a stack. */ -export default interface StackFrame { +export interface StackFrame { /** * The fileName, relative to the project root, of the stack frame. */ diff --git a/packages/telemetry/browser-telemetry/src/api/stack/StackTrace.ts b/packages/telemetry/browser-telemetry/src/api/stack/StackTrace.ts index 783a900402..f5341d01c9 100644 --- a/packages/telemetry/browser-telemetry/src/api/stack/StackTrace.ts +++ b/packages/telemetry/browser-telemetry/src/api/stack/StackTrace.ts @@ -1,9 +1,9 @@ -import StackFrame from './StackFrame'; +import { StackFrame } from './StackFrame'; /** * Represents a stack trace. */ -export default interface StackTrace { +export interface StackTrace { /** * Frames associated with the stack. If no frames can be collected, then this * will be an empty array. diff --git a/packages/telemetry/browser-telemetry/src/api/stack/index.ts b/packages/telemetry/browser-telemetry/src/api/stack/index.ts new file mode 100644 index 0000000000..eab39b6cf3 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/stack/index.ts @@ -0,0 +1,2 @@ +export * from './StackFrame'; +export * from './StackTrace'; diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/ClickCollector.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/ClickCollector.ts new file mode 100644 index 0000000000..c9df0052d5 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/ClickCollector.ts @@ -0,0 +1,39 @@ +import { UiBreadcrumb } from '../../api/Breadcrumb'; +import { Collector } from '../../api/Collector'; +import { Recorder } from '../../api/Recorder'; +import getTarget from './getTarget'; +import toSelector from './toSelector'; + +/** + * Collects mouse click events and adds them as breadcrumbs. + */ +export default class ClickCollector implements Collector { + private _destination?: Recorder; + + constructor() { + window.addEventListener( + 'click', + (event: MouseEvent) => { + const target = getTarget(event); + if (target) { + const breadcrumb: UiBreadcrumb = { + class: 'ui', + type: 'click', + level: 'info', + timestamp: Date.now(), + message: toSelector(target), + }; + this._destination?.addBreadcrumb(breadcrumb); + } + }, + true, + ); + } + + register(recorder: Recorder, _sessionId: string): void { + this._destination = recorder; + } + unregister(): void { + this._destination = undefined; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts new file mode 100644 index 0000000000..9942027aa3 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts @@ -0,0 +1,69 @@ +import { Breadcrumb, UiBreadcrumb } from '../../api/Breadcrumb'; +import { Collector } from '../../api/Collector'; +import { Recorder } from '../../api/Recorder'; +import getTarget from './getTarget'; +import toSelector from './toSelector'; + +const THROTTLE_TIME_MS = 1000; + +const INPUT_TAG_NAMES = ['INPUT', 'TEXTAREA']; + +/** + * Collects key press events and adds them as breadcrumbs. + */ +export default class KeypressCollector implements Collector { + private _destination?: Recorder; + private _lastEvent?: UiBreadcrumb; + + constructor() { + // Currently we use the keypress event, but it is technically deprecated. + // It is the simplest way to currently get the most broad coverage. + // In the future we may want to consider some check to attempt to selectively use a more + // targetted event. + window.addEventListener( + 'keypress', + (event: KeyboardEvent) => { + const target = getTarget(event); + const htmlElement = target as HTMLElement; + // An example of `isContentEditable` would be an editable

tag. + // Input and textarea tags do not have the isContentEditable property. + if ( + target && + (INPUT_TAG_NAMES.includes(target.tagName) || htmlElement?.isContentEditable) + ) { + const breadcrumb: UiBreadcrumb = { + class: 'ui', + type: 'input', + level: 'info', + timestamp: Date.now(), + message: toSelector(target), + }; + + if (!this._shouldDeduplicate(breadcrumb)) { + this._destination?.addBreadcrumb(breadcrumb); + this._lastEvent = breadcrumb; + } + } + }, + true, + ); + } + + register(recorder: Recorder, _sessionId: string): void { + this._destination = recorder; + } + unregister(): void { + this._destination = undefined; + } + + private _shouldDeduplicate(crumb: Breadcrumb): boolean { + // If this code every is demonstrably a performance issue, then we may be able to implement + // some scheme to de-duplicate these via some DOM mechanism. Like adding a debounce annotation + // of some kind. + if (this._lastEvent) { + const timeDiff = Math.abs(crumb.timestamp - this._lastEvent.timestamp); + return timeDiff <= THROTTLE_TIME_MS && this._lastEvent.message === crumb.message; + } + return false; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/getTarget.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/getTarget.ts new file mode 100644 index 0000000000..1c86718a30 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/getTarget.ts @@ -0,0 +1,14 @@ +/** + * Get the event target. This is wrapped because in some situations a browser may throw when + * accessing the event target. + * + * @param event The event to get the target from. + * @returns The event target, or undefined if one is not available. + */ +export default function getTarget(event: { target: any }): Element | undefined { + try { + return event.target as Element; + } catch { + return undefined; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/toSelector.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/toSelector.ts new file mode 100644 index 0000000000..89413f2088 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/toSelector.ts @@ -0,0 +1,137 @@ +// https://developer.mozilla.org/en-US/docs/Web/CSS/Child_combinator +const CHILD_COMBINATOR = '>'; +// Spacing around the combinator is optional, but it increases readability. +const CHILD_SEPARATOR = ` ${CHILD_COMBINATOR} `; + +/** + * The elements of a node we need for traversal. + */ +interface NodeWithParent { + parentNode?: NodeWithParent; +} + +/** + * The elements of a node we need to generate a string representation. + * + * All element fields are optional, so a type guard is not required to use this typing. + */ +interface BasicElement { + tagName?: string; + id?: string; + className?: string; +} + +/** + * Type guard that verifies that an element complies with {@link NodeWithParent}. + */ +function isNode(element: unknown): element is NodeWithParent { + const anyElement = element as any; + // Parent node being null or undefined fill be falsy. + // The type of `null` is object, so check for null as well. + return typeof anyElement === 'object' && anyElement != null && anyElement.parentNode; +} + +/** + * Given an element produce a class name in CSS selector format. + * + * Exported for testing. + * + * @param element The element to get a class name for. + * @returns The class name, or undefined if there is no class name. + */ +export function getClassName(element: BasicElement): string | undefined { + if (typeof element.className !== `string`) { + return undefined; + } + let value = element.className; + // Elements should be space separated in a class attribute. If there are other kinds of + // whitespace, then this code could need adjustment. + if (element.className.includes(' ')) { + value = element.className.replace(' ', '.'); + } + + if (value !== '') { + return `.${value}`; + } + // There was no class name. + return undefined; +} + +/** + * Produce a string representation for a single DOM element. Does not produce the full selector. + * + * Exported for testing. + * + * @param element The element to produce a text representation for. + * @returns A text representation of the element, or an empty string if one cannot be produced. + */ +export function elementToString(element: BasicElement): string { + if (!element.tagName) { + return ''; + } + + const components: string[] = []; + + components.push(element.tagName.toLowerCase()); + if (element.id) { + components.push(`#${element.id}`); + } + + const className = getClassName(element); + if (className) { + components.push(className); + } + + return components.join(''); +} + +/** + * Given an HTML element produce a CSS selector. + * + * Defaults to a maximum depth of 10 components. + * + * Example: + * ``` + * + * + *

+ *
    + *
  • + *

    toaster

    + *
  • + *
+ *
+ * + * + * ``` + * The

element in the above HTML would produce: + * `body > div > ul > li.some-class > p#some-id` + * + * @param element The element to generate a selector from. + * @param options Options which control selector generation. + * @returns The generated selector. + */ +export default function toSelector( + element: unknown, + options: { + maxDepth: number; + } = { maxDepth: 10 }, +): string { + // For production we may want to consider if we additionally limit the maximum selector length. + // Limiting the components should generate reasonable selectors in most cases. + const components: string[] = []; + let ptr = element; + while (isNode(ptr) && ptr.parentNode && components.length < options.maxDepth) { + const asString = elementToString(ptr as BasicElement); + // We do not need to include the 'html' component in the selector. + // The HTML element can be assumed to be the top. If there are more elements + // we would not want to include them (they would be something non-standard). + if (asString === 'html') { + break; + } + + components.push(asString); + ptr = ptr.parentNode; + } + return components.reverse().join(CHILD_SEPARATOR); +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/error.ts b/packages/telemetry/browser-telemetry/src/collectors/error.ts new file mode 100644 index 0000000000..cbd606b034 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/error.ts @@ -0,0 +1,32 @@ +import { Collector } from '../api/Collector'; +import { Recorder } from '../api/Recorder'; + +export default class ErrorCollector implements Collector { + private _destination?: Recorder; + + constructor() { + window.addEventListener( + 'error', + (event: ErrorEvent) => { + this._destination?.captureErrorEvent(event); + }, + true, + ); + window.addEventListener( + 'unhandledrejection', + (event: PromiseRejectionEvent) => { + if (event.reason) { + this._destination?.captureError(event.reason); + } + }, + true, + ); + } + + register(recorder: Recorder): void { + this._destination = recorder; + } + unregister(): void { + this._destination = undefined; + } +} diff --git a/packages/telemetry/browser-telemetry/src/index.ts b/packages/telemetry/browser-telemetry/src/index.ts index 14ce0d7f05..b1c13e7340 100644 --- a/packages/telemetry/browser-telemetry/src/index.ts +++ b/packages/telemetry/browser-telemetry/src/index.ts @@ -1,7 +1 @@ -/** - * Empty function for typedoc. - */ -export function empty() { - // eslint-disable-next-line no-console - console.log('Hello'); -} +export * from './api'; diff --git a/packages/telemetry/browser-telemetry/tsconfig.json b/packages/telemetry/browser-telemetry/tsconfig.json index 3e0991c951..68c41137db 100644 --- a/packages/telemetry/browser-telemetry/tsconfig.json +++ b/packages/telemetry/browser-telemetry/tsconfig.json @@ -3,7 +3,7 @@ "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, - "lib": ["ES2017", "dom"], + "lib": ["ES6", "dom"], "module": "ESNext", "moduleResolution": "node", "noImplicitOverride": true, @@ -14,7 +14,7 @@ "sourceMap": false, "strict": true, "stripInternal": true, - "target": "ES2017", + "target": "ES2018", "types": ["node", "jest"], "allowJs": true }, diff --git a/packages/telemetry/browser-telemetry/tsconfig.test.json b/packages/telemetry/browser-telemetry/tsconfig.test.json index 6087e302dd..024a272d13 100644 --- a/packages/telemetry/browser-telemetry/tsconfig.test.json +++ b/packages/telemetry/browser-telemetry/tsconfig.test.json @@ -2,14 +2,15 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "lib": ["es6", "DOM"], + "lib": ["ES6", "DOM"], "module": "CommonJS", "strict": true, "noImplicitOverride": true, "sourceMap": true, "declaration": true, "declarationMap": true, - "stripInternal": true + "stripInternal": true, + "target": "ES2018" }, "exclude": [ "vite.config.ts",