Skip to content

Commit 3817909

Browse files
Feat: Fallback to stacktrace parsing (#5946)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee2fefe commit 3817909

File tree

5 files changed

+186
-6
lines changed

5 files changed

+186
-6
lines changed

packages/core/src/js/integrations/debugsymbolicator.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ async function processEvent(event: Event, hint: EventHint): Promise<Event> {
6969
async function symbolicate(rawStack: string, skipFirstFrames: number = 0): Promise<SentryStackFrame[] | null> {
7070
try {
7171
const parsedStack = parseErrorStack(rawStack);
72+
if (parsedStack.length === 0) {
73+
debug.warn('parseErrorStack returned empty array, skipping symbolication');
74+
return null;
75+
}
7276

7377
const prettyStack = await symbolicateStackTrace(parsedStack);
7478
if (!prettyStack) {

packages/core/src/js/integrations/debugsymbolicatorutils.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { StackFrame as SentryStackFrame } from '@sentry/core';
22

3-
import { debug } from '@sentry/core';
3+
import { debug, parseStackFrames } from '@sentry/core';
4+
import { defaultStackParser } from '@sentry/react';
45

56
import type * as ReactNative from '../vendor/react-native';
67

@@ -72,13 +73,65 @@ function getSentryMetroSourceContextUrl(): string | undefined {
7273
}
7374

7475
/**
75-
* Loads and calls RN Core Devtools parseErrorStack function.
76+
* Converts Sentry StackFrames to React Native StackFrames.
77+
* This is the reverse of convertReactNativeFramesToSentryFrames in debugsymbolicator.ts
78+
*/
79+
function convertSentryFramesToReactNativeFrames(frames: SentryStackFrame[]): Array<ReactNative.StackFrame> {
80+
// Reverse the frames because Sentry parser returns them in reverse order compared to RN
81+
return frames.reverse().map((frame): ReactNative.StackFrame => {
82+
const rnFrame: ReactNative.StackFrame = {
83+
methodName: frame.function || '?',
84+
};
85+
86+
if (frame.filename !== undefined) {
87+
rnFrame.file = frame.filename;
88+
}
89+
90+
if (frame.lineno !== undefined) {
91+
rnFrame.lineNumber = frame.lineno;
92+
}
93+
94+
if (frame.colno !== undefined) {
95+
rnFrame.column = frame.colno;
96+
}
97+
98+
return rnFrame;
99+
});
100+
}
101+
102+
/**
103+
* Parses an error stack string into React Native StackFrames.
104+
* Uses RN Devtools parseErrorStack by default for compatibility.
105+
* Falls back to Sentry's built-in stack parser if Devtools is not available.
106+
*
107+
* @param errorStack - Raw stack trace string from Error.stack
108+
* @returns Array of React Native StackFrame objects
76109
*/
77110
export function parseErrorStack(errorStack: string): Array<ReactNative.StackFrame> {
78-
if (!ReactNativeLibraries.Devtools) {
79-
throw new Error('React Native Devtools not available.');
111+
// Try using RN Devtools first for maximum compatibility with existing tooling
112+
if (ReactNativeLibraries.Devtools?.parseErrorStack) {
113+
try {
114+
return ReactNativeLibraries.Devtools.parseErrorStack(errorStack);
115+
} catch (error) {
116+
debug.warn('RN Devtools parseErrorStack failed, falling back to Sentry stack parser');
117+
}
118+
}
119+
120+
// Fallback: Use Sentry's stack parser (works without RN Devtools dependency)
121+
try {
122+
// Create a temporary Error object with the stack
123+
const error = new Error();
124+
error.stack = errorStack;
125+
126+
// Use Sentry's parser to parse the stack
127+
const sentryFrames = parseStackFrames(defaultStackParser, error);
128+
129+
// Convert Sentry frames back to RN format
130+
return convertSentryFramesToReactNativeFrames(sentryFrames);
131+
} catch (error) {
132+
debug.error('Failed to parse error stack:', error);
133+
return [];
80134
}
81-
return ReactNativeLibraries.Devtools.parseErrorStack(errorStack);
82135
}
83136

84137
/**

packages/core/src/js/utils/rnlibrariesinterface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type { EmitterSubscription } from 'react-native/Libraries/vendor/emitter/
1212

1313
export interface ReactNativeLibrariesInterface {
1414
Devtools?: {
15-
parseErrorStack: (errorStack: string) => Array<ReactNative.StackFrame>;
15+
parseErrorStack?: (errorStack: string) => Array<ReactNative.StackFrame>;
1616
symbolicateStackTrace: (
1717
stack: Array<ReactNative.StackFrame>,
1818
extraData?: Record<string, unknown>,

packages/core/test/integrations/debugsymbolicator.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,61 @@ describe('Debug Symbolicator Integration', () => {
708708
});
709709
});
710710

711+
it('should not wipe original frames when parseErrorStack returns empty array', async () => {
712+
(parseErrorStack as jest.Mock).mockReturnValue([]);
713+
714+
const originalFrames = [
715+
{
716+
function: 'originalFoo',
717+
filename: '/original/path/foo.js',
718+
lineno: 10,
719+
colno: 5,
720+
},
721+
{
722+
function: 'originalBar',
723+
filename: '/original/path/bar.js',
724+
lineno: 20,
725+
colno: 15,
726+
},
727+
];
728+
729+
const symbolicatedEvent = await processEvent(
730+
{
731+
exception: {
732+
values: [
733+
{
734+
type: 'Error',
735+
value: 'Error: test',
736+
stacktrace: {
737+
frames: originalFrames,
738+
},
739+
},
740+
],
741+
},
742+
},
743+
{
744+
originalException: {
745+
stack: mockRawStack,
746+
},
747+
},
748+
);
749+
750+
// Original frames should be preserved when parseErrorStack returns empty array
751+
expect(symbolicatedEvent).toStrictEqual(<Event>{
752+
exception: {
753+
values: [
754+
{
755+
type: 'Error',
756+
value: 'Error: test',
757+
stacktrace: {
758+
frames: originalFrames,
759+
},
760+
},
761+
],
762+
},
763+
});
764+
});
765+
711766
it('should symbolicate error with different amount of exception hints ', async () => {
712767
// Example: Sentry captures an Error with 20 Causes, but limits the captured exceptions to
713768
// 5 in event.exception. Meanwhile, hint.originalException contains all 20 items.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { parseErrorStack } from '../../src/js/integrations/debugsymbolicatorutils';
2+
3+
describe('parseErrorStack', () => {
4+
it('should parse Chrome-style stack trace', () => {
5+
const stack = `Error: Test error
6+
at foo (http://localhost:8081/index.bundle:10:15)
7+
at bar (http://localhost:8081/index.bundle:20:25)`;
8+
9+
const frames = parseErrorStack(stack);
10+
11+
expect(frames).toHaveLength(2);
12+
expect(frames[0]).toMatchObject({
13+
methodName: 'foo',
14+
file: 'http://localhost:8081/index.bundle',
15+
lineNumber: 10,
16+
});
17+
expect(frames[0].column).toBeDefined();
18+
expect(frames[1]).toMatchObject({
19+
methodName: 'bar',
20+
file: 'http://localhost:8081/index.bundle',
21+
lineNumber: 20,
22+
});
23+
expect(frames[1].column).toBeDefined();
24+
});
25+
26+
it('should handle anonymous functions', () => {
27+
const stack = `Error: Test error
28+
at <anonymous> (http://localhost:8081/index.bundle:10:15)`;
29+
30+
const frames = parseErrorStack(stack);
31+
32+
expect(frames.length).toBeGreaterThanOrEqual(1);
33+
expect(frames[0].methodName).toBeDefined();
34+
expect(frames[0].lineNumber).toBe(10);
35+
});
36+
37+
it('should handle empty stack', () => {
38+
const frames = parseErrorStack('');
39+
40+
expect(frames).toEqual([]);
41+
});
42+
43+
it('should handle malformed stack gracefully', () => {
44+
const frames = parseErrorStack('Not a valid stack trace');
45+
46+
expect(Array.isArray(frames)).toBe(true);
47+
});
48+
49+
it('should preserve Metro bundle URLs with query params', () => {
50+
const stack = `Error: Test error
51+
at App (http://localhost:8081/index.bundle?platform=ios&dev=true:1:1)`;
52+
53+
const frames = parseErrorStack(stack);
54+
55+
expect(frames[0].file).toContain('platform=ios');
56+
expect(frames[0].methodName).toBe('App');
57+
});
58+
59+
it('should handle frames without line/column info', () => {
60+
const stack = `Error: Test error
61+
at native`;
62+
63+
const frames = parseErrorStack(stack);
64+
65+
// Should not crash, may return empty or partial frames
66+
expect(Array.isArray(frames)).toBe(true);
67+
});
68+
});

0 commit comments

Comments
 (0)