Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(debug): Symbolicate message and non-Error stacktraces locally #3420

Merged
merged 4 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

## 5.14.1

Expand Down
227 changes: 132 additions & 95 deletions src/js/integrations/debugsymbolicator.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core';
import type { Event, EventHint, Integration, StackFrame } from '@sentry/types';
import type { Event, EventHint, EventProcessor, Hub, Integration, StackFrame as SentryStackFrame } from '@sentry/types';
import { addContextToFrame, logger } from '@sentry/utils';

const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|'));

interface GetDevServer {
(): { url: string };
}
import type * as ReactNative from '../vendor/react-native';

/**
* React Native Stack Frame
*/
interface ReactNativeFrame {
// arguments: []
column: number;
file: string;
lineNumber: number;
methodName: string;
}
const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|'));

/**
* React Native Error
Expand All @@ -43,29 +29,42 @@ export class DebugSymbolicator implements Integration {
/**
* @inheritDoc
*/
public setupOnce(): void {
addGlobalEventProcessor(async (event: Event, hint?: EventHint) => {
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
addGlobalEventProcessor(async (event: Event, hint: EventHint) => {
const self = getCurrentHub().getIntegration(DebugSymbolicator);

if (!self || hint === undefined || hint.originalException === undefined) {
if (!self) {
return event;
}

const reactError = hint.originalException as ReactNativeError;

// eslint-disable-next-line @typescript-eslint/no-var-requires
const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack');

let stack;
try {
stack = parseErrorStack(reactError);
} catch (e) {
// In RN 0.64 `parseErrorStack` now only takes a string
stack = parseErrorStack(reactError.stack);
if (
event.exception &&
hint.originalException &&
typeof hint.originalException === 'object' &&
'stack' in hint.originalException &&
typeof hint.originalException.stack === 'string'
) {
// originalException is ErrorLike object
const symbolicatedFrames = await this._symbolicate(hint.originalException.stack);
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (
hint.syntheticException &&
typeof hint.syntheticException === 'object' &&
'stack' in hint.syntheticException &&
typeof hint.syntheticException.stack === 'string'
) {
// syntheticException is Error object
const symbolicatedFrames = await this._symbolicate(hint.syntheticException.stack);

if (event.exception) {
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (event.threads) {
// RN JS doesn't have threads
// syntheticException is used for Sentry.captureMessage() threads
symbolicatedFrames && this._replaceThreadFramesInEvent(event, symbolicatedFrames);
}
}

await self._symbolicate(event, stack);

return event;
});
}
Expand All @@ -74,80 +73,56 @@ export class DebugSymbolicator implements Integration {
* Symbolicates the stack on the device talking to local dev server.
* Mutates the passed event.
*/
private async _symbolicate(event: Event, stack: string | undefined): Promise<void> {
private async _symbolicate(rawStack: string): Promise<SentryStackFrame[] | null> {
const parsedStack = this._parseErrorStack(rawStack);

try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace');
const prettyStack = await symbolicateStackTrace(stack);

if (prettyStack) {
let newStack = prettyStack;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (prettyStack.stack) {
// This has been changed in an react-native version so stack is contained in here
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
newStack = prettyStack.stack;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const stackWithoutInternalCallsites = newStack.filter(
(frame: { file?: string }) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
);

const symbolicatedFrames = await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
this._replaceFramesInEvent(event, symbolicatedFrames);
} else {
logger.error('The stack is null');
const prettyStack = await this._symbolicateStackTrace(parsedStack);
if (!prettyStack) {
logger.error('React Native DevServer could not symbolicate the stack trace.');
return null;
}

// This has been changed in an react-native version so stack is contained in here
const newStack = prettyStack.stack || prettyStack;

const stackWithoutInternalCallsites = newStack.filter(
(frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
);

return await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
} catch (error) {
if (error instanceof Error) {
logger.warn(`Unable to symbolicate stack trace: ${error.message}`);
}
return null;
}
}

/**
* Converts ReactNativeFrames to frames in the Sentry format
* @param frames ReactNativeFrame[]
*/
private async _convertReactNativeFramesToSentryFrames(frames: ReactNativeFrame[]): Promise<StackFrame[]> {
let getDevServer: GetDevServer;
try {
getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer');
} catch (_oO) {
// We can't load devserver URL
}
private async _convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise<SentryStackFrame[]> {
return Promise.all(
frames.map(async (frame: ReactNativeFrame): Promise<StackFrame> => {
frames.map(async (frame: ReactNative.StackFrame): Promise<SentryStackFrame> => {
let inApp = !!frame.column && !!frame.lineNumber;
inApp =
inApp &&
frame.file !== undefined &&
!frame.file.includes('node_modules') &&
!frame.file.includes('native code');

const newFrame: StackFrame = {
const newFrame: SentryStackFrame = {
lineno: frame.lineNumber,
colno: frame.column,
filename: frame.file,
function: frame.methodName,
in_app: inApp,
};

// The upstream `[email protected]` delegates parsing of stacks to `stacktrace-parser`, which is buggy and
// leaves a trailing `(address at` in the function name.
// `[email protected]` seems to have custom logic to parse hermes frames specially.
// Anyway, all we do here is throw away the bogus suffix.
if (newFrame.function) {
const addressAtPos = newFrame.function.indexOf('(address at');
if (addressAtPos >= 0) {
newFrame.function = newFrame.function.substring(0, addressAtPos).trim();
}
}

if (inApp) {
await this._addSourceContext(newFrame, getDevServer);
await this._addSourceContext(newFrame);
}

return newFrame;
Expand All @@ -160,7 +135,7 @@ export class DebugSymbolicator implements Integration {
* @param event Event
* @param frames StackFrame[]
*/
private _replaceFramesInEvent(event: Event, frames: StackFrame[]): void {
private _replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
if (
event.exception &&
event.exception.values &&
Expand All @@ -171,36 +146,98 @@ export class DebugSymbolicator implements Integration {
}
}

/**
* Replaces the frames in the thread of a message.
* @param event Event
* @param frames StackFrame[]
*/
private _replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
if (event.threads && event.threads.values && event.threads.values[0] && event.threads.values[0].stacktrace) {
event.threads.values[0].stacktrace.frames = frames.reverse();
}
}

/**
* This tries to add source context for in_app Frames
*
* @param frame StackFrame
* @param getDevServer function from RN to get DevServer URL
*/
private async _addSourceContext(frame: StackFrame, getDevServer?: GetDevServer): Promise<void> {
let response;
private async _addSourceContext(frame: SentryStackFrame): Promise<void> {
let sourceContext: string | null = null;

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

if (getDevServer) {
for (const idx in segments) {
if (Object.prototype.hasOwnProperty.call(segments, idx)) {
response = await fetch(`${getDevServer().url}${segments.slice(-idx).join('/')}`, {
method: 'GET',
});
const serverUrl = this._getDevServer()?.url;
if (!serverUrl) {
return;
}

if (response.ok) {
break;
}
}
for (const idx in segments) {
if (!Object.prototype.hasOwnProperty.call(segments, idx)) {
continue;
}

sourceContext = await this._fetchSourceContext(serverUrl, segments, -idx);
if (sourceContext) {
break;
}
}

if (response && response.ok) {
const content = await response.text();
const lines = content.split('\n');
if (!sourceContext) {
return;
}

const lines = sourceContext.split('\n');
addContextToFrame(lines, frame);
}

/**
* Get source context for segment
*/
private async _fetchSourceContext(url: string, segments: Array<string>, start: number): Promise<string | null> {
const response = await fetch(`${url}${segments.slice(start).join('/')}`, {
method: 'GET',
});

if (response.ok) {
return response.text();
}
return null;
}

addContextToFrame(lines, frame);
/**
* Loads and calls RN Core Devtools parseErrorStack function.
*/
private _parseErrorStack(errorStack: string): Array<ReactNative.StackFrame> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack');
return parseErrorStack(errorStack);
}

/**
* Loads and calls RN Core Devtools symbolicateStackTrace function.
*/
private _symbolicateStackTrace(
stack: Array<ReactNative.StackFrame>,
extraData?: Record<string, unknown>,
): Promise<ReactNative.SymbolicatedStackTrace> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace');
return symbolicateStackTrace(stack, extraData);
}

/**
* Loads and returns the RN DevServer URL.
*/
private _getDevServer(): ReactNative.DevServerInfo | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer');
return getDevServer();
} catch (_oO) {
// We can't load devserver URL
}
return undefined;
}
}
2 changes: 1 addition & 1 deletion src/js/vendor/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { utf8ToBytes } from './buffer';

export * from './react-native';
export { base64StringFromByteArray } from './base64-js';
55 changes: 55 additions & 0 deletions src/js/vendor/react-native/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// MIT License

// Copyright (c) Meta Platforms, Inc. and affiliates.

// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/NativeExceptionsManager.js#L17
export type StackFrame = {
column?: number;
file?: string;
lineNumber?: number;
methodName: string;
collapse?: boolean;
};

// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/symbolicateStackTrace.js#L17
export type CodeFrame = Readonly<{
content: string;
location?: {
[key: string]: unknown;
row: number;
column: number;
};
fileName: string;
}>;

// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/symbolicateStackTrace.js#L27
export type SymbolicatedStackTrace = Readonly<{
stack: Array<StackFrame>;
codeFrame?: CodeFrame;
}>;

// Adapted from https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/Libraries/Core/Devtools/getDevServer.js#L17
export type DevServerInfo = {
[key: string]: unknown;
url: string;
fullBundleUrl?: string;
bundleLoadedFromServer: boolean;
};
Loading
Loading