Skip to content

Commit 24a235d

Browse files
authored
fix(replay): Ensure console breadcrumb args are truncated (#7917)
1 parent 15d9102 commit 24a235d

File tree

6 files changed

+346
-22
lines changed

6 files changed

+346
-22
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button data-log>Log button</button>
8+
<button data-log-large>Log button</button>
9+
10+
<script>
11+
document.querySelector('[data-log]').addEventListener('click', () => {
12+
console.log('Test log', document.body);
13+
});
14+
15+
function createLargeObject(remainingDepth) {
16+
const massiveObject = {};
17+
18+
for (let i = 0; i < 3; i++) {
19+
const item = {
20+
aa: remainingDepth > 0 ? createLargeObject(remainingDepth - 1) : 'a'.repeat(50),
21+
bb: 'b'.repeat(50),
22+
cc: 'c'.repeat(50),
23+
dd: 'd'.repeat(50),
24+
};
25+
26+
massiveObject[`item-${i}`] = item;
27+
}
28+
29+
return massiveObject;
30+
}
31+
32+
const massiveObject = createLargeObject(10);
33+
34+
document.querySelector('[data-log-large]').addEventListener('click', () => {
35+
console.log(massiveObject);
36+
});
37+
</script>
38+
</body>
39+
</html>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
5+
6+
sentryTest('should capture console messages in replay', async ({ getLocalTestPath, page, forceFlushReplay }) => {
7+
// console integration is not used in bundles/loader
8+
const bundle = process.env.PW_BUNDLE || '';
9+
if (shouldSkipReplayTest() || bundle.startsWith('bundle_') || bundle.startsWith('loader_')) {
10+
sentryTest.skip();
11+
}
12+
13+
const reqPromise0 = waitForReplayRequest(page, 0);
14+
15+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
16+
return route.fulfill({
17+
status: 200,
18+
contentType: 'application/json',
19+
body: JSON.stringify({ id: 'test-id' }),
20+
});
21+
});
22+
23+
const url = await getLocalTestPath({ testDir: __dirname });
24+
25+
await page.goto(url);
26+
await reqPromise0;
27+
28+
const reqPromise1 = waitForReplayRequest(
29+
page,
30+
(_event, res) => {
31+
const { breadcrumbs } = getCustomRecordingEvents(res);
32+
33+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'console');
34+
},
35+
5_000,
36+
);
37+
38+
await page.click('[data-log]');
39+
40+
// Sometimes this doesn't seem to trigger, so we trigger it twice to be sure...
41+
await page.click('[data-log]');
42+
43+
await forceFlushReplay();
44+
45+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
46+
47+
expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual(
48+
expect.arrayContaining([
49+
{
50+
timestamp: expect.any(Number),
51+
type: 'default',
52+
category: 'console',
53+
data: { arguments: ['Test log', '[HTMLElement: HTMLBodyElement]'], logger: 'console' },
54+
level: 'log',
55+
message: 'Test log [object HTMLBodyElement]',
56+
},
57+
]),
58+
);
59+
});
60+
61+
sentryTest('should capture very large console logs', async ({ getLocalTestPath, page, forceFlushReplay }) => {
62+
// console integration is not used in bundles/loader
63+
const bundle = process.env.PW_BUNDLE || '';
64+
if (shouldSkipReplayTest() || bundle.startsWith('bundle_') || bundle.startsWith('loader_')) {
65+
sentryTest.skip();
66+
}
67+
68+
const reqPromise0 = waitForReplayRequest(page, 0);
69+
70+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
71+
return route.fulfill({
72+
status: 200,
73+
contentType: 'application/json',
74+
body: JSON.stringify({ id: 'test-id' }),
75+
});
76+
});
77+
78+
const url = await getLocalTestPath({ testDir: __dirname });
79+
80+
await page.goto(url);
81+
await reqPromise0;
82+
83+
const reqPromise1 = waitForReplayRequest(
84+
page,
85+
(_event, res) => {
86+
const { breadcrumbs } = getCustomRecordingEvents(res);
87+
88+
return breadcrumbs.some(breadcrumb => breadcrumb.category === 'console');
89+
},
90+
5_000,
91+
);
92+
93+
await page.click('[data-log-large]');
94+
95+
// Sometimes this doesn't seem to trigger, so we trigger it twice to be sure...
96+
await page.click('[data-log-large]');
97+
98+
await forceFlushReplay();
99+
100+
const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1);
101+
102+
expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual(
103+
expect.arrayContaining([
104+
{
105+
timestamp: expect.any(Number),
106+
type: 'default',
107+
category: 'console',
108+
data: {
109+
arguments: [
110+
expect.objectContaining({
111+
'item-0': {
112+
aa: expect.objectContaining({
113+
'item-0': {
114+
aa: expect.any(Object),
115+
bb: expect.any(String),
116+
cc: expect.any(String),
117+
dd: expect.any(String),
118+
},
119+
}),
120+
bb: expect.any(String),
121+
cc: expect.any(String),
122+
dd: expect.any(String),
123+
},
124+
}),
125+
],
126+
logger: 'console',
127+
_meta: {
128+
warnings: ['CONSOLE_ARG_TRUNCATED'],
129+
},
130+
},
131+
level: 'log',
132+
message: '[object Object]',
133+
},
134+
]),
135+
);
136+
});

packages/browser-integration-tests/utils/replayHelpers.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,38 +49,42 @@ export type RecordingSnapshot = FullRecordingSnapshot | IncrementalRecordingSnap
4949
export function waitForReplayRequest(
5050
page: Page,
5151
segmentIdOrCallback?: number | ((event: ReplayEvent, res: Response) => boolean),
52+
timeout?: number,
5253
): Promise<Response> {
5354
const segmentId = typeof segmentIdOrCallback === 'number' ? segmentIdOrCallback : undefined;
5455
const callback = typeof segmentIdOrCallback === 'function' ? segmentIdOrCallback : undefined;
5556

56-
return page.waitForResponse(res => {
57-
const req = res.request();
57+
return page.waitForResponse(
58+
res => {
59+
const req = res.request();
5860

59-
const postData = req.postData();
60-
if (!postData) {
61-
return false;
62-
}
63-
64-
try {
65-
const event = envelopeRequestParser(req);
66-
67-
if (!isReplayEvent(event)) {
61+
const postData = req.postData();
62+
if (!postData) {
6863
return false;
6964
}
7065

71-
if (callback) {
72-
return callback(event, res);
73-
}
66+
try {
67+
const event = envelopeRequestParser(req);
7468

75-
if (segmentId !== undefined) {
76-
return event.segment_id === segmentId;
77-
}
69+
if (!isReplayEvent(event)) {
70+
return false;
71+
}
7872

79-
return true;
80-
} catch {
81-
return false;
82-
}
83-
});
73+
if (callback) {
74+
return callback(event, res);
75+
}
76+
77+
if (segmentId !== undefined) {
78+
return event.segment_id === segmentId;
79+
}
80+
81+
return true;
82+
} catch {
83+
return false;
84+
}
85+
},
86+
timeout ? { timeout } : undefined,
87+
);
8488
}
8589

8690
export function isReplayEvent(event: Event): event is ReplayEvent {

packages/replay/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ export const RETRY_MAX_COUNT = 3;
3131

3232
/* The max (uncompressed) size in bytes of a network body. Any body larger than this will be truncated. */
3333
export const NETWORK_BODY_MAX_SIZE = 150_000;
34+
35+
/* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */
36+
export const CONSOLE_ARG_MAX_SIZE = 5_000;

packages/replay/src/coreHandlers/handleScope.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { Breadcrumb, Scope } from '@sentry/types';
2+
import { normalize } from '@sentry/utils';
23

4+
import { CONSOLE_ARG_MAX_SIZE } from '../constants';
35
import type { ReplayContainer } from '../types';
46
import { createBreadcrumb } from '../util/createBreadcrumb';
7+
import { fixJson } from '../util/truncateJson/fixJson';
58
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
69

710
let _LAST_BREADCRUMB: null | Breadcrumb = null;
@@ -48,5 +51,62 @@ export function handleScope(scope: Scope): Breadcrumb | null {
4851
return null;
4952
}
5053

54+
if (newBreadcrumb.category === 'console') {
55+
return normalizeConsoleBreadcrumb(newBreadcrumb);
56+
}
57+
5158
return createBreadcrumb(newBreadcrumb);
5259
}
60+
61+
/** exported for tests only */
62+
export function normalizeConsoleBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
63+
const args = breadcrumb.data && breadcrumb.data.arguments;
64+
65+
if (!Array.isArray(args) || args.length === 0) {
66+
return createBreadcrumb(breadcrumb);
67+
}
68+
69+
let isTruncated = false;
70+
71+
// Avoid giant args captures
72+
const normalizedArgs = args.map(arg => {
73+
if (!arg) {
74+
return arg;
75+
}
76+
if (typeof arg === 'string') {
77+
if (arg.length > CONSOLE_ARG_MAX_SIZE) {
78+
isTruncated = true;
79+
return `${arg.slice(0, CONSOLE_ARG_MAX_SIZE)}…`;
80+
}
81+
82+
return arg;
83+
}
84+
if (typeof arg === 'object') {
85+
try {
86+
const normalizedArg = normalize(arg, 7);
87+
const stringified = JSON.stringify(normalizedArg);
88+
if (stringified.length > CONSOLE_ARG_MAX_SIZE) {
89+
const fixedJson = fixJson(stringified.slice(0, CONSOLE_ARG_MAX_SIZE));
90+
const json = JSON.parse(fixedJson);
91+
// We only set this after JSON.parse() was successfull, so we know we didn't run into `catch`
92+
isTruncated = true;
93+
return json;
94+
}
95+
return normalizedArg;
96+
} catch {
97+
// fall back to default
98+
}
99+
}
100+
101+
return arg;
102+
});
103+
104+
return createBreadcrumb({
105+
...breadcrumb,
106+
data: {
107+
...breadcrumb.data,
108+
arguments: normalizedArgs,
109+
...(isTruncated ? { _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'] } } : {}),
110+
},
111+
});
112+
}

0 commit comments

Comments
 (0)