Skip to content

Commit 0ffe7ff

Browse files
antonisclaude
andcommitted
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) <noreply@anthropic.com>
1 parent cac4a4f commit 0ffe7ff

16 files changed

Lines changed: 447 additions & 7 deletions

File tree

packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ class RNSentrySDKTest {
176176
}
177177

178178
private fun verifyDefaults(actualOptions: SentryAndroidOptions) {
179-
assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
179+
assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
180180
assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name)
181181
assertEquals(
182182
io.sentry.android.core.BuildConfig.VERSION_NAME,

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Activity
44
import com.facebook.react.bridge.JavaOnlyMap
55
import com.facebook.react.common.JavascriptException
66
import io.sentry.Breadcrumb
7+
import io.sentry.Hint
78
import io.sentry.ILogger
89
import io.sentry.SentryEvent
910
import io.sentry.android.core.CurrentActivityHolder
@@ -32,6 +33,7 @@ class RNSentryStartTest {
3233
MockitoAnnotations.openMocks(this)
3334
logger = mock(ILogger::class.java)
3435
activity = mock(Activity::class.java)
36+
RNSentryJavascriptExceptionCache.clear()
3537
}
3638

3739
@Test
@@ -196,10 +198,37 @@ class RNSentryStartTest {
196198
}
197199

198200
@Test
199-
fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() {
201+
fun `the JavascriptException is not added to the ignoredExceptionsForType list`() {
200202
val actualOptions = SentryAndroidOptions()
201203
RNSentryStart.updateWithReactDefaults(actualOptions, activity)
202-
assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
204+
assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
205+
}
206+
207+
@Test
208+
fun `beforeSend caches JavascriptException stack and drops the event`() {
209+
val options = SentryAndroidOptions()
210+
RNSentryStart.updateWithReactFinals(options)
211+
212+
val jsStackTrace = "TypeError: Cannot read property 'content' of undefined\n at UserMessage (index.android.bundle:1:5274251)"
213+
val jsException = JavascriptException(jsStackTrace)
214+
val event = SentryEvent(jsException)
215+
216+
val result = options.beforeSend?.execute(event, Hint())
217+
218+
assertNull("JavascriptException event should be dropped", result)
219+
val cached = RNSentryJavascriptExceptionCache.getAndClear()
220+
assertEquals(jsStackTrace, cached)
221+
}
222+
223+
@Test
224+
fun `beforeSend does not drop non-JavascriptException events`() {
225+
val options = SentryAndroidOptions()
226+
val event = SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") }
227+
228+
RNSentryStart.updateWithReactFinals(options)
229+
val result = options.beforeSend?.execute(event, Hint())
230+
231+
assertNotNull("Non-JavascriptException event should not be dropped", result)
203232
}
204233

205234
@Test
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.sentry.react;
2+
3+
import java.util.concurrent.atomic.AtomicReference;
4+
import org.jetbrains.annotations.Nullable;
5+
6+
/**
7+
* Thread-safe cache for the last JavascriptException stack trace string.
8+
*
9+
* <p>When React Native throws a JavascriptException on Android, the native Sentry SDK intercepts it
10+
* in beforeSend and caches the stack trace here. The JS side can then retrieve it to enrich error
11+
* events that arrive without a stack trace.
12+
*/
13+
final class RNSentryJavascriptExceptionCache {
14+
15+
private static final long TTL_MS = 5000;
16+
17+
private static final AtomicReference<CachedEntry> cache = new AtomicReference<>(null);
18+
19+
private RNSentryJavascriptExceptionCache() {}
20+
21+
static void cache(@Nullable String jsStackTrace) {
22+
if (jsStackTrace == null || jsStackTrace.isEmpty()) {
23+
return;
24+
}
25+
cache.set(new CachedEntry(jsStackTrace, System.currentTimeMillis()));
26+
}
27+
28+
@Nullable
29+
static String getAndClear() {
30+
CachedEntry entry = cache.getAndSet(null);
31+
if (entry == null) {
32+
return null;
33+
}
34+
if (System.currentTimeMillis() - entry.timestampMs > TTL_MS) {
35+
return null;
36+
}
37+
return entry.jsStackTrace;
38+
}
39+
40+
/** Clears the cache. Visible for testing. */
41+
static void clear() {
42+
cache.set(null);
43+
}
44+
45+
private static final class CachedEntry {
46+
final String jsStackTrace;
47+
final long timestampMs;
48+
49+
CachedEntry(String jsStackTrace, long timestampMs) {
50+
this.jsStackTrace = jsStackTrace;
51+
this.timestampMs = timestampMs;
52+
}
53+
}
54+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,10 @@ public String fetchNativePackageName() {
993993
return packageInfo.packageName;
994994
}
995995

996+
public String fetchCachedJavascriptExceptionStack() {
997+
return RNSentryJavascriptExceptionCache.getAndClear();
998+
}
999+
9961000
public void getDataFromUri(String uri, Promise promise) {
9971001
try {
9981002
Uri contentUri = Uri.parse(uri);

packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,10 +297,6 @@ static void updateWithReactDefaults(
297297
options.setTracesSampleRate(null);
298298
options.setTracesSampler(null);
299299

300-
// React native internally throws a JavascriptException.
301-
// we want to ignore it on the native side to avoid sending it twice.
302-
options.addIgnoredExceptionForType(JavascriptException.class);
303-
304300
setCurrentActivity(currentActivity);
305301
}
306302

@@ -312,6 +308,15 @@ static void updateWithReactFinals(@NotNull SentryAndroidOptions options) {
312308
BeforeSendCallback userBeforeSend = options.getBeforeSend();
313309
options.setBeforeSend(
314310
(event, hint) -> {
311+
// React Native internally throws a JavascriptException when a JS error occurs.
312+
// We cache its stack trace (which may contain frames missing from the JS error)
313+
// and drop the native event to avoid sending duplicates.
314+
Throwable throwable = event.getThrowable();
315+
if (throwable instanceof JavascriptException) {
316+
RNSentryJavascriptExceptionCache.cache(throwable.getMessage());
317+
return null;
318+
}
319+
315320
setEventOriginTag(event);
316321
// Note: In Sentry Android SDK v7, native SDK packages/integrations are already
317322
// included in the SDK version set during initialization, so no need to copy them here.

packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ public String fetchNativePackageName() {
173173
return this.impl.fetchNativePackageName();
174174
}
175175

176+
@Override
177+
public String fetchCachedJavascriptExceptionStack() {
178+
return this.impl.fetchCachedJavascriptExceptionStack();
179+
}
180+
176181
@Override
177182
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
178183
// Not used on Android

packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ public String fetchNativePackageName() {
173173
return this.impl.fetchNativePackageName();
174174
}
175175

176+
@ReactMethod(isBlockingSynchronousMethod = true)
177+
public String fetchCachedJavascriptExceptionStack() {
178+
return this.impl.fetchCachedJavascriptExceptionStack();
179+
}
180+
176181
@ReactMethod(isBlockingSynchronousMethod = true)
177182
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
178183
// Not used on Android

packages/core/ios/RNSentry.mm

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,12 @@ - (void)handleShakeDetected
371371
return packageName;
372372
}
373373

374+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, fetchCachedJavascriptExceptionStack)
375+
{
376+
// Android-only feature, iOS does not need this.
377+
return nil;
378+
}
379+
374380
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(
375381
NSDictionary *, fetchNativeStackFramesBy : (NSArray *)instructionsAddr)
376382
{

packages/core/src/js/NativeRNSentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
4848
error?: string;
4949
};
5050
fetchNativePackageName(): string | undefined | null;
51+
fetchCachedJavascriptExceptionStack(): string | undefined | null;
5152
fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null;
5253
initNativeReactNavigationNewFrameTracking(): Promise<void>;
5354
captureReplay(isHardCrash: boolean): Promise<string | undefined | null>;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
modulesLoaderIntegration,
3434
nativeLinkedErrorsIntegration,
3535
nativeReleaseIntegration,
36+
nativeStackRecoveryIntegration,
3637
primitiveTagIntegration,
3738
reactNativeErrorHandlersIntegration,
3839
reactNativeInfoIntegration,
@@ -62,6 +63,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
6263
}),
6364
);
6465
integrations.push(nativeLinkedErrorsIntegration());
66+
integrations.push(nativeStackRecoveryIntegration());
6567
} else {
6668
integrations.push(browserApiErrorsIntegration());
6769
integrations.push(browserGlobalHandlersIntegration());

0 commit comments

Comments
 (0)