diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef91d1cc7..5e61f21138f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0162) - [diff](https://github.com/getsentry/sentry-native/compare/0.16.1...0.16.2) +### 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` ([#5918](https://github.com/getsentry/sentry-java/pull/5918)) + ## 8.52.0 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index da80a74e32c..33ffc24da7a 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -315,6 +315,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/build.gradle.kts b/sentry-android-core/build.gradle.kts index 23248d6dae4..0e3708a89bf 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -115,6 +115,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) 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 2779f803a69..babe34294df 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,14 +169,13 @@ public static SentryId captureEnvelope( final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); - try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { - final @NotNull ISerializer serializer = options.getSerializer(); - final @Nullable SentryEnvelope envelope = - options.getEnvelopeReader().read(envelopeInputStream); - if (envelope == null) { - return null; - } + final @Nullable SentryEnvelope envelope = readEnvelope(options, envelopeData); + if (envelope == null) { + return null; + } + try { + final @NotNull ISerializer serializer = options.getSerializer(); final @NotNull List envelopeItems = new ArrayList<>(); // determine session state based on events inside envelope @@ -207,12 +212,111 @@ public static SentryId captureEnvelope( final SentryEnvelope repackagedEnvelope = new SentryEnvelope(envelope.getHeader(), envelopeItems); return scopes.captureEnvelope(repackagedEnvelope); - } catch (Throwable t) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + } + 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: + * + *

+ * + *

The session is finalized later by normal lifecycle ({@code endSession} / background / + * previous-session recovery) as {@code unhandled}, unless a native crash escalates it to {@code + * crashed}. + * + *

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; + } + + try { + final @NotNull ISerializer serializer = options.getSerializer(); + boolean hasUnhandled = false; + boolean addErrorsCount = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.getUnhandledException() != null) { + hasUnhandled = true; + addErrorsCount = true; + } else if (event.isErrored()) { + addErrorsCount = true; + } + } + } + + if (hasUnhandled || addErrorsCount) { + final boolean unhandled = hasUnhandled; + final boolean addErrors = addErrorsCount; + scopes.configureScope( + scope -> { + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + unhandled + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, addErrors, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()) + .persistCurrentSession(session); + } + } else { + options + .getLogger() + .log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); + }); + } + + // Capture the original envelope as-is (no session item attached). + return scopes.captureEnvelope(envelope); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); } return null; } + /** + * Reads an envelope from the given bytes. Besides the declared {@link IOException}, {@link + * io.sentry.IEnvelopeReader#read(InputStream)} also rejects malformed payloads with an unchecked + * {@link IllegalArgumentException}, hence the broader catch. + */ + 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 (Exception 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<>(); 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 5917d44d11d..c7716321212 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() @@ -452,6 +469,110 @@ 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 pending survives process death + 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 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 pendingSession = AtomicReference() + Sentry.configureScope { scope -> pendingSession.set(scope.session) } + val oldSid = pendingSession.get().sessionId + assertThat(pendingSession.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 e898e2cd59c..1423ee9b65a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2497,6 +2497,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 25f36cd3f59..54e8b893555 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