From 6e60ddc6932341ace2d16ace688d7774bc6340d4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:36:50 -0800 Subject: [PATCH] feat: Add http collectors. (#673) Best reviewed after: https://github.com/launchdarkly/js-core/pull/672 --- .../__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 insertions(+) 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/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/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts new file mode 100644 index 000000000..b11c9a506 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts @@ -0,0 +1,142 @@ +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 new file mode 100644 index 000000000..125b639ab --- /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 000000000..9b4876d62 --- /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 000000000..e1735595b --- /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 000000000..79e731548 --- /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/src/collectors/http/HttpCollectorOptions.ts b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts new file mode 100644 index 000000000..2f6c4bab4 --- /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 000000000..0baa0739b --- /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 000000000..85dea49da --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts @@ -0,0 +1,96 @@ +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 (SDK-884): Check if already wrapped? + // TODO (SDK-884): Centralized mechanism to wrapping? + + // In this function we add type annotations for `this`. In this case we are telling the 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 000000000..bf9f3b9b1 --- /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 000000000..9c8fcf298 --- /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 000000000..a81c45722 --- /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 000000000..5d58514fa --- /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 000000000..f66ac4177 --- /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); +}