diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3c5677dc..58dbc7ab8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - Scopes that are explicitly made current, e.g. via `Sentry.setCurrentScopes` or the `SentryContext` coroutine integration, are now also honoured when `globalHubMode` is enabled - `Sentry.pushScope`, `Sentry.pushIsolationScope` and `Sentry.popScope` remain no-ops when `globalHubMode` is enabled +### Internal + +- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed` ([#5921](https://github.com/getsentry/sentry-java/pull/5921)) + ## 8.54.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4b8b41d41c..f2162e72c5 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -318,6 +318,7 @@ public abstract interface class io/sentry/android/core/IDebugImagesLoader { public final class io/sentry/android/core/InternalSentrySdk { public fun ()V public static fun captureEnvelope ([BZ)Lio/sentry/protocol/SentryId; + public static fun captureEnvelopeNonTerminating ([B)Lio/sentry/protocol/SentryId; public static fun getAppStartMeasurement ()Ljava/util/Map; public static fun getCurrentScope ()Lio/sentry/IScope; public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 2779f803a6..cb612cd7b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -34,6 +34,7 @@ import io.sentry.util.TracingUtils; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; @@ -153,7 +154,12 @@ public static Map serializeScope( * - will not perform any sampling: it's up to the caller to take care of this
* - will enrich the envelope with a Session update if applicable
* + *

