Skip to content

Commit

Permalink
feat: enable rrweb to record and replay log messages in console (#424)
Browse files Browse the repository at this point in the history
* wip: working on rrweb logger

* wip: can record and replay some simple log

* wip: can record and replay log's stack

* wip: try to serialize object

* wip: record and replay console logger

hijack all of the console functions.
add listener to thrown errors

* wip: record and replay console logger
add limit to the max number of log records

* feat: enable rrweb to record and replay log messages in console

this is the implementation of new feature request(issue #234)

here are a few points of description.
1. users need to set recordLog option in rrweb.record's parameter to record log messages.  The log recorder is off by default.
2. support recording and replaying all kinds of console functions. But the reliability of them should be tested more
3. the stringify function in  stringify.ts needs improvement. e.g. robustness, handler for cyclical structures and better support for more kinds of object
4. we can replay the log messages in a simulated html console like LogRocket by implementing the interface "ReplayLogger" in the future

* improve: the stringify function

1. handle cyclical structures
2. add stringify option to limit the length of result
3. handle function type

* refactor: simplify the type definition of ReplayLogger
  • Loading branch information
YunFeng0817 authored Nov 29, 2020
1 parent e3beeb4 commit 4e7146e
Show file tree
Hide file tree
Showing 13 changed files with 1,289 additions and 8 deletions.
44 changes: 44 additions & 0 deletions src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
recordOptions,
IncrementalSource,
listenerHandler,
LogRecordOptions,
} from '../types';

function wrapEvent(e: event): eventWithTime {
Expand Down Expand Up @@ -46,6 +47,7 @@ function record<T = eventWithTime>(
mousemoveWait,
recordCanvas = false,
collectFonts = false,
recordLog = false,
} = options;
// runtime checks for user options
if (!emit) {
Expand Down Expand Up @@ -98,6 +100,37 @@ function record<T = eventWithTime>(
: _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();

Expand Down Expand Up @@ -312,6 +345,16 @@ function record<T = eventWithTime>(
},
}),
),
logCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Log,
...p,
},
}),
),
blockClass,
blockSelector,
ignoreClass,
Expand All @@ -322,6 +365,7 @@ function record<T = eventWithTime>(
recordCanvas,
collectFonts,
slimDOMOptions,
logOptions,
},
hooks,
),
Expand Down
110 changes: 110 additions & 0 deletions src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<string> = [];
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<string> {
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,
Expand All @@ -511,6 +610,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
styleSheetRuleCb,
canvasMutationCb,
fontCb,
logCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
Expand Down Expand Up @@ -572,6 +672,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
}
fontCb(...p);
};
o.logCb = (...p: Arguments<logCallback>) => {
if (hooks.log) {
hooks.log(...p);
}
logCb(...p);
};
}

export function initObservers(
Expand Down Expand Up @@ -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();
Expand All @@ -629,5 +738,6 @@ export function initObservers(
styleSheetObserver();
canvasMutationObserver();
fontObserver();
logObserver();
};
}
126 changes: 126 additions & 0 deletions src/record/stringify.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 4e7146e

Please sign in to comment.