Skip to content

Commit d00e8e8

Browse files
feat(android): Add nativeStackAndroid support to NativeLinkedErrors
Captures the JVM stack trace attached to rejected native module promises as a linked exception, so the Java cause of a rejected promise is reported alongside the JS error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f170ec3 commit d00e8e8

5 files changed

Lines changed: 194 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Note: Expo Router span/breadcrumb attributes that may contain user identifiers (`route.href`, `route.params`, and concrete pathnames derived from string hrefs such as `/users/42`) are now gated behind `sendDefaultPii`. When `sendDefaultPii` is off (the default), prefetch spans for string hrefs use `route.name: 'unknown'` and omit `route.href`. Templated object hrefs (e.g. `{ pathname: '/users/[id]' }`) are unaffected.
1717
- Warn when Gradle resolves `sentry-android` to a version incompatible with the SDK ([#6238](https://github.com/getsentry/sentry-react-native/pull/6238))
1818
- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227))
19+
- Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#3257](https://github.com/getsentry/sentry-react-native/issues/3257))
1920

2021
### Fixes
2122

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

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ function preprocessEvent(event: Event, hint: EventHint | undefined, client: Clie
5050
}
5151

5252
const parser = client.getOptions().stackParser;
53+
const originalException = hint.originalException as ExtendedError;
54+
55+
const { exceptions: linkedErrors, debugImages } = walkErrorTree(parser, limit, originalException, key);
56+
57+
const nativeStackAndroidException = exceptionFromNativeStackAndroid(originalException);
58+
if (nativeStackAndroidException && linkedErrors.length + 1 < limit) {
59+
linkedErrors.push(nativeStackAndroidException);
60+
}
5361

54-
const { exceptions: linkedErrors, debugImages } = walkErrorTree(
55-
parser,
56-
limit,
57-
hint.originalException as ExtendedError,
58-
key,
59-
);
6062
event.exception.values = [...event.exception.values, ...linkedErrors];
6163

6264
event.debug_meta = event.debug_meta || {};
@@ -146,22 +148,70 @@ function exceptionFromJavaStackElements(javaThrowable: {
146148
value: javaThrowable.message,
147149
stacktrace: {
148150
frames: javaThrowable.stackElements
149-
.map(
150-
stackElement =>
151-
<StackFrame>{
152-
platform: 'java',
153-
module: stackElement.className,
154-
filename: stackElement.fileName,
155-
lineno: stackElement.lineNumber >= 0 ? stackElement.lineNumber : undefined,
156-
function: stackElement.methodName,
157-
in_app: nativePackage !== null && stackElement.className.startsWith(nativePackage) ? true : undefined,
158-
},
151+
.map(stackElement =>
152+
javaStackFrame(
153+
stackElement.className,
154+
stackElement.fileName,
155+
stackElement.methodName,
156+
stackElement.lineNumber,
157+
nativePackage,
158+
),
159159
)
160160
.reverse(),
161161
},
162162
};
163163
}
164164

165+
/**
166+
* Converts the `nativeStackAndroid` frames attached to errors from rejected promises
167+
* (`Promise.reject(code, message, throwable, userInfo)`, see Android's `PromiseImpl.java`) to a SentryException.
168+
*/
169+
function exceptionFromNativeStackAndroid(error: ExtendedError): Exception | undefined {
170+
const nativeStackAndroid = error.nativeStackAndroid as
171+
| {
172+
class: string;
173+
file: string;
174+
lineNumber: number;
175+
methodName: string;
176+
}[]
177+
| undefined;
178+
179+
if (!Array.isArray(nativeStackAndroid) || nativeStackAndroid.length === 0) {
180+
return undefined;
181+
}
182+
183+
const nativePackage = fetchNativePackage();
184+
return {
185+
type: error.name,
186+
value: error.message,
187+
stacktrace: {
188+
frames: nativeStackAndroid
189+
.map(frame => javaStackFrame(frame.class, frame.file, frame.methodName, frame.lineNumber, nativePackage))
190+
.reverse(),
191+
},
192+
};
193+
}
194+
195+
/**
196+
* Converts a Java stack trace element to a Sentry stack frame.
197+
*/
198+
function javaStackFrame(
199+
className: string,
200+
fileName: string,
201+
methodName: string,
202+
lineNumber: number,
203+
nativePackage: string | null,
204+
): StackFrame {
205+
return {
206+
platform: 'java',
207+
module: className,
208+
filename: fileName,
209+
lineno: lineNumber >= 0 ? lineNumber : undefined,
210+
function: methodName,
211+
in_app: nativePackage !== null && className.startsWith(nativePackage) ? true : undefined,
212+
};
213+
}
214+
165215
/**
166216
* Converts StackAddresses to a SentryException with DebugMetaImages
167217
*/

packages/core/test/integrations/nativelinkederrors.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,107 @@ describe('NativeLinkedErrors', () => {
340340
);
341341
});
342342