Unhandled events ({@code handled=false}) end the session as {@code crashed}. Prefer {@link + * #captureEnvelopeNonTerminating(byte[])} for hybrid runtimes where the process is expected to + * continue (e.g. Flutter). + * * @param envelopeData the serialized envelope data + * @param maybeStartNewSession if true, starts a new session after a crashed session is cleared * @return The Id (SentryId object) of the event, or null in case the envelope could not be * captured */ @@ -163,35 +169,25 @@ public static SentryId captureEnvelope( final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); - try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { + final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData); + if (envelope == null) { + return null; + } + + try { final @NotNull ISerializer serializer = options.getSerializer(); - final @Nullable SentryEnvelope envelope = - options.getEnvelopeReader().read(envelopeInputStream); - if (envelope == null) { - return null; - } + final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); final @NotNull List envelopeItems = new ArrayList<>(); - - // determine session state based on events inside envelope - @Nullable Session.State status = null; - boolean crashedOrErrored = false; for (SentryEnvelopeItem item : envelope.getItems()) { envelopeItems.add(item); - - final SentryEvent event = item.getEvent(serializer); - if (event != null) { - if (event.isCrashed()) { - status = Session.State.Crashed; - } - if (event.isCrashed() || event.isErrored()) { - crashedOrErrored = true; - } - } } // update session and add it to envelope if necessary - final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); + final @Nullable Session.State status = + eventState == EnvelopeEventState.UNHANDLED ? Session.State.Crashed : null; + final @Nullable Session session = + updateSession(scopes, options, status, eventState != EnvelopeEventState.NONE); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); @@ -213,6 +209,139 @@ public static SentryId captureEnvelope( return null; } + /** + * Captures the provided envelope for a non-terminating hybrid exception (e.g. Flutter). + * + *

Compared to {@link #captureEnvelope(byte[], boolean)} this method does not + * treat {@code handled=false} as a crash that ends the session. Instead it: + * + *

+ * + *

Persisting is a blocking disk write on the calling thread, so call this off the main thread + * as the hybrid SDKs do. It is synchronous on purpose: a deferred write would not be on disk yet + * if the process dies right after this returns. + * + *

The session is finalized later by normal lifecycle ({@code endSession} / background / + * previous-session recovery) as {@code unhandled}, unless a terminal status takes over first, + * such as {@code crashed} for a native crash or {@code abnormal} for an ANR. + * + *

Same as {@link #captureEnvelope(byte[], boolean)}, this method will not enrich events, run + * {@code beforeSend}, or sample — the caller is responsible for that. + * + * @param envelopeData the serialized envelope data + * @return the id of the captured envelope, or null if capture failed + */ + @Nullable + public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); + + final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData); + if (envelope == null) { + return null; + } + + final @NotNull EnvelopeEventState eventState; + try { + eventState = eventStateOf(envelope, options.getSerializer()); + } catch (Exception e) { + // getEvent reads through a Callable, whose call() declares Exception + options.getLogger().log(SentryLevel.ERROR, "Failed to inspect envelope events", e); + return null; + } + + if (eventState != EnvelopeEventState.NONE) { + updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED); + } + + return scopes.captureEnvelope(envelope); + } + + /** + * Flags the current session for a non-terminating hybrid error and persists it before returning, + * so the marker survives an immediate process death. + * + *

The write stays inside {@code withSession}: the scope's session lock is what keeps the + * session from being ended or replaced mid-write. + * + * @param unhandled {@code true} if the error was unhandled ({@code mechanism.handled=false}) + */ + private static void updateSessionNonTerminating(final boolean unhandled) { + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); + scopes.configureScope( + scope -> + scope.withSession( + session -> { + if (session == null) { + options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating"); + return; + } + if (session.isTerminated()) { + options + .getLogger() + .log(INFO, "Session already terminated, not recording the error."); + return; + } + final boolean recorded = + unhandled + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (recorded && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + } + })); + } + + /** What the events inside an envelope amount to, from the session's point of view. */ + private enum EnvelopeEventState { + /** No event carried an exception. */ + NONE, + /** At least one event carried an exception, none of them unhandled. */ + ERRORED, + /** At least one event carried an unhandled exception. */ + UNHANDLED + } + + private static @NotNull EnvelopeEventState eventStateOf( + final @NotNull SentryEnvelope envelope, final @NotNull ISerializer serializer) + throws Exception { + boolean unhandled = false; + boolean errored = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.isCrashed()) { + unhandled = true; + } + if (event.isCrashed() || event.isErrored()) { + errored = true; + } + } + } + if (unhandled) { + return EnvelopeEventState.UNHANDLED; + } + return errored ? EnvelopeEventState.ERRORED : EnvelopeEventState.NONE; + } + + private static @Nullable SentryEnvelope readEnvelope( + final @NotNull SentryOptions options, final @NotNull byte[] envelopeData) { + try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { + return options.getEnvelopeReader().read(envelopeInputStream); + } catch (IOException | IllegalArgumentException e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to read envelope", e); + return null; + } + } + public static Map getAppStartMeasurement() { final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); final @NotNull List> spans = new ArrayList<>(); @@ -305,22 +434,26 @@ private static Session updateSession( final @NotNull AtomicReference sessionRef = new AtomicReference<>(); scopes.configureScope( scope -> { - final @Nullable Session session = scope.getSession(); - if (session != null) { - final boolean updated = session.update(status, null, crashedOrErrored, null); - // if we have an uncaughtExceptionHint we can end the session. - if (updated) { - if (session.getStatus() == Session.State.Crashed) { - session.end(); - // Session needs to be removed from the scope, otherwise it will be send twice - // standalone and with the crash event - scope.clearSession(); - } - sessionRef.set(session); - } - } else { - options.getLogger().log(INFO, "Session is null on updateSession"); - } + scope.withSession( + session -> { + if (session != null) { + final boolean updated = session.update(status, null, crashedOrErrored, null); + // if we have an uncaughtExceptionHint we can end the session. + if (updated) { + if (session.getStatus() == Session.State.Crashed) { + session.end(); + // Session needs to be removed from the scope, otherwise it will be send twice + // standalone and with the crash event + scope.clearSession(); + } + // fromSession serializes lazily, on the transport thread, so handing out the + // live session would race a later mutation + sessionRef.set(session.clone()); + } + } else { + options.getLogger().log(INFO, "Session is null on updateSession"); + } + }); }); return sessionRef.get(); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 5917d44d11..88b54ce7c8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -5,6 +5,7 @@ import android.content.ContentProvider import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.IScope @@ -22,6 +23,7 @@ import io.sentry.Session import io.sentry.SpanId import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.cache.EnvelopeCache import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.App import io.sentry.protocol.Contexts @@ -107,6 +109,21 @@ class InternalSentrySdkTest { InternalSentrySdk.captureEnvelope(data, maybeStartNewSession) } + fun captureEnvelopeNonTerminatingWithEvent(event: SentryEvent = SentryEvent()) { + val options = Sentry.getCurrentScopes().options + val eventId = SentryId() + val header = SentryEnvelopeHeader(eventId) + val eventItem = SentryEnvelopeItem.fromEvent(options.serializer, event) + + val envelope = SentryEnvelope(header, listOf(eventItem)) + + val outputStream = ByteArrayOutputStream() + options.serializer.serialize(envelope, outputStream) + val data = outputStream.toByteArray() + + InternalSentrySdk.captureEnvelopeNonTerminating(data) + } + fun createSentryEventWithUnhandledException(): SentryEvent { return SentryEvent(RuntimeException()).apply { val mechanism = Mechanism() @@ -119,6 +136,17 @@ class InternalSentrySdkTest { } } + fun createSentryEventWithHandledException(): SentryEvent { + return SentryEvent(RuntimeException()).apply { + val mechanism = Mechanism() + mechanism.isHandled = true + + val factory = SentryExceptionFactory(mock()) + exceptions = + factory.getSentryExceptions(ExceptionMechanismException(mechanism, Throwable(), Thread())) + } + } + fun mockFinishedAppStart() { val metrics = AppStartMetrics.getInstance() @@ -452,6 +480,138 @@ class InternalSentrySdkTest { assertNotEquals(capturedSession.sessionId, scopeRef.get().session!!.sessionId) } + @Test + fun `captureEnvelopeNonTerminating keeps the session Ok and flags the unhandled error`() { + val fixture = Fixture() + fixture.init(context) + + val originalSid = AtomicReference() + Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) } + + // when capture envelope is called with an unhandled event through the non-terminating API + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + + // then only the original event envelope is captured, without a session item + assertThat(fixture.capturedEnvelopes).hasSize(1) + val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList() + assertThat(capturedEnvelopeItems).hasSize(1) + assertThat(capturedEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event) + + // and the session stays alive on the scope, same id, flagged with the unhandled error + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue() + assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) + + // and it is persisted so the flag survives process death. The fixture leaves the real executor + // service in place, so this only holds if the write happened on the calling thread. + val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! + assertThat(persistedSession.status).isEqualTo(Session.State.Ok) + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue() + assertThat(persistedSession.sessionId).isEqualTo(originalSid.get()) + } + + @Test + fun `captureEnvelopeNonTerminating does not record onto an already terminated session`() { + val fixture = Fixture() + fixture.init(context) + + // given a session already finalized on the scope, as an ANR leaves it + val terminatedSession = AtomicReference() + Sentry.configureScope { scope -> + scope.withSession { session -> + session!!.update(Session.State.Abnormal, null, false, "anr_foreground") + session.end() + } + terminatedSession.set(scope.session) + } + val errorCountBefore = terminatedSession.get().errorCount() + val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + sessionFile.delete() + + // when a handled error arrives through the non-terminating API + fixture.captureEnvelopeNonTerminatingWithEvent(fixture.createSentryEventWithHandledException()) + + // then the finalized session is left alone and never written back to the session file + assertThat(terminatedSession.get().status).isEqualTo(Session.State.Abnormal) + assertThat(terminatedSession.get().errorCount()).isEqualTo(errorCountBefore) + assertThat(sessionFile.exists()).isFalse() + } + + @Test + fun `captureEnvelopeNonTerminating then endSession finalizes the session as unhandled`() { + val fixture = Fixture() + fixture.init(context) + + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + fixture.capturedEnvelopes.clear() + + // when the session is ended by normal lifecycle + Sentry.endSession() + + // then the ended session is captured as unhandled + val sessionItems = + fixture.capturedEnvelopes + .flatMap { it.items.toList() } + .filter { + it.header.type == SentryItemType.Session + } + assertThat(sessionItems).hasSize(1) + val endedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertThat(endedSession.status).isEqualTo(Session.State.Unhandled) + } + + @Test + fun `captureEnvelopeNonTerminating then a crash finalizes old session and starts a new one`() { + val fixture = Fixture() + fixture.init(context) + + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + val unhandledSession = AtomicReference() + Sentry.configureScope { scope -> unhandledSession.set(scope.session) } + val oldSid = unhandledSession.get().sessionId + assertThat(unhandledSession.get().hasNonTerminatingUnhandledError()).isTrue() + fixture.capturedEnvelopes.clear() + + // when a subsequent hard crash is captured through the existing terminating API + fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true) + + // then the crash envelope contains the finalized old session + assertThat(fixture.capturedEnvelopes).hasSize(2) + val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList() + assertThat(crashEnvelopeItems).hasSize(2) + assertThat(crashEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event) + assertThat(crashEnvelopeItems[1].header.type).isEqualTo(SentryItemType.Session) + val crashedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)), + Session::class.java, + )!! + assertThat(crashedSession.status).isEqualTo(Session.State.Crashed) + assertThat(crashedSession.hasNonTerminatingUnhandledError()).isFalse() + assertThat(crashedSession.sessionId).isEqualTo(oldSid) + + // and a new Ok session with a different id is active + val activeSession = AtomicReference() + Sentry.configureScope { scope -> activeSession.set(scope.session) } + assertThat(activeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(activeSession.get().hasNonTerminatingUnhandledError()).isFalse() + assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) + } + @Test fun `getAppStartMeasurement returns correct serialized data from the app start instance`() { Fixture().mockFinishedAppStart() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 093c788981..39fbbe3096 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2517,6 +2517,10 @@ public abstract interface class io/sentry/Scope$IWithPropagationContext { public abstract fun accept (Lio/sentry/PropagationContext;)V } +public abstract interface class io/sentry/Scope$IWithSession { + public abstract fun accept (Lio/sentry/Session;)V +} + public abstract interface class io/sentry/Scope$IWithTransaction { public abstract fun accept (Lio/sentry/ITransaction;)V } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 25f36cd3f5..54e8b89355 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1018,7 +1018,8 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ - interface IWithSession { + @ApiStatus.Internal + public interface IWithSession { /** * The accept method of the callback diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 26300f7300..598fe994fd 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -307,11 +307,12 @@ public boolean update( final boolean addErrorsCount, final @Nullable String abnormalMechanism) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + // TODO(buenaflor): should we reject updates if we are already in a terminal status? boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // the flag only decides how an Ok session is finalized, so an explicit terminal status - // such as a crash or an ANR takes precedence over a non-terminating error. + // a terminal status makes the flag meaningless, so drop it rather than serialize a crashed + // session that claims it did not terminate if (status != State.Ok) { hasNonTerminatingUnhandledError = false; }