From 2a2ffa1dcd6df2c79f90b7e274e2c968ba81c3bf Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Wed, 17 May 2023 13:41:29 +0200 Subject: [PATCH] feat(tracker): new axios capturing; tracker 7.0.1 --- frontend/app/player/web/MessageLoader.ts | 7 +- tracker/tracker/CHANGELOG.md | 3 +- tracker/tracker/src/main/modules/axiosSpy.ts | 144 +++++++++++++++++++ tracker/tracker/src/main/modules/network.ts | 28 ++-- 4 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 tracker/tracker/src/main/modules/axiosSpy.ts diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts index 0acb282dd..4e35aff91 100644 --- a/frontend/app/player/web/MessageLoader.ts +++ b/frontend/app/player/web/MessageLoader.ts @@ -69,10 +69,10 @@ export default class MessageLoader { } } - loadDevtools() { + loadDevtools(parser: (b: Uint8Array) => Promise) { if (!this.isClickmap) { this.store.update({ devtoolsLoading: true }) - return loadFiles(this.session.devtoolsURL, this.createNewParser(true, 'devtools')) + return loadFiles(this.session.devtoolsURL, parser) // TODO: also in case of dynamic update through assist .then(() => { // @ts-ignore ? @@ -91,6 +91,7 @@ export default class MessageLoader { : { url: this.session.mobsUrl, parser: () => this.createNewParser(false, 'dom') } const parser = loadMethod.parser() + const devtoolsParser = this.createNewParser(true, 'devtools') /** * We load first dom mob file before the rest * to speed up time to replay @@ -100,7 +101,7 @@ export default class MessageLoader { try { await loadFiles([loadMethod.url[0]], parser) const restDomFilesPromise = this.loadDomFiles([...loadMethod.url.slice(1)], parser) - const restDevtoolsFilesPromise = this.loadDevtools() + const restDevtoolsFilesPromise = this.loadDevtools(devtoolsParser) await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]) this.messageManager.onFileReadSuccess() diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md index 803db28a1..5fdf39f7e 100644 --- a/tracker/tracker/CHANGELOG.md +++ b/tracker/tracker/CHANGELOG.md @@ -1,7 +1,8 @@ # 7.0.1 - fix time inputs capturing -- add option to disable network tracking inside iframes +- add option `{ network: { captureInIframes: boolean } }` to disable network tracking inside iframes (default true) +- added option `{ network: { axiosInstances: AxiosInstance[] } }` to include custom axios instances for better tracking # 7.0.0 diff --git a/tracker/tracker/src/main/modules/axiosSpy.ts b/tracker/tracker/src/main/modules/axiosSpy.ts new file mode 100644 index 000000000..c54ab91ef --- /dev/null +++ b/tracker/tracker/src/main/modules/axiosSpy.ts @@ -0,0 +1,144 @@ +import type App from '../app/index.js' +import { NetworkRequest } from '../app/messages.gen.js' +import { getTimeOrigin } from '../utils.js' +import type { RequestResponseData, Options } from './network.js' + +interface RawAxiosHeaders { + [key: string]: string +} + +interface AxiosRequestConfig { + url: string + method?: string + baseURL?: string + status?: number + headers: { + toJSON(): RawAxiosHeaders + } + params?: any + data?: any +} + +interface InternalAxiosRequestConfig extends AxiosRequestConfig { + __openreplay_timing: number + headers: { + toJSON(): RawAxiosHeaders + set(name: string, value: string): void + } +} + +interface AxiosResponse { + data: T + status: number + statusText: string + headers: { + toJSON(): RawAxiosHeaders + } + config: InternalAxiosRequestConfig + request?: any + response?: AxiosRequestConfig +} + +export interface AxiosInstance extends Record { + interceptors: { + request: AxiosInterceptorManager + response: AxiosInterceptorManager + } +} + +export interface AxiosInterceptorOptions { + synchronous?: boolean +} + +export interface AxiosInterceptorManager { + use( + onFulfilled?: ((value: V) => V | Promise) | null, + onRejected?: ((error: any) => any) | null, + options?: AxiosInterceptorOptions, + ): number + + eject?: (id: number) => void + clear?: () => void +} + +export default function ( + app: App, + instance: AxiosInstance, + opts: Options, + sanitize: (data: RequestResponseData) => RequestResponseData | null, + stringify: (data: { headers: Record; body: any }) => string, +) { + function captureResponseData(axiosResponseObj: AxiosResponse) { + const { headers: reqHs, data: reqData, method, url } = axiosResponseObj.config + const { data: rData, headers: rHs, status: globStatus, response } = axiosResponseObj + const { data: resData, headers: resHs, status: resStatus } = response || {} + + const reqResInfo = sanitize({ + url, + method: method || '', + status: globStatus || resStatus || 0, + request: { + headers: reqHs.toJSON(), + body: reqData, + }, + response: { + headers: resHs?.toJSON() || rHs.toJSON(), + body: resData || rData, + }, + }) + if (!reqResInfo) { + return + } + const requestStart = axiosResponseObj.config.__openreplay_timing + const duration = performance.now() - requestStart + + app.send( + NetworkRequest( + 'xhr', + String(method), + String(reqResInfo.url), + stringify(reqResInfo.request), + stringify(reqResInfo.response), + reqResInfo.status, + requestStart + getTimeOrigin(), + duration, + ), + ) + } + + function getStartTime(config: InternalAxiosRequestConfig) { + config.__openreplay_timing = performance.now() + if (opts.sessionTokenHeader) { + const header = + typeof opts.sessionTokenHeader === 'string' + ? opts.sessionTokenHeader + : 'X-OpenReplay-Session-Token' + const headerValue = app.getSessionToken() + if (headerValue) { + config.headers.set(header, headerValue) + } + } + return config + } + + function captureNetworkRequest(response: AxiosResponse) { + if (opts.failuresOnly) return response + captureResponseData(response) + return response + } + + function captureNetworkError(error: any) { + captureResponseData(error as AxiosResponse) + return Promise.reject(error) + } + + const reqInt = instance.interceptors.request.use(getStartTime, null, { synchronous: true }) + const resInt = instance.interceptors.response.use(captureNetworkRequest, captureNetworkError, { + synchronous: true, + }) + + app.attachStopCallback(() => { + instance.interceptors.request.eject?.(reqInt) + instance.interceptors.response.eject?.(resInt) + }) +} diff --git a/tracker/tracker/src/main/modules/network.ts b/tracker/tracker/src/main/modules/network.ts index a0c2eedbe..35ad2d0c8 100644 --- a/tracker/tracker/src/main/modules/network.ts +++ b/tracker/tracker/src/main/modules/network.ts @@ -1,6 +1,8 @@ import type App from '../app/index.js' import { NetworkRequest } from '../app/messages.gen.js' import { getTimeOrigin } from '../utils.js' +import type { AxiosInstance } from './axiosSpy.js' +import axiosSpy from './axiosSpy.js' type WindowFetch = typeof window.fetch type XHRRequestBody = Parameters[0] @@ -45,7 +47,7 @@ interface ResponseData { headers: Record } -interface RequestResponseData { +export interface RequestResponseData { readonly status: number readonly method: string url: string @@ -81,6 +83,7 @@ export interface Options { capturePayload: boolean captureInIframes: boolean sanitizer?: Sanitizer + axiosInstances?: Array } export default function (app: App, opts: Partial = {}) { @@ -91,6 +94,7 @@ export default function (app: App, opts: Partial = {}) { capturePayload: false, sessionTokenHeader: false, captureInIframes: true, + axiosInstances: undefined, }, opts, ) @@ -237,8 +241,9 @@ export default function (app: App, opts: Partial = {}) { /* ====== <> ====== */ /* ====== XHR ====== */ - const nativeOpen = context.XMLHttpRequest.prototype.open + const nativeSetRequestHeader = context.XMLHttpRequest.prototype.setRequestHeader + const nativeSend = context.XMLHttpRequest.prototype.send function trackXMLHttpReqOpen(this: XMLHttpRequest, initMethod: string, url: string | URL) { const xhr = this @@ -304,10 +309,6 @@ export default function (app: App, opts: Partial = {}) { return nativeOpen.apply(this, arguments) } - context.XMLHttpRequest.prototype.open = trackXMLHttpReqOpen - - const nativeSend = context.XMLHttpRequest.prototype.send - function trackXHRSend( this: XMLHttpRequest, body: Document | XMLHttpRequestBodyInit | null | undefined, @@ -318,10 +319,6 @@ export default function (app: App, opts: Partial = {}) { return nativeSend.apply(this, arguments) } - context.XMLHttpRequest.prototype.send = trackXHRSend - - const nativeSetRequestHeader = context.XMLHttpRequest.prototype.setRequestHeader - function trackSetReqHeader(this: XMLHttpRequest, name: string, value: string) { if (!isHIgnored(name)) { const rdo = getXHRRequestDataObject(this) @@ -330,12 +327,21 @@ export default function (app: App, opts: Partial = {}) { return nativeSetRequestHeader.apply(this, arguments) } - context.XMLHttpRequest.prototype.setRequestHeader = trackSetReqHeader + if (!options.axiosInstances) { + context.XMLHttpRequest.prototype.open = trackXMLHttpReqOpen + context.XMLHttpRequest.prototype.send = trackXHRSend + context.XMLHttpRequest.prototype.setRequestHeader = trackSetReqHeader + } /* ====== <> ====== */ } patchWindow(window) + if (options.axiosInstances) { + options.axiosInstances.forEach((axiosInstance) => { + axiosSpy(app, axiosInstance, options, sanitize, stringify) + }) + } if (options.captureInIframes) { app.observer.attachContextCallback(app.safe(patchWindow))