diff --git a/cypress/e2e/session-recording.cy.js b/cypress/e2e/session-recording.cy.js index 896c114b0..e4c829084 100644 --- a/cypress/e2e/session-recording.cy.js +++ b/cypress/e2e/session-recording.cy.js @@ -43,8 +43,7 @@ describe('Session recording', () => { cy.phCaptures({ full: true }).then((captures) => { // should be a pageview and a $snapshot expect(captures.map((c) => c.event)).to.deep.equal(['$pageview', '$snapshot']) - // the amount of captured data should be deterministic - // but of course that would be too easy + expect(captures[1]['properties']['$snapshot_data']).to.have.length.above(33).and.below(38) // a meta and then a full snapshot expect(captures[1]['properties']['$snapshot_data'][0].type).to.equal(4) // meta diff --git a/src/__tests__/extensions/replay/config.test.ts b/src/__tests__/extensions/replay/config.test.ts new file mode 100644 index 000000000..4892c6356 --- /dev/null +++ b/src/__tests__/extensions/replay/config.test.ts @@ -0,0 +1,80 @@ +import { defaultConfig } from '../../../posthog-core' +import { buildNetworkRequestOptions } from '../../../extensions/replay/config' + +describe('config', () => { + describe('network request options', () => { + describe('maskRequestFn', () => { + it('can enable header recording remotely', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), { recordHeaders: true }) + expect(networkOptions.recordHeaders).toBe(true) + expect(networkOptions.recordBody).toBe(undefined) + }) + + it('can enable body recording remotely', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), { recordBody: true }) + expect(networkOptions.recordHeaders).toBe(undefined) + expect(networkOptions.recordBody).toBe(true) + }) + + it('client can force disable recording', () => { + const posthogConfig = defaultConfig() + posthogConfig.session_recording.recordHeaders = false + posthogConfig.session_recording.recordBody = false + const networkOptions = buildNetworkRequestOptions(posthogConfig, { + recordHeaders: true, + recordBody: true, + }) + expect(networkOptions.recordHeaders).toBe(false) + expect(networkOptions.recordBody).toBe(false) + }) + + it('should remove the Authorization header from requests even if no other config is set', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const cleaned = networkOptions.maskRequestFn!({ + url: 'something', + requestHeaders: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, + }) + expect(cleaned?.requestHeaders).toEqual({ + 'content-type': 'application/json', + }) + }) + + it('should cope with no headers when even if no other config is set', () => { + const networkOptions = buildNetworkRequestOptions(defaultConfig(), {}) + const cleaned = networkOptions.maskRequestFn!({ + url: 'something', + requestHeaders: undefined, + }) + expect(cleaned?.requestHeaders).toBeUndefined() + }) + + it('should remove the Authorization header from requests even when a mask request fn is set', () => { + const posthogConfig = defaultConfig() + posthogConfig.session_recording.maskNetworkRequestFn = (data) => { + return { + ...data, + requestHeaders: { + ...(data.requestHeaders ? data.requestHeaders : {}), + 'content-type': 'edited', + }, + } + } + const networkOptions = buildNetworkRequestOptions(posthogConfig, {}) + + const cleaned = networkOptions.maskRequestFn!({ + url: 'something', + requestHeaders: { + Authorization: 'Bearer 123', + 'content-type': 'application/json', + }, + }) + expect(cleaned?.requestHeaders).toEqual({ + 'content-type': 'edited', + }) + }) + }) + }) +}) diff --git a/src/extensions/replay/config.ts b/src/extensions/replay/config.ts new file mode 100644 index 000000000..a435b4343 --- /dev/null +++ b/src/extensions/replay/config.ts @@ -0,0 +1,71 @@ +import { NetworkRecordOptions, NetworkRequest, PostHogConfig } from '../../types' +import { _isFunction } from '../../utils/type-utils' + +export const defaultNetworkOptions: NetworkRecordOptions = { + initiatorTypes: [ + 'audio', + 'beacon', + 'body', + 'css', + 'early-hint', + 'embed', + 'fetch', + 'frame', + 'iframe', + 'icon', + 'image', + 'img', + 'input', + 'link', + 'navigation', + 'object', + 'ping', + 'script', + 'track', + 'video', + 'xmlhttprequest', + ], + maskRequestFn: (data: NetworkRequest) => data, + recordHeaders: false, + recordBody: false, + recordInitialRequests: false, +} + +const removeAuthorizationHeader = (data: NetworkRequest): NetworkRequest => { + delete data.requestHeaders?.['Authorization'] + return data +} + +/** + * whether a maskRequestFn is provided or not, + * we ensure that we remove the Authorization header from requests + * we _never_ want to record that header by accident + * if someone complains then we'll add an opt-in to let them override it + */ +export const buildNetworkRequestOptions = ( + instanceConfig: PostHogConfig, + remoteNetworkOptions: Pick +): NetworkRecordOptions => { + const config = instanceConfig.session_recording as NetworkRecordOptions + // client can always disable despite remote options + const canRecordHeaders = config.recordHeaders === false ? false : remoteNetworkOptions.recordHeaders + const canRecordBody = config.recordBody === false ? false : remoteNetworkOptions.recordBody + + config.maskRequestFn = _isFunction(instanceConfig.session_recording.maskNetworkRequestFn) + ? (data) => { + const cleanedRequest = removeAuthorizationHeader(data) + return instanceConfig.session_recording.maskNetworkRequestFn?.(cleanedRequest) ?? undefined + } + : undefined + + if (!config.maskRequestFn) { + config.maskRequestFn = removeAuthorizationHeader + } + + return { + ...defaultNetworkOptions, + ...config, + recordHeaders: canRecordHeaders, + recordBody: canRecordBody, + } +} diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 483bdf719..d846c5ae2 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -14,13 +14,15 @@ import { truncateLargeConsoleLogs, } from './sessionrecording-utils' import { PostHog } from '../../posthog-core' -import { DecideResponse, NetworkRequest, Properties } from '../../types' +import { DecideResponse, NetworkRecordOptions, NetworkRequest, Properties } from '../../types' import { EventType, type eventWithTime, type listenerHandler } from '@rrweb/types' import Config from '../../config' import { _timestamp, loadScript } from '../../utils' -import { _isBoolean, _isNull, _isNumber, _isObject, _isString, _isUndefined } from '../../utils/type-utils' +import { _isBoolean, _isFunction, _isNull, _isNumber, _isObject, _isString, _isUndefined } from '../../utils/type-utils' import { logger } from '../../utils/logger' +import { window } from '../../utils/globals' +import { buildNetworkRequestOptions } from './config' const BASE_ENDPOINT = '/s/' @@ -90,6 +92,7 @@ export class SessionRecording { private receivedDecide: boolean private rrwebRecord: rrwebRecord | undefined private isIdle = false + private _networkPayloadCapture: Pick | undefined = undefined private _linkedFlagSeen: boolean = false private _lastActivityTimestamp: number = Date.now() @@ -252,6 +255,8 @@ export class SessionRecording { }) } + this._networkPayloadCapture = response.sessionRecording?.networkPayloadCapture + const receivedSampleRate = response.sessionRecording?.sampleRate this._sampleRate = _isUndefined(receivedSampleRate) || _isNull(receivedSampleRate) ? null : parseFloat(receivedSampleRate) @@ -462,14 +467,26 @@ export class SessionRecording { }, }) + const plugins = [] + + if ((window as any).rrwebConsoleRecord && this.isConsoleLogCaptureEnabled) { + plugins.push((window as any).rrwebConsoleRecord.getRecordConsolePlugin()) + } + if (this._networkPayloadCapture) { + if (_isFunction((window as any).getRecordNetworkPlugin)) { + plugins.push( + (window as any).getRecordNetworkPlugin( + buildNetworkRequestOptions(this.instance.config, this._networkPayloadCapture) + ) + ) + } + } + this.stopRrweb = this.rrwebRecord({ emit: (event) => { this.onRRwebEmit(event) }, - plugins: - (window as any).rrwebConsoleRecord && this.isConsoleLogCaptureEnabled - ? [(window as any).rrwebConsoleRecord.getRecordConsolePlugin()] - : [], + plugins, ...sessionRecordingOptions, }) @@ -550,6 +567,8 @@ export class SessionRecording { url, } + // TODO we should deprecate this and use the same function for this masking and the rrweb/network plugin + // TODO or deprecate this and provide a new clearer name so this would be `maskURLPerformanceFn` or similar networkRequest = userSessionRecordingOptions.maskNetworkRequestFn(networkRequest) return networkRequest?.url diff --git a/src/loader-recorder-v2.ts b/src/loader-recorder-v2.ts index bd06c7c71..598a991a2 100644 --- a/src/loader-recorder-v2.ts +++ b/src/loader-recorder-v2.ts @@ -8,11 +8,456 @@ import rrwebRecord from 'rrweb/es/rrweb/packages/rrweb/src/record' // @ts-ignore import { getRecordConsolePlugin } from 'rrweb/es/rrweb/packages/rrweb/src/plugins/console/record' -import { _isUndefined } from './utils/type-utils' +// 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 { InitiatorType, NetworkRecordOptions, NetworkRequest, Headers } from './types' +import { _isBoolean, _isFunction, _isArray, _isUndefined, _isNull } from './utils/type-utils' +import { logger } from './utils/logger' +import { defaultNetworkOptions } from './extensions/replay/config' + +export type NetworkData = { + requests: NetworkRequest[] + 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 (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.map((entry) => ({ + url: entry.name, + initiatorType: entry.initiatorType as InitiatorType, + status: 'responseStatus' in entry ? entry.responseStatus : undefined, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + })), + isInitial: true, + }) + } + const observer = new win.PerformanceObserver((entries) => { + const performanceEntries = entries + .getEntries() + .filter( + (entry): entry is ObservedPerformanceEntry => + isNavigationTiming(entry) || + (isResourceTiming(entry) && + options.initiatorTypes.includes(entry.initiatorType as InitiatorType) && + entry.initiatorType !== 'xmlhttprequest' && + entry.initiatorType !== 'fetch') + ) + cb({ + requests: performanceEntries.map((entry) => ({ + url: entry.name, + initiatorType: entry.initiatorType as InitiatorType, + status: 'responseStatus' in entry ? entry.responseStatus : undefined, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + })), + }) + }) + observer.observe({ entryTypes: ['navigation', 'resource'] }) + 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 +} + +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', + // TODO how should this be typed? + // 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 = 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 (_isUndefined(xhr.response) || _isNull(xhr.response)) { + networkRequest.responseBody = null + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + networkRequest.responseBody = xhr.response + } + } + getRequestPerformanceEntry(win, 'xmlhttprequest', req.url, after, before) + .then((entry) => { + if (_isNull(entry)) { + return + } + const request: NetworkRequest = { + url: entry.name, + method: req.method, + initiatorType: entry.initiatorType as InitiatorType, + status: xhr.status, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + } + cb({ requests: [request] }) + }) + .catch(() => { + // + }) + }) + originalOpen.call(xhr, method, url, async, username, password) + } + } + ) + return () => { + restorePatch() + } +} + +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) + // TODO how should this be typed? + // 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)) { + if (_isUndefined(req.body) || _isNull(req.body)) { + networkRequest.requestBody = null + } else { + networkRequest.requestBody = req.body + } + } + 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)) { + let body: string | undefined + try { + body = await res.clone().text() + } catch { + // + } + if (_isUndefined(res.body) || _isNull(res.body)) { + networkRequest.responseBody = null + } else { + networkRequest.responseBody = body + } + } + return res + } finally { + getRequestPerformanceEntry(win, 'fetch', req.url, after, before) + .then((entry) => { + if (_isNull(entry)) { + return + } + const request: NetworkRequest = { + url: entry.name, + method: req.method, + initiatorType: entry.initiatorType as InitiatorType, + status: res?.status, + startTime: Math.round(entry.startTime), + endTime: Math.round(entry.responseEnd), + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + responseHeaders: networkRequest.responseHeaders, + responseBody: networkRequest.responseBody, + } + cb({ requests: [request] }) + }) + .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: NetworkRequest[] = [] + data.requests.forEach((request) => { + const maskedRequest = networkOptions.maskRequestFn(request) + if (maskedRequest) { + requests.push(maskedRequest) + } + }) + + if (requests.length > 0 || data.isInitial) { + callback({ ...data, requests }) + } + } + const performanceObserver = initPerformanceObserver(cb, win, networkOptions) + const xhrObserver = initXhrObserver(cb, win, networkOptions) + const 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 const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window ;(win as any).rrweb = { record: rrwebRecord, version: 'v2', rrwebVersion: version } ;(win as any).rrwebConsoleRecord = { getRecordConsolePlugin } +;(win as any).getRecordNetworkPlugin = getRecordNetworkPlugin export default rrwebRecord diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 5f45bb463..7a844ed3d 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -99,7 +99,7 @@ const USE_XHR = window.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest // should only be true for Opera<12 let ENQUEUE_REQUESTS = !USE_XHR && userAgent.indexOf('MSIE') === -1 && userAgent.indexOf('Mozilla') === -1 -const defaultConfig = (): PostHogConfig => ({ +export const defaultConfig = (): PostHogConfig => ({ api_host: 'https://app.posthog.com', api_method: 'POST', api_transport: 'XHR', diff --git a/src/types.ts b/src/types.ts index ed607a8ab..a413967a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,13 +154,27 @@ export interface SessionRecordingOptions { maskAllInputs?: boolean maskInputOptions?: MaskInputOptions maskInputFn?: ((text: string, element?: HTMLElement) => string) | null - /** Modify the network request before it is captured. Returning null stops it being captured */ - maskNetworkRequestFn?: ((url: NetworkRequest) => NetworkRequest | null | undefined) | null slimDOMOptions?: SlimDOMOptions | 'all' | true collectFonts?: boolean inlineStylesheet?: boolean recorderVersion?: 'v1' | 'v2' recordCrossOriginIframes?: boolean + /** Modify the network request before it is captured. Returning null stops it being captured */ + // TODO this has to work for both capture mechanisms? 😱 + maskNetworkRequestFn?: ((data: NetworkRequest) => NetworkRequest | null | undefined) | null + // properties below here are ALPHA, don't rely on them, they may change without notice + // TODO which of these do we actually expose? + // if this isn't provided a default will be used + // this only applies to the payload recorder + // TODO I guess it should apply to the other recorder to + initiatorTypes?: InitiatorType[] + recordHeaders?: boolean | { request: boolean; response: boolean } + // true means record all bodies + // false means record no bodies + // string[] means record bodies matching the provided content-type headers + recordBody?: boolean | string[] | { request: boolean | string[]; response: boolean | string[] } + // I can't think why you wouldn't want this... so + // recordInitialRequests?: boolean } export type SessionIdChangedCallback = (sessionId: string, windowId: string | null | undefined) => void @@ -240,6 +254,7 @@ export interface DecideResponse { sampleRate?: string | null minimumDurationMilliseconds?: number linkedFlag?: string | null + networkPayloadCapture?: Pick } surveys?: boolean toolbarParams: ToolbarParams @@ -346,6 +361,68 @@ export interface EarlyAccessFeatureResponse { earlyAccessFeatures: EarlyAccessFeature[] } +export type Headers = Record + +export type Body = + | string + | Document + | Blob + | ArrayBufferView + | ArrayBuffer + | FormData + | URLSearchParams + | ReadableStream + | null + +/* for rrweb/network@1 + ** when that is released as part of rrweb this can be removed + ** don't rely on this type, it may change without notice + */ +export type InitiatorType = + | 'audio' + | 'beacon' + | 'body' + | 'css' + | 'early-hint' + | 'embed' + | 'fetch' + | 'frame' + | 'iframe' + | 'icon' + | 'image' + | 'img' + | 'input' + | 'link' + | 'navigation' + | 'object' + | 'ping' + | 'script' + | 'track' + | 'video' + | 'xmlhttprequest' + +export type NetworkRecordOptions = { + initiatorTypes?: InitiatorType[] + maskRequestFn?: (data: NetworkRequest) => NetworkRequest | undefined + recordHeaders?: boolean | { request: boolean; response: boolean } + recordBody?: boolean | string[] | { request: boolean | string[]; response: boolean | string[] } + recordInitialRequests?: boolean +} + +// extending this to match the rrweb NetworkRequest type +// it is different in that the rrweb type will have initator type, starttime, and endtime +// as required properties. but we don't want to require them here +// because we've previously exposed this type as only having `url` export type NetworkRequest = { url: string + // properties below here are ALPHA, don't rely on them, they may change without notice + method?: string + initiatorType?: InitiatorType + status?: number + startTime?: number + endTime?: number + requestHeaders?: Headers + requestBody?: Body + responseHeaders?: Headers + responseBody?: Body }