diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 82ddd8156..2a4c1f641 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -24,6 +24,7 @@ beforeEach(() => { cy.readFile('dist/recorder.js').then((body) => { cy.intercept('**/static/recorder.js*', { body }).as('recorder') + cy.intercept('**/static/recorder-v2.js*', { body }).as('recorder') }) cy.readFile('dist/surveys.js').then((body) => { diff --git a/package.json b/package.json index 5545b7dcf..1d97f4213 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "rollup-plugin-visualizer": "^5.12.0", "rrweb": "2.0.0-alpha.11", "rrweb-snapshot": "2.0.0-alpha.11", - "rrweb-v1": "npm:rrweb@1.1.3", "sinon": "9.0.2", "testcafe": "1.19.0", "testcafe-browser-provider-browserstack": "1.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752169882..7b0604df1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,9 +174,6 @@ devDependencies: rrweb-snapshot: specifier: 2.0.0-alpha.11 version: 2.0.0-alpha.11 - rrweb-v1: - specifier: npm:rrweb@1.1.3 - version: /rrweb@1.1.3 sinon: specifier: 9.0.2 version: 9.0.2 @@ -7946,10 +7943,6 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true - /mitt@1.2.0: - resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==} - dev: true - /mitt@3.0.0: resolution: {integrity: sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==} dev: true @@ -9197,25 +9190,10 @@ packages: rrweb-snapshot: 2.0.0-alpha.11 dev: true - /rrweb-snapshot@1.1.14: - resolution: {integrity: sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ==} - dev: true - /rrweb-snapshot@2.0.0-alpha.11: resolution: {integrity: sha512-N0dzeJA2VhrlSOadkKwCVmV/DuNOwBH+Lhx89hAf9PQK4lCS8AP4AaylhqUdZOYHqwVjqsYel/uZ4hN79vuLhw==} dev: true - /rrweb@1.1.3: - resolution: {integrity: sha512-F2qp8LteJLyycsv+lCVJqtVpery63L3U+/ogqMA0da8R7Jx57o6gT+HpjrzdeeGMIBZR7kKNaKyJwDupTTu5KA==} - dependencies: - '@types/css-font-loading-module': 0.0.7 - '@xstate/fsm': 1.5.2 - base64-arraybuffer: 1.0.2 - fflate: 0.4.8 - mitt: 1.2.0 - rrweb-snapshot: 1.1.14 - dev: true - /rrweb@2.0.0-alpha.11(patch_hash=tjoktulcrliiekxagfypcvjnr4): resolution: {integrity: sha512-vJ2gNvF+pUG9C2aaau7iSNqhWBSc4BwtUO4FpegOtDObuH4PIaxNJOlgHz82+WxKr9XPm93ER0LqmNpy0KYdKg==} dependencies: diff --git a/rollup.config.js b/rollup.config.js index ea4905a89..3d0585456 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -31,13 +31,8 @@ export default [ format: 'iife', name: 'posthog', }, - ], - plugins: [...plugins], - }, - { - input: 'src/loader-recorder-v2.ts', - output: [ { + // Backwards compatibility for older SDK versions file: 'dist/recorder-v2.js', sourcemap: true, format: 'iife', diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 5f5578a88..1a5ea0977 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -7,7 +7,6 @@ import { SESSION_RECORDING_CANVAS_RECORDING, SESSION_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_IS_SAMPLED, - SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, } from '../../../constants' import { SessionIdManager } from '../../../sessionid' import { @@ -145,7 +144,6 @@ describe('SessionRecording', () => { // defaults posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: true, - [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v2', [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false, [SESSION_RECORDING_IS_SAMPLED]: undefined, }) @@ -193,33 +191,6 @@ describe('SessionRecording', () => { }) }) - describe('getRecordingVersion', () => { - it('uses client side setting v2 over server side', () => { - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v1' }) - posthog.config.session_recording.recorderVersion = 'v2' - expect(sessionRecording['recordingVersion']).toBe('v2') - }) - - it('uses client side setting v1 over server side', () => { - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v2' }) - posthog.config.session_recording.recorderVersion = 'v1' - expect(sessionRecording['recordingVersion']).toBe('v1') - }) - - it('uses server side setting if client side setting is not set', () => { - posthog.config.session_recording.recorderVersion = undefined - - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v1' }) - expect(sessionRecording['recordingVersion']).toBe('v1') - - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v2' }) - expect(sessionRecording['recordingVersion']).toBe('v2') - - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: undefined }) - expect(sessionRecording['recordingVersion']).toBe('v1') - }) - }) - describe('startRecordingIfEnabled', () => { beforeEach(() => { // need to cast as any to mock private methods @@ -719,16 +690,7 @@ describe('SessionRecording', () => { expect(loadScript).not.toHaveBeenCalled() }) - it('loads recording v1 script from right place', () => { - posthog.config.session_recording.recorderVersion = 'v1' - - sessionRecording.startRecordingIfEnabled() - - expect(loadScript).toHaveBeenCalledWith('https://test.com/static/recorder.js?v=v0.0.1', expect.anything()) - }) - it('loads recording v2 script from right place', () => { - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v2' }) sessionRecording.startRecordingIfEnabled() expect(loadScript).toHaveBeenCalledWith( @@ -739,7 +701,6 @@ describe('SessionRecording', () => { it('load correct recording version if there is a cached mismatch', () => { posthog.__loaded_recorder_version = 'v1' - posthog.persistence?.register({ [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: 'v2' }) sessionRecording.startRecordingIfEnabled() expect(loadScript).toHaveBeenCalledWith( diff --git a/src/constants.ts b/src/constants.ts index 8423864c3..9dc632a0e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,7 +13,6 @@ export const EVENT_TIMERS_KEY = '__timers' export const AUTOCAPTURE_DISABLED_SERVER_SIDE = '$autocapture_disabled_server_side' export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side' export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side' -export const SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE = '$session_recording_recorder_version_server_side' // follows rrweb versioning export const SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE = '$session_recording_network_payload_capture' export const SESSION_RECORDING_CANVAS_RECORDING = '$session_recording_canvas_recording' export const SESSION_ID = '$sesid' diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 7d10e0a59..46047a017 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -4,7 +4,6 @@ import { SESSION_RECORDING_ENABLED_SERVER_SIDE, SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, - SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, } from '../../constants' import { FULL_SNAPSHOT_EVENT_TYPE, @@ -192,12 +191,6 @@ export class SessionRecording { : undefined } - private get recordingVersion() { - const recordingVersion_server_side = this.instance.get_property(SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE) - const recordingVersion_client_side = this.instance.config.session_recording?.recorderVersion - return recordingVersion_client_side || recordingVersion_server_side || 'v1' - } - // network payload capture config has three parts // each can be configured server side or client side private get networkPayloadCapture(): @@ -341,7 +334,6 @@ export class SessionRecording { this.instance.persistence.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: !!response['sessionRecording'], [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: response.sessionRecording?.consoleLogRecordingEnabled, - [SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE]: response.sessionRecording?.recorderVersion, [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: { capturePerformance: response.capturePerformance, ...response.sessionRecording?.networkPayloadCapture, @@ -429,17 +421,15 @@ export class SessionRecording { // We want to ensure the sessionManager is reset if necessary on load of the recorder this.sessionManager.checkAndGetSessionAndWindowId() - const recorderJS = this.recordingVersion === 'v2' ? 'recorder-v2.js' : 'recorder.js' - // If recorder.js is already loaded (if array.full.js snippet is used or posthog-js/dist/recorder is // imported) or matches the requested recorder version, don't load script. Otherwise, remotely import // recorder.js from cdn since it hasn't been loaded. - if (this.instance.__loaded_recorder_version !== this.recordingVersion) { + if (this.instance.__loaded_recorder_version !== 'v2') { loadScript( - this.instance.requestRouter.endpointFor('assets', `/static/${recorderJS}?v=${Config.LIB_VERSION}`), + this.instance.requestRouter.endpointFor('assets', `/static/recorder-v2.js?v=${Config.LIB_VERSION}`), (err) => { if (err) { - return logger.error(LOGGER_PREFIX + ` could not load ${recorderJS}`, err) + return logger.error(LOGGER_PREFIX + ` could not load recorder-v2.js`, err) } this._onScriptLoaded() diff --git a/src/loader-recorder-v2.ts b/src/loader-recorder-v2.ts deleted file mode 100644 index d38d06494..000000000 --- a/src/loader-recorder-v2.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { version } from 'rrweb/package.json' - -// Same as loader-globals.ts except includes rrweb2 scripts. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import rrwebRecord from 'rrweb/es/rrweb/packages/rrweb/src/record' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { getRecordConsolePlugin } from 'rrweb/es/rrweb/packages/rrweb/src/plugins/console/record' - -// rrweb/network@1 code starts -// most of what is below here will be removed when rrweb release their code for this -// see https://github.com/rrweb-io/rrweb/pull/1105 -/// -// NB adopted from https://github.com/rrweb-io/rrweb/pull/1105 which looks like it will be accepted into rrweb -// however, in the PR, it throws when the performance observer data is not available -// and assumes it is running in a browser with the Request API (i.e. not IE11) -// copying here so that we can use it before rrweb adopt it -import type { IWindow, listenerHandler, RecordPlugin } from '@rrweb/types' -import { CapturedNetworkRequest, Headers, InitiatorType, NetworkRecordOptions } from './types' -import { - _isArray, - _isBoolean, - _isDocument, - _isFormData, - _isFunction, - _isNull, - _isNullish, - _isObject, - _isString, - _isUndefined, -} from './utils/type-utils' -import { logger } from './utils/logger' -import { window } from './utils/globals' -import { defaultNetworkOptions } from './extensions/replay/config' -import { _formDataToQuery } from './utils/request-utils' - -export type NetworkData = { - requests: CapturedNetworkRequest[] - isInitial?: boolean -} - -type networkCallback = (data: NetworkData) => void - -const isNavigationTiming = (entry: PerformanceEntry): entry is PerformanceNavigationTiming => - entry.entryType === 'navigation' -const isResourceTiming = (entry: PerformanceEntry): entry is PerformanceResourceTiming => entry.entryType === 'resource' - -type ObservedPerformanceEntry = (PerformanceNavigationTiming | PerformanceResourceTiming) & { - responseStatus?: number -} - -// import { patch } from 'rrweb/typings/utils' -// copied from https://github.com/rrweb-io/rrweb/blob/8aea5b00a4dfe5a6f59bd2ae72bb624f45e51e81/packages/rrweb/src/utils.ts#L129 -// which was copied from https://github.com/getsentry/sentry-javascript/blob/b2109071975af8bf0316d3b5b38f519bdaf5dc15/packages/utils/src/object.ts -export function patch( - source: { [key: string]: any }, - name: string, - replacement: (...args: unknown[]) => unknown -): () => void { - try { - if (!(name in source)) { - return () => { - // - } - } - - const original = source[name] as () => unknown - const wrapped = replacement(original) - - // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work - // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" - if (_isFunction(wrapped)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - wrapped.prototype = wrapped.prototype || {} - Object.defineProperties(wrapped, { - __rrweb_original__: { - enumerable: false, - value: original, - }, - }) - } - - source[name] = wrapped - - return () => { - source[name] = original - } - } catch { - return () => { - // - } - // This can throw if multiple fill happens on a global object like XMLHttpRequest - // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 - } -} - -export function findLast(array: Array, predicate: (value: T) => boolean): T | undefined { - const length = array.length - for (let i = length - 1; i >= 0; i -= 1) { - if (predicate(array[i])) { - return array[i] - } - } - return undefined -} - -function initPerformanceObserver(cb: networkCallback, win: IWindow, options: Required) { - // if we are only observing timings then we could have a single observer for all types, with buffer true, - // but we are going to filter by initiatorType _if we are wrapping fetch and xhr as the wrapped functions - // will deal with those. - // so we have a block which captures requests from before fetch/xhr is wrapped - // these are marked `isInitial` so playback can display them differently if needed - // they will never have method/status/headers/body because they are pre-wrapping that provides that - if (options.recordInitialRequests) { - const initialPerformanceEntries = win.performance - .getEntries() - .filter( - (entry): entry is ObservedPerformanceEntry => - isNavigationTiming(entry) || - (isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType as InitiatorType)) - ) - cb({ - requests: initialPerformanceEntries.flatMap((entry) => - prepareRequest(entry, undefined, undefined, {}, true) - ), - isInitial: true, - }) - } - const observer = new win.PerformanceObserver((entries) => { - // if recordBody or recordHeaders is true then we don't want to record fetch or xhr here - // as the wrapped functions will do that. Otherwise, this filter becomes a noop - // because we do want to record them here - const wrappedInitiatorFilter = (entry: ObservedPerformanceEntry) => - options.recordBody || options.recordHeaders - ? entry.initiatorType !== 'xmlhttprequest' && entry.initiatorType !== 'fetch' - : true - - const performanceEntries = entries.getEntries().filter( - (entry): entry is ObservedPerformanceEntry => - isNavigationTiming(entry) || - (isResourceTiming(entry) && - options.initiatorTypes.includes(entry.initiatorType as InitiatorType) && - // TODO if we are _only_ capturing timing we don't want to filter initiator here - wrappedInitiatorFilter(entry)) - ) - - cb({ - requests: performanceEntries.flatMap((entry) => prepareRequest(entry, undefined, undefined, {})), - }) - }) - // compat checked earlier - // eslint-disable-next-line compat/compat - const entryTypes = PerformanceObserver.supportedEntryTypes.filter((x) => - options.performanceEntryTypeToObserve.includes(x) - ) - // initial records are gathered above, so we don't need to observe and buffer each type separately - observer.observe({ entryTypes }) - return () => { - observer.disconnect() - } -} - -function shouldRecordHeaders(type: 'request' | 'response', recordHeaders: NetworkRecordOptions['recordHeaders']) { - return !!recordHeaders && (_isBoolean(recordHeaders) || recordHeaders[type]) -} - -function shouldRecordBody( - type: 'request' | 'response', - recordBody: NetworkRecordOptions['recordBody'], - headers: Headers -) { - function matchesContentType(contentTypes: string[]) { - const contentTypeHeader = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type') - const contentType = contentTypeHeader && headers[contentTypeHeader] - return contentTypes.some((ct) => contentType?.includes(ct)) - } - - if (!recordBody) return false - if (_isBoolean(recordBody)) return true - if (_isArray(recordBody)) return matchesContentType(recordBody) - const recordBodyType = recordBody[type] - if (_isBoolean(recordBodyType)) return recordBodyType - return matchesContentType(recordBodyType) -} - -async function getRequestPerformanceEntry( - win: IWindow, - initiatorType: string, - url: string, - after?: number, - before?: number, - attempt = 0 -): Promise { - if (attempt > 10) { - logger.warn('Failed to get performance entry for request', { url, initiatorType }) - return null - } - const urlPerformanceEntries = win.performance.getEntriesByName(url) as PerformanceResourceTiming[] - const performanceEntry = findLast( - urlPerformanceEntries, - (entry) => - isResourceTiming(entry) && - entry.initiatorType === initiatorType && - (!after || entry.startTime >= after) && - (!before || entry.startTime <= before) - ) - if (!performanceEntry) { - await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) - return getRequestPerformanceEntry(win, initiatorType, url, after, before, attempt + 1) - } - return performanceEntry -} - -/** - * According to MDN https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response - * xhr response is typed as any but can be an ArrayBuffer, a Blob, a Document, a JavaScript object, - * or a string, depending on the value of XMLHttpRequest.responseType, that contains the response entity body. - * - * XHR request body is Document | XMLHttpRequestBodyInit | null | undefined - */ -function _tryReadXHRBody(body: Document | XMLHttpRequestBodyInit | any | null | undefined): string | null { - if (_isNullish(body)) { - return null - } - - if (_isString(body)) { - return body - } - - if (_isDocument(body)) { - return body.textContent - } - - if (_isFormData(body)) { - return _formDataToQuery(body) - } - - if (_isObject(body)) { - try { - return JSON.stringify(body) - } catch (e) { - return '[SessionReplay] Failed to stringify response object' - } - } - - return '[SessionReplay] Cannot read body of type ' + toString.call(body) -} - -function initXhrObserver(cb: networkCallback, win: IWindow, options: Required): listenerHandler { - if (!options.initiatorTypes.includes('xmlhttprequest')) { - return () => { - // - } - } - const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders) - const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders) - - const restorePatch = patch( - win.XMLHttpRequest.prototype, - 'open', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - (originalOpen: typeof XMLHttpRequest.prototype.open) => { - return function ( - method: string, - url: string | URL, - async = true, - username?: string | null, - password?: string | null - ) { - // because this function is returned in its actual context `this` _is_ an XMLHttpRequest - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const xhr = this as XMLHttpRequest - - // check IE earlier than this, we only initialize if Request is present - // eslint-disable-next-line compat/compat - const req = new Request(url) - const networkRequest: Partial = {} - let after: number | undefined - let before: number | undefined - - const requestHeaders: Headers = {} - const originalSetRequestHeader = xhr.setRequestHeader.bind(xhr) - xhr.setRequestHeader = (header: string, value: string) => { - requestHeaders[header] = value - return originalSetRequestHeader(header, value) - } - if (recordRequestHeaders) { - networkRequest.requestHeaders = requestHeaders - } - - const originalSend = xhr.send.bind(xhr) - xhr.send = (body) => { - if (shouldRecordBody('request', options.recordBody, requestHeaders)) { - if (_isUndefined(body) || _isNull(body)) { - networkRequest.requestBody = null - } else { - networkRequest.requestBody = _tryReadXHRBody(body) - } - } - after = win.performance.now() - return originalSend(body) - } - - xhr.addEventListener('readystatechange', () => { - if (xhr.readyState !== xhr.DONE) { - return - } - before = win.performance.now() - const responseHeaders: Headers = {} - const rawHeaders = xhr.getAllResponseHeaders() - const headers = rawHeaders.trim().split(/[\r\n]+/) - headers.forEach((line) => { - const parts = line.split(': ') - const header = parts.shift() - const value = parts.join(': ') - if (header) { - responseHeaders[header] = value - } - }) - if (recordResponseHeaders) { - networkRequest.responseHeaders = responseHeaders - } - if (shouldRecordBody('response', options.recordBody, responseHeaders)) { - if (_isNullish(xhr.response)) { - networkRequest.responseBody = null - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - networkRequest.responseBody = _tryReadXHRBody(xhr.response) - } - } - getRequestPerformanceEntry(win, 'xmlhttprequest', req.url, after, before) - .then((entry) => { - if (_isNull(entry)) { - return - } - const requests = prepareRequest(entry, req.method, xhr?.status, networkRequest) - cb({ requests }) - }) - .catch(() => { - // - }) - }) - originalOpen.call(xhr, method, url, async, username, password) - } - } - ) - return () => { - restorePatch() - } -} - -/** - * Check if this PerformanceEntry is either a PerformanceResourceTiming or a PerformanceNavigationTiming - * NB PerformanceNavigationTiming extends PerformanceResourceTiming - * Here we don't care which interface it implements as both expose `serverTimings` - */ -const exposesServerTiming = (event: PerformanceEntry): event is PerformanceResourceTiming => - event.entryType === 'navigation' || event.entryType === 'resource' - -function prepareRequest( - entry: PerformanceResourceTiming, - method: string | undefined, - status: number | undefined, - networkRequest: Partial, - isInitial?: boolean -): CapturedNetworkRequest[] { - // kudos to sentry javascript sdk for excellent background on why to use Date.now() here - // https://github.com/getsentry/sentry-javascript/blob/e856e40b6e71a73252e788cd42b5260f81c9c88e/packages/utils/src/time.ts#L70 - // can't start observer if performance.now() is not available - // eslint-disable-next-line compat/compat - const timeOrigin = Math.floor(Date.now() - performance.now()) - // clickhouse can't ingest timestamps that are floats - // (in this case representing fractions of a millisecond we don't care about anyway) - const timestamp = Math.floor(timeOrigin + entry.startTime) - - const requests: CapturedNetworkRequest[] = [ - { - ...entry.toJSON(), - startTime: Math.round(entry.startTime), - endTime: Math.round(entry.responseEnd), - timeOrigin, - timestamp, - method: method, - initiatorType: entry.initiatorType as InitiatorType, - status, - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - responseHeaders: networkRequest.responseHeaders, - responseBody: networkRequest.responseBody, - isInitial, - }, - ] - - if (exposesServerTiming(entry)) { - for (const timing of entry.serverTiming || []) { - requests.push({ - timeOrigin, - timestamp, - startTime: Math.round(entry.startTime), - name: timing.name, - duration: timing.duration, - // the spec has a closed list of possible types - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType - // but, we need to know this was a server timing so that we know to - // match it to the appropriate navigation or resource timing - // that matching will have to be on timestamp and $current_url - entryType: 'serverTiming', - }) - } - } - - return requests -} - -const contentTypePrefixDenyList = ['video/', 'audio/'] - -function _checkForCannotReadResponseBody(r: Response): string | null { - if (r.headers.get('Transfer-Encoding') === 'chunked') { - return 'Chunked Transfer-Encoding is not supported' - } - - // `get` and `has` are case-insensitive - // but return the header value with the casing that was supplied - const contentType = r.headers.get('Content-Type')?.toLowerCase() - const contentTypeIsDenied = contentTypePrefixDenyList.some((prefix) => contentType?.startsWith(prefix)) - if (contentType && contentTypeIsDenied) { - return `Content-Type ${contentType} is not supported` - } - - return null -} - -function _tryReadBody(r: Request | Response): Promise { - // there are now already multiple places where we're using Promise... - // eslint-disable-next-line compat/compat - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => resolve('[SessionReplay] Timeout while trying to read body'), 500) - r.clone() - .text() - .then( - (txt) => resolve(txt), - (reason) => reject(reason) - ) - .finally(() => clearTimeout(timeout)) - }) -} - -async function _tryReadResponseBody(r: Response): Promise { - const cannotReadBodyReason: string | null = _checkForCannotReadResponseBody(r) - if (!_isNull(cannotReadBodyReason)) { - return Promise.resolve(cannotReadBodyReason) - } - - return _tryReadBody(r) -} - -function initFetchObserver( - cb: networkCallback, - win: IWindow, - options: Required -): listenerHandler { - if (!options.initiatorTypes.includes('fetch')) { - return () => { - // - } - } - const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders) - const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const restorePatch = patch(win, 'fetch', (originalFetch: typeof fetch) => { - return async function (url: URL | RequestInfo, init?: RequestInit | undefined) { - // check IE earlier than this, we only initialize if Request is present - // eslint-disable-next-line compat/compat - const req = new Request(url, init) - let res: Response | undefined - const networkRequest: Partial = {} - let after: number | undefined - let before: number | undefined - try { - const requestHeaders: Headers = {} - req.headers.forEach((value, header) => { - requestHeaders[header] = value - }) - if (recordRequestHeaders) { - networkRequest.requestHeaders = requestHeaders - } - if (shouldRecordBody('request', options.recordBody, requestHeaders)) { - networkRequest.requestBody = await _tryReadBody(req) - } - - after = win.performance.now() - res = await originalFetch(req) - before = win.performance.now() - - const responseHeaders: Headers = {} - res.headers.forEach((value, header) => { - responseHeaders[header] = value - }) - if (recordResponseHeaders) { - networkRequest.responseHeaders = responseHeaders - } - if (shouldRecordBody('response', options.recordBody, responseHeaders)) { - networkRequest.responseBody = await _tryReadResponseBody(res) - } - - return res - } finally { - getRequestPerformanceEntry(win, 'fetch', req.url, after, before) - .then((entry) => { - if (_isNull(entry)) { - return - } - const requests = prepareRequest(entry, req.method, res?.status, networkRequest) - cb({ requests }) - }) - .catch(() => { - // - }) - } - } - }) - return () => { - restorePatch() - } -} - -function initNetworkObserver( - callback: networkCallback, - win: IWindow, // top window or in an iframe - options: NetworkRecordOptions -): listenerHandler { - if (!('performance' in win)) { - return () => { - // - } - } - const networkOptions = ( - options ? Object.assign({}, defaultNetworkOptions, options) : defaultNetworkOptions - ) as Required - - const cb: networkCallback = (data) => { - const requests: CapturedNetworkRequest[] = [] - data.requests.forEach((request) => { - const maskedRequest = networkOptions.maskRequestFn(request) - if (maskedRequest) { - requests.push(maskedRequest) - } - }) - - if (requests.length > 0) { - callback({ ...data, requests }) - } - } - const performanceObserver = initPerformanceObserver(cb, win, networkOptions) - - // only wrap fetch and xhr if headers or body are being recorded - let xhrObserver: listenerHandler = () => {} - let fetchObserver: listenerHandler = () => {} - if (networkOptions.recordHeaders || networkOptions.recordBody) { - xhrObserver = initXhrObserver(cb, win, networkOptions) - fetchObserver = initFetchObserver(cb, win, networkOptions) - } - - return () => { - performanceObserver() - xhrObserver() - fetchObserver() - } -} - -// use the plugin name so that when this functionality is adopted into rrweb -// we can remove this plugin and use the core functionality with the same data -export const NETWORK_PLUGIN_NAME = 'rrweb/network@1' - -// TODO how should this be typed? -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -export const getRecordNetworkPlugin: (options?: NetworkRecordOptions) => RecordPlugin = (options) => { - return { - name: NETWORK_PLUGIN_NAME, - observer: initNetworkObserver, - options: options, - } -} - -// rrweb/networ@1 ends - -if (window) { - ;(window as any).rrweb = { record: rrwebRecord, version: 'v2', rrwebVersion: version } - ;(window as any).rrwebConsoleRecord = { getRecordConsolePlugin } - ;(window as any).getRecordNetworkPlugin = getRecordNetworkPlugin -} - -export default rrwebRecord diff --git a/src/loader-recorder.ts b/src/loader-recorder.ts index 2680a5358..d38d06494 100644 --- a/src/loader-recorder.ts +++ b/src/loader-recorder.ts @@ -1,18 +1,598 @@ -import { version } from 'rrweb-v1/package.json' +import { version } from 'rrweb/package.json' -// Same as loader-globals.ts except includes rrweb scripts. +// Same as loader-globals.ts except includes rrweb2 scripts. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -import rrwebRecord from 'rrweb-v1/es/rrweb/packages/rrweb/src/record' +import rrwebRecord from 'rrweb/es/rrweb/packages/rrweb/src/record' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -import { getRecordConsolePlugin } from 'rrweb-v1/es/rrweb/packages/rrweb/src/plugins/console/record' +import { getRecordConsolePlugin } from 'rrweb/es/rrweb/packages/rrweb/src/plugins/console/record' +// rrweb/network@1 code starts +// most of what is below here will be removed when rrweb release their code for this +// see https://github.com/rrweb-io/rrweb/pull/1105 +/// +// NB adopted from https://github.com/rrweb-io/rrweb/pull/1105 which looks like it will be accepted into rrweb +// however, in the PR, it throws when the performance observer data is not available +// and assumes it is running in a browser with the Request API (i.e. not IE11) +// copying here so that we can use it before rrweb adopt it +import type { IWindow, listenerHandler, RecordPlugin } from '@rrweb/types' +import { CapturedNetworkRequest, Headers, InitiatorType, NetworkRecordOptions } from './types' +import { + _isArray, + _isBoolean, + _isDocument, + _isFormData, + _isFunction, + _isNull, + _isNullish, + _isObject, + _isString, + _isUndefined, +} from './utils/type-utils' +import { logger } from './utils/logger' import { window } from './utils/globals' +import { defaultNetworkOptions } from './extensions/replay/config' +import { _formDataToQuery } from './utils/request-utils' + +export type NetworkData = { + requests: CapturedNetworkRequest[] + isInitial?: boolean +} + +type networkCallback = (data: NetworkData) => void + +const isNavigationTiming = (entry: PerformanceEntry): entry is PerformanceNavigationTiming => + entry.entryType === 'navigation' +const isResourceTiming = (entry: PerformanceEntry): entry is PerformanceResourceTiming => entry.entryType === 'resource' + +type ObservedPerformanceEntry = (PerformanceNavigationTiming | PerformanceResourceTiming) & { + responseStatus?: number +} + +// import { patch } from 'rrweb/typings/utils' +// copied from https://github.com/rrweb-io/rrweb/blob/8aea5b00a4dfe5a6f59bd2ae72bb624f45e51e81/packages/rrweb/src/utils.ts#L129 +// which was copied from https://github.com/getsentry/sentry-javascript/blob/b2109071975af8bf0316d3b5b38f519bdaf5dc15/packages/utils/src/object.ts +export function patch( + source: { [key: string]: any }, + name: string, + replacement: (...args: unknown[]) => unknown +): () => void { + try { + if (!(name in source)) { + return () => { + // + } + } + + const original = source[name] as () => unknown + const wrapped = replacement(original) + + // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work + // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" + if (_isFunction(wrapped)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + wrapped.prototype = wrapped.prototype || {} + Object.defineProperties(wrapped, { + __rrweb_original__: { + enumerable: false, + value: original, + }, + }) + } + + source[name] = wrapped + + return () => { + source[name] = original + } + } catch { + return () => { + // + } + // This can throw if multiple fill happens on a global object like XMLHttpRequest + // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 + } +} + +export function findLast(array: Array, predicate: (value: T) => boolean): T | undefined { + const length = array.length + for (let i = length - 1; i >= 0; i -= 1) { + if (predicate(array[i])) { + return array[i] + } + } + return undefined +} + +function initPerformanceObserver(cb: networkCallback, win: IWindow, options: Required) { + // if we are only observing timings then we could have a single observer for all types, with buffer true, + // but we are going to filter by initiatorType _if we are wrapping fetch and xhr as the wrapped functions + // will deal with those. + // so we have a block which captures requests from before fetch/xhr is wrapped + // these are marked `isInitial` so playback can display them differently if needed + // they will never have method/status/headers/body because they are pre-wrapping that provides that + if (options.recordInitialRequests) { + const initialPerformanceEntries = win.performance + .getEntries() + .filter( + (entry): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && options.initiatorTypes.includes(entry.initiatorType as InitiatorType)) + ) + cb({ + requests: initialPerformanceEntries.flatMap((entry) => + prepareRequest(entry, undefined, undefined, {}, true) + ), + isInitial: true, + }) + } + const observer = new win.PerformanceObserver((entries) => { + // if recordBody or recordHeaders is true then we don't want to record fetch or xhr here + // as the wrapped functions will do that. Otherwise, this filter becomes a noop + // because we do want to record them here + const wrappedInitiatorFilter = (entry: ObservedPerformanceEntry) => + options.recordBody || options.recordHeaders + ? entry.initiatorType !== 'xmlhttprequest' && entry.initiatorType !== 'fetch' + : true + + const performanceEntries = entries.getEntries().filter( + (entry): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && + options.initiatorTypes.includes(entry.initiatorType as InitiatorType) && + // TODO if we are _only_ capturing timing we don't want to filter initiator here + wrappedInitiatorFilter(entry)) + ) + + cb({ + requests: performanceEntries.flatMap((entry) => prepareRequest(entry, undefined, undefined, {})), + }) + }) + // compat checked earlier + // eslint-disable-next-line compat/compat + const entryTypes = PerformanceObserver.supportedEntryTypes.filter((x) => + options.performanceEntryTypeToObserve.includes(x) + ) + // initial records are gathered above, so we don't need to observe and buffer each type separately + observer.observe({ entryTypes }) + return () => { + observer.disconnect() + } +} + +function shouldRecordHeaders(type: 'request' | 'response', recordHeaders: NetworkRecordOptions['recordHeaders']) { + return !!recordHeaders && (_isBoolean(recordHeaders) || recordHeaders[type]) +} + +function shouldRecordBody( + type: 'request' | 'response', + recordBody: NetworkRecordOptions['recordBody'], + headers: Headers +) { + function matchesContentType(contentTypes: string[]) { + const contentTypeHeader = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type') + const contentType = contentTypeHeader && headers[contentTypeHeader] + return contentTypes.some((ct) => contentType?.includes(ct)) + } + + if (!recordBody) return false + if (_isBoolean(recordBody)) return true + if (_isArray(recordBody)) return matchesContentType(recordBody) + const recordBodyType = recordBody[type] + if (_isBoolean(recordBodyType)) return recordBodyType + return matchesContentType(recordBodyType) +} + +async function getRequestPerformanceEntry( + win: IWindow, + initiatorType: string, + url: string, + after?: number, + before?: number, + attempt = 0 +): Promise { + if (attempt > 10) { + logger.warn('Failed to get performance entry for request', { url, initiatorType }) + return null + } + const urlPerformanceEntries = win.performance.getEntriesByName(url) as PerformanceResourceTiming[] + const performanceEntry = findLast( + urlPerformanceEntries, + (entry) => + isResourceTiming(entry) && + entry.initiatorType === initiatorType && + (!after || entry.startTime >= after) && + (!before || entry.startTime <= before) + ) + if (!performanceEntry) { + await new Promise((resolve) => setTimeout(resolve, 50 * attempt)) + return getRequestPerformanceEntry(win, initiatorType, url, after, before, attempt + 1) + } + return performanceEntry +} + +/** + * According to MDN https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response + * xhr response is typed as any but can be an ArrayBuffer, a Blob, a Document, a JavaScript object, + * or a string, depending on the value of XMLHttpRequest.responseType, that contains the response entity body. + * + * XHR request body is Document | XMLHttpRequestBodyInit | null | undefined + */ +function _tryReadXHRBody(body: Document | XMLHttpRequestBodyInit | any | null | undefined): string | null { + if (_isNullish(body)) { + return null + } + + if (_isString(body)) { + return body + } + + if (_isDocument(body)) { + return body.textContent + } + + if (_isFormData(body)) { + return _formDataToQuery(body) + } + + if (_isObject(body)) { + try { + return JSON.stringify(body) + } catch (e) { + return '[SessionReplay] Failed to stringify response object' + } + } + + return '[SessionReplay] Cannot read body of type ' + toString.call(body) +} + +function initXhrObserver(cb: networkCallback, win: IWindow, options: Required): listenerHandler { + if (!options.initiatorTypes.includes('xmlhttprequest')) { + return () => { + // + } + } + const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders) + const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders) + + const restorePatch = patch( + win.XMLHttpRequest.prototype, + 'open', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (originalOpen: typeof XMLHttpRequest.prototype.open) => { + return function ( + method: string, + url: string | URL, + async = true, + username?: string | null, + password?: string | null + ) { + // because this function is returned in its actual context `this` _is_ an XMLHttpRequest + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const xhr = this as XMLHttpRequest + + // check IE earlier than this, we only initialize if Request is present + // eslint-disable-next-line compat/compat + const req = new Request(url) + const networkRequest: Partial = {} + let after: number | undefined + let before: number | undefined + + const requestHeaders: Headers = {} + const originalSetRequestHeader = xhr.setRequestHeader.bind(xhr) + xhr.setRequestHeader = (header: string, value: string) => { + requestHeaders[header] = value + return originalSetRequestHeader(header, value) + } + if (recordRequestHeaders) { + networkRequest.requestHeaders = requestHeaders + } + + const originalSend = xhr.send.bind(xhr) + xhr.send = (body) => { + if (shouldRecordBody('request', options.recordBody, requestHeaders)) { + if (_isUndefined(body) || _isNull(body)) { + networkRequest.requestBody = null + } else { + networkRequest.requestBody = _tryReadXHRBody(body) + } + } + after = win.performance.now() + return originalSend(body) + } + + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState !== xhr.DONE) { + return + } + before = win.performance.now() + const responseHeaders: Headers = {} + const rawHeaders = xhr.getAllResponseHeaders() + const headers = rawHeaders.trim().split(/[\r\n]+/) + headers.forEach((line) => { + const parts = line.split(': ') + const header = parts.shift() + const value = parts.join(': ') + if (header) { + responseHeaders[header] = value + } + }) + if (recordResponseHeaders) { + networkRequest.responseHeaders = responseHeaders + } + if (shouldRecordBody('response', options.recordBody, responseHeaders)) { + if (_isNullish(xhr.response)) { + networkRequest.responseBody = null + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + networkRequest.responseBody = _tryReadXHRBody(xhr.response) + } + } + getRequestPerformanceEntry(win, 'xmlhttprequest', req.url, after, before) + .then((entry) => { + if (_isNull(entry)) { + return + } + const requests = prepareRequest(entry, req.method, xhr?.status, networkRequest) + cb({ requests }) + }) + .catch(() => { + // + }) + }) + originalOpen.call(xhr, method, url, async, username, password) + } + } + ) + return () => { + restorePatch() + } +} + +/** + * Check if this PerformanceEntry is either a PerformanceResourceTiming or a PerformanceNavigationTiming + * NB PerformanceNavigationTiming extends PerformanceResourceTiming + * Here we don't care which interface it implements as both expose `serverTimings` + */ +const exposesServerTiming = (event: PerformanceEntry): event is PerformanceResourceTiming => + event.entryType === 'navigation' || event.entryType === 'resource' + +function prepareRequest( + entry: PerformanceResourceTiming, + method: string | undefined, + status: number | undefined, + networkRequest: Partial, + isInitial?: boolean +): CapturedNetworkRequest[] { + // kudos to sentry javascript sdk for excellent background on why to use Date.now() here + // https://github.com/getsentry/sentry-javascript/blob/e856e40b6e71a73252e788cd42b5260f81c9c88e/packages/utils/src/time.ts#L70 + // can't start observer if performance.now() is not available + // eslint-disable-next-line compat/compat + const timeOrigin = Math.floor(Date.now() - performance.now()) + // clickhouse can't ingest timestamps that are floats + // (in this case representing fractions of a millisecond we don't care about anyway) + const timestamp = Math.floor(timeOrigin + entry.startTime) + + const requests: CapturedNetworkRequest[] = [ + { + ...entry.toJSON(), + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + timeOrigin, + timestamp, + method: method, + initiatorType: entry.initiatorType as InitiatorType, + status, + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + isInitial, + }, + ] + + if (exposesServerTiming(entry)) { + for (const timing of entry.serverTiming || []) { + requests.push({ + timeOrigin, + timestamp, + startTime: Math.round(entry.startTime), + name: timing.name, + duration: timing.duration, + // the spec has a closed list of possible types + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry/entryType + // but, we need to know this was a server timing so that we know to + // match it to the appropriate navigation or resource timing + // that matching will have to be on timestamp and $current_url + entryType: 'serverTiming', + }) + } + } + + return requests +} + +const contentTypePrefixDenyList = ['video/', 'audio/'] + +function _checkForCannotReadResponseBody(r: Response): string | null { + if (r.headers.get('Transfer-Encoding') === 'chunked') { + return 'Chunked Transfer-Encoding is not supported' + } + + // `get` and `has` are case-insensitive + // but return the header value with the casing that was supplied + const contentType = r.headers.get('Content-Type')?.toLowerCase() + const contentTypeIsDenied = contentTypePrefixDenyList.some((prefix) => contentType?.startsWith(prefix)) + if (contentType && contentTypeIsDenied) { + return `Content-Type ${contentType} is not supported` + } + + return null +} + +function _tryReadBody(r: Request | Response): Promise { + // there are now already multiple places where we're using Promise... + // eslint-disable-next-line compat/compat + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve('[SessionReplay] Timeout while trying to read body'), 500) + r.clone() + .text() + .then( + (txt) => resolve(txt), + (reason) => reject(reason) + ) + .finally(() => clearTimeout(timeout)) + }) +} + +async function _tryReadResponseBody(r: Response): Promise { + const cannotReadBodyReason: string | null = _checkForCannotReadResponseBody(r) + if (!_isNull(cannotReadBodyReason)) { + return Promise.resolve(cannotReadBodyReason) + } + + return _tryReadBody(r) +} + +function initFetchObserver( + cb: networkCallback, + win: IWindow, + options: Required +): listenerHandler { + if (!options.initiatorTypes.includes('fetch')) { + return () => { + // + } + } + const recordRequestHeaders = shouldRecordHeaders('request', options.recordHeaders) + const recordResponseHeaders = shouldRecordHeaders('response', options.recordHeaders) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const restorePatch = patch(win, 'fetch', (originalFetch: typeof fetch) => { + return async function (url: URL | RequestInfo, init?: RequestInit | undefined) { + // check IE earlier than this, we only initialize if Request is present + // eslint-disable-next-line compat/compat + const req = new Request(url, init) + let res: Response | undefined + const networkRequest: Partial = {} + let after: number | undefined + let before: number | undefined + try { + const requestHeaders: Headers = {} + req.headers.forEach((value, header) => { + requestHeaders[header] = value + }) + if (recordRequestHeaders) { + networkRequest.requestHeaders = requestHeaders + } + if (shouldRecordBody('request', options.recordBody, requestHeaders)) { + networkRequest.requestBody = await _tryReadBody(req) + } + + after = win.performance.now() + res = await originalFetch(req) + before = win.performance.now() + + const responseHeaders: Headers = {} + res.headers.forEach((value, header) => { + responseHeaders[header] = value + }) + if (recordResponseHeaders) { + networkRequest.responseHeaders = responseHeaders + } + if (shouldRecordBody('response', options.recordBody, responseHeaders)) { + networkRequest.responseBody = await _tryReadResponseBody(res) + } + + return res + } finally { + getRequestPerformanceEntry(win, 'fetch', req.url, after, before) + .then((entry) => { + if (_isNull(entry)) { + return + } + const requests = prepareRequest(entry, req.method, res?.status, networkRequest) + cb({ requests }) + }) + .catch(() => { + // + }) + } + } + }) + return () => { + restorePatch() + } +} + +function initNetworkObserver( + callback: networkCallback, + win: IWindow, // top window or in an iframe + options: NetworkRecordOptions +): listenerHandler { + if (!('performance' in win)) { + return () => { + // + } + } + const networkOptions = ( + options ? Object.assign({}, defaultNetworkOptions, options) : defaultNetworkOptions + ) as Required + + const cb: networkCallback = (data) => { + const requests: CapturedNetworkRequest[] = [] + data.requests.forEach((request) => { + const maskedRequest = networkOptions.maskRequestFn(request) + if (maskedRequest) { + requests.push(maskedRequest) + } + }) + + if (requests.length > 0) { + callback({ ...data, requests }) + } + } + const performanceObserver = initPerformanceObserver(cb, win, networkOptions) + + // only wrap fetch and xhr if headers or body are being recorded + let xhrObserver: listenerHandler = () => {} + let fetchObserver: listenerHandler = () => {} + if (networkOptions.recordHeaders || networkOptions.recordBody) { + xhrObserver = initXhrObserver(cb, win, networkOptions) + fetchObserver = initFetchObserver(cb, win, networkOptions) + } + + return () => { + performanceObserver() + xhrObserver() + fetchObserver() + } +} + +// use the plugin name so that when this functionality is adopted into rrweb +// we can remove this plugin and use the core functionality with the same data +export const NETWORK_PLUGIN_NAME = 'rrweb/network@1' + +// TODO how should this be typed? +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export const getRecordNetworkPlugin: (options?: NetworkRecordOptions) => RecordPlugin = (options) => { + return { + name: NETWORK_PLUGIN_NAME, + observer: initNetworkObserver, + options: options, + } +} + +// rrweb/networ@1 ends if (window) { - ;(window as any).rrweb = { record: rrwebRecord, version: 'v1', rrwebVersion: version } + ;(window as any).rrweb = { record: rrwebRecord, version: 'v2', rrwebVersion: version } ;(window as any).rrwebConsoleRecord = { getRecordConsolePlugin } + ;(window as any).getRecordNetworkPlugin = getRecordNetworkPlugin } export default rrwebRecord diff --git a/src/types.ts b/src/types.ts index ff5032b41..2ed1420e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -175,7 +175,6 @@ export interface SessionRecordingOptions { slimDOMOptions?: SlimDOMOptions | 'all' | true collectFonts?: boolean inlineStylesheet?: boolean - recorderVersion?: 'v1' | 'v2' recordCrossOriginIframes?: boolean /** @deprecated - use maskCapturedNetworkRequestFn instead */ maskNetworkRequestFn?: ((data: NetworkRequest) => NetworkRequest | null | undefined) | null @@ -267,7 +266,6 @@ export interface DecideResponse { sessionRecording?: { endpoint?: string consoleLogRecordingEnabled?: boolean - recorderVersion?: 'v1' | 'v2' // the API returns a decimal between 0 and 1 as a string sampleRate?: string | null minimumDurationMilliseconds?: number