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