feat: enable rrweb to record and replay log messages in console (#424)

* 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
This commit is contained in:
Lucky Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent a47e3af343
commit 9d2db86d5a
13 changed files with 1289 additions and 8 deletions

View File

@@ -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,

View File

@@ -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) {