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

feat(integrations): Update integration to new JS interface #3721

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
349 changes: 128 additions & 221 deletions src/js/integrations/debugsymbolicator.ts
@@ -1,10 +1,9 @@
import type { Event, EventHint, EventProcessor, Hub, Integration, StackFrame as SentryStackFrame } from '@sentry/types';
import type { Event, EventHint, IntegrationFn, StackFrame as SentryStackFrame } from '@sentry/types';
import { addContextToFrame, logger } from '@sentry/utils';

import { getFramesToPop, isErrorLike } from '../utils/error';
import { ReactNativeLibraries } from '../utils/rnlibraries';
import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr';
import type * as ReactNative from '../vendor/react-native';
import { fetchSourceContext, getDevServer, parseErrorStack, symbolicateStackTrace } from './debugsymbolicatorutils';

// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor
const INTERNAL_CALLSITES_REGEX = new RegExp(['ReactNativeRenderer-dev\\.js$', 'MessageQueue\\.js$'].join('|'));
Expand All @@ -20,255 +19,163 @@ export type ReactNativeError = Error & {
};

/** Tries to symbolicate the JS stack trace on the device. */
export class DebugSymbolicator implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'DebugSymbolicator';
/**
* @inheritDoc
*/
public name: string = DebugSymbolicator.id;

