diff --git a/.changeset/violet-melons-itch.md b/.changeset/violet-melons-itch.md new file mode 100644 index 0000000000..af7f60957f --- /dev/null +++ b/.changeset/violet-melons-itch.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +fix: Recursive logging bug with console recording diff --git a/packages/rrweb/src/plugins/console/record/index.ts b/packages/rrweb/src/plugins/console/record/index.ts index 3413cc0727..a3bd4d3843 100644 --- a/packages/rrweb/src/plugins/console/record/index.ts +++ b/packages/rrweb/src/plugins/console/record/index.ts @@ -111,6 +111,7 @@ function initLogObserver( logger = loggerType; } let logCount = 0; + let inStack = false; const cancelHandlers: listenerHandler[] = []; // add listener to thrown errors if (logOptions.level.includes('error')) { @@ -188,6 +189,12 @@ function initLogObserver( (original: (...args: Array) => void) => { return (...args: Array) => { original.apply(this, args); + if (inStack) { + // If we are already in a stack this means something from the following code is calling a console method + // likely a proxy method called from stringify. We don't want to log this as it will cause an infinite loop + return; + } + inStack = true; try { const trace = ErrorStackParser.parse(new Error()) .map((stackFrame: StackFrame) => stackFrame.toString()) @@ -214,6 +221,8 @@ function initLogObserver( } } catch (error) { original('rrweb logger error:', error, ...args); + } finally { + inStack = false; } }; }, diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index ea037dff38..011e3523f3 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -4723,6 +4723,186 @@ exports[`record integration tests mutations should work when blocked class is un ]" `; +exports[`record integration tests should handle recursive console messages 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\\": 6, + \\"data\\": { + \\"plugin\\": \\"rrweb/console@1\\", + \\"payload\\": { + \\"level\\": \\"log\\", + \\"trace\\": [ + \\"__puppeteer_evaluation_script__:20:21\\" + ], + \\"payload\\": [ + \\"\\\\\\"Proxied object:\\\\\\"\\", + \\"\\\\\\"[object Object]\\\\\\"\\" + ] + } + } + } +]" +`; + exports[`record integration tests should mask texts 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index fed6aae61a..57fbd072f1 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -542,6 +542,53 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('should handle recursive console messages', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml('log.html', { + plugins: + '[rrwebConsoleRecord.getRecordConsolePlugin()]' as unknown as RecordPlugin[], + }), + ); + + await page.evaluate(() => { + // Some frameworks like Vue.js use proxies to implement reactivity. + // This can cause infinite loops when logging objects. + let recursiveTarget = { foo: 'bar', proxied: 'i-am', proxy: null }; + let count = 0; + + const handler = { + get(target: any, prop: any, ...args: any[]) { + if (prop === 'proxied') { + if (count > 9) { + return; + } + count++; // We don't want out test to get into an infinite loop... + console.warn( + 'proxied was accessed so triggering a console.warn', + target, + ); + } + return Reflect.get(target, prop, ...args); + }, + }; + + const proxy = new Proxy(recursiveTarget, handler); + recursiveTarget.proxy = proxy; + + console.log('Proxied object:', proxy); + }); + + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + // The snapshots should containe 1 console log, not multiple. + assertSnapshot(snapshots); + }); + it('should nest record iframe', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto(`${serverURL}/html`);