From 325b21592c54d2e88fc48bfec31e81e7de725c36 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:04:45 -0800 Subject: [PATCH 1/5] feat: Add telemetry collectors for DOM and HTTP. --- .../collectors/dom/ClickCollector.test.ts | 107 +++++++++++ .../collectors/dom/KeyPressCollector.test.ts | 178 ++++++++++++++++++ .../collectors/dom/toSelector.test.ts | 84 +++++++++ .../__tests__/collectors/http/fetch.test.ts | 129 +++++++++++++ .../__tests__/collectors/http/xhr.test.ts | 139 ++++++++++++++ .../filters/defaultUrlFilter.test.ts | 41 ++++ .../filters/filterHttpBreadcrumb.test.ts | 21 +++ .../__tests__/filters/filterUrl.test.ts | 13 ++ .../telemetry/browser-telemetry/package.json | 2 +- .../telemetry/browser-telemetry/setup-jest.js | 33 ++++ .../browser-telemetry/src/api/ErrorData.ts | 2 +- .../browser-telemetry/src/api/index.ts | 6 + .../src/api/stack/StackFrame.ts | 2 +- .../src/api/stack/StackTrace.ts | 4 +- .../browser-telemetry/src/api/stack/index.ts | 2 + .../src/collectors/dom/ClickCollector.ts | 39 ++++ .../src/collectors/dom/KeypressCollector.ts | 63 +++++++ .../src/collectors/dom/getTarget.ts | 14 ++ .../src/collectors/dom/toSelector.ts | 137 ++++++++++++++ .../browser-telemetry/src/collectors/error.ts | 32 ++++ .../collectors/http/HttpCollectorOptions.ts | 13 ++ .../src/collectors/http/fetch.ts | 27 +++ .../src/collectors/http/fetchDecorator.ts | 95 ++++++++++ .../src/collectors/http/xhr.ts | 28 +++ .../src/collectors/http/xhrDecorator.ts | 123 ++++++++++++ .../src/filters/defaultUrlFilter.ts | 29 +++ .../src/filters/filterHttpBreadcrumb.ts | 20 ++ .../src/filters/filterUrl.ts | 8 + .../telemetry/browser-telemetry/src/index.ts | 8 +- .../telemetry/browser-telemetry/tsconfig.json | 4 +- .../browser-telemetry/tsconfig.test.json | 5 +- 31 files changed, 1392 insertions(+), 16 deletions(-) create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/dom/toSelector.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts create mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/index.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/stack/index.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/dom/ClickCollector.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/dom/getTarget.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/dom/toSelector.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/error.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts create mode 100644 packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts create mode 100644 packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts create mode 100644 packages/telemetry/browser-telemetry/src/filters/filterUrl.ts 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/__tests__/collectors/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts new file mode 100644 index 0000000000..dc1e6900b9 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts @@ -0,0 +1,129 @@ +import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; +import { Recorder } from '../../../src/api/Recorder'; +import FetchCollector from '../../../src/collectors/http/fetch'; + +const initialFetch = window.fetch; + +describe('given a FetchCollector with a mock recorder', () => { + let mockRecorder: Recorder; + let collector: FetchCollector; + + beforeEach(() => { + // Create mock recorder + mockRecorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + // Create collector with default options + collector = new FetchCollector({ + urlFilters: [], // Add required urlFilters property + }); + }); + + it('registers recorder and uses it for fetch calls', async () => { + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data', { + method: 'POST', + body: JSON.stringify({ test: true }), + }); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'http', + type: 'fetch', + level: 'info', + timestamp: expect.any(Number), + data: { + method: 'POST', + url: 'https://api.example.com/data', + statusCode: 200, + statusText: 'OK', + }, + }), + ); + }); + + // it('stops adding breadcrumbs after unregistering', async () => { + // collector.register(mockRecorder, 'test-session'); + // collector.unregister(); + + // const mockResponse = new Response('test response', { status: 200 }); + // window.fetch = jest.fn().mockResolvedValue(mockResponse); + + // await fetch('https://api.example.com/data'); + + // expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + // }); + + // it('filters URLs based on provided options', async () => { + // const options = { + // urlFilter: (url: string) => url.replace(/token=.*/, 'token=REDACTED'), + // }; + + // collector = new FetchCollector(options); + // collector.register(mockRecorder, 'test-session'); + + // const mockResponse = new Response('test response', { status: 200 }); + // window.fetch = jest.fn().mockResolvedValue(mockResponse); + + // await fetch('https://api.example.com/data?token=secret123'); + + // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + // expect.objectContaining({ + // data: { + // method: 'GET', + // url: 'https://api.example.com/data?token=REDACTED', + // }, + // }), + // ); + // }); + + // it('handles fetch calls with Request objects', async () => { + // collector.register(mockRecorder, 'test-session'); + + // const mockResponse = new Response('test response', { status: 200 }); + // window.fetch = jest.fn().mockResolvedValue(mockResponse); + + // const request = new Request('https://api.example.com/data', { + // method: 'PUT', + // body: JSON.stringify({ test: true }), + // }); + + // await fetch(request); + + // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + // expect.objectContaining({ + // message: 'PUT https://api.example.com/data', + // data: { + // method: 'PUT', + // url: 'https://api.example.com/data', + // }, + // }), + // ); + // }); + + // it('handles fetch calls with URL objects', async () => { + // collector.register(mockRecorder, 'test-session'); + + // const mockResponse = new Response('test response', { status: 200 }); + // window.fetch = jest.fn().mockResolvedValue(mockResponse); + + // const url = new URL('https://api.example.com/data'); + // await fetch(url); + + // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + // expect.objectContaining({ + // message: 'GET https://api.example.com/data', + // data: { + // method: 'GET', + // url: 'https://api.example.com/data', + // }, + // }), + // ); + // }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts new file mode 100644 index 0000000000..125b639ab3 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts @@ -0,0 +1,139 @@ +import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; +import { Recorder } from '../../../src/api/Recorder'; +import XhrCollector from '../../../src/collectors/http/xhr'; + +const initialXhr = window.XMLHttpRequest; + +it('registers recorder and uses it for xhr calls', () => { + const mockRecorder: Recorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + const collector = new XhrCollector({ + urlFilters: [], + }); + + collector.register(mockRecorder, 'test-session'); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://api.example.com/data'); + xhr.send(JSON.stringify({ test: true })); + + // Simulate successful response + Object.defineProperty(xhr, 'status', { value: 200 }); + Object.defineProperty(xhr, 'statusText', { value: 'OK' }); + xhr.dispatchEvent(new Event('loadend')); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + class: 'http', + type: 'xhr', + level: 'info', + timestamp: expect.any(Number), + data: { + method: 'POST', + url: 'https://api.example.com/data', + statusCode: 200, + statusText: 'OK', + }, + }), + ); +}); + +it('stops adding breadcrumbs after unregistering', () => { + const mockRecorder: Recorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + const collector = new XhrCollector({ + urlFilters: [], + }); + + collector.register(mockRecorder, 'test-session'); + collector.unregister(); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data'); + xhr.send(); + + xhr.dispatchEvent(new Event('loadend')); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); +}); + +it('marks requests with error events as errors', () => { + const mockRecorder: Recorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + const collector = new XhrCollector({ + urlFilters: [], + }); + + collector.register(mockRecorder, 'test-session'); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data'); + xhr.send(); + + xhr.dispatchEvent(new Event('error')); + xhr.dispatchEvent(new Event('loadend')); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + data: expect.objectContaining({ + method: 'GET', + statusCode: 0, + statusText: '', + url: 'https://api.example.com/data', + }), + class: 'http', + timestamp: expect.any(Number), + type: 'xhr', + }), + ); +}); + +it('applies URL filters to requests', () => { + const mockRecorder: Recorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + + const collector = new XhrCollector({ + urlFilters: [(url) => url.replace(/token=.*/, 'token=REDACTED')], + }); + + collector.register(mockRecorder, 'test-session'); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data?token=secret123'); + xhr.send(); + + Object.defineProperty(xhr, 'status', { value: 200 }); + xhr.dispatchEvent(new Event('loadend')); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'https://api.example.com/data?token=REDACTED', + }), + class: 'http', + timestamp: expect.any(Number), + level: 'info', + type: 'xhr', + }), + ); +}); + +afterEach(() => { + window.XMLHttpRequest = initialXhr; +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts new file mode 100644 index 0000000000..9b4876d62f --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts @@ -0,0 +1,41 @@ +import defaultUrlFilter from '../../src/filters/defaultUrlFilter'; + +it('filters polling urls', () => { + // Added -_ to the end as we use those in the base64 URL safe character set. + const context = + 'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_'; + const filteredCotext = + '************************************************************************************'; + const baseUrl = 'https://sdk.launchdarkly.com/sdk/evalx/thesdkkey/contexts/'; + const filteredUrl = `${baseUrl}${filteredCotext}`; + const testUrl = `${baseUrl}${context}`; + const testUrlWithReasons = `${testUrl}?withReasons=true`; + const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`; + + expect(defaultUrlFilter(testUrl)).toBe(filteredUrl); + expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons); +}); + +it('filters streaming urls', () => { + // Added -_ to the end as we use those in the base64 URL safe character set. + const context = + 'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_'; + const filteredCotext = + '************************************************************************************'; + const baseUrl = `https://clientstream.launchdarkly.com/eval/thesdkkey/`; + const filteredUrl = `${baseUrl}${filteredCotext}`; + const testUrl = `${baseUrl}${context}`; + const testUrlWithReasons = `${testUrl}?withReasons=true`; + const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`; + + expect(defaultUrlFilter(testUrl)).toBe(filteredUrl); + expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons); +}); + +it.each([ + 'http://events.launchdarkly.com/events/bulk/thesdkkey', + 'http://localhost:8080', + 'http://some.other.base64like/eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6vcmciOnsiaIjoidGFjb2h1dCJ9fQ-_', +])('passes through other URLs unfiltered', (url) => { + expect(defaultUrlFilter(url)).toBe(url); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts new file mode 100644 index 0000000000..e1735595bb --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts @@ -0,0 +1,21 @@ +import { HttpBreadcrumb } from '../../src/api/Breadcrumb'; +import filterHttpBreadcrumb from '../../src/filters/filterHttpBreadcrumb'; + +it('filters breadcrumbs with the provided filters', () => { + const breadcrumb: HttpBreadcrumb = { + class: 'http', + timestamp: Date.now(), + level: 'info', + type: 'xhr', + data: { + method: 'GET', + url: 'dog', + statusCode: 200, + statusText: 'ok', + }, + }; + filterHttpBreadcrumb(breadcrumb, { + urlFilters: [(url) => url.replace('dog', 'cat')], + }); + expect(breadcrumb.data?.url).toBe('cat'); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts new file mode 100644 index 0000000000..79e7315487 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts @@ -0,0 +1,13 @@ +import filterUrl from '../../src/filters/filterUrl'; + +it('runs the specified filters in the given order', () => { + const filterA = (url: string): string => url.replace('dog', 'cat'); + const filterB = (url: string): string => url.replace('cat', 'mouse'); + + // dog -> cat -> mouse + expect(filterUrl([filterA, filterB], 'dog')).toBe('mouse'); + // dog -> dog -> cat + expect(filterUrl([filterB, filterA], 'dog')).toBe('cat'); + // cat -> mouse -> mouse + expect(filterUrl([filterB, filterA], 'cat')).toBe('mouse'); +}); 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..102b6d579f 100644 --- a/packages/telemetry/browser-telemetry/setup-jest.js +++ b/packages/telemetry/browser-telemetry/setup-jest.js @@ -5,6 +5,39 @@ 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, +}); + // 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..21172a2070 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts @@ -0,0 +1,63 @@ +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 mouse click events and adds them as breadcrumbs. + */ +export default class KeypressCollector implements Collector { + private _destination?: Recorder; + private _lastEvent?: UiBreadcrumb; + + constructor() { + 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 { + // TODO: Consider de-duplication at the dom level. + if (this._lastEvent) { + const timeDiff = Math.abs(crumb.timestamp - this._lastEvent.timestamp); + return this._lastEvent.message === crumb.message && timeDiff <= THROTTLE_TIME_MS; + } + 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/collectors/http/HttpCollectorOptions.ts b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts new file mode 100644 index 0000000000..2f6c4bab47 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts @@ -0,0 +1,13 @@ +import { UrlFilter } from '../../api/Options'; + +/** + * Options which impact the behavior of http collectors. + */ +export default interface HttpCollectorOptions { + /** + * A list of filters to execute on the URL of the breadcrumb. + * + * This allows for redaction of potentially sensitive information in URLs. + */ + urlFilters: UrlFilter[]; +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts new file mode 100644 index 0000000000..0baa0739b4 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts @@ -0,0 +1,27 @@ +import { Collector } from '../../api/Collector'; +import { Recorder } from '../../api/Recorder'; +import filterHttpBreadcrumb from '../../filters/filterHttpBreadcrumb'; +import decorateFetch from './fetchDecorator'; +import HttpCollectorOptions from './HttpCollectorOptions'; + +/** + * Instrument fetch requests and generate a breadcrumb for each request. + */ +export default class FetchCollector implements Collector { + private _destination?: Recorder; + + constructor(options: HttpCollectorOptions) { + decorateFetch((breadcrumb) => { + filterHttpBreadcrumb(breadcrumb, options); + this._destination?.addBreadcrumb(breadcrumb); + }); + } + + register(recorder: Recorder, _sessionId: string): void { + this._destination = recorder; + } + + unregister(): void { + this._destination = undefined; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts new file mode 100644 index 0000000000..d78d9e6f23 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts @@ -0,0 +1,95 @@ +import { HttpBreadcrumb } from '../../api/Breadcrumb'; + +const LD_ORIGINAL_FETCH = '__LaunchDarkly_original_fetch'; + +const originalFetch = window.fetch; + +/** + * Given fetch arguments produce a URL and method. + * + * Exposed for testing. + * + * @param input First parameter to fetch. + * @param init Second, optional, parameter to fetch. + * @returns Return the URL and method. If not method or url can be accessed, then 'GET' will be the + * method and the url will be an empty string. + */ +export function processFetchArgs( + input: RequestInfo | URL, + init?: RequestInit | undefined, +): { url: string; method: string } { + let url = ''; + let method = 'GET'; + + if (typeof input === 'string') { + url = input; + } + // We may want to consider prop checks if this ends up being a problem for people. + // `instanceof` was not added to Edge until 2015. + if (typeof Request !== 'undefined' && input instanceof Request) { + url = input.url; + } + if (input instanceof URL) { + url = input.toString(); + } + + if (init) { + method = init.method ?? method; + } + return { url, method }; +} + +/** + * Decorate fetch and execute the callback whenever a fetch is completed providing a breadcrumb. + * + * @param callback Function which handles a breadcrumb. + */ +export default function decorateFetch(callback: (breadcrumb: HttpBreadcrumb) => void) { + // TODO: Check if already wrapped? + // TODO: Centralized mechanism to wrapping? + + // In this function we add type annotations for `this`. In this case we are telling teh compiler + // we don't care about the typing. + + // This is a function instead of an arrow function in order to preserve the original `this`. + // Arrow functions capture the enclosing `this`. + function wrapper(this: any, ...args: any[]): Promise { + const timestamp = Date.now(); + // We are taking the original parameters and passing them through. We are not specifying their + // type information and the number of parameters could be changed over time and the wrapper + // would still function. + return originalFetch.apply(this, args as any).then((response: Response) => { + const crumb: HttpBreadcrumb = { + class: 'http', + timestamp, + level: response.ok ? 'info' : 'error', + type: 'fetch', + data: { + // We know these will be fetch args. We only can take 2 of them, one of which may be + // undefined. We still use all the ars to apply to the original function. + ...processFetchArgs(args[0], args[1]), + statusCode: response.status, + statusText: response.statusText, + }, + }; + callback(crumb); + return response; + }); + } + wrapper.prototype = originalFetch.prototype; + + try { + // Use defineProperty to prevent this value from being enumerable. + Object.defineProperty(wrapper, LD_ORIGINAL_FETCH, { + // Defaults to non-enumerable. + value: originalFetch, + writable: true, + configurable: true, + }); + } catch { + // Intentional ignore. + // TODO: If we add debug logging, then this should be logged. + } + + window.fetch = wrapper; +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts b/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts new file mode 100644 index 0000000000..bf9f3b9b12 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts @@ -0,0 +1,28 @@ +import { Collector } from '../../api/Collector'; +import { Recorder } from '../../api/Recorder'; +import filterHttpBreadcrumb from '../../filters/filterHttpBreadcrumb'; +import HttpCollectorOptions from './HttpCollectorOptions'; +import decorateXhr from './xhrDecorator'; + +/** + * Instrument XMLHttpRequest and provide a breadcrumb for every XMLHttpRequest + * which is completed. + */ +export default class XhrCollector implements Collector { + private _destination?: Recorder; + + constructor(options: HttpCollectorOptions) { + decorateXhr((breadcrumb) => { + filterHttpBreadcrumb(breadcrumb, options); + this._destination?.addBreadcrumb(breadcrumb); + }); + } + + register(recorder: Recorder, _sessionId: string): void { + this._destination = recorder; + } + + unregister(): void { + this._destination = undefined; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts new file mode 100644 index 0000000000..9c8fcf2985 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts @@ -0,0 +1,123 @@ +import { HttpBreadcrumb } from '../../api/Breadcrumb'; + +const LD_ORIGINAL_XHR = '__LaunchDarkly_original_xhr'; +const LD_ORIGINAL_XHR_OPEN = `${LD_ORIGINAL_XHR}_open`; +const LD_ORIGINAL_XHR_SEND = `${LD_ORIGINAL_XHR}_send`; + +// Key used to store data inside the xhr. +const LD_DATA_XHR = '__LaunchDarkly_data_xhr'; + +// We want to monitor open to collect the URL and method. +const originalOpen = window.XMLHttpRequest.prototype.open; +// We want to monitor send in order to generate an accurate timestamp. +const originalSend = window.XMLHttpRequest.prototype.send; + +interface LDXhrData { + method?: string; + url?: string; + timestamp?: number; + error?: boolean; +} + +/** + * Decorate XMLHttpRequest and execute the callback whenever a request is completed. + * + * @param callback Function which handles a breadcrumb. + */ +export default function decorateXhr(callback: (breadcrumb: HttpBreadcrumb) => void) { + // In these functions we add type annotations for `this`. The impact here should just + // be that we get correct typing for typescript. They should not affect the output. + + // We are using functions instead of an arrow functions in order to preserve the original `this`. + // Arrow functions capture the enclosing `this`. + + function wrappedOpen(this: XMLHttpRequest, ...args: any[]) { + // Listen to error so we can tag this request as having an error. If there is no error event + // then the request will assume to not have errored. + // eslint-disable-next-line func-names + this.addEventListener('error', function (_event: ProgressEvent) { + // We know, if the data is present, that it has this shape, as we injected it. + const data: LDXhrData = (this as any)[LD_DATA_XHR]; + data.error = true; + }); + + this.addEventListener( + 'loadend', + // eslint-disable-next-line func-names + function (_event: ProgressEvent) { + // We know, if the data is present, that it has this shape, as we injected it. + const data: LDXhrData = (this as any)[LD_DATA_XHR]; + // Timestamp could be falsy for 0, but obviously that isn't a good timestamp, so we are ok. + if (data && data.timestamp) { + callback({ + class: 'http', + timestamp: data.timestamp, + level: data.error ? 'error' : 'info', + type: 'xhr', + data: { + url: data.url, + method: data.method, + statusCode: this.status, + statusText: this.statusText, + }, + }); + } + }, + true, + ); + + // We know these will be open arguments. + originalOpen.apply(this, args as any); + + try { + const xhrData: LDXhrData = { + method: args?.[0], + url: args?.[1], + }; + // Use defineProperty to prevent this value from being enumerable. + Object.defineProperty(this, LD_DATA_XHR, { + // Defaults to non-enumerable. + value: xhrData, + writable: true, + configurable: true, + }); + } catch { + // Intentional ignore. + // TODO: If we add debug logging, then this should be logged. + } + } + + function wrappedSend(this: XMLHttpRequest, ...args: any[]) { + // We know these will be open arguments. + originalSend.apply(this, args as any); + + // We know, if the data is present, that it has this shape, as we injected it. + const data: LDXhrData = (this as any)[LD_DATA_XHR]; + if (data) { + data.timestamp = Date.now(); + } + } + + window.XMLHttpRequest.prototype.open = wrappedOpen; + window.XMLHttpRequest.prototype.send = wrappedSend; + + try { + // Use defineProperties to prevent these values from being enumerable. + // The properties default to non-enumerable. + Object.defineProperties(window.XMLHttpRequest, { + [LD_ORIGINAL_XHR_OPEN]: { + value: originalOpen, + writable: true, + configurable: true, + }, + [LD_ORIGINAL_XHR_SEND]: { + value: originalSend, + writable: true, + configurable: true, + }, + }); + } catch { + // Intentional ignore. + // TODO: If we add debug logging, then this should be logged. + } +} diff --git a/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts b/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts new file mode 100644 index 0000000000..a81c45722c --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts @@ -0,0 +1,29 @@ +const pollingRegex = /sdk\/evalx\/[^/]+\/contexts\/(?[^/?]*)\??.*?/; +const streamingREgex = /\/eval\/[^/]+\/(?[^/?]*)\??.*?/; + +/** + * Filter which removes context information for browser JavaScript endpoints. + * + * @param url URL to filter. + * @returns A filtered URL. + */ +export default function defaultUrlFilter(url: string): string { + // TODO: Maybe we consider a way to identify LD requests so they can be filtered without + // regular expressions. + + if (url.includes('/sdk/evalx')) { + const regexMatch = url.match(pollingRegex); + const context = regexMatch?.groups?.context; + if (context) { + return url.replace(context, '*'.repeat(context.length)); + } + } + if (url.includes('/eval/')) { + const regexMatch = url.match(streamingREgex); + const context = regexMatch?.groups?.context; + if (context) { + return url.replace(context, '*'.repeat(context.length)); + } + } + return url; +} diff --git a/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts b/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts new file mode 100644 index 0000000000..5d58514fa3 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts @@ -0,0 +1,20 @@ +import { HttpBreadcrumb } from '../api/Breadcrumb'; +import HttpCollectorOptions from '../collectors/http/HttpCollectorOptions'; +import filterUrl from './filterUrl'; + +/** + * This function does in-place filtering of http breadcrumbs. + * + * @param crumb The breadcrumb to filter. + */ +export default function filterHttpBreadcrumb( + crumb: HttpBreadcrumb, + options: HttpCollectorOptions, +): void { + if (crumb.data?.url) { + // Re-assigning for performance. The contract of the function is clear that the input + // data is modified. + // eslint-disable-next-line no-param-reassign + crumb.data.url = filterUrl(options.urlFilters, crumb.data.url); + } +} diff --git a/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts b/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts new file mode 100644 index 0000000000..f66ac4177d --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts @@ -0,0 +1,8 @@ +import { UrlFilter } from '../api/Options'; + +export default function filterUrl(filters: UrlFilter[], url?: string): string { + if (!url) { + return ''; + } + return filters.reduce((filtered, filter) => filter(filtered), url); +} 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", From 92d5a6a2c70da8c10b550ba59fac0850f64aa69b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:14:15 -0800 Subject: [PATCH 2/5] Remaining fetch tests. --- .../__tests__/collectors/http/fetch.test.ts | 169 ++++++++++-------- .../telemetry/browser-telemetry/setup-jest.js | 35 ++++ .../src/collectors/http/fetchDecorator.ts | 3 +- 3 files changed, 128 insertions(+), 79 deletions(-) diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts index dc1e6900b9..b11c9a506c 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts @@ -48,82 +48,95 @@ describe('given a FetchCollector with a mock recorder', () => { ); }); - // it('stops adding breadcrumbs after unregistering', async () => { - // collector.register(mockRecorder, 'test-session'); - // collector.unregister(); - - // const mockResponse = new Response('test response', { status: 200 }); - // window.fetch = jest.fn().mockResolvedValue(mockResponse); - - // await fetch('https://api.example.com/data'); - - // expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); - // }); - - // it('filters URLs based on provided options', async () => { - // const options = { - // urlFilter: (url: string) => url.replace(/token=.*/, 'token=REDACTED'), - // }; - - // collector = new FetchCollector(options); - // collector.register(mockRecorder, 'test-session'); - - // const mockResponse = new Response('test response', { status: 200 }); - // window.fetch = jest.fn().mockResolvedValue(mockResponse); - - // await fetch('https://api.example.com/data?token=secret123'); - - // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - // expect.objectContaining({ - // data: { - // method: 'GET', - // url: 'https://api.example.com/data?token=REDACTED', - // }, - // }), - // ); - // }); - - // it('handles fetch calls with Request objects', async () => { - // collector.register(mockRecorder, 'test-session'); - - // const mockResponse = new Response('test response', { status: 200 }); - // window.fetch = jest.fn().mockResolvedValue(mockResponse); - - // const request = new Request('https://api.example.com/data', { - // method: 'PUT', - // body: JSON.stringify({ test: true }), - // }); - - // await fetch(request); - - // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - // expect.objectContaining({ - // message: 'PUT https://api.example.com/data', - // data: { - // method: 'PUT', - // url: 'https://api.example.com/data', - // }, - // }), - // ); - // }); - - // it('handles fetch calls with URL objects', async () => { - // collector.register(mockRecorder, 'test-session'); - - // const mockResponse = new Response('test response', { status: 200 }); - // window.fetch = jest.fn().mockResolvedValue(mockResponse); - - // const url = new URL('https://api.example.com/data'); - // await fetch(url); - - // expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - // expect.objectContaining({ - // message: 'GET https://api.example.com/data', - // data: { - // method: 'GET', - // url: 'https://api.example.com/data', - // }, - // }), - // ); - // }); + it('stops adding breadcrumbs after unregistering', async () => { + collector.register(mockRecorder, 'test-session'); + collector.unregister(); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data'); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('filters URLs based on provided options', async () => { + collector = new FetchCollector({ + urlFilters: [(url: string) => url.replace(/token=.*/, 'token=REDACTED')], // Convert urlFilter to urlFilters array + }); + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data?token=secret123'); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + method: 'GET', + url: 'https://api.example.com/data?token=REDACTED', + statusCode: 200, + statusText: 'OK', + }, + class: 'http', + timestamp: expect.any(Number), + level: 'info', + type: 'fetch', + }), + ); + }); + + it('handles fetch calls with Request objects', async () => { + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + const request = new Request('https://api.example.com/data', { + method: 'PUT', + body: JSON.stringify({ test: true }), + }); + await fetch(request); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + method: 'PUT', + url: 'https://api.example.com/data', + statusCode: 200, + statusText: 'OK', + }, + class: 'http', + timestamp: expect.any(Number), + level: 'info', + type: 'fetch', + }), + ); + }); + + it('handles fetch calls with URL objects', async () => { + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + const url = new URL('https://api.example.com/data'); + await fetch(url); + + expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + method: 'GET', + url: 'https://api.example.com/data', + statusCode: 200, + statusText: 'OK', + }, + class: 'http', + timestamp: expect.any(Number), + level: 'info', + type: 'fetch', + }), + ); + }); }); diff --git a/packages/telemetry/browser-telemetry/setup-jest.js b/packages/telemetry/browser-telemetry/setup-jest.js index 102b6d579f..14fd78a6b4 100644 --- a/packages/telemetry/browser-telemetry/setup-jest.js +++ b/packages/telemetry/browser-telemetry/setup-jest.js @@ -38,6 +38,41 @@ Object.defineProperty(global, 'Response', { 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/collectors/http/fetchDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts index d78d9e6f23..37c824cc62 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts @@ -28,8 +28,9 @@ export function processFetchArgs( // `instanceof` was not added to Edge until 2015. if (typeof Request !== 'undefined' && input instanceof Request) { url = input.url; + method = input.method; } - if (input instanceof URL) { + if (typeof URL !== 'undefined' && input instanceof URL) { url = input.toString(); } From 1530c61f374f55c5058802fec970e5080d573bbf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:15:35 -0800 Subject: [PATCH 3/5] Remove http collectors for a different PR. --- .../__tests__/collectors/http/fetch.test.ts | 142 ------------------ .../__tests__/collectors/http/xhr.test.ts | 139 ----------------- .../filters/defaultUrlFilter.test.ts | 41 ----- .../filters/filterHttpBreadcrumb.test.ts | 21 --- .../__tests__/filters/filterUrl.test.ts | 13 -- .../collectors/http/HttpCollectorOptions.ts | 13 -- .../src/collectors/http/fetch.ts | 27 ---- .../src/collectors/http/fetchDecorator.ts | 96 ------------ .../src/collectors/http/xhr.ts | 28 ---- .../src/collectors/http/xhrDecorator.ts | 123 --------------- .../src/filters/defaultUrlFilter.ts | 29 ---- .../src/filters/filterHttpBreadcrumb.ts | 20 --- .../src/filters/filterUrl.ts | 8 - 13 files changed, 700 deletions(-) delete mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts delete mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts delete mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts delete mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts delete mode 100644 packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts delete mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts delete mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts delete mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts delete mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts delete mode 100644 packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts delete mode 100644 packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts delete mode 100644 packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts delete mode 100644 packages/telemetry/browser-telemetry/src/filters/filterUrl.ts diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts deleted file mode 100644 index b11c9a506c..0000000000 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; -import { Recorder } from '../../../src/api/Recorder'; -import FetchCollector from '../../../src/collectors/http/fetch'; - -const initialFetch = window.fetch; - -describe('given a FetchCollector with a mock recorder', () => { - let mockRecorder: Recorder; - let collector: FetchCollector; - - beforeEach(() => { - // Create mock recorder - mockRecorder = { - addBreadcrumb: jest.fn(), - captureError: jest.fn(), - captureErrorEvent: jest.fn(), - }; - // Create collector with default options - collector = new FetchCollector({ - urlFilters: [], // Add required urlFilters property - }); - }); - - it('registers recorder and uses it for fetch calls', async () => { - collector.register(mockRecorder, 'test-session'); - - const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); - (initialFetch as jest.Mock).mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data', { - method: 'POST', - body: JSON.stringify({ test: true }), - }); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - class: 'http', - type: 'fetch', - level: 'info', - timestamp: expect.any(Number), - data: { - method: 'POST', - url: 'https://api.example.com/data', - statusCode: 200, - statusText: 'OK', - }, - }), - ); - }); - - it('stops adding breadcrumbs after unregistering', async () => { - collector.register(mockRecorder, 'test-session'); - collector.unregister(); - - const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); - (initialFetch as jest.Mock).mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data'); - - expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); - }); - - it('filters URLs based on provided options', async () => { - collector = new FetchCollector({ - urlFilters: [(url: string) => url.replace(/token=.*/, 'token=REDACTED')], // Convert urlFilter to urlFilters array - }); - collector.register(mockRecorder, 'test-session'); - - const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); - (initialFetch as jest.Mock).mockResolvedValue(mockResponse); - - await fetch('https://api.example.com/data?token=secret123'); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - method: 'GET', - url: 'https://api.example.com/data?token=REDACTED', - statusCode: 200, - statusText: 'OK', - }, - class: 'http', - timestamp: expect.any(Number), - level: 'info', - type: 'fetch', - }), - ); - }); - - it('handles fetch calls with Request objects', async () => { - collector.register(mockRecorder, 'test-session'); - - const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); - (initialFetch as jest.Mock).mockResolvedValue(mockResponse); - - const request = new Request('https://api.example.com/data', { - method: 'PUT', - body: JSON.stringify({ test: true }), - }); - await fetch(request); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - method: 'PUT', - url: 'https://api.example.com/data', - statusCode: 200, - statusText: 'OK', - }, - class: 'http', - timestamp: expect.any(Number), - level: 'info', - type: 'fetch', - }), - ); - }); - - it('handles fetch calls with URL objects', async () => { - collector.register(mockRecorder, 'test-session'); - - const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); - (initialFetch as jest.Mock).mockResolvedValue(mockResponse); - - const url = new URL('https://api.example.com/data'); - await fetch(url); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - method: 'GET', - url: 'https://api.example.com/data', - statusCode: 200, - statusText: 'OK', - }, - class: 'http', - timestamp: expect.any(Number), - level: 'info', - type: 'fetch', - }), - ); - }); -}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts deleted file mode 100644 index 125b639ab3..0000000000 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; -import { Recorder } from '../../../src/api/Recorder'; -import XhrCollector from '../../../src/collectors/http/xhr'; - -const initialXhr = window.XMLHttpRequest; - -it('registers recorder and uses it for xhr calls', () => { - const mockRecorder: Recorder = { - addBreadcrumb: jest.fn(), - captureError: jest.fn(), - captureErrorEvent: jest.fn(), - }; - - const collector = new XhrCollector({ - urlFilters: [], - }); - - collector.register(mockRecorder, 'test-session'); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'https://api.example.com/data'); - xhr.send(JSON.stringify({ test: true })); - - // Simulate successful response - Object.defineProperty(xhr, 'status', { value: 200 }); - Object.defineProperty(xhr, 'statusText', { value: 'OK' }); - xhr.dispatchEvent(new Event('loadend')); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - class: 'http', - type: 'xhr', - level: 'info', - timestamp: expect.any(Number), - data: { - method: 'POST', - url: 'https://api.example.com/data', - statusCode: 200, - statusText: 'OK', - }, - }), - ); -}); - -it('stops adding breadcrumbs after unregistering', () => { - const mockRecorder: Recorder = { - addBreadcrumb: jest.fn(), - captureError: jest.fn(), - captureErrorEvent: jest.fn(), - }; - - const collector = new XhrCollector({ - urlFilters: [], - }); - - collector.register(mockRecorder, 'test-session'); - collector.unregister(); - - const xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://api.example.com/data'); - xhr.send(); - - xhr.dispatchEvent(new Event('loadend')); - - expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); -}); - -it('marks requests with error events as errors', () => { - const mockRecorder: Recorder = { - addBreadcrumb: jest.fn(), - captureError: jest.fn(), - captureErrorEvent: jest.fn(), - }; - - const collector = new XhrCollector({ - urlFilters: [], - }); - - collector.register(mockRecorder, 'test-session'); - - const xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://api.example.com/data'); - xhr.send(); - - xhr.dispatchEvent(new Event('error')); - xhr.dispatchEvent(new Event('loadend')); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - level: 'error', - data: expect.objectContaining({ - method: 'GET', - statusCode: 0, - statusText: '', - url: 'https://api.example.com/data', - }), - class: 'http', - timestamp: expect.any(Number), - type: 'xhr', - }), - ); -}); - -it('applies URL filters to requests', () => { - const mockRecorder: Recorder = { - addBreadcrumb: jest.fn(), - captureError: jest.fn(), - captureErrorEvent: jest.fn(), - }; - - const collector = new XhrCollector({ - urlFilters: [(url) => url.replace(/token=.*/, 'token=REDACTED')], - }); - - collector.register(mockRecorder, 'test-session'); - - const xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://api.example.com/data?token=secret123'); - xhr.send(); - - Object.defineProperty(xhr, 'status', { value: 200 }); - xhr.dispatchEvent(new Event('loadend')); - - expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - url: 'https://api.example.com/data?token=REDACTED', - }), - class: 'http', - timestamp: expect.any(Number), - level: 'info', - type: 'xhr', - }), - ); -}); - -afterEach(() => { - window.XMLHttpRequest = initialXhr; -}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts deleted file mode 100644 index 9b4876d62f..0000000000 --- a/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import defaultUrlFilter from '../../src/filters/defaultUrlFilter'; - -it('filters polling urls', () => { - // Added -_ to the end as we use those in the base64 URL safe character set. - const context = - 'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_'; - const filteredCotext = - '************************************************************************************'; - const baseUrl = 'https://sdk.launchdarkly.com/sdk/evalx/thesdkkey/contexts/'; - const filteredUrl = `${baseUrl}${filteredCotext}`; - const testUrl = `${baseUrl}${context}`; - const testUrlWithReasons = `${testUrl}?withReasons=true`; - const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`; - - expect(defaultUrlFilter(testUrl)).toBe(filteredUrl); - expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons); -}); - -it('filters streaming urls', () => { - // Added -_ to the end as we use those in the base64 URL safe character set. - const context = - 'eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6ImJvYiJ9LCJvcmciOnsia2V5IjoidGFjb2h1dCJ9fQ-_'; - const filteredCotext = - '************************************************************************************'; - const baseUrl = `https://clientstream.launchdarkly.com/eval/thesdkkey/`; - const filteredUrl = `${baseUrl}${filteredCotext}`; - const testUrl = `${baseUrl}${context}`; - const testUrlWithReasons = `${testUrl}?withReasons=true`; - const filteredUrlWithReasons = `${filteredUrl}?withReasons=true`; - - expect(defaultUrlFilter(testUrl)).toBe(filteredUrl); - expect(defaultUrlFilter(testUrlWithReasons)).toBe(filteredUrlWithReasons); -}); - -it.each([ - 'http://events.launchdarkly.com/events/bulk/thesdkkey', - 'http://localhost:8080', - 'http://some.other.base64like/eyJraW5kIjoibXVsdGkiLCJ1c2VyIjp7ImtleSI6vcmciOnsiaIjoidGFjb2h1dCJ9fQ-_', -])('passes through other URLs unfiltered', (url) => { - expect(defaultUrlFilter(url)).toBe(url); -}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts deleted file mode 100644 index e1735595bb..0000000000 --- a/packages/telemetry/browser-telemetry/__tests__/filters/filterHttpBreadcrumb.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HttpBreadcrumb } from '../../src/api/Breadcrumb'; -import filterHttpBreadcrumb from '../../src/filters/filterHttpBreadcrumb'; - -it('filters breadcrumbs with the provided filters', () => { - const breadcrumb: HttpBreadcrumb = { - class: 'http', - timestamp: Date.now(), - level: 'info', - type: 'xhr', - data: { - method: 'GET', - url: 'dog', - statusCode: 200, - statusText: 'ok', - }, - }; - filterHttpBreadcrumb(breadcrumb, { - urlFilters: [(url) => url.replace('dog', 'cat')], - }); - expect(breadcrumb.data?.url).toBe('cat'); -}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts deleted file mode 100644 index 79e7315487..0000000000 --- a/packages/telemetry/browser-telemetry/__tests__/filters/filterUrl.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import filterUrl from '../../src/filters/filterUrl'; - -it('runs the specified filters in the given order', () => { - const filterA = (url: string): string => url.replace('dog', 'cat'); - const filterB = (url: string): string => url.replace('cat', 'mouse'); - - // dog -> cat -> mouse - expect(filterUrl([filterA, filterB], 'dog')).toBe('mouse'); - // dog -> dog -> cat - expect(filterUrl([filterB, filterA], 'dog')).toBe('cat'); - // cat -> mouse -> mouse - expect(filterUrl([filterB, filterA], 'cat')).toBe('mouse'); -}); diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts deleted file mode 100644 index 2f6c4bab47..0000000000 --- a/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { UrlFilter } from '../../api/Options'; - -/** - * Options which impact the behavior of http collectors. - */ -export default interface HttpCollectorOptions { - /** - * A list of filters to execute on the URL of the breadcrumb. - * - * This allows for redaction of potentially sensitive information in URLs. - */ - urlFilters: UrlFilter[]; -} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts deleted file mode 100644 index 0baa0739b4..0000000000 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Collector } from '../../api/Collector'; -import { Recorder } from '../../api/Recorder'; -import filterHttpBreadcrumb from '../../filters/filterHttpBreadcrumb'; -import decorateFetch from './fetchDecorator'; -import HttpCollectorOptions from './HttpCollectorOptions'; - -/** - * Instrument fetch requests and generate a breadcrumb for each request. - */ -export default class FetchCollector implements Collector { - private _destination?: Recorder; - - constructor(options: HttpCollectorOptions) { - decorateFetch((breadcrumb) => { - filterHttpBreadcrumb(breadcrumb, options); - this._destination?.addBreadcrumb(breadcrumb); - }); - } - - register(recorder: Recorder, _sessionId: string): void { - this._destination = recorder; - } - - unregister(): void { - this._destination = undefined; - } -} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts deleted file mode 100644 index 37c824cc62..0000000000 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { HttpBreadcrumb } from '../../api/Breadcrumb'; - -const LD_ORIGINAL_FETCH = '__LaunchDarkly_original_fetch'; - -const originalFetch = window.fetch; - -/** - * Given fetch arguments produce a URL and method. - * - * Exposed for testing. - * - * @param input First parameter to fetch. - * @param init Second, optional, parameter to fetch. - * @returns Return the URL and method. If not method or url can be accessed, then 'GET' will be the - * method and the url will be an empty string. - */ -export function processFetchArgs( - input: RequestInfo | URL, - init?: RequestInit | undefined, -): { url: string; method: string } { - let url = ''; - let method = 'GET'; - - if (typeof input === 'string') { - url = input; - } - // We may want to consider prop checks if this ends up being a problem for people. - // `instanceof` was not added to Edge until 2015. - if (typeof Request !== 'undefined' && input instanceof Request) { - url = input.url; - method = input.method; - } - if (typeof URL !== 'undefined' && input instanceof URL) { - url = input.toString(); - } - - if (init) { - method = init.method ?? method; - } - return { url, method }; -} - -/** - * Decorate fetch and execute the callback whenever a fetch is completed providing a breadcrumb. - * - * @param callback Function which handles a breadcrumb. - */ -export default function decorateFetch(callback: (breadcrumb: HttpBreadcrumb) => void) { - // TODO: Check if already wrapped? - // TODO: Centralized mechanism to wrapping? - - // In this function we add type annotations for `this`. In this case we are telling teh compiler - // we don't care about the typing. - - // This is a function instead of an arrow function in order to preserve the original `this`. - // Arrow functions capture the enclosing `this`. - function wrapper(this: any, ...args: any[]): Promise { - const timestamp = Date.now(); - // We are taking the original parameters and passing them through. We are not specifying their - // type information and the number of parameters could be changed over time and the wrapper - // would still function. - return originalFetch.apply(this, args as any).then((response: Response) => { - const crumb: HttpBreadcrumb = { - class: 'http', - timestamp, - level: response.ok ? 'info' : 'error', - type: 'fetch', - data: { - // We know these will be fetch args. We only can take 2 of them, one of which may be - // undefined. We still use all the ars to apply to the original function. - ...processFetchArgs(args[0], args[1]), - statusCode: response.status, - statusText: response.statusText, - }, - }; - callback(crumb); - return response; - }); - } - wrapper.prototype = originalFetch.prototype; - - try { - // Use defineProperty to prevent this value from being enumerable. - Object.defineProperty(wrapper, LD_ORIGINAL_FETCH, { - // Defaults to non-enumerable. - value: originalFetch, - writable: true, - configurable: true, - }); - } catch { - // Intentional ignore. - // TODO: If we add debug logging, then this should be logged. - } - - window.fetch = wrapper; -} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts b/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts deleted file mode 100644 index bf9f3b9b12..0000000000 --- a/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Collector } from '../../api/Collector'; -import { Recorder } from '../../api/Recorder'; -import filterHttpBreadcrumb from '../../filters/filterHttpBreadcrumb'; -import HttpCollectorOptions from './HttpCollectorOptions'; -import decorateXhr from './xhrDecorator'; - -/** - * Instrument XMLHttpRequest and provide a breadcrumb for every XMLHttpRequest - * which is completed. - */ -export default class XhrCollector implements Collector { - private _destination?: Recorder; - - constructor(options: HttpCollectorOptions) { - decorateXhr((breadcrumb) => { - filterHttpBreadcrumb(breadcrumb, options); - this._destination?.addBreadcrumb(breadcrumb); - }); - } - - register(recorder: Recorder, _sessionId: string): void { - this._destination = recorder; - } - - unregister(): void { - this._destination = undefined; - } -} diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts deleted file mode 100644 index 9c8fcf2985..0000000000 --- a/packages/telemetry/browser-telemetry/src/collectors/http/xhrDecorator.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { HttpBreadcrumb } from '../../api/Breadcrumb'; - -const LD_ORIGINAL_XHR = '__LaunchDarkly_original_xhr'; -const LD_ORIGINAL_XHR_OPEN = `${LD_ORIGINAL_XHR}_open`; -const LD_ORIGINAL_XHR_SEND = `${LD_ORIGINAL_XHR}_send`; - -// Key used to store data inside the xhr. -const LD_DATA_XHR = '__LaunchDarkly_data_xhr'; - -// We want to monitor open to collect the URL and method. -const originalOpen = window.XMLHttpRequest.prototype.open; -// We want to monitor send in order to generate an accurate timestamp. -const originalSend = window.XMLHttpRequest.prototype.send; - -interface LDXhrData { - method?: string; - url?: string; - timestamp?: number; - error?: boolean; -} - -/** - * Decorate XMLHttpRequest and execute the callback whenever a request is completed. - * - * @param callback Function which handles a breadcrumb. - */ -export default function decorateXhr(callback: (breadcrumb: HttpBreadcrumb) => void) { - // In these functions we add type annotations for `this`. The impact here should just - // be that we get correct typing for typescript. They should not affect the output. - - // We are using functions instead of an arrow functions in order to preserve the original `this`. - // Arrow functions capture the enclosing `this`. - - function wrappedOpen(this: XMLHttpRequest, ...args: any[]) { - // Listen to error so we can tag this request as having an error. If there is no error event - // then the request will assume to not have errored. - // eslint-disable-next-line func-names - this.addEventListener('error', function (_event: ProgressEvent) { - // We know, if the data is present, that it has this shape, as we injected it. - const data: LDXhrData = (this as any)[LD_DATA_XHR]; - data.error = true; - }); - - this.addEventListener( - 'loadend', - // eslint-disable-next-line func-names - function (_event: ProgressEvent) { - // We know, if the data is present, that it has this shape, as we injected it. - const data: LDXhrData = (this as any)[LD_DATA_XHR]; - // Timestamp could be falsy for 0, but obviously that isn't a good timestamp, so we are ok. - if (data && data.timestamp) { - callback({ - class: 'http', - timestamp: data.timestamp, - level: data.error ? 'error' : 'info', - type: 'xhr', - data: { - url: data.url, - method: data.method, - statusCode: this.status, - statusText: this.statusText, - }, - }); - } - }, - true, - ); - - // We know these will be open arguments. - originalOpen.apply(this, args as any); - - try { - const xhrData: LDXhrData = { - method: args?.[0], - url: args?.[1], - }; - // Use defineProperty to prevent this value from being enumerable. - Object.defineProperty(this, LD_DATA_XHR, { - // Defaults to non-enumerable. - value: xhrData, - writable: true, - configurable: true, - }); - } catch { - // Intentional ignore. - // TODO: If we add debug logging, then this should be logged. - } - } - - function wrappedSend(this: XMLHttpRequest, ...args: any[]) { - // We know these will be open arguments. - originalSend.apply(this, args as any); - - // We know, if the data is present, that it has this shape, as we injected it. - const data: LDXhrData = (this as any)[LD_DATA_XHR]; - if (data) { - data.timestamp = Date.now(); - } - } - - window.XMLHttpRequest.prototype.open = wrappedOpen; - window.XMLHttpRequest.prototype.send = wrappedSend; - - try { - // Use defineProperties to prevent these values from being enumerable. - // The properties default to non-enumerable. - Object.defineProperties(window.XMLHttpRequest, { - [LD_ORIGINAL_XHR_OPEN]: { - value: originalOpen, - writable: true, - configurable: true, - }, - [LD_ORIGINAL_XHR_SEND]: { - value: originalSend, - writable: true, - configurable: true, - }, - }); - } catch { - // Intentional ignore. - // TODO: If we add debug logging, then this should be logged. - } -} diff --git a/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts b/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts deleted file mode 100644 index a81c45722c..0000000000 --- a/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts +++ /dev/null @@ -1,29 +0,0 @@ -const pollingRegex = /sdk\/evalx\/[^/]+\/contexts\/(?[^/?]*)\??.*?/; -const streamingREgex = /\/eval\/[^/]+\/(?[^/?]*)\??.*?/; - -/** - * Filter which removes context information for browser JavaScript endpoints. - * - * @param url URL to filter. - * @returns A filtered URL. - */ -export default function defaultUrlFilter(url: string): string { - // TODO: Maybe we consider a way to identify LD requests so they can be filtered without - // regular expressions. - - if (url.includes('/sdk/evalx')) { - const regexMatch = url.match(pollingRegex); - const context = regexMatch?.groups?.context; - if (context) { - return url.replace(context, '*'.repeat(context.length)); - } - } - if (url.includes('/eval/')) { - const regexMatch = url.match(streamingREgex); - const context = regexMatch?.groups?.context; - if (context) { - return url.replace(context, '*'.repeat(context.length)); - } - } - return url; -} diff --git a/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts b/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts deleted file mode 100644 index 5d58514fa3..0000000000 --- a/packages/telemetry/browser-telemetry/src/filters/filterHttpBreadcrumb.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { HttpBreadcrumb } from '../api/Breadcrumb'; -import HttpCollectorOptions from '../collectors/http/HttpCollectorOptions'; -import filterUrl from './filterUrl'; - -/** - * This function does in-place filtering of http breadcrumbs. - * - * @param crumb The breadcrumb to filter. - */ -export default function filterHttpBreadcrumb( - crumb: HttpBreadcrumb, - options: HttpCollectorOptions, -): void { - if (crumb.data?.url) { - // Re-assigning for performance. The contract of the function is clear that the input - // data is modified. - // eslint-disable-next-line no-param-reassign - crumb.data.url = filterUrl(options.urlFilters, crumb.data.url); - } -} diff --git a/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts b/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts deleted file mode 100644 index f66ac4177d..0000000000 --- a/packages/telemetry/browser-telemetry/src/filters/filterUrl.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { UrlFilter } from '../api/Options'; - -export default function filterUrl(filters: UrlFilter[], url?: string): string { - if (!url) { - return ''; - } - return filters.reduce((filtered, filter) => filter(filtered), url); -} From 69894747436c5a569a0d7feaad7f7e3a57ed1d78 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:03:22 -0800 Subject: [PATCH 4/5] PR Feedback. --- .../src/collectors/dom/KeypressCollector.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts index 21172a2070..7b8b7edf58 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts @@ -9,7 +9,7 @@ const THROTTLE_TIME_MS = 1000; const INPUT_TAG_NAMES = ['INPUT', 'TEXTAREA']; /** - * Collects mouse click events and adds them as breadcrumbs. + * Collects key press events and adds them as breadcrumbs. */ export default class KeypressCollector implements Collector { private _destination?: Recorder; @@ -53,10 +53,12 @@ export default class KeypressCollector implements Collector { } private _shouldDeduplicate(crumb: Breadcrumb): boolean { - // TODO: Consider de-duplication at the dom level. + // 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 this._lastEvent.message === crumb.message && timeDiff <= THROTTLE_TIME_MS; + return timeDiff <= THROTTLE_TIME_MS && this._lastEvent.message === crumb.message; } return false; } From 22dd887dc4db5adf174eb9dfe963bd29d4ab6806 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:06:43 -0800 Subject: [PATCH 5/5] Keypress note. --- .../browser-telemetry/src/collectors/dom/KeypressCollector.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts index 7b8b7edf58..9942027aa3 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/dom/KeypressCollector.ts @@ -16,6 +16,10 @@ export default class KeypressCollector implements Collector { 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) => {