343+
it('adds android nativeStackAndroid from a rejected promise to the event', async () => {
344+
const actualEvent = await executeIntegrationFor(
345+
{
346+
exception: {
347+
values: [
348+
{
349+
type: 'Error',
350+
value: 'Captured exception',
351+
stacktrace: {
352+
frames: [
353+
{
354+
colno: 17,
355+
filename: 'app:///Pressability.js',
356+
function: '_performTransitionSideEffects',
357+
},
358+
],
359+
},
360+
mechanism: {
361+
type: 'generic',
362+
handled: true,
363+
},
364+
},
365+
],
366+
},
367+
},
368+
{
369+
originalException: createNewError({
370+
message: 'Java error message.',
371+
name: 'java.lang.RuntimeException',
372+
stack:
373+
'java.lang.RuntimeException: Java error message.\n' +
374+
'at onPress (index.bundle:75:33)\n' +
375+
'at _performTransitionSideEffects (index.bundle:65919:22)',
376+
nativeStackAndroid: [
377+
{
378+
class: 'mock.native.bundle.id.Crash',
379+
file: 'Crash.kt',
380+
lineNumber: 10,
381+
methodName: 'getDataCrash',
382+
},
383+
{
384+
class: 'com.facebook.jni.NativeRunnable',
385+
file: 'NativeRunnable.java',
386+
lineNumber: 2,
387+
methodName: 'run',
388+
},
389+
],
390+
}),
391+
},
392+
);
393+
394+
expect(actualEvent).toEqual(
395+
expect.objectContaining(<Partial<Event>>{
396+
exception: {
397+
values: [
398+
{
399+
type: 'Error',
400+
value: 'Captured exception',
401+
stacktrace: {
402+
frames: [
403+
{
404+
colno: 17,
405+
filename: 'app:///Pressability.js',
406+
function: '_performTransitionSideEffects',
407+
},
408+
],
409+
},
410+
mechanism: {
411+
type: 'generic',
412+
handled: true,
413+
},
414+
},
415+
{
416+
type: 'java.lang.RuntimeException',
417+
value: 'Java error message.',
418+
stacktrace: {
419+
frames: [
420+
expect.objectContaining({
421+
platform: 'java',
422+
module: 'com.facebook.jni.NativeRunnable',
423+
filename: 'NativeRunnable.java',
424+
lineno: 2,
425+
function: 'run',
426+
}),
427+
expect.objectContaining({
428+
platform: 'java',
429+
module: 'mock.native.bundle.id.Crash',
430+
filename: 'Crash.kt',
431+
lineno: 10,
432+
function: 'getDataCrash',
433+
in_app: true,
434+
}),
435+
],
436+
},
437+
},
438+
],
439+
},
440+
}),
441+
);
442+
});
443+
343444
it('handles events with a string cause', async () => {
344445
const actualEvent = await executeIntegrationFor(
345446
{
@@ -390,12 +491,21 @@ function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Event
390491
return mockedEvent;
391492
}
392493

393-
function createNewError(from: { message: string; name?: string; stack?: string; cause?: unknown }): ExtendedError {
494+
function createNewError(from: {
495+
message: string;
496+
name?: string;
497+
stack?: string;
498+
cause?: unknown;
499+
nativeStackAndroid?: unknown;
500+
}): ExtendedError {
394501
const error: ExtendedError = new Error(from.message);
395502
if (from.name) {
396503
error.name = from.name;
397504
}
398505
error.stack = from.stack;
399506
error.cause = from.cause;
507+
if (from.nativeStackAndroid) {
508+
error.nativeStackAndroid = from.nativeStackAndroid;
509+
}
400510
return error;
401511
}

samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ public void crashOrNumber(Promise promise) {
7272
promise.resolve(42);
7373
}
7474

75+
@ReactMethod
76+
public void rejectWithNativeStack(Promise promise) {
77+
promise.reject(
78+
"ERROR",
79+
"Rejected with native stack",
80+
new RuntimeException("CrashModule.rejectWithNativeStack()"));
81+
}
82+
7583
private void crashNow() {
7684
throw new RuntimeException("CrashModule.crashNow()");
7785
}

samples/react-native/src/Screens/ErrorsScreen.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ const ErrorsScreen = (_props: Props) => {
207207
});
208208
}}
209209
/>
210+
<Button
211+
title="Reject Promise with Native Stack"
212+
onPress={() => {
213+
CrashModule.rejectWithNativeStack().catch((e: Error) => {
214+
Sentry.captureException(e);
215+
});
216+
}}
217+
/>
210218
<Button
211219
title="Enable Crash on Start"
212220
onPress={() => {

0 commit comments

Comments
 (0)