From d78dd315edf6cd1e92e868bb06ce9eeb2297284b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 23 Oct 2026 12:26:39 +0200 Subject: [PATCH 1/2] fix(android): Persist hard-crash envelopes to disk before the process exits A fatal JS error was captured through InternalSentrySdk.captureEnvelope, which only queues the envelope on the single-threaded transport executor. React Native then rethrows the error as JavascriptException, an ignored exception type, so UncaughtExceptionHandlerIntegration skips the disk flush and the process dies with the envelope still in memory. In release builds most fatal JS errors never reached Sentry. When hardCrashed is set, read the envelope, end the current session as crashed and attach it (the same session handling as InternalSentrySdk.captureEnvelope), delete the current-session file, and write the envelope to options.getEnvelopeDiskCache() on the calling thread. The next launch sends the cached file, which matches the iOS store path. Without a disk cache, or when the envelope cannot be read, the previous queued path runs. When the store itself fails, the repackaged envelope is queued instead so only one copy exists. Fixes #6918 --- CHANGELOG.md | 5 + .../io/sentry/react/RNSentryModuleImpl.java | 78 +++++- .../react/RNSentryCaptureEnvelopeTest.java | 235 ++++++++++++++++++ 3 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3185190039..352240cbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ ## Unreleased +### Fixes + +- Persist fatal JS error envelopes to disk on Android before the process exits ([#NNNN](https://github.com/getsentry/sentry-react-native/pull/NNNN)) + - The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS. + ### Dependencies - Bump CLI from v3.8.1 to v3.8.2 ([#6933](https://github.com/getsentry/sentry-react-native/pull/6933)) 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 2b313e8050..af092a5680 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 @@ -30,8 +30,10 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; +import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.IScope; +import io.sentry.IScopes; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; import io.sentry.PerformanceCollectionData; @@ -41,9 +43,12 @@ import io.sentry.SentryAttributes; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.Session; import io.sentry.android.core.AndroidProfiler; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.InternalSentrySdk; @@ -55,12 +60,15 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; +import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -69,6 +77,7 @@ import io.sentry.vendor.Base64; import java.io.BufferedInputStream; import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; @@ -628,10 +637,12 @@ public void registerReplayTraceId(String traceId) { public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT); + final boolean hardCrashed = options.hasKey("hardCrashed") && options.getBoolean("hardCrashed"); try { - InternalSentrySdk.captureEnvelope( - bytes, !options.hasKey("hardCrashed") || !options.getBoolean("hardCrashed")); + if (!hardCrashed || !storeHardCrashEnvelope(bytes)) { + InternalSentrySdk.captureEnvelope(bytes, !hardCrashed); + } } catch (Throwable e) { // NOPMD - We don't want to crash in any case logger.log(SentryLevel.ERROR, "Error while capturing envelope"); promise.resolve(false); @@ -639,6 +650,69 @@ public void captureEnvelope(String rawBytes, ReadableMap options, Promise promis promise.resolve(true); } + /** + * Writes a hard-crash envelope to the offline cache on the calling thread, like iOS {@code + * store}. {@code InternalSentrySdk.captureEnvelope} only queues the envelope on the transport + * executor; React Native then rethrows the error as {@code JavascriptException}, which is an + * ignored exception type, so the process dies before the executor writes the envelope to disk. + * The next launch sends the cached file. Returns false without touching anything when there is no + * disk cache, so the caller falls back to the queued path. + */ + private boolean storeHardCrashEnvelope(final @NotNull byte[] bytes) throws IOException { + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions sentryOptions = scopes.getOptions(); + final @NotNull IEnvelopeCache cache = sentryOptions.getEnvelopeDiskCache(); + final @Nullable String cacheDirPath = sentryOptions.getCacheDirPath(); + if (!sentryOptions.isEnabled() || cacheDirPath == null || cache instanceof NoOpEnvelopeCache) { + return false; + } + + final @Nullable SentryEnvelope envelope; + try (InputStream in = new ByteArrayInputStream(bytes)) { + envelope = sentryOptions.getEnvelopeReader().read(in); + } catch (IOException | IllegalArgumentException e) { + logger.log(SentryLevel.ERROR, "Failed to read the hard crash envelope.", e); + return false; + } + if (envelope == null) { + return false; + } + + final @NotNull List items = new ArrayList<>(); + for (final SentryEnvelopeItem item : envelope.getItems()) { + items.add(item); + } + + // Same session handling as InternalSentrySdk.captureEnvelope for a crash: end the session as + // crashed, remove it from the scope so it is not sent twice, and delete the persisted + // current-session file so the next launch does not also report it as abnormal. + final @NotNull AtomicReference crashedSession = new AtomicReference<>(); + scopes.configureScope( + scope -> + scope.withSession( + session -> { + if (session != null && session.update(Session.State.Crashed, null, true, null)) { + session.end(); + scope.clearSession(); + crashedSession.set(session.clone()); + } + })); + final @Nullable Session session = crashedSession.get(); + if (session != null) { + items.add(SentryEnvelopeItem.fromSession(sentryOptions.getSerializer(), session)); + if (!EnvelopeCache.getCurrentSessionFile(cacheDirPath).delete()) { + logger.log(SentryLevel.WARNING, "Failed to delete the current session file."); + } + } + + final @NotNull SentryEnvelope repackaged = new SentryEnvelope(envelope.getHeader(), items); + if (!cache.storeEnvelope(repackaged, new Hint())) { + logger.log(SentryLevel.ERROR, "Failed to store the hard crash envelope, queueing it."); + scopes.captureEnvelope(repackaged); + } + return true; + } + public void captureScreenshot(Promise promise) { final Activity activity = getCurrentActivity(); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java new file mode 100644 index 0000000000..03f6c2d795 --- /dev/null +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java @@ -0,0 +1,235 @@ +package io.sentry.react; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.util.Log; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReadableMap; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.Scope; +import io.sentry.ScopeCallback; +import io.sentry.Sentry; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryOptions; +import io.sentry.Session; +import io.sentry.android.core.InternalSentrySdk; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; +import io.sentry.transport.NoOpEnvelopeCache; +import io.sentry.vendor.Base64; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** + * A fatal JS error is rethrown by React Native as JavascriptException, which the SDK ignores, so + * the process dies right after captureEnvelope returns. The hard-crash path must write the envelope + * to disk on the calling thread instead of queueing it on the transport executor + * (https://github.com/getsentry/sentry-react-native/issues/6918). + */ +public class RNSentryCaptureEnvelopeTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + private RNSentryModuleImpl module; + private SentryOptions options; + private Scope scope; + private IScopes scopes; + private IEnvelopeCache cache; + private Promise promise; + + @Before + public void setUp() throws Exception { + ReactApplicationContext reactContext = mock(ReactApplicationContext.class); + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo()); + when(reactContext.getPackageManager()).thenReturn(packageManager); + when(reactContext.getPackageName()).thenReturn("com.test.app"); + module = new RNSentryModuleImpl(reactContext); + + options = new SentryOptions(); + options.setDsn("https://key@sentry.io/1"); + options.setRelease("1.0.0"); + options.setCacheDirPath(tmp.getRoot().getAbsolutePath()); + cache = mock(IEnvelopeCache.class); + when(cache.storeEnvelope(any(), any())).thenReturn(true); + options.setEnvelopeDiskCache(cache); + + scope = new Scope(options); + scopes = mock(IScopes.class); + when(scopes.getOptions()).thenReturn(options); + doAnswer( + invocation -> { + ((ScopeCallback) invocation.getArgument(0)).run(scope); + return null; + }) + .when(scopes) + .configureScope(any(ScopeCallback.class)); + + promise = mock(Promise.class); + } + + private static ReadableMap hardCrashed(final boolean value) { + final ReadableMap map = mock(ReadableMap.class); + when(map.hasKey("hardCrashed")).thenReturn(true); + when(map.getBoolean("hardCrashed")).thenReturn(value); + return map; + } + + private String eventEnvelope() throws Exception { + final SentryEnvelope envelope = + SentryEnvelope.from(options.getSerializer(), new SentryEvent(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + options.getSerializer().serialize(envelope, out); + return Base64.encodeToString(out.toByteArray(), Base64.DEFAULT); + } + + private static List itemTypes(final SentryEnvelope envelope) { + final List types = new ArrayList<>(); + for (final SentryEnvelopeItem item : envelope.getItems()) { + types.add(item.getHeader().getType()); + } + return types; + } + + @Test + public void hardCrashStoresEnvelopeOnCallingThreadAndDoesNotQueueIt() throws Exception { + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + final ArgumentCaptor stored = ArgumentCaptor.forClass(SentryEnvelope.class); + verify(cache).storeEnvelope(stored.capture(), any(Hint.class)); + assertEquals(List.of(SentryItemType.Event), itemTypes(stored.getValue())); + verify(scopes, never()).captureEnvelope(any()); + internal.verify(() -> InternalSentrySdk.captureEnvelope(any(), anyBoolean()), never()); + verify(promise).resolve(true); + } + } + + @Test + public void hardCrashEndsSessionAsCrashedAndAttachesIt() throws Exception { + scope.startSession(); + final File sessionFile = EnvelopeCache.getCurrentSessionFile(options.getCacheDirPath()); + assertTrue(sessionFile.getParentFile().mkdirs()); + assertTrue(sessionFile.createNewFile()); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic ignored = mockStatic(Log.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + final ArgumentCaptor stored = ArgumentCaptor.forClass(SentryEnvelope.class); + verify(cache).storeEnvelope(stored.capture(), any(Hint.class)); + assertEquals( + List.of(SentryItemType.Event, SentryItemType.Session), itemTypes(stored.getValue())); + + Session attached = null; + for (final SentryEnvelopeItem item : stored.getValue().getItems()) { + if (item.getHeader().getType() == SentryItemType.Session) { + attached = + options + .getSerializer() + .deserialize( + new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8), + Session.class); + } + } + assertNotNull(attached); + assertEquals(Session.State.Crashed, attached.getStatus()); + assertEquals(1, attached.errorCount()); + assertNotNull(attached.getTimestamp()); + assertNull(scope.withSession(session -> {})); + assertFalse(sessionFile.exists()); + } + } + + @Test + public void hardCrashFallsBackToQueueWhenStoreFails() throws Exception { + when(cache.storeEnvelope(any(), any())).thenReturn(false); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic ignored = mockStatic(Log.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + verify(cache).storeEnvelope(any(), any(Hint.class)); + verify(scopes).captureEnvelope(any(SentryEnvelope.class)); + verify(promise).resolve(true); + } + } + + @Test + public void hardCrashUsesQueuedPathWithoutDiskCache() throws Exception { + options.setEnvelopeDiskCache(NoOpEnvelopeCache.getInstance()); + final String rawBytes = eventEnvelope(); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(rawBytes, hardCrashed(true), promise); + + internal.verify( + () -> + InternalSentrySdk.captureEnvelope( + eq(Base64.decode(rawBytes, Base64.DEFAULT)), eq(false))); + verify(promise).resolve(true); + } + } + + @Test + public void nonFatalEventUsesQueuedPath() throws Exception { + final String rawBytes = eventEnvelope(); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(rawBytes, hardCrashed(false), promise); + + verify(cache, never()).storeEnvelope(any(), any()); + internal.verify( + () -> + InternalSentrySdk.captureEnvelope( + eq(Base64.decode(rawBytes, Base64.DEFAULT)), eq(true))); + verify(promise).resolve(true); + } + } +} From dc4fb97b72a3157f89f60f77ee593fdd0833fcc8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 23 Oct 2026 13:13:13 +0200 Subject: [PATCH 2/2] docs(changelog): Add PR number to the hard-crash envelope entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 352240cbf6..de681fe18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixes -- Persist fatal JS error envelopes to disk on Android before the process exits ([#NNNN](https://github.com/getsentry/sentry-react-native/pull/NNNN)) +- Persist fatal JS error envelopes to disk on Android before the process exits ([#6937](https://github.com/getsentry/sentry-react-native/pull/6937)) - The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS. ### Dependencies