Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

## Unreleased

### Fixes

- 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

- Bump CLI from v3.8.1 to v3.8.2 ([#6933](https://github.com/getsentry/sentry-react-native/pull/6933))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -628,17 +637,82 @@

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);
}
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<SentryEnvelopeItem> 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<Session> 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);

Check warning on line 711 in packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

View check run for this annotation

@sentry/warden / warden: find-bugs

Current session file is deleted even when hard-crash envelope persistence fails

The hard-crash path deletes the current-session file before storing the repackaged envelope. If `cache.storeEnvelope` fails, the envelope is only queued (or a thrown exception is caught by the outer handler), while the session file is already gone. Preserve the session file unless the envelope is successfully stored to disk, so the next launch can still report the session as abnormal.
Comment on lines +703 to +711

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current session file is deleted even when hard-crash envelope persistence fails

The hard-crash path deletes the current-session file before storing the repackaged envelope. If cache.storeEnvelope fails, the envelope is only queued (or a thrown exception is caught by the outer handler), while the session file is already gone. Preserve the session file unless the envelope is successfully stored to disk, so the next launch can still report the session as abnormal.

Evidence
  • The session is attached to items, then its current-session file is deleted before cache.storeEnvelope is called.
  • When storeEnvelope returns false, the code queues the envelope through scopes.captureEnvelope; the enclosing capture path can then exit while the process is about to terminate.
  • The existing store-failure test checks queue fallback but does not cover a session file surviving a failed store.

Identified by Warden · find-bugs · 638-86W

}
return true;
}

public void captureScreenshot(Promise promise) {

final Activity activity = getCurrentActivity();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SentryItemType> itemTypes(final SentryEnvelope envelope) {
final List<SentryItemType> 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> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> internal = mockStatic(InternalSentrySdk.class)) {
sentry.when(Sentry::getCurrentScopes).thenReturn(scopes);

module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise);

final ArgumentCaptor<SentryEnvelope> 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> sentry = mockStatic(Sentry.class);
MockedStatic<Log> ignored = mockStatic(Log.class)) {
sentry.when(Sentry::getCurrentScopes).thenReturn(scopes);

module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise);

final ArgumentCaptor<SentryEnvelope> 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> sentry = mockStatic(Sentry.class);
MockedStatic<Log> 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> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> 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> sentry = mockStatic(Sentry.class);
MockedStatic<InternalSentrySdk> 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);
}
}
}
Loading