-
-
Notifications
You must be signed in to change notification settings - Fork 359
fix(android): Capture native exceptions consumed by Expo's bridgeless error handling #5871
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
Changes from 4 commits
897a02d
421026b
210681f
9567e56
157ed88
9c01257
34008f8
bac2288
5811daf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,3 +37,4 @@ | |
| !/expo.d.ts | ||
| !/app.plugin.js | ||
| !/plugin/build/**/* | ||
| !/expo-module.config.json | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package io.sentry.react.expo | ||
|
|
||
| import io.sentry.Sentry | ||
| import io.sentry.exception.ExceptionMechanismException | ||
| import org.junit.After | ||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertNotNull | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import org.junit.runners.JUnit4 | ||
| import org.mockito.MockedStatic | ||
| import org.mockito.Mockito.mockStatic | ||
| import org.mockito.kotlin.any | ||
| import org.mockito.kotlin.argumentCaptor | ||
| import org.mockito.kotlin.never | ||
| import org.mockito.kotlin.verify | ||
|
|
||
| @RunWith(JUnit4::class) | ||
| class SentryReactNativeHostHandlerTest { | ||
|
|
||
| private var sentryMock: MockedStatic<Sentry>? = null | ||
|
|
||
| @After | ||
| fun tearDown() { | ||
| sentryMock?.close() | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not capture when in developer support mode`() { | ||
| sentryMock = mockStatic(Sentry::class.java).also { | ||
| it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true) | ||
| } | ||
|
|
||
| val handler = SentryReactNativeHostHandler() | ||
| handler.onReactInstanceException(true, RuntimeException("test")) | ||
|
|
||
| sentryMock!!.verify({ Sentry.captureException(any()) }, never()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not capture when sentry is not enabled`() { | ||
| sentryMock = mockStatic(Sentry::class.java).also { | ||
| it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(false) | ||
| } | ||
|
|
||
| val handler = SentryReactNativeHostHandler() | ||
| handler.onReactInstanceException(false, RuntimeException("test")) | ||
|
|
||
| sentryMock!!.verify({ Sentry.captureException(any()) }, never()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `captures exception with unhandled mechanism when sentry is enabled`() { | ||
| sentryMock = mockStatic(Sentry::class.java).also { | ||
| it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true) | ||
| } | ||
|
|
||
| val handler = SentryReactNativeHostHandler() | ||
| val originalException = IllegalStateException("Fabric crash") | ||
|
|
||
| handler.onReactInstanceException(false, originalException) | ||
|
|
||
| val captor = argumentCaptor<Throwable>() | ||
| sentryMock!!.verify { Sentry.captureException(captor.capture()) } | ||
|
|
||
| val captured = captor.firstValue | ||
| assertTrue( | ||
| "Expected ExceptionMechanismException but got ${captured::class.java}", | ||
| captured is ExceptionMechanismException, | ||
| ) | ||
|
|
||
| val mechanismException = captured as ExceptionMechanismException | ||
| val mechanism = mechanismException.exceptionMechanism | ||
| assertEquals("expoReactHost", mechanism.type) | ||
| assertFalse("Mechanism should be unhandled", mechanism.isHandled!!) | ||
| assertEquals(originalException, mechanismException.throwable) | ||
| assertNotNull(mechanismException.thread) | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not throw when sentry capture fails`() { | ||
| sentryMock = mockStatic(Sentry::class.java).also { | ||
| it.`when`<Boolean> { Sentry.isEnabled() }.thenReturn(true) | ||
| it.`when`<Any> { Sentry.captureException(any()) }.thenThrow(RuntimeException("Sentry internal error")) | ||
| } | ||
|
|
||
| val handler = SentryReactNativeHostHandler() | ||
| // Should not throw | ||
| handler.onReactInstanceException(false, IllegalStateException("test")) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package io.sentry.react.expo; | ||
|
|
||
| import android.content.Context; | ||
| import expo.modules.core.interfaces.Package; | ||
| import expo.modules.core.interfaces.ReactNativeHostHandler; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * Expo package that registers {@link SentryReactNativeHostHandler} to capture native exceptions | ||
| * swallowed by Expo's bridgeless error handling. | ||
| * | ||
| * <p>This package is auto-discovered by Expo's autolinking system when {@code @sentry/react-native} | ||
| * is installed in an Expo project. It is not used in non-Expo React Native projects. | ||
| */ | ||
| public class SentryExpoPackage implements Package { | ||
|
|
||
| @Override | ||
| public List<? extends ReactNativeHostHandler> createReactNativeHostHandlers(Context context) { | ||
| return Collections.singletonList(new SentryReactNativeHostHandler()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package io.sentry.react.expo; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you be able to add some tests for this file?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added unit tests in the latest commit (9567e56). Tests cover:
Created an
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Wdyt of adding a short readme with the context on what is the jar file and how to regenerate it if needed (similar to replay-stubs)?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't even know there is a document descrbing replay-stubs! :) Will check it out. |
||
|
|
||
| import androidx.annotation.NonNull; | ||
| import expo.modules.core.interfaces.ReactNativeHostHandler; | ||
| import io.sentry.Sentry; | ||
| import io.sentry.exception.ExceptionMechanismException; | ||
| import io.sentry.protocol.Mechanism; | ||
|
|
||
| /** | ||
| * Captures native Android exceptions that are routed through Expo's {@code | ||
| * ExpoReactHostDelegate.handleInstanceException}. | ||
| * | ||
| * <p>On Expo SDK 53+, certain native exceptions (e.g., IllegalStateException from Fabric's | ||
| * SurfaceMountingManager) are caught by React Native and routed to {@code handleInstanceException}. | ||
| * Expo's implementation iterates registered host handlers but does not rethrow, so the exception | ||
| * never reaches Java's {@code UncaughtExceptionHandler} which Sentry relies on for crash capture. | ||
| * | ||
| * <p>This handler captures those exceptions directly via {@code Sentry.captureException} with an | ||
| * unhandled mechanism, ensuring they appear as crashes in Sentry. | ||
| */ | ||
| public class SentryReactNativeHostHandler implements ReactNativeHostHandler { | ||
|
|
||
| private static final String MECHANISM_TYPE = "expoReactHost"; | ||
|
|
||
| @Override | ||
| public void onReactInstanceException(boolean useDeveloperSupport, @NonNull Exception exception) { | ||
| if (useDeveloperSupport) { | ||
| return; | ||
| } | ||
|
|
||
| if (!Sentry.isEnabled()) { | ||
| return; | ||
| } | ||
|
|
||
| try { // NOPMD - We don't want to crash in any case | ||
| final Mechanism mechanism = new Mechanism(); | ||
| mechanism.setType(MECHANISM_TYPE); | ||
| mechanism.setHandled(false); | ||
|
|
||
| final ExceptionMechanismException mechanismException = | ||
| new ExceptionMechanismException(mechanism, exception, Thread.currentThread()); | ||
|
|
||
| Sentry.captureException(mechanismException); | ||
| } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case | ||
| // ignore | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "platforms": ["android"] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.