From 0ffe7ff9e09345cb3e4e79bee2414dfda58b3a21 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 14:46:01 +0200 Subject: [PATCH 1/3] fix(android): Recover missing JS stack traces from native JavascriptException On Android with Hermes, React render errors can arrive at the JS-side ErrorUtils.setGlobalHandler without a .stack property. The same error is captured by RN's native layer as a JavascriptException which contains the full JS stack trace string. Previously, JavascriptException was fully ignored via addIgnoredExceptionForType, discarding the stack information. Now we intercept it in beforeSend, cache the stack trace, and expose it to JS via a sync bridge method. A new NativeStackRecovery integration fetches and parses the cached stack when an event has no frames. Fixes #5071 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/io/sentry/react/RNSentrySDKTest.kt | 2 +- .../java/io/sentry/react/RNSentryStartTest.kt | 33 ++- .../RNSentryJavascriptExceptionCache.java | 54 ++++ .../io/sentry/react/RNSentryModuleImpl.java | 4 + .../java/io/sentry/react/RNSentryStart.java | 13 +- .../java/io/sentry/react/RNSentryModule.java | 5 + .../java/io/sentry/react/RNSentryModule.java | 5 + packages/core/ios/RNSentry.mm | 6 + packages/core/src/js/NativeRNSentry.ts | 1 + packages/core/src/js/integrations/default.ts | 2 + packages/core/src/js/integrations/exports.ts | 1 + .../js/integrations/nativestackrecovery.ts | 62 +++++ packages/core/src/js/wrapper.ts | 17 ++ .../integrationsexecutionorder.test.ts | 18 ++ .../integrations/nativestackrecovery.test.ts | 230 ++++++++++++++++++ packages/core/test/mockWrapper.ts | 1 + 16 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java create mode 100644 packages/core/src/js/integrations/nativestackrecovery.ts create mode 100644 packages/core/test/integrations/nativestackrecovery.test.ts diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt index bfa1647cbc..305e42459f 100644 --- a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt @@ -176,7 +176,7 @@ class RNSentrySDKTest { } private fun verifyDefaults(actualOptions: SentryAndroidOptions) { - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) assertEquals( io.sentry.android.core.BuildConfig.VERSION_NAME, diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt index c60776c942..aa883f2264 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.common.JavascriptException import io.sentry.Breadcrumb +import io.sentry.Hint import io.sentry.ILogger import io.sentry.SentryEvent import io.sentry.android.core.CurrentActivityHolder @@ -32,6 +33,7 @@ class RNSentryStartTest { MockitoAnnotations.openMocks(this) logger = mock(ILogger::class.java) activity = mock(Activity::class.java) + RNSentryJavascriptExceptionCache.clear() } @Test @@ -196,10 +198,37 @@ class RNSentryStartTest { } @Test - fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() { + fun `the JavascriptException is not added to the ignoredExceptionsForType list`() { val actualOptions = SentryAndroidOptions() RNSentryStart.updateWithReactDefaults(actualOptions, activity) - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + } + + @Test + fun `beforeSend caches JavascriptException stack and drops the event`() { + val options = SentryAndroidOptions() + RNSentryStart.updateWithReactFinals(options) + + val jsStackTrace = "TypeError: Cannot read property 'content' of undefined\n at UserMessage (index.android.bundle:1:5274251)" + val jsException = JavascriptException(jsStackTrace) + val event = SentryEvent(jsException) + + val result = options.beforeSend?.execute(event, Hint()) + + assertNull("JavascriptException event should be dropped", result) + val cached = RNSentryJavascriptExceptionCache.getAndClear() + assertEquals(jsStackTrace, cached) + } + + @Test + fun `beforeSend does not drop non-JavascriptException events`() { + val options = SentryAndroidOptions() + val event = SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + + RNSentryStart.updateWithReactFinals(options) + val result = options.beforeSend?.execute(event, Hint()) + + assertNotNull("Non-JavascriptException event should not be dropped", result) } @Test diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java new file mode 100644 index 0000000000..7e170c5e81 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJavascriptExceptionCache.java @@ -0,0 +1,54 @@ +package io.sentry.react; + +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.Nullable; + +/** + * Thread-safe cache for the last JavascriptException stack trace string. + * + *

