diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0fbbd62a..baab52bb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes +- errors with cause are now correctly serialized on debug build ([#3920](https://github.com/getsentry/sentry-react-native/pull/3920)) - `sentry-expo-upload-sourcemaps` no longer requires Sentry url when uploading sourcemaps to `sentry.io` ([#3915](https://github.com/getsentry/sentry-react-native/pull/3915)) - Flavor aware Android builds use `SENTRY_AUTH_TOKEN` env as fallback when token not found in `sentry-flavor-type.properties`. ([#3917](https://github.com/getsentry/sentry-react-native/pull/3917)) - `mechanism.handled:false` should crash current session ([#3900](https://github.com/getsentry/sentry-react-native/pull/3900)) diff --git a/src/js/integrations/debugsymbolicator.ts b/src/js/integrations/debugsymbolicator.ts index de7a18c294..5375723f0b 100644 --- a/src/js/integrations/debugsymbolicator.ts +++ b/src/js/integrations/debugsymbolicator.ts @@ -2,6 +2,7 @@ import { convertIntegrationFnToClass } from '@sentry/core'; import type { Event, EventHint, + Exception, Integration, IntegrationClass, IntegrationFnResult, @@ -9,6 +10,7 @@ import type { } from '@sentry/types'; import { addContextToFrame, logger } from '@sentry/utils'; +import type { ExtendedError } from '../utils/error'; import { getFramesToPop, isErrorLike } from '../utils/error'; import type * as ReactNative from '../vendor/react-native'; import { fetchSourceContext, getDevServer, parseErrorStack, symbolicateStackTrace } from './debugsymbolicatorutils'; @@ -51,13 +53,14 @@ export const DebugSymbolicator = convertIntegrationFnToClass( ) as IntegrationClass; async function processEvent(event: Event, hint: EventHint): Promise { - if (event.exception && isErrorLike(hint.originalException)) { + if (event.exception?.values && isErrorLike(hint.originalException)) { // originalException is ErrorLike object - const symbolicatedFrames = await symbolicate( - hint.originalException.stack, - getFramesToPop(hint.originalException as Error), - ); - symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames); + const errorGroup = getExceptionGroup(hint.originalException); + for (const [index, error] of errorGroup.entries()) { + const symbolicatedFrames = await symbolicate(error.stack, getFramesToPop(error)); + + symbolicatedFrames && replaceExceptionFramesInException(event.exception.values[index], symbolicatedFrames); + } } else if (hint.syntheticException && isErrorLike(hint.syntheticException)) { // syntheticException is Error object const symbolicatedFrames = await symbolicate( @@ -66,7 +69,9 @@ async function processEvent(event: Event, hint: EventHint): Promise { ); if (event.exception) { - symbolicatedFrames && replaceExceptionFramesInEvent(event, symbolicatedFrames); + symbolicatedFrames && + event.exception.values && + replaceExceptionFramesInException(event.exception.values[0], symbolicatedFrames); } else if (event.threads) { // RN JS doesn't have threads symbolicatedFrames && replaceThreadFramesInEvent(event, symbolicatedFrames); @@ -149,9 +154,9 @@ async function convertReactNativeFramesToSentryFrames(frames: ReactNative.StackF * @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(); +function replaceExceptionFramesInException(exception: Exception, frames: SentryStackFrame[]): void { + if (exception?.stacktrace) { + exception.stacktrace.frames = frames.reverse(); } } @@ -200,3 +205,17 @@ async function addSourceContext(frame: SentryStackFrame): Promise { const lines = sourceContext.split('\n'); addContextToFrame(lines, frame); } + +/** + * Return a list containing the original exception and also the cause if found. + * + * @param originalException The original exception. + */ +function getExceptionGroup(originalException: unknown): (Error & { stack: string })[] { + const err = originalException as ExtendedError; + const errorGroup: (Error & { stack: string })[] = []; + for (let cause: ExtendedError | undefined = err; isErrorLike(cause); cause = cause.cause) { + errorGroup.push(cause); + } + return errorGroup; +} diff --git a/src/js/utils/error.ts b/src/js/utils/error.ts index 044e3a8516..6c79160739 100644 --- a/src/js/utils/error.ts +++ b/src/js/utils/error.ts @@ -1,5 +1,6 @@ export interface ExtendedError extends Error { framesToPop?: number | undefined; + cause?: Error | undefined; } // Sentry Stack Parser is skipping lines not frames diff --git a/test/integrations/debugsymbolicator.test.ts b/test/integrations/debugsymbolicator.test.ts index 035fa63fe2..67718b8d93 100644 --- a/test/integrations/debugsymbolicator.test.ts +++ b/test/integrations/debugsymbolicator.test.ts @@ -54,6 +54,36 @@ describe('Debug Symbolicator Integration', () => { }, ]; + const customMockRawStack = (errorNumber: number, value: number) => + `Error${errorNumber}: This is mocked error stack trace + at foo${errorNumber} (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:${value}:${value}) + at bar${errorNumber} (http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:${value + 1}:${ + value + 1 + }) + at baz${errorNumber} (native) + `; + + const customMockSentryParsedFrames = (errorNumber: number, value: number): Array => { + const value2 = value + 1; + return [ + { + function: `[native] baz${errorNumber}`, + }, + { + function: `bar${errorNumber}`, + filename: `http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:${value}:${value}`, + lineno: value, + colno: value, + }, + { + function: `foo${errorNumber}`, + filename: `http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false:${value2}:${value2}`, + lineno: value2, + colno: value2, + }, + ]; + }; + beforeEach(() => { (parseErrorStack as jest.Mock).mockReturnValue(>[ { @@ -334,5 +364,485 @@ describe('Debug Symbolicator Integration', () => { }, }); }); + + it('should symbolicate error with cause ', async () => { + (parseErrorStack as jest.Mock) + .mockReturnValueOnce(>[ + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: 1, + column: 1, + methodName: 'foo', + }, + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: 2, + column: 2, + methodName: 'bar', + }, + ]) + .mockReturnValueOnce(>[ + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: 3, + column: 3, + methodName: 'foo2', + }, + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: 4, + column: 4, + methodName: 'bar2', + }, + ]); + (symbolicateStackTrace as jest.Mock) + .mockReturnValueOnce( + Promise.resolve({ + stack: [ + { + file: '/User/project/foo.js', + lineNumber: 1, + column: 1, + methodName: 'foo', + }, + { + file: '/User/project/node_modules/bar/bar.js', + lineNumber: 2, + column: 2, + methodName: 'bar', + }, + ], + }), + ) + .mockReturnValueOnce( + Promise.resolve({ + stack: [ + { + file: '/User/project/foo2.js', + lineNumber: 3, + column: 3, + methodName: 'foo2', + }, + { + file: '/User/project/node_modules/bar/bar2.js', + lineNumber: 4, + column: 4, + methodName: 'bar2', + }, + ], + }), + ); + + const symbolicatedEvent = await processEvent( + { + exception: { + values: [ + { + type: 'Error', + value: 'Error: test', + stacktrace: { + frames: mockSentryParsedFrames, + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: customMockSentryParsedFrames(2, 2), + }, + }, + ], + }, + }, + { + originalException: { + stack: mockRawStack, + cause: { + stack: customMockRawStack(1, 1), + }, + }, + }, + ); + + expect(symbolicatedEvent).toStrictEqual({ + exception: { + values: [ + { + type: 'Error', + value: 'Error: test', + stacktrace: { + frames: [ + { + function: 'bar', + filename: '/User/project/node_modules/bar/bar.js', + lineno: 2, + colno: 2, + in_app: false, + }, + { + function: 'foo', + filename: '/User/project/foo.js', + lineno: 1, + colno: 1, + in_app: true, + }, + ], + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: [ + { + function: 'bar2', + filename: '/User/project/node_modules/bar/bar2.js', + lineno: 4, + colno: 4, + in_app: false, + }, + { + function: 'foo2', + filename: '/User/project/foo2.js', + lineno: 3, + colno: 3, + in_app: true, + }, + ], + }, + }, + ], + }, + }); + }); + + it('should symbolicate error with multiple causes ', async () => { + const setupStackFrame = (value: number): Array => [ + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: value, + column: value, + methodName: `foo${value}`, + }, + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: value + 1, + column: value + 1, + methodName: `bar${value}`, + }, + ]; + + (parseErrorStack as jest.Mock) + .mockReturnValueOnce(setupStackFrame(1)) + .mockReturnValueOnce(setupStackFrame(3)) + .mockReturnValueOnce(setupStackFrame(5)) + .mockReturnValueOnce(setupStackFrame(7)); + + const mocksymbolicateStackTrace = (value: number): ReactNative.SymbolicatedStackTrace => ({ + stack: [ + { + file: `/User/project/foo${value}.js`, + lineNumber: value, + column: value, + methodName: `foo${value}`, + }, + { + file: `/User/project/node_modules/bar/bar${value + 1}.js`, + lineNumber: value + 1, + column: value + 1, + methodName: `bar${value + 1}`, + }, + ], + }); + (symbolicateStackTrace as jest.Mock) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(1))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(3))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(5))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(7))); + + const symbolicatedEvent = await processEvent( + { + exception: { + values: [ + { + type: 'Error1', + value: 'Error1: test', + stacktrace: { + frames: customMockSentryParsedFrames(1, 1), + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: customMockSentryParsedFrames(2, 2), + }, + }, + { + type: 'Error3', + value: 'Error3: test', + stacktrace: { + frames: customMockSentryParsedFrames(3, 3), + }, + }, + { + type: 'Error4', + value: 'Error4: test', + stacktrace: { + frames: customMockSentryParsedFrames(4, 4), + }, + }, + ], + }, + }, + { + originalException: { + stack: customMockRawStack(1, 1), + cause: { + stack: customMockRawStack(3, 3), + cause: { + stack: customMockRawStack(5, 5), + cause: { + stack: customMockRawStack(7, 7), + }, + }, + }, + }, + }, + ); + + expect(symbolicatedEvent).toStrictEqual({ + exception: { + values: [ + { + type: 'Error1', + value: 'Error1: test', + stacktrace: { + frames: [ + { + function: 'bar2', + filename: '/User/project/node_modules/bar/bar2.js', + lineno: 2, + colno: 2, + in_app: false, + }, + { + function: 'foo1', + filename: '/User/project/foo1.js', + lineno: 1, + colno: 1, + in_app: true, + }, + ], + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: [ + { + function: 'bar4', + filename: '/User/project/node_modules/bar/bar4.js', + lineno: 4, + colno: 4, + in_app: false, + }, + { + function: 'foo3', + filename: '/User/project/foo3.js', + lineno: 3, + colno: 3, + in_app: true, + }, + ], + }, + }, + { + type: 'Error3', + value: 'Error3: test', + stacktrace: { + frames: [ + { + function: 'bar6', + filename: '/User/project/node_modules/bar/bar6.js', + lineno: 6, + colno: 6, + in_app: false, + }, + { + function: 'foo5', + filename: '/User/project/foo5.js', + lineno: 5, + colno: 5, + in_app: true, + }, + ], + }, + }, + { + type: 'Error4', + value: 'Error4: test', + stacktrace: { + frames: [ + { + function: 'bar8', + filename: '/User/project/node_modules/bar/bar8.js', + lineno: 8, + colno: 8, + in_app: false, + }, + { + function: 'foo7', + filename: '/User/project/foo7.js', + lineno: 7, + colno: 7, + in_app: true, + }, + ], + }, + }, + ], + }, + }); + }); + + it('should symbolicate error with different amount of exception hints ', async () => { + // Example: Sentry captures an Error with 20 Causes, but limits the captured exceptions to + // 5 in event.exception. Meanwhile, hint.originalException contains all 20 items. + // This test ensures no exceptions are thrown in an uneven scenario and ensures all + // consumed errors are symbolicated. + + const setupStackFrame = (value: number): Array => [ + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: value, + column: value, + methodName: `foo${value}`, + }, + { + file: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + lineNumber: value + 1, + column: value + 1, + methodName: `bar${value}`, + }, + ]; + + (parseErrorStack as jest.Mock) + .mockReturnValueOnce(setupStackFrame(1)) + .mockReturnValueOnce(setupStackFrame(3)) + .mockReturnValueOnce(setupStackFrame(5)) + .mockReturnValueOnce(setupStackFrame(7)); + + const mocksymbolicateStackTrace = (value: number): ReactNative.SymbolicatedStackTrace => ({ + stack: [ + { + file: `/User/project/foo${value}.js`, + lineNumber: value, + column: value, + methodName: `foo${value}`, + }, + { + file: `/User/project/node_modules/bar/bar${value + 1}.js`, + lineNumber: value + 1, + column: value + 1, + methodName: `bar${value + 1}`, + }, + ], + }); + (symbolicateStackTrace as jest.Mock) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(1))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(3))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(5))) + .mockReturnValueOnce(Promise.resolve(mocksymbolicateStackTrace(7))); + + const symbolicatedEvent = await processEvent( + { + exception: { + values: [ + { + type: 'Error1', + value: 'Error1: test', + stacktrace: { + frames: customMockSentryParsedFrames(1, 1), + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: customMockSentryParsedFrames(2, 2), + }, + }, + ], + }, + }, + { + originalException: { + stack: customMockRawStack(1, 1), + cause: { + stack: customMockRawStack(3, 3), + cause: { + stack: customMockRawStack(5, 5), + cause: { + stack: customMockRawStack(7, 7), + }, + }, + }, + }, + }, + ); + + expect(symbolicatedEvent).toStrictEqual({ + exception: { + values: [ + { + type: 'Error1', + value: 'Error1: test', + stacktrace: { + frames: [ + { + function: 'bar2', + filename: '/User/project/node_modules/bar/bar2.js', + lineno: 2, + colno: 2, + in_app: false, + }, + { + function: 'foo1', + filename: '/User/project/foo1.js', + lineno: 1, + colno: 1, + in_app: true, + }, + ], + }, + }, + { + type: 'Error2', + value: 'Error2: test', + stacktrace: { + frames: [ + { + function: 'bar4', + filename: '/User/project/node_modules/bar/bar4.js', + lineno: 4, + colno: 4, + in_app: false, + }, + { + function: 'foo3', + filename: '/User/project/foo3.js', + lineno: 3, + colno: 3, + in_app: true, + }, + ], + }, + }, + ], + }, + }); + }); }); });