diff --git a/src/record/index.ts b/src/record/index.ts index c6f566731d..fdaad963f7 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -14,6 +14,7 @@ import { recordOptions, IncrementalSource, listenerHandler, + LogRecordOptions, } from '../types'; function wrapEvent(e: event): eventWithTime { @@ -46,6 +47,7 @@ function record( mousemoveWait, recordCanvas = false, collectFonts = false, + recordLog = false, } = options; // runtime checks for user options if (!emit) { @@ -98,6 +100,37 @@ function record( : _slimDOMOptions ? _slimDOMOptions : {}; + const defaultLogOptions: LogRecordOptions = { + level: [ + 'assert', + 'clear', + 'count', + 'countReset', + 'debug', + 'dir', + 'dirxml', + 'error', + 'group', + 'groupCollapsed', + 'groupEnd', + 'info', + 'log', + 'table', + 'time', + 'timeEnd', + 'timeLog', + 'trace', + 'warn', + ], + lengthThreshold: 1000, + logger: console, + }; + + const logOptions: LogRecordOptions = recordLog + ? recordLog === true + ? defaultLogOptions + : Object.assign({}, defaultLogOptions, recordLog) + : {}; polyfill(); @@ -312,6 +345,16 @@ function record( }, }), ), + logCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Log, + ...p, + }, + }), + ), blockClass, blockSelector, ignoreClass, @@ -322,6 +365,7 @@ function record( recordCanvas, collectFonts, slimDOMOptions, + logOptions, }, hooks, ), diff --git a/src/record/observer.ts b/src/record/observer.ts index 96243616dd..de471ba771 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -36,8 +36,13 @@ import { fontCallback, fontParam, MaskInputFn, + logCallback, + LogRecordOptions, + Logger, + LogLevel, } from '../types'; import MutationBuffer from './mutation'; +import { stringify } from './stringify'; export const mutationBuffer = new MutationBuffer(); @@ -499,6 +504,100 @@ function initFontObserver(cb: fontCallback): listenerHandler { }; } +function initLogObserver( + cb: logCallback, + logOptions: LogRecordOptions, +): listenerHandler { + const logger = logOptions.logger; + if (!logger) return () => {}; + let logCount = 0; + const cancelHandlers: any[] = []; + // add listener to thrown errors + if (logOptions.level!.includes('error')) { + if (window) { + const originalOnError = window.onerror; + window.onerror = (...args: any[]) => { + originalOnError && originalOnError.apply(this, args); + let stack: Array = []; + if (args[args.length - 1] instanceof Error) + // 0(the second parameter) tells parseStack that every stack in Error is useful + stack = parseStack(args[args.length - 1].stack, 0); + const payload = [stringify(args[0], logOptions.stringifyOptions)]; + cb({ + level: 'error', + trace: stack, + payload: payload, + }); + }; + cancelHandlers.push(() => { + window.onerror = originalOnError; + }); + } + } + for (const levelType of logOptions.level!) + cancelHandlers.push(replace(logger, levelType)); + return () => { + cancelHandlers.forEach((h) => h()); + }; + + /** + * replace the original console function and record logs + * @param logger the logger object such as Console + * @param level the name of log function to be replaced + */ + function replace(logger: Logger, level: LogLevel) { + if (!logger[level]) return () => {}; + // replace the logger.{level}. return a restore function + return patch(logger, level, (original) => { + return (...args: any[]) => { + original.apply(this, args); + try { + const stack = parseStack(new Error().stack); + const payload = args.map((s) => + stringify(s, logOptions.stringifyOptions), + ); + logCount++; + if (logCount < logOptions.lengthThreshold!) + cb({ + level: level, + trace: stack, + payload: payload, + }); + else if (logCount === logOptions.lengthThreshold) + // notify the user + cb({ + level: 'warn', + trace: [], + payload: [ + stringify('The number of log records reached the threshold.'), + ], + }); + } catch (error) { + original('rrweb logger error:', error, ...args); + } + }; + }); + } + /** + * parse single stack message to an stack array. + * @param stack the stack message to be parsed + * @param omitDepth omit specific depth of useless stack. omit hijacked log function by default + */ + function parseStack( + stack: string | undefined, + omitDepth: number = 1, + ): Array { + let stacks: string[] = []; + if (stack) { + stacks = stack + .split('at') + .splice(1 + omitDepth) + .map((s) => s.trim()); + } + return stacks; + } +} + function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -511,6 +610,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { styleSheetRuleCb, canvasMutationCb, fontCb, + logCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -572,6 +672,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } fontCb(...p); }; + o.logCb = (...p: Arguments) => { + if (hooks.log) { + hooks.log(...p); + } + logCb(...p); + }; } export function initObservers( @@ -617,6 +723,9 @@ export function initObservers( ? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass) : () => {}; const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {}; + const logObserver = o.logOptions + ? initLogObserver(o.logCb, o.logOptions) + : () => {}; return () => { mutationObserver.disconnect(); @@ -629,5 +738,6 @@ export function initObservers( styleSheetObserver(); canvasMutationObserver(); fontObserver(); + logObserver(); }; } diff --git a/src/record/stringify.ts b/src/record/stringify.ts new file mode 100644 index 0000000000..0547f2f32a --- /dev/null +++ b/src/record/stringify.ts @@ -0,0 +1,126 @@ +/** + * this file is used to serialize log message to string + * + */ + +import { StringifyOptions } from '../types'; + +/** + * transfer the node path in Event to string + * @param node the first node in a node path array + */ +function pathToSelector(node: HTMLElement): string | '' { + if (!node || !node.outerHTML) { + return ''; + } + + var path = ''; + while (node.parentElement) { + var name = node.localName; + if (!name) break; + name = name.toLowerCase(); + var parent = node.parentElement; + + var domSiblings = []; + + if (parent.children && parent.children.length > 0) { + for (var i = 0; i < parent.children.length; i++) { + var sibling = parent.children[i]; + if (sibling.localName && sibling.localName.toLowerCase) { + if (sibling.localName.toLowerCase() === name) { + domSiblings.push(sibling); + } + } + } + } + + if (domSiblings.length > 1) { + name += ':eq(' + domSiblings.indexOf(node) + ')'; + } + path = name + (path ? '>' + path : ''); + node = parent; + } + + return path; +} + +/** + * stringify any js object + * @param obj the object to stringify + */ +export function stringify( + obj: any, + stringifyOptions?: StringifyOptions, +): string { + const options: StringifyOptions = { + numOfKeysLimit: 50, + }; + Object.assign(options, stringifyOptions); + let stack: any[] = [], + keys: any[] = []; + return JSON.stringify(obj, function (key, value) { + /** + * forked from https://github.com/moll/json-stringify-safe/blob/master/stringify.js + * to deCycle the object + */ + if (stack.length > 0) { + var thisPos = stack.indexOf(this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + if (~stack.indexOf(value)) { + if (stack[0] === value) value = '[Circular ~]'; + else + value = + '[Circular ~.' + + keys.slice(0, stack.indexOf(value)).join('.') + + ']'; + } + } else stack.push(value); + /* END of the FORK */ + + if (value === null || value === undefined) return value; + if (shouldToString(value)) { + return toString(value); + } + if (value instanceof Event) { + const eventResult: any = {}; + for (const key in value) { + const eventValue = (value as any)[key]; + if (Array.isArray(eventValue)) + eventResult[key] = pathToSelector( + eventValue.length ? eventValue[0] : null, + ); + else eventResult[key] = eventValue; + } + return eventResult; + } else if (value instanceof Node) { + if (value instanceof HTMLElement) return value ? value.outerHTML : ''; + return value.nodeName; + } + return value; + }); + + /** + * whether we should call toString function of this object + */ + function shouldToString(obj: object): boolean { + if ( + typeof obj === 'object' && + Object.keys(obj).length > options.numOfKeysLimit + ) + return true; + if (typeof obj === 'function') return true; + return false; + } + + /** + * limit the toString() result according to option + */ + function toString(obj: object): string { + let str = obj.toString(); + if (options.stringLengthLimit && str.length > options.stringLengthLimit) { + str = `${str.slice(0, options.stringLengthLimit)}...`; + } + return str; + } +} diff --git a/src/replay/index.ts b/src/replay/index.ts index 7888f76858..6f2f7a604b 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -27,6 +27,9 @@ import { inputData, canvasMutationData, ElementState, + LogReplayConfig, + logData, + ReplayLogger, } from '../types'; import { mirror, @@ -54,6 +57,31 @@ const defaultMouseTailConfig = { strokeStyle: 'red', } as const; +const defaultLogConfig: LogReplayConfig = { + level: [ + 'assert', + 'clear', + 'count', + 'countReset', + 'debug', + 'dir', + 'dirxml', + 'error', + 'group', + 'groupCollapsed', + 'groupEnd', + 'info', + 'log', + 'table', + 'time', + 'timeEnd', + 'timeLog', + 'trace', + 'warn', + ], + replayLogger: undefined, +}; + export class Replayer { public wrapper: HTMLDivElement; public iframe: HTMLIFrameElement; @@ -104,8 +132,11 @@ export class Replayer { UNSAFE_replayCanvas: false, pauseAnimation: true, mouseTail: defaultMouseTailConfig, + logConfig: defaultLogConfig, }; this.config = Object.assign({}, defaultConfig, config); + if (!this.config.logConfig.replayLogger) + this.config.logConfig.replayLogger = this.getConsoleLogger(); this.handleResize = this.handleResize.bind(this); this.getCastFn = this.getCastFn.bind(this); @@ -904,6 +935,18 @@ export class Replayer { } break; } + case IncrementalSource.Log: { + try { + const logData = e.data as logData; + const replayLogger = this.config.logConfig.replayLogger!; + if (typeof replayLogger[logData.level] === 'function') + replayLogger[logData.level]!(logData); + } catch (error) { + if (this.config.showWarning) { + console.warn(error); + } + } + } default: } } @@ -1159,6 +1202,48 @@ export class Replayer { } } + /** + * format the trace data to a string + * @param data the log data + */ + private formatMessage(data: logData): string { + if (data.trace.length === 0) return ''; + const stackPrefix = '\n\tat '; + let result = stackPrefix; + result += data.trace.join(stackPrefix); + return result; + } + + /** + * generate a console log replayer which implement the interface ReplayLogger + */ + private getConsoleLogger(): ReplayLogger { + const rrwebOriginal = '__rrweb_original__'; + const replayLogger: ReplayLogger = {}; + for (const level of this.config.logConfig.level!) + if (level === 'trace') + replayLogger[level] = (data: logData) => { + const logger = (console.log as any)[rrwebOriginal] + ? (console.log as any)[rrwebOriginal] + : console.log; + logger( + ...data.payload.map((s) => JSON.parse(s)), + this.formatMessage(data), + ); + }; + else + replayLogger[level] = (data: logData) => { + const logger = (console[level] as any)[rrwebOriginal] + ? (console[level] as any)[rrwebOriginal] + : console[level]; + logger( + ...data.payload.map((s) => JSON.parse(s)), + this.formatMessage(data), + ); + }; + return replayLogger; + } + private legacy_resolveMissingNode( map: missingNodeMap, parent: Node, diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 8df6747411..fc53b02313 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -165,6 +165,7 @@ export function createPlayerService( }; }), play(ctx) { + console.warn('play'); const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); for (const event of events) { diff --git a/src/types.ts b/src/types.ts index 0bacc34da0..02f5ce1c0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,11 @@ export type metaEvent = { }; }; +export type logEvent = { + type: EventType.IncrementalSnapshot; + data: incrementalData; +}; + export type customEvent = { type: EventType.Custom; data: { @@ -74,6 +79,7 @@ export enum IncrementalSource { StyleSheetRule, CanvasMutation, Font, + Log, } export type mutationData = { @@ -118,6 +124,10 @@ export type fontData = { source: IncrementalSource.Font; } & fontParam; +export type logData = { + source: IncrementalSource.Log; +} & LogParam; + export type incrementalData = | mutationData | mousemoveData @@ -128,7 +138,8 @@ export type incrementalData = | mediaInteractionData | styleSheetRuleData | canvasMutationData - | fontData; + | fontData + | logData; export type event = | domContentLoadedEvent @@ -136,6 +147,7 @@ export type event = | fullSnapshotEvent | incrementalSnapshotEvent | metaEvent + | logEvent | customEvent; export type eventWithTime = event & { @@ -186,6 +198,7 @@ export type recordOptions = { collectFonts?: boolean; // departed, please use sampling options mousemoveWait?: number; + recordLog?: boolean | LogRecordOptions; }; export type observerParam = { @@ -205,6 +218,8 @@ export type observerParam = { styleSheetRuleCb: styleSheetRuleCallback; canvasMutationCb: canvasMutationCallback; fontCb: fontCallback; + logCb: logCallback; + logOptions: LogRecordOptions; sampling: SamplingStrategy; recordCanvas: boolean; collectFonts: boolean; @@ -222,6 +237,7 @@ export type hooksParam = { styleSheetRule?: styleSheetRuleCallback; canvasMutation?: canvasMutationCallback; font?: fontCallback; + log?: logCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -353,8 +369,67 @@ export type fontParam = { descriptors?: FontFaceDescriptors; }; +export type LogLevel = + | 'assert' + | 'clear' + | 'count' + | 'countReset' + | 'debug' + | 'dir' + | 'dirxml' + | 'error' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'info' + | 'log' + | 'table' + | 'time' + | 'timeEnd' + | 'timeLog' + | 'trace' + | 'warn'; + +/* fork from interface Console */ +// all kinds of console functions +export type Logger = { + assert?: (value: any, message?: string, ...optionalParams: any[]) => void; + clear?: () => void; + count?: (label?: string) => void; + countReset?: (label?: string) => void; + debug?: (message?: any, ...optionalParams: any[]) => void; + dir?: (obj: any, options?: NodeJS.InspectOptions) => void; + dirxml?: (...data: any[]) => void; + error?: (message?: any, ...optionalParams: any[]) => void; + group?: (...label: any[]) => void; + groupCollapsed?: (label?: any[]) => void; + groupEnd?: () => void; + info?: (message?: any, ...optionalParams: any[]) => void; + log?: (message?: any, ...optionalParams: any[]) => void; + table?: (tabularData: any, properties?: ReadonlyArray) => void; + time?: (label?: string) => void; + timeEnd?: (label?: string) => void; + timeLog?: (label?: string, ...data: any[]) => void; + trace?: (message?: any, ...optionalParams: any[]) => void; + warn?: (message?: any, ...optionalParams: any[]) => void; +}; + +/** + * define an interface to replay log records + * (data: logData) => void> function to display the log data + */ +export type ReplayLogger = Partial void>>; + +export type LogParam = { + level: LogLevel; + trace: Array; + payload: Array; +}; + export type fontCallback = (p: fontParam) => void; +export type logCallback = (p: LogParam) => void; + export type viewportResizeDimention = { width: number; height: number; @@ -419,6 +494,12 @@ export type playerConfig = { strokeStyle?: string; }; unpackFn?: UnpackFn; + logConfig: LogReplayConfig; +}; + +export type LogReplayConfig = { + level?: Array | undefined; + replayLogger: ReplayLogger | undefined; }; export type playerMetaData = { @@ -477,3 +558,20 @@ export type ElementState = { // [scrollLeft,scrollTop] scroll?: [number, number]; }; + +export type StringifyOptions = { + // limit of string length + stringLengthLimit?: number; + /** + * limit of number of keys in an object + * if an object contains more keys than this limit, we would call its toString function directly + */ + numOfKeysLimit: number; +}; + +export type LogRecordOptions = { + level?: Array | undefined; + lengthThreshold?: number; + stringifyOptions?: StringifyOptions; + logger?: Logger; +}; diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index f88e31bde4..19f34fb608 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -2002,6 +2002,720 @@ exports[`ignore 1`] = ` ]" `; +exports[`log`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Log record\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 20 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"assert\\", + \\"payload\\": [ + \\"true\\", + \\"\\"assert\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"count\\", + \\"payload\\": [ + \\"\\"count\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"countReset\\", + \\"payload\\": [ + \\"\\"count\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"debug\\", + \\"payload\\": [ + \\"\\"debug\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"dir\\", + \\"payload\\": [ + \\"\\"dir\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"dirxml\\", + \\"payload\\": [ + \\"\\"dirxml\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"group\\", + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"groupCollapsed\\", + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"info\\", + \\"payload\\": [ + \\"\\"info\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"log\\", + \\"payload\\": [ + \\"\\"log\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"table\\", + \\"payload\\": [ + \\"\\"table\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"time\\", + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"timeEnd\\", + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"timeLog\\", + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"trace\\", + \\"payload\\": [ + \\"\\"trace\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"warn\\", + \\"payload\\": [ + \\"\\"warn\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"clear\\", + \\"payload\\": [] + } + } +]" +`; + +exports[`log 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Log record\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 20 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"assert\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:2:37\\" + ], + \\"payload\\": [ + \\"true\\", + \\"\\\\\\"assert\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"count\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:3:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"count\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"countReset\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:4:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"count\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"debug\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:5:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"debug\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"dir\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:6:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"dir\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"dirxml\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:7:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"dirxml\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"group\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:8:37\\" + ], + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"groupCollapsed\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:9:37\\" + ], + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"info\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:10:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"info\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"log\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:11:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"log\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"table\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:12:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"table\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"time\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:13:37\\" + ], + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"timeEnd\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:14:37\\" + ], + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"timeLog\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:15:37\\" + ], + \\"payload\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"trace\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:16:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"trace\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"warn\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:17:37\\" + ], + \\"payload\\": [ + \\"\\\\\\"warn\\\\\\"\\" + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 11, + \\"level\\": \\"clear\\", + \\"trace\\": [ + \\"__puppeteer_evalu\\", + \\"ion_script__:18:37\\" + ], + \\"payload\\": [] + } + } +]" +`; + exports[`mask 1`] = ` "[ { diff --git a/test/html/log.html b/test/html/log.html new file mode 100644 index 0000000000..ba60e26e13 --- /dev/null +++ b/test/html/log.html @@ -0,0 +1,10 @@ + + + + + + + Log record + + + diff --git a/test/integration.test.ts b/test/integration.test.ts index 8d1f2d32cb..767dc413c0 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -29,13 +29,13 @@ describe('record integration tests', function (this: ISuite) { window.Date.now = () => new Date(Date.UTC(2018, 10, 15, 8)).valueOf(); window.snapshots = []; rrweb.record({ - emit: event => { - console.log(event); + emit: event => { window.snapshots.push(event); }, maskAllInputs: ${options.maskAllInputs}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, - recordCanvas: ${options.recordCanvas} + recordCanvas: ${options.recordCanvas}, + recordLog: ${options.recordLog}, }); @@ -334,4 +334,37 @@ describe('record integration tests', function (this: ISuite) { expect(text).to.equal('4\n3\n2\n1\n5'); }); + + it('can record log mutation', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'log.html', { + recordLog: true, + }), + ); + + await page.evaluate(() => { + console.assert(0 == 0, 'assert'); + console.count('count'); + console.countReset('count'); + console.debug('debug'); + console.dir('dir'); + console.dirxml('dirxml'); + console.group(); + console.groupCollapsed(); + console.info('info'); + console.log('log'); + console.table('table'); + console.time(); + console.timeEnd(); + console.timeLog(); + console.trace('trace'); + console.warn('warn'); + console.clear(); + }); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'log'); + }); }); diff --git a/test/record.test.ts b/test/record.test.ts index 417c3a2782..61121b2e29 100644 --- a/test/record.test.ts +++ b/test/record.test.ts @@ -179,7 +179,7 @@ describe('record', function (this: ISuite) { document.body.appendChild(span); }, 10); }); - await this.page.waitFor(50); + await this.page.waitFor(100); assertSnapshot(this.events, __filename, 'async-checkout'); }); diff --git a/typings/record/stringify.d.ts b/typings/record/stringify.d.ts new file mode 100644 index 0000000000..d861956be3 --- /dev/null +++ b/typings/record/stringify.d.ts @@ -0,0 +1,2 @@ +import { StringifyOptions } from '../types'; +export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string; diff --git a/typings/replay/index.d.ts b/typings/replay/index.d.ts index fa7093c0b6..f3720ce277 100644 --- a/typings/replay/index.d.ts +++ b/typings/replay/index.d.ts @@ -42,6 +42,8 @@ export declare class Replayer { private applyMutation; private applyScroll; private applyInput; + private formatMessage; + private getConsoleLogger; private legacy_resolveMissingNode; private moveAndHover; private drawMouseTail; diff --git a/typings/types.d.ts b/typings/types.d.ts index 2d8d59bca6..5e8542ad4b 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -39,6 +39,10 @@ export declare type metaEvent = { height: number; }; }; +export declare type logEvent = { + type: EventType.IncrementalSnapshot; + data: incrementalData; +}; export declare type customEvent = { type: EventType.Custom; data: { @@ -58,7 +62,8 @@ export declare enum IncrementalSource { MediaInteraction = 7, StyleSheetRule = 8, CanvasMutation = 9, - Font = 10 + Font = 10, + Log = 11 } export declare type mutationData = { source: IncrementalSource.Mutation; @@ -92,8 +97,11 @@ export declare type canvasMutationData = { export declare type fontData = { source: IncrementalSource.Font; } & fontParam; -export declare type incrementalData = mutationData | mousemoveData | mouseInteractionData | scrollData | viewportResizeData | inputData | mediaInteractionData | styleSheetRuleData | canvasMutationData | fontData; -export declare type event = domContentLoadedEvent | loadedEvent | fullSnapshotEvent | incrementalSnapshotEvent | metaEvent | customEvent; +export declare type logData = { + source: IncrementalSource.Log; +} & LogParam; +export declare type incrementalData = mutationData | mousemoveData | mouseInteractionData | scrollData | viewportResizeData | inputData | mediaInteractionData | styleSheetRuleData | canvasMutationData | fontData | logData; +export declare type event = domContentLoadedEvent | loadedEvent | fullSnapshotEvent | incrementalSnapshotEvent | metaEvent | logEvent | customEvent; export declare type eventWithTime = event & { timestamp: number; delay?: number; @@ -123,6 +131,7 @@ export declare type recordOptions = { recordCanvas?: boolean; collectFonts?: boolean; mousemoveWait?: number; + recordLog?: boolean | LogRecordOptions; }; export declare type observerParam = { mutationCb: mutationCallBack; @@ -141,6 +150,8 @@ export declare type observerParam = { styleSheetRuleCb: styleSheetRuleCallback; canvasMutationCb: canvasMutationCallback; fontCb: fontCallback; + logCb: logCallback; + logOptions: LogRecordOptions; sampling: SamplingStrategy; recordCanvas: boolean; collectFonts: boolean; @@ -157,6 +168,7 @@ export declare type hooksParam = { styleSheetRule?: styleSheetRuleCallback; canvasMutation?: canvasMutationCallback; font?: fontCallback; + log?: logCallback; }; export declare type mutationRecord = { type: string; @@ -261,7 +273,36 @@ export declare type fontParam = { buffer: boolean; descriptors?: FontFaceDescriptors; }; +export declare type LogLevel = 'assert' | 'clear' | 'count' | 'countReset' | 'debug' | 'dir' | 'dirxml' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'log' | 'table' | 'time' | 'timeEnd' | 'timeLog' | 'trace' | 'warn'; +export declare type Logger = { + assert?: (value: any, message?: string, ...optionalParams: any[]) => void; + clear?: () => void; + count?: (label?: string) => void; + countReset?: (label?: string) => void; + debug?: (message?: any, ...optionalParams: any[]) => void; + dir?: (obj: any, options?: NodeJS.InspectOptions) => void; + dirxml?: (...data: any[]) => void; + error?: (message?: any, ...optionalParams: any[]) => void; + group?: (...label: any[]) => void; + groupCollapsed?: (label?: any[]) => void; + groupEnd?: () => void; + info?: (message?: any, ...optionalParams: any[]) => void; + log?: (message?: any, ...optionalParams: any[]) => void; + table?: (tabularData: any, properties?: ReadonlyArray) => void; + time?: (label?: string) => void; + timeEnd?: (label?: string) => void; + timeLog?: (label?: string, ...data: any[]) => void; + trace?: (message?: any, ...optionalParams: any[]) => void; + warn?: (message?: any, ...optionalParams: any[]) => void; +}; +export declare type ReplayLogger = Partial void>>; +export declare type LogParam = { + level: LogLevel; + trace: Array; + payload: Array; +}; export declare type fontCallback = (p: fontParam) => void; +export declare type logCallback = (p: LogParam) => void; export declare type viewportResizeDimention = { width: number; height: number; @@ -316,6 +357,11 @@ export declare type playerConfig = { strokeStyle?: string; }; unpackFn?: UnpackFn; + logConfig: LogReplayConfig; +}; +export declare type LogReplayConfig = { + level?: Array | undefined; + replayLogger: ReplayLogger | undefined; }; export declare type playerMetaData = { startTime: number; @@ -361,4 +407,14 @@ export declare type MaskInputFn = (text: string) => string; export declare type ElementState = { scroll?: [number, number]; }; +export declare type StringifyOptions = { + stringLengthLimit?: number; + numOfKeysLimit: number; +}; +export declare type LogRecordOptions = { + level?: Array | undefined; + lengthThreshold?: number; + stringifyOptions?: StringifyOptions; + logger?: Logger; +}; export {};