When React Native throws a JavascriptException on Android, the native Sentry SDK intercepts it + * in beforeSend and caches the stack trace here. The JS side can then retrieve it to enrich error + * events that arrive without a stack trace. + */ +final class RNSentryJavascriptExceptionCache { + + private static final long TTL_MS = 5000; + + private static final AtomicReference cache = new AtomicReference<>(null); + + private RNSentryJavascriptExceptionCache() {} + + static void cache(@Nullable String jsStackTrace) { + if (jsStackTrace == null || jsStackTrace.isEmpty()) { + return; + } + cache.set(new CachedEntry(jsStackTrace, System.currentTimeMillis())); + } + + @Nullable + static String getAndClear() { + CachedEntry entry = cache.getAndSet(null); + if (entry == null) { + return null; + } + if (System.currentTimeMillis() - entry.timestampMs > TTL_MS) { + return null; + } + return entry.jsStackTrace; + } + + /** Clears the cache. Visible for testing. */ + static void clear() { + cache.set(null); + } + + private static final class CachedEntry { + final String jsStackTrace; + final long timestampMs; + + CachedEntry(String jsStackTrace, long timestampMs) { + this.jsStackTrace = jsStackTrace; + this.timestampMs = timestampMs; + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index ec1e75607a..161deb5b42 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -993,6 +993,10 @@ public String fetchNativePackageName() { return packageInfo.packageName; } + public String fetchCachedJavascriptExceptionStack() { + return RNSentryJavascriptExceptionCache.getAndClear(); + } + public void getDataFromUri(String uri, Promise promise) { try { Uri contentUri = Uri.parse(uri); diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index 77851bf83b..c91a94d47b 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -297,10 +297,6 @@ static void updateWithReactDefaults( options.setTracesSampleRate(null); options.setTracesSampler(null); - // React native internally throws a JavascriptException. - // we want to ignore it on the native side to avoid sending it twice. - options.addIgnoredExceptionForType(JavascriptException.class); - setCurrentActivity(currentActivity); } @@ -312,6 +308,15 @@ static void updateWithReactFinals(@NotNull SentryAndroidOptions options) { BeforeSendCallback userBeforeSend = options.getBeforeSend(); options.setBeforeSend( (event, hint) -> { + // React Native internally throws a JavascriptException when a JS error occurs. + // We cache its stack trace (which may contain frames missing from the JS error) + // and drop the native event to avoid sending duplicates. + Throwable throwable = event.getThrowable(); + if (throwable instanceof JavascriptException) { + RNSentryJavascriptExceptionCache.cache(throwable.getMessage()); + return null; + } + setEventOriginTag(event); // Note: In Sentry Android SDK v7, native SDK packages/integrations are already // included in the SDK version set during initialization, so no need to copy them here. diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 9215c09c36..dd912adcac 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -173,6 +173,11 @@ public String fetchNativePackageName() { return this.impl.fetchNativePackageName(); } + @Override + public String fetchCachedJavascriptExceptionStack() { + return this.impl.fetchCachedJavascriptExceptionStack(); + } + @Override public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 85fdb97a35..9908801cba 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -173,6 +173,11 @@ public String fetchNativePackageName() { return this.impl.fetchNativePackageName(); } + @ReactMethod(isBlockingSynchronousMethod = true) + public String fetchCachedJavascriptExceptionStack() { + return this.impl.fetchCachedJavascriptExceptionStack(); + } + @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index a43557079c..a6ba069ddb 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -371,6 +371,12 @@ - (void)handleShakeDetected return packageName; } +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, fetchCachedJavascriptExceptionStack) +{ + // Android-only feature, iOS does not need this. + return nil; +} + RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD( NSDictionary *, fetchNativeStackFramesBy : (NSArray *)instructionsAddr) { diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 0a4aff54b5..ceafebeb65 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -48,6 +48,7 @@ export interface Spec extends TurboModule { error?: string; }; fetchNativePackageName(): string | undefined | null; + fetchCachedJavascriptExceptionStack(): string | undefined | null; fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null; initNativeReactNavigationNewFrameTracking(): Promise; captureReplay(isHardCrash: boolean): Promise; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 30dd3a156e..c12354f9cb 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -33,6 +33,7 @@ import { modulesLoaderIntegration, nativeLinkedErrorsIntegration, nativeReleaseIntegration, + nativeStackRecoveryIntegration, primitiveTagIntegration, reactNativeErrorHandlersIntegration, reactNativeInfoIntegration, @@ -62,6 +63,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ }), ); integrations.push(nativeLinkedErrorsIntegration()); + integrations.push(nativeStackRecoveryIntegration()); } else { integrations.push(browserApiErrorsIntegration()); integrations.push(browserGlobalHandlersIntegration()); diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index d4e80f8ef6..b210240fbd 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -2,6 +2,7 @@ export { debugSymbolicatorIntegration } from './debugsymbolicator'; export { deviceContextIntegration } from './devicecontext'; export { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers'; export { nativeLinkedErrorsIntegration } from './nativelinkederrors'; +export { nativeStackRecoveryIntegration } from './nativestackrecovery'; export { nativeReleaseIntegration } from './release'; export { eventOriginIntegration } from './eventorigin'; export { sdkInfoIntegration } from './sdkinfo'; diff --git a/packages/core/src/js/integrations/nativestackrecovery.ts b/packages/core/src/js/integrations/nativestackrecovery.ts new file mode 100644 index 0000000000..065f49e014 --- /dev/null +++ b/packages/core/src/js/integrations/nativestackrecovery.ts @@ -0,0 +1,62 @@ +import type { Client, Event, EventHint, Integration } from '@sentry/core'; + +import { debug, parseStackFrames } from '@sentry/core'; +import { Platform } from 'react-native'; + +import { notWeb } from '../utils/environment'; +import { NATIVE } from '../wrapper'; + +const INTEGRATION_NAME = 'NativeStackRecovery'; + +/** + * Recovers missing JS stack traces from the native JavascriptException cache. + * + * On Android, when a React render error occurs with Hermes, the JS error may arrive + * at the global error handler without a stack trace. However, the same error is caught + * by React Native's native layer as a JavascriptException which contains the full + * JS stack trace. This integration fetches that cached stack and attaches it to the event. + */ +export const nativeStackRecoveryIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + setupOnce: () => { + /* noop */ + }, + preprocessEvent: (event: Event, hint: EventHint, client: Client): void => + preprocessEvent(event, hint, client), + }; +}; + +function isEnabled(): boolean { + return notWeb() && NATIVE.enableNative && Platform.OS === 'android'; +} + +function preprocessEvent(event: Event, _hint: EventHint, client: Client): void { + if (!isEnabled()) { + return; + } + + const primaryException = event.exception?.values?.[0]; + if (!primaryException) { + return; + } + + if (primaryException.stacktrace?.frames && primaryException.stacktrace.frames.length > 0) { + return; + } + + const cachedStack = NATIVE.fetchCachedJavascriptExceptionStack(); + if (!cachedStack) { + return; + } + + const parser = client.getOptions().stackParser; + const syntheticError = new Error(); + syntheticError.stack = cachedStack; + + const frames = parseStackFrames(parser, syntheticError); + if (frames.length > 0) { + primaryException.stacktrace = { frames }; + debug.log(`[${INTEGRATION_NAME}] Recovered ${frames.length} frames from native JavascriptException cache`); + } +} diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 6a71e0ff6f..f7a780823e 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -123,6 +123,12 @@ interface SentryNativeWrapper { fetchNativePackageName(): string | null; + /** + * Fetches and clears the cached JavascriptException stack trace from the native side. + * Returns null if no cached stack is available or the cache has expired. + */ + fetchCachedJavascriptExceptionStack(): string | null; + /** * Fetches native stack frames and debug images for the instructions addresses. */ @@ -756,6 +762,17 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.fetchNativePackageName() || null; }, + fetchCachedJavascriptExceptionStack(): string | null { + if (!this.enableNative) { + return null; + } + if (!this._isModuleLoaded(RNSentry)) { + return null; + } + + return RNSentry.fetchCachedJavascriptExceptionStack() || null; + }, + fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null { if (!this.enableNative) { return null; diff --git a/packages/core/test/integrations/integrationsexecutionorder.test.ts b/packages/core/test/integrations/integrationsexecutionorder.test.ts index df04a9327c..266acf3031 100644 --- a/packages/core/test/integrations/integrationsexecutionorder.test.ts +++ b/packages/core/test/integrations/integrationsexecutionorder.test.ts @@ -43,6 +43,24 @@ describe('Integration execution order', () => { expect(nativeLinkedErrors.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!); expect(rewriteFrames.processEvent!).toHaveBeenCalledBefore(debugSymbolicator.processEvent!); }); + + it('NativeStackRecovery is before RewriteFrames', async () => { + // NativeStackRecovery has to process event before RewriteFrames + // otherwise recovered stack trace frames won't be rewritten + + const client = createTestClient(); + const { integrations } = client.getOptions(); + + const nativeStackRecovery = spyOnIntegrationById('NativeStackRecovery', integrations); + const rewriteFrames = spyOnIntegrationById('RewriteFrames', integrations); + + client.init(); + + client.captureException(new Error('test')); + await client.flush(); + + expect(nativeStackRecovery.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!); + }); }); describe('web', () => { diff --git a/packages/core/test/integrations/nativestackrecovery.test.ts b/packages/core/test/integrations/nativestackrecovery.test.ts new file mode 100644 index 0000000000..3603400e41 --- /dev/null +++ b/packages/core/test/integrations/nativestackrecovery.test.ts @@ -0,0 +1,230 @@ +jest.mock('../../src/js/utils/environment'); + +import type { Client, Event, EventHint } from '@sentry/core'; + +import { defaultStackParser } from '@sentry/browser'; +import { Platform } from 'react-native'; + +import { nativeStackRecoveryIntegration } from '../../src/js/integrations/nativestackrecovery'; +import { notWeb } from '../../src/js/utils/environment'; +import { NATIVE } from '../../src/js/wrapper'; + +jest.mock('../../src/js/wrapper'); + +function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Event { + const mockedClient = { + getOptions: () => ({ stackParser: defaultStackParser }), + } as unknown as Client; + + const integration = nativeStackRecoveryIntegration(); + integration.preprocessEvent!(mockedEvent, mockedHint, mockedClient); + return mockedEvent; +} + +describe('NativeStackRecovery', () => { + let originalPlatformOS: typeof Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + originalPlatformOS = Platform.OS; + Platform.OS = 'android'; + (notWeb as jest.Mock).mockReturnValue(true); + (NATIVE as any).enableNative = true; + }); + + afterEach(() => { + Platform.OS = originalPlatformOS; + }); + + it('does nothing when native is disabled', () => { + (NATIVE as any).enableNative = false; + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( + 'Error: test\n at foo (file.js:1:1)', + ); + + const event = executeIntegrationFor( + { + exception: { + values: [{ type: 'Error', value: 'test' }], + }, + }, + {}, + ); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled(); + expect(event.exception?.values?.[0].stacktrace).toBeUndefined(); + }); + + it('does nothing on non-android platforms', () => { + Platform.OS = 'ios'; + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( + 'Error: test\n at foo (file.js:1:1)', + ); + + const event = executeIntegrationFor( + { + exception: { + values: [{ type: 'Error', value: 'test' }], + }, + }, + {}, + ); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled(); + expect(event.exception?.values?.[0].stacktrace).toBeUndefined(); + }); + + it('does nothing when the event already has stacktrace frames', () => { + const event = executeIntegrationFor( + { + exception: { + values: [ + { + type: 'Error', + value: 'test', + stacktrace: { + frames: [ + { + filename: 'app.js', + function: 'myFunc', + lineno: 10, + colno: 5, + in_app: true, + }, + ], + }, + }, + ], + }, + }, + {}, + ); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled(); + expect(event.exception?.values?.[0].stacktrace?.frames).toHaveLength(1); + }); + + it('does nothing when no cached stack is available', () => { + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(null); + + const event = executeIntegrationFor( + { + exception: { + values: [{ type: 'Error', value: 'test' }], + }, + }, + {}, + ); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).toHaveBeenCalledTimes(1); + expect(event.exception?.values?.[0].stacktrace).toBeUndefined(); + }); + + it('parses and attaches frames when cached stack is available and event has no frames', () => { + const cachedStack = [ + 'Error: Value is undefined, expected an Object', + ' at UserMessage (http://localhost:8081/index.bundle:1:5274251)', + ' at renderItem (http://localhost:8081/index.bundle:1:5280705)', + ' at Container (http://localhost:8081/index.bundle:1:5288922)', + ].join('\n'); + + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack); + + const event = executeIntegrationFor( + { + exception: { + values: [{ type: 'Error', value: 'Value is undefined, expected an Object' }], + }, + }, + {}, + ); + + expect(event.exception?.values?.[0].stacktrace?.frames).toBeDefined(); + expect(event.exception?.values?.[0].stacktrace!.frames!.length).toBeGreaterThan(0); + }); + + it('does nothing when cached stack cannot be parsed', () => { + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( + 'not a valid stack trace', + ); + + const event = executeIntegrationFor( + { + exception: { + values: [{ type: 'Error', value: 'test' }], + }, + }, + {}, + ); + + expect(event.exception?.values?.[0].stacktrace).toBeUndefined(); + }); + + it('does nothing when event has no exception', () => { + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( + 'Error: test\n at foo (file.js:1:1)', + ); + + executeIntegrationFor({}, {}); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).not.toHaveBeenCalled(); + }); + + it('does nothing when exception has empty frames array', () => { + const cachedStack = [ + 'Error: test', + ' at foo (http://localhost:8081/index.bundle:1:100)', + ].join('\n'); + + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack); + + const event = executeIntegrationFor( + { + exception: { + values: [ + { + type: 'Error', + value: 'test', + stacktrace: { frames: [] }, + }, + ], + }, + }, + {}, + ); + + expect(NATIVE.fetchCachedJavascriptExceptionStack).toHaveBeenCalledTimes(1); + expect(event.exception?.values?.[0].stacktrace?.frames!.length).toBeGreaterThan(0); + }); + + it('only patches the primary exception (values[0]) when multiple exception values exist', () => { + const cachedStack = [ + 'Error: test', + ' at foo (http://localhost:8081/index.bundle:1:100)', + ].join('\n'); + + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack); + + const event = executeIntegrationFor( + { + exception: { + values: [ + { type: 'Error', value: 'primary error without stack' }, + { + type: 'Error', + value: 'linked cause', + stacktrace: { + frames: [{ filename: 'cause.js', function: 'causeFn', lineno: 5, colno: 1, in_app: true }], + }, + }, + ], + }, + }, + {}, + ); + + expect(event.exception?.values?.[0].stacktrace?.frames!.length).toBeGreaterThan(0); + expect(event.exception?.values?.[1].stacktrace?.frames).toHaveLength(1); + expect(event.exception?.values?.[1].stacktrace?.frames![0].filename).toBe('cause.js'); + }); +}); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index afa8c6d1fb..1eed7300f9 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -55,6 +55,7 @@ const NATIVE: MockInterface = { stopProfiling: jest.fn(), fetchNativePackageName: jest.fn(), + fetchCachedJavascriptExceptionStack: jest.fn(), fetchNativeStackFramesBy: jest.fn(), initNativeReactNavigationNewFrameTracking: jest.fn(), From 8657b8cb845d00b5be4c598f4a09b0de4b9aced3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 14:50:09 +0200 Subject: [PATCH 2/3] docs: Add changelog entry for missing stacktrace fix Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 002c91cb0a..35a0b8a1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Recover missing JS stack traces from native `JavascriptException` on Android ([#5964](https://github.com/getsentry/sentry-react-native/pull/5964)) - Lazy-load Metro internal modules to prevent Expo 55 import errors ([#5958](https://github.com/getsentry/sentry-react-native/pull/5958)) ### Dependencies From 153436453966eb54775771f98d497211806296f0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 7 Apr 2026 14:57:38 +0200 Subject: [PATCH 3/3] Fix lint issues --- .../js/integrations/nativestackrecovery.ts | 3 +-- .../integrations/nativestackrecovery.test.ts | 26 +++++-------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/core/src/js/integrations/nativestackrecovery.ts b/packages/core/src/js/integrations/nativestackrecovery.ts index 065f49e014..b1dc383cec 100644 --- a/packages/core/src/js/integrations/nativestackrecovery.ts +++ b/packages/core/src/js/integrations/nativestackrecovery.ts @@ -22,8 +22,7 @@ export const nativeStackRecoveryIntegration = (): Integration => { setupOnce: () => { /* noop */ }, - preprocessEvent: (event: Event, hint: EventHint, client: Client): void => - preprocessEvent(event, hint, client), + preprocessEvent: (event: Event, hint: EventHint, client: Client): void => preprocessEvent(event, hint, client), }; }; diff --git a/packages/core/test/integrations/nativestackrecovery.test.ts b/packages/core/test/integrations/nativestackrecovery.test.ts index 3603400e41..a16ab0ca14 100644 --- a/packages/core/test/integrations/nativestackrecovery.test.ts +++ b/packages/core/test/integrations/nativestackrecovery.test.ts @@ -38,9 +38,7 @@ describe('NativeStackRecovery', () => { it('does nothing when native is disabled', () => { (NATIVE as any).enableNative = false; - (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( - 'Error: test\n at foo (file.js:1:1)', - ); + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)'); const event = executeIntegrationFor( { @@ -57,9 +55,7 @@ describe('NativeStackRecovery', () => { it('does nothing on non-android platforms', () => { Platform.OS = 'ios'; - (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( - 'Error: test\n at foo (file.js:1:1)', - ); + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)'); const event = executeIntegrationFor( { @@ -144,9 +140,7 @@ describe('NativeStackRecovery', () => { }); it('does nothing when cached stack cannot be parsed', () => { - (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( - 'not a valid stack trace', - ); + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('not a valid stack trace'); const event = executeIntegrationFor( { @@ -161,9 +155,7 @@ describe('NativeStackRecovery', () => { }); it('does nothing when event has no exception', () => { - (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue( - 'Error: test\n at foo (file.js:1:1)', - ); + (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue('Error: test\n at foo (file.js:1:1)'); executeIntegrationFor({}, {}); @@ -171,10 +163,7 @@ describe('NativeStackRecovery', () => { }); it('does nothing when exception has empty frames array', () => { - const cachedStack = [ - 'Error: test', - ' at foo (http://localhost:8081/index.bundle:1:100)', - ].join('\n'); + const cachedStack = ['Error: test', ' at foo (http://localhost:8081/index.bundle:1:100)'].join('\n'); (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack); @@ -198,10 +187,7 @@ describe('NativeStackRecovery', () => { }); it('only patches the primary exception (values[0]) when multiple exception values exist', () => { - const cachedStack = [ - 'Error: test', - ' at foo (http://localhost:8081/index.bundle:1:100)', - ].join('\n'); + const cachedStack = ['Error: test', ' at foo (http://localhost:8081/index.bundle:1:100)'].join('\n'); (NATIVE.fetchCachedJavascriptExceptionStack as jest.Mock).mockReturnValue(cachedStack);