From 80e45cba5bc0f07a7df82af7de7f3cc367742d5d Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Mon, 13 Nov 2023 14:31:27 +0000 Subject: [PATCH] feat: capture network payloads (internal alpha) (#886) rrweb has network payloads queued up as a feature... but it's taking a while. The easiest way to test it is to adopt it ourselves. This adds a copy of the plugin proposed for rrweb, and uses it to wrap xhr and fetch. We can match performance timings and these new NetworkRequests based on URL and timings used by PostHog/posthog#18562 for now this can only be enabled via decide response, which allows header and body capture to be configured separately, that config is only enabled via flag while we test internally --- cypress/e2e/session-recording.cy.js | 3 +- .../extensions/replay/config.test.ts | 80 ++++ src/extensions/replay/config.ts | 71 +++ src/extensions/replay/sessionrecording.ts | 31 +- src/loader-recorder-v2.ts | 447 +++++++++++++++++- src/posthog-core.ts | 2 +- src/types.ts | 81 +++- 7 files changed, 703 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/extensions/replay/config.test.ts create mode 100644 src/extensions/replay/config.ts 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 }