/**
* @inheritDoc
*/
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
addGlobalEventProcessor(async (event: Event, hint: EventHint) => {
const self = getCurrentHub().getIntegration(DebugSymbolicator);

if (!self) {
return event;
}

if (event.exception && isErrorLike(hint.originalException)) {
// originalException is ErrorLike object
const symbolicatedFrames = await this._symbolicate(
hint.originalException.stack,
getFramesToPop(hint.originalException as Error),
);
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (hint.syntheticException && isErrorLike(hint.syntheticException)) {
// syntheticException is Error object
const symbolicatedFrames = await this._symbolicate(
hint.syntheticException.stack,
getFramesToPop(hint.syntheticException),
);
export const debugSymbolicatorIntegration: IntegrationFn = () => {
return {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should backport the integrations to a v5 release so that users can migrate to the functional integrations more incrementally.

You can look at https://github.com/getsentry/sentry-javascript/blob/fdcd1adfe65becd9c605412cdd474eb676714bc4/packages/integrations/src/dedupe.ts#L9-L45 as an example of this in v7.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree, I'll prepare it.

name: 'DebugSymbolicator',
setupOnce: () => {
/* noop */
},
processEvent,
};
};

if (event.exception) {
symbolicatedFrames && this._replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (event.threads) {
// RN JS doesn't have threads
symbolicatedFrames && this._replaceThreadFramesInEvent(event, symbolicatedFrames);
}
}
async function processEvent(event: Event, hint: EventHint): Promise<Event> {
if (event.exception && isErrorLike(hint.originalException)) {
// originalException is ErrorLike object
const symbolicatedFrames = await symbolicate(
hint.originalException.stack,
getFramesToPop(hint.originalException as Error),
);
symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (hint.syntheticException && isErrorLike(hint.syntheticException)) {
// syntheticException is Error object
const symbolicatedFrames = await symbolicate(
hint.syntheticException.stack,
getFramesToPop(hint.syntheticException),
);

return event;
});
if (event.exception) {
symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames);
} else if (event.threads) {
// RN JS doesn't have threads
symbolicatedFrames && replaceThreadFramesInEvent(event, symbolicatedFrames);
}
}

/**
* Symbolicates the stack on the device talking to local dev server.
* Mutates the passed event.
*/
private async _symbolicate(rawStack: string, skipFirstFrames: number = 0): Promise<SentryStackFrame[] | null> {
try {
const parsedStack = this._parseErrorStack(rawStack);

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;

// https://github.com/getsentry/sentry-javascript/blob/739d904342aaf9327312f409952f14ceff4ae1ab/packages/utils/src/stacktrace.ts#L23
// Match SentryParser which counts lines of stack (-1 for first line with the Error message)
const skipFirstAdjustedToSentryStackParser = Math.max(skipFirstFrames - 1, 0);
const stackWithoutPoppedFrames = skipFirstAdjustedToSentryStackParser
? newStack.slice(skipFirstAdjustedToSentryStackParser)
: newStack;
return event;
}

const stackWithoutInternalCallsites = stackWithoutPoppedFrames.filter(
(frame: { file?: string }) => frame.file && frame.file.match(INTERNAL_CALLSITES_REGEX) === null,
);
/**
* Symbolicates the stack on the device talking to local dev server.
* Mutates the passed event.
*/
async function symbolicate(rawStack: string, skipFirstFrames: number = 0): Promise<SentryStackFrame[] | null> {
try {
const parsedStack = parseErrorStack(rawStack);

return await this._convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
} catch (error) {
if (error instanceof Error) {
logger.warn(`Unable to symbolicate stack trace: ${error.message}`);
}
const prettyStack = await symbolicateStackTrace(parsedStack);
if (!prettyStack) {
logger.error('React Native DevServer could not symbolicate the stack trace.');
return null;
}
}

/**
* Converts ReactNativeFrames to frames in the Sentry format
* @param frames ReactNativeFrame[]
*/
private async _convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise<SentryStackFrame[]> {
return Promise.all(
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: SentryStackFrame = {
lineno: frame.lineNumber,
colno: frame.column,
filename: frame.file,
function: frame.methodName,
in_app: inApp,
};
// This has been changed in an react-native version so stack is contained in here
const newStack = prettyStack.stack || prettyStack;

if (inApp) {
await this._addSourceContext(newFrame);
}
// https://github.com/getsentry/sentry-javascript/blob/739d904342aaf9327312f409952f14ceff4ae1ab/packages/utils/src/stacktrace.ts#L23
// Match SentryParser which counts lines of stack (-1 for first line with the Error message)
const skipFirstAdjustedToSentryStackParser = Math.max(skipFirstFrames - 1, 0);
const stackWithoutPoppedFrames = skipFirstAdjustedToSentryStackParser
? newStack.slice(skipFirstAdjustedToSentryStackParser)
: newStack;

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

/**
* Replaces the frames in the exception of a error.
* @param event Event
* @param frames StackFrame[]
*/
private _replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
if (
event.exception &&
event.exception.values &&
event.exception.values[0] &&
event.exception.values[0].stacktrace
) {
event.exception.values[0].stacktrace.frames = frames.reverse();
return await convertReactNativeFramesToSentryFrames(stackWithoutInternalCallsites);
} catch (error) {
if (error instanceof Error) {
logger.warn(`Unable to symbolicate stack trace: ${error.message}`);
}
return null;
}
}

/**
* 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: SentryStackFrame): Promise<void> {
let sourceContext: string | null = null;

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

const serverUrl = this._getDevServer()?.url;
if (!serverUrl) {
return;
}

for (const idx in segments) {
if (!Object.prototype.hasOwnProperty.call(segments, idx)) {
continue;
}
/**
* Converts ReactNativeFrames to frames in the Sentry format
* @param frames ReactNativeFrame[]
*/
async function convertReactNativeFramesToSentryFrames(frames: ReactNative.StackFrame[]): Promise<SentryStackFrame[]> {
return Promise.all(
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: SentryStackFrame = {
lineno: frame.lineNumber,
colno: frame.column,
filename: frame.file,
function: frame.methodName,
in_app: inApp,
};

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

if (!sourceContext) {
return;
}
return newFrame;
}),
);
}

const lines = sourceContext.split('\n');
addContextToFrame(lines, frame);
/**
* Replaces the frames in the exception of a error.
* @param event Event
* @param frames StackFrame[]
*/
function replaceExceptionFramesInEvent(event: Event, frames: SentryStackFrame[]): void {
if (event.exception && event.exception.values && event.exception.values[0] && event.exception.values[0].stacktrace) {
event.exception.values[0].stacktrace.frames = frames.reverse();
}
}

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

const xhr = createStealthXhr();
if (!xhr) {
resolve(null);
return;
}
/**
* Replaces the frames in the thread of a message.
* @param event Event
* @param frames StackFrame[]
*/
function 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();
}
}

xhr.open('GET', fullUrl, true);
xhr.send();
/**
* This tries to add source context for in_app Frames
*
* @param frame StackFrame
* @param getDevServer function from RN to get DevServer URL
*/
async function addSourceContext(frame: SentryStackFrame): Promise<void> {
let sourceContext: string | null = null;

xhr.onreadystatechange = (): void => {
if (xhr.readyState === XHR_READYSTATE_DONE) {
if (xhr.status !== 200) {
resolve(null);
}
const response = xhr.responseText;
if (
typeof response !== 'string' ||
// Expo Dev Server responses with status 200 and config JSON
// when web support not enabled and requested file not found
response.startsWith('{')
) {
resolve(null);
}
const segments = frame.filename?.split('/') ?? [];

resolve(response);
}
};
xhr.onerror = (): void => {
resolve(null);
};
});
const serverUrl = getDevServer()?.url;
if (!serverUrl) {
return;
}

/**
* Loads and calls RN Core Devtools parseErrorStack function.
*/
private _parseErrorStack(errorStack: string): Array<ReactNative.StackFrame> {
if (!ReactNativeLibraries.Devtools) {
throw new Error('React Native Devtools not available.');
for (const idx in segments) {
if (!Object.prototype.hasOwnProperty.call(segments, idx)) {
continue;
}
return ReactNativeLibraries.Devtools.parseErrorStack(errorStack);
}

/**
* Loads and calls RN Core Devtools symbolicateStackTrace function.
*/
private _symbolicateStackTrace(
stack: Array<ReactNative.StackFrame>,
extraData?: Record<string, unknown>,
): Promise<ReactNative.SymbolicatedStackTrace> {
if (!ReactNativeLibraries.Devtools) {
throw new Error('React Native Devtools not available.');
sourceContext = await fetchSourceContext(serverUrl, segments, -idx);
if (sourceContext) {
break;
}
return ReactNativeLibraries.Devtools.symbolicateStackTrace(stack, extraData);
}

/**
* Loads and returns the RN DevServer URL.
*/
private _getDevServer(): ReactNative.DevServerInfo | undefined {
try {
return ReactNativeLibraries.Devtools?.getDevServer();
} catch (_oO) {
// We can't load devserver URL
}
return undefined;
if (!sourceContext) {
return;
}

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