Skip to content

Commit 5762512

Browse files
fix(debug): Symbolicate message and non-Error stacktraces locally (#3420)
1 parent 4367b7a commit 5762512

File tree

5 files changed

+457
-96
lines changed

5 files changed

+457
-96
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Encode envelopes using Base64, fix array length limit when transferring over Bridge. ([#2852](https://github.com/getsentry/sentry-react-native/pull/2852))
88
- This fix requires a rebuild of the native app
9+
- Symbolicate message and non-Error stacktraces locally in debug mode ([#3420](https://github.com/getsentry/sentry-react-native/pull/3420))
910

1011
## 5.14.1
1112

Lines changed: 132 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
1-
import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core';
2-
import type { Event, EventHint, Integration, StackFrame } from '@sentry/types';
1+
import type { Event, EventHint, EventProcessor, Hub, Integration, StackFrame as SentryStackFrame } from '@sentry/types';
32
import { addContextToFrame, logger } from '@sentry/utils';
43

5-
const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|'));
6-
7-
interface GetDevServer {
8-
(): { url: string };
9-
}
4+
import type * as ReactNative from '../vendor/react-native';
105

11-
/**
12-
* React Native Stack Frame
13-
*/
14-
interface ReactNativeFrame {
15-
// arguments: []
16-
column: number;
17-
file: string;
18-
lineNumber: number;
19-
methodName: string;
20-
}
6+
const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|'));
217

228
/**
239
* React Native Error
@@ -43,29 +29,42 @@ export class DebugSymbolicator implements Integration {
4329
/**
4430
* @inheritDoc
4531
*/
46-
public setupOnce(): void {
47-
addGlobalEventProcessor(async (event: Event, hint?: EventHint) => {
32+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
33+
addGlobalEventProcessor(async (event: Event, hint: EventHint) => {
4834
const self = getCurrentHub().getIntegration(DebugSymbolicator);
4935

50-
if (!self || hint === undefined || hint.originalException === undefined) {
36+
if (!self) {
5137
return event;
5238
}
5339

54-
const reactError = hint.originalException as ReactNativeError;
55-
56-
// eslint-disable-next-line @typescript-eslint/no-var-requires
57-
const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack');
58-
59-
let stack;
60-
try {
61-
stack = parseErrorStack(reactError);
62-
} catch (e) {
63-
// In RN 0.64 `parseErrorStack` now only takes a string
64-
stack = parseErrorStack(reactError.stack);
40+
if (
41+
event.exception &&
42+
hint.originalException &&
43+
typeof hint.originalException === 'object' &&
44+
'stack' in hint.originalException &&
45+
typeof hint.originalException.stack === 'string'
46+
) {
47+
// originalException is ErrorLike object
48+
const symbolicatedFrames = await this._symbolicate(hint.originalException.stack);
49+
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
50+
} else if (
51+
hint.syntheticException &&
52+
typeof hint.syntheticException === 'object' &&
53+
'stack' in hint.syntheticException &&
54+
typeof hint.syntheticException.stack === 'string'
55+
) {
56+
// syntheticException is Error object
57+
const symbolicatedFrames = await this._symbolicate(hint.syntheticException.stack);
58+
59+
if (event.exception) {
60+
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
61+
} else if (event.threads) {
62+
// RN JS doesn't have threads
63+
// syntheticException is used for Sentry.captureMessage() threads
64+
symbolicatedFrames && this._replaceThreadFramesInEvent(event, symbolicatedFrames);
65+
}
6566
}
6667

67-
await self._symbolicate(event, stack);
68-
6968
return event;
7069
});
7170
}
@@ -74,80 +73,56 @@ export class DebugSymbolicator implements Integration {
7473
* Symbolicates the stack on the device talking to local dev server.
7574
* Mutates the passed event.
7675
*/
77-
private async _symbolicate(event: Event, stack: string | undefined): Promise<void> {
76+
private async _symbolicate(rawStack: string): Promise<SentryStackFrame[] | null> {
77+
const parsedStack = this._parseErrorStack(rawStack);
78+
7879
try {
79-
// eslint-disable-next-line @typescript-eslint/no-var-requires
80-
const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace');
81-
const prettyStack = await symbolicateStackTrace(stack);
82-
83-
if (prettyStack) {
84-
let newStack = prettyStack;
85-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
86-
if (prettyStack.stack) {
87-
// This has been changed in an react-native version so stack is contained in here
88-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
89-
newStack = prettyStack.stack;
90-
}
91-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
92-
const stackWithoutInternalCallsites = newStack.filter(
93-
(frame: { file?: string }) =>
94-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
95-
frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
96-
);
97-
98-
const symbolicatedFrames = await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
99-
this._replaceFramesInEvent(event, symbolicatedFrames);
100-
} else {
101-
logger.error('The stack is null');
80+
const prettyStack = await this._symbolicateStackTrace(parsedStack);
81+
if (!prettyStack) {
82+
logger.error('React Native DevServer could not symbolicate the stack trace.');
83+
return null;
10284
}
85+
86+
// This has been changed in an react-native version so stack is contained in here
87+
const newStack = prettyStack.stack || prettyStack;
88+
89+
const stackWithoutInternalCallsites = newStack.filter(
90+
(frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
91+
);
92+
93+
return await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
10394
} catch (error) {
10495
if (error instanceof Error) {
10596
logger.warn(`Unable to symbolicate stack trace: ${error.message}`);
10697
}
98+
return null;
10799
}
108100
}
109101

110102
/**
111103
* Converts ReactNativeFrames to frames in the Sentry format
112104
* @param frames ReactNativeFrame[]
113105
*/
114-
private async _convertReactNativeFramesToSentryFrames(frames: ReactNativeFrame[]): Promise<StackFrame[]> {
115-
let getDevServer: GetDevServer;
116-
try {
117-
getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer');
118-
} catch (_oO) {
119-
// We can't load devserver URL
120-
}
106+
private async _convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise<SentryStackFrame[]> {
121107
return Promise.all(
122-
frames.map(async (frame: ReactNativeFrame): Promise<StackFrame> => {
108+
frames.map(async (frame: ReactNative.StackFrame): Promise<SentryStackFrame> => {
123109
let inApp = !!frame.column && !!frame.lineNumber;
124110
inApp =
125111
inApp &&
126112
frame.file !== undefined &&
127113
!frame.file.includes('node_modules') &&
128114
!frame.file.includes('native code');
129115

130-
const newFrame: StackFrame = {
116+
const newFrame: SentryStackFrame = {
131117
lineno: frame.lineNumber,
132118
colno: frame.column,
133119
filename: frame.file,
134120
function: frame.methodName,
135121
in_app: inApp,
136122
};
137123

138-
// The upstream `react-native@0.61` delegates parsing of stacks to `stacktrace-parser`, which is buggy and
139-
// leaves a trailing `(address at` in the function name.
140-
// `react-native@0.62` seems to have custom logic to parse hermes frames specially.
141-
// Anyway, all we do here is throw away the bogus suffix.
142-
if (newFrame.function) {
143-
const addressAtPos = newFrame.function.indexOf('(address at');
144-
if (addressAtPos >= 0) {
145-
newFrame.function = newFrame.function.substring(0, addressAtPos).trim();
146-
}
147-
}
148-
149124
if (inApp) {
150-
await this._addSourceContext(newFrame, getDevServer);
125+
await this._addSourceContext(newFrame);
151126
}
152127

153128
return newFrame;
@@ -160,7 +135,7 @@ export class DebugSymbolicator implements Integration {
160135
* @param event Event
161136
* @param frames StackFrame[]
162137
*/
163-
private _replaceFramesInEvent(event: Event, frames: StackFrame[]): void {
138+
private _replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
164139
if (
165140
event.exception &&
166141
event.exception.values &&
@@ -171,36 +146,98 @@ export class DebugSymbolicator implements Integration {
171146
}
172147
}
173148

149+
/**
150+
* Replaces the frames in the thread of a message.
151+
* @param event Event
152+
* @param frames StackFrame[]
153+
*/
154+
private _replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
155+
if (event.threads && event.threads.values && event.threads.values[0] && event.threads.values[0].stacktrace) {
156+
event.threads.values[0].stacktrace.frames = frames.reverse();
157+
}
158+
}
159+
174160
/**
175161
* This tries to add source context for in_app Frames
176162
*
177163
* @param frame StackFrame
178164
* @param getDevServer function from RN to get DevServer URL
179165
*/
180-
private async _addSourceContext(frame: StackFrame, getDevServer?: GetDevServer): Promise<void> {
181-
let response;
166+
private async _addSourceContext(frame: SentryStackFrame): Promise<void> {
167+
let sourceContext: string | null = null;
182168

183169
const segments = frame.filename?.split('/') ?? [];
184170

185-
if (getDevServer) {
186-
for (const idx in segments) {
187-
if (Object.prototype.hasOwnProperty.call(segments, idx)) {
188-
response = await fetch(`${getDevServer().url}${segments.slice(-idx).join('/')}`, {
189-
method: 'GET',
190-
});
171+
const serverUrl = this._getDevServer()?.url;
172+
if (!serverUrl) {
173+
return;
174+
}
191175

192-
if (response.ok) {
193-
break;
194-
}
195-
}
176+
for (const idx in segments) {
177+
if (!Object.prototype.hasOwnProperty.call(segments, idx)) {
178+
continue;
179+
}
180+
181+
sourceContext = await this._fetchSourceContext(serverUrl, segments, -idx);
182+
if (sourceContext) {
183+
break;
196184
}
197185
}
198186

199-
if (response && response.ok) {
200-
const content = await response.text();
201-
const lines = content.split('\n');
187+
if (!sourceContext) {
188+
return;
189+
}
190+
191+
const lines = sourceContext.split('\n');
192+
addContextToFrame(lines, frame);
193+
}
194+
195+
/**
196+
* Get source context for segment
197+
*/
198+
private async _fetchSourceContext(url: string, segments: Array<string>, start: number): Promise<string | null> {
199+
const response = await fetch(`${url}${segments.slice(start).join('/')}`, {
200+
method: 'GET',
201+
});
202+
203+
if (response.ok) {
204+
return response.text();
205+
}
206+
return null;
207+
}
202208

203-
addContextToFrame(lines, frame);
209+
/**
210+
* Loads and calls RN Core Devtools parseErrorStack function.
211+
*/
212+
private _parseErrorStack(errorStack: string): Array<ReactNative.StackFrame> {
213+
// eslint-disable-next-line @typescript-eslint/no-var-requires
214+
const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack');
215+
return parseErrorStack(errorStack);
216+
}
217+
218+
/**
219+
* Loads and calls RN Core Devtools symbolicateStackTrace function.
220+
*/
221+
private _symbolicateStackTrace(
222+
stack: Array<ReactNative.StackFrame>,
223+
extraData?: Record<string, unknown>,
224+
): Promise<ReactNative.SymbolicatedStackTrace> {
225+
// eslint-disable-next-line @typescript-eslint/no-var-requires
226+
const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace');
227+
return symbolicateStackTrace(stack, extraData);
228+
}
229+
230+
/**
231+
* Loads and returns the RN DevServer URL.
232+
*/
233+
private _getDevServer(): ReactNative.DevServerInfo | undefined {
234+
try {
235+
// eslint-disable-next-line @typescript-eslint/no-var-requires
236+
const getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer');
237+
return getDevServer();
238+
} catch (_oO) {
239+
// We can't load devserver URL
204240
}
241+
return undefined;
205242
}
206243
}

src/js/vendor/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export { utf8ToBytes } from './buffer';
2-
2+
export * from './react-native';
33
export { base64StringFromByteArray } from './base64-js';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// MIT License
2+
3+
// Copyright (c) Meta Platforms, Inc. and affiliates.
4+
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/NativeExceptionsManager.js#L17
24+
export type StackFrame = {
25+
column?: number;
26+
file?: string;
27+
lineNumber?: number;
28+
methodName: string;
29+
collapse?: boolean;
30+
};
31+
32+
// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/symbolicateStackTrace.js#L17
33+
export type CodeFrame = Readonly<{
34+
content: string;
35+
location?: {
36+
[key: string]: unknown;
37+
row: number;
38+
column: number;
39+
};
40+
fileName: string;
41+
}>;
42+
43+
// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/symbolicateStackTrace.js#L27
44+
export type SymbolicatedStackTrace = Readonly<{
45+
stack: Array<StackFrame>;
46+
codeFrame?: CodeFrame;
47+
}>;
48+
49+
// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/getDevServer.js#L17
50+
export type DevServerInfo = {
51+
[key: string]: unknown;
52+
url: string;
53+
fullBundleUrl?: string;
54+
bundleLoadedFromServer: boolean;
55+
};

0 commit comments

Comments
 (0)