Skip to content

Commit cc327ae

Browse files
authored
fix(core): Replace regex with string check in stack parser to prevent main thread blocking (#20089)
The stack parser used the regex `/\S*Error: / to skip error description lines (e.g. "TypeError: foo")` when parsing stack traces. The `\S*` quantifier causes O(n²) backtracking on long lines without whitespace, which are common in minified bundle stack traces. In apps using `thirdPartyErrorFilterIntegration`, the stack parser is invoked on every `_sentryModuleMetadata` entry on the first error event. In large Next.js apps with many bundled chunks, this caused 1s+ main thread blocking. Replacing ` .match(/\S*Error: /)` with `.includes('Error: ')` which is O(n) and semantically equivalent — the `\S*` prefix could match zero characters, so it never affected which lines were matched. Fixes #20052
1 parent 8804c4e commit cc327ae

File tree

3 files changed

+101
-2
lines changed

3 files changed

+101
-2
lines changed

packages/core/src/utils/stacktrace.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export function createStackParser(...parsers: StackLineParser[]): StackParser {
3939

4040
// https://github.com/getsentry/sentry-javascript/issues/7813
4141
// Skip Error: lines
42-
if (cleanedLine.match(/\S*Error: /)) {
42+
// Using includes() instead of a regex to avoid O(n²) backtracking on long lines
43+
// https://github.com/getsentry/sentry-javascript/issues/20052
44+
if (cleanedLine.includes('Error: ')) {
4345
continue;
4446
}
4547

packages/core/test/lib/integrations/third-party-errors-filter.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,4 +672,37 @@ describe('ThirdPartyErrorFilter', () => {
672672
});
673673
});
674674
});
675+
676+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/20052
677+
// The thirdPartyErrorFilterIntegration triggers addMetadataToStackFrames on every error event,
678+
// which calls ensureMetadataStacksAreParsed to parse all _sentryModuleMetadata stack keys.
679+
// This test verifies that metadata is correctly resolved even when the module metadata stacks
680+
// contain long lines (e.g. from minified bundles with long URLs/identifiers).
681+
describe('metadata stack parsing with long stack lines', () => {
682+
it('resolves metadata for frames whose filenames appear in module metadata stacks with long URLs', () => {
683+
const longFilename = `https://example.com/_next/static/chunks/${'a'.repeat(200)}.js`;
684+
685+
// Simulate a module metadata entry with a realistic stack containing a long filename
686+
const fakeStack = [`Error: Sentry Module Metadata`, ` at Object.<anonymous> (${longFilename}:1:1)`].join('\n');
687+
GLOBAL_OBJ._sentryModuleMetadata![fakeStack] = { '_sentryBundlerPluginAppKey:long-url-key': true };
688+
689+
const event: Event = {
690+
exception: {
691+
values: [
692+
{
693+
stacktrace: {
694+
frames: [{ filename: longFilename, function: 'test', lineno: 1, colno: 1 }],
695+
},
696+
},
697+
],
698+
},
699+
};
700+
701+
addMetadataToStackFrames(stackParser, event);
702+
703+
// The frame should have module_metadata attached from the parsed metadata stack
704+
const frame = event.exception!.values![0]!.stacktrace!.frames![0]!;
705+
expect(frame.module_metadata).toEqual({ '_sentryBundlerPluginAppKey:long-url-key': true });
706+
});
707+
});
675708
});

packages/core/test/lib/utils/stacktrace.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,72 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { nodeStackLineParser } from '../../../src/utils/node-stack-trace';
3-
import { stripSentryFramesAndReverse } from '../../../src/utils/stacktrace';
3+
import { createStackParser, stripSentryFramesAndReverse } from '../../../src/utils/stacktrace';
44

55
describe('Stacktrace', () => {
6+
describe('createStackParser()', () => {
7+
it('skips lines that contain "Error: " (e.g. "TypeError: foo")', () => {
8+
const mockParser = vi.fn().mockReturnValue({ filename: 'test.js', function: 'test', lineno: 1, colno: 1 });
9+
const parser = createStackParser([0, mockParser]);
10+
11+
const stack = ['TypeError: foo is not a function', ' at test (test.js:1:1)'].join('\n');
12+
13+
const frames = parser(stack);
14+
15+
// The parser should only be called for the frame line, not the Error line
16+
expect(mockParser).toHaveBeenCalledTimes(1);
17+
expect(frames).toHaveLength(1);
18+
});
19+
20+
it('skips various Error type lines', () => {
21+
const mockParser = vi.fn().mockReturnValue({ filename: 'test.js', function: 'test', lineno: 1, colno: 1 });
22+
const parser = createStackParser([0, mockParser]);
23+
24+
const stack = [
25+
'Error: something went wrong',
26+
'TypeError: foo is not a function',
27+
'RangeError: Maximum call stack size exceeded',
28+
'SomeCustomError: custom message',
29+
' at test (test.js:1:1)',
30+
].join('\n');
31+
32+
const frames = parser(stack);
33+
34+
// Only the frame line should be parsed, all Error lines should be skipped
35+
expect(mockParser).toHaveBeenCalledTimes(1);
36+
expect(frames).toHaveLength(1);
37+
});
38+
39+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/20052
40+
it('processes long non-whitespace lines without hanging', () => {
41+
const mockParser = vi.fn().mockReturnValue(undefined);
42+
const parser = createStackParser([0, mockParser]);
43+
44+
// Long non-whitespace lines (e.g. minified URLs) previously caused O(n²) backtracking
45+
const longLine = 'a'.repeat(2000);
46+
const stack = [longLine, ' at test (test.js:1:1)'].join('\n');
47+
48+
// Should complete without hanging (line gets truncated to 1024 chars internally)
49+
parser(stack);
50+
expect(mockParser).toHaveBeenCalledTimes(2);
51+
});
52+
53+
it('does not skip lines that do not contain "Error: "', () => {
54+
const mockParser = vi.fn().mockReturnValue({ filename: 'test.js', function: 'test', lineno: 1, colno: 1 });
55+
const parser = createStackParser([0, mockParser]);
56+
57+
const stack = [
58+
' at foo (test.js:1:1)',
59+
' at bar (test.js:2:1)',
60+
'ResizeObserver loop completed with undelivered notifications.',
61+
].join('\n');
62+
63+
parser(stack);
64+
65+
// All lines should be attempted by the parser (none contain "Error: ")
66+
expect(mockParser).toHaveBeenCalledTimes(3);
67+
});
68+
});
69+
670
describe('stripSentryFramesAndReverse()', () => {
771
describe('removed top frame if its internally reserved word (public API)', () => {
872
it('reserved captureException', () => {

0 commit comments

Comments
 (0)