From ca093b415edd29eea31bbc153afb78b04c65ee0c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:26:52 +0200 Subject: [PATCH 1/8] feat(android): Add InternalSentrySdk.captureEnvelopeNonTerminating Hybrid runtimes such as Flutter report unhandled exceptions that do not terminate the process. Routing those through captureEnvelope ends the session as crashed and starts a replacement one, which understates crash-free session rates. The new entry point keeps the session alive with the same id, increments its error count, and marks it pending-unhandled so it finalizes as unhandled at its natural end. Co-authored-by: Cursor --- .../api/sentry-android-core.api | 1 + sentry-android-core/build.gradle.kts | 1 + .../android/core/InternalSentrySdk.java | 121 ++++++++++++++++-- .../android/core/InternalSentrySdkTest.kt | 121 ++++++++++++++++++ sentry/api/sentry.api | 4 + sentry/src/main/java/io/sentry/Scope.java | 3 +- 6 files changed, 241 insertions(+), 10 deletions(-) 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..227ee2078ce 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,110 @@ 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: + * + *

    + *
  • marks the current session as pending-unhandled and increments the error count + *
  • keeps session status {@code Ok} and the same session id on the scope + *
  • does not attach a session update item to this envelope + *
  • does not start a new session + *
  • persists the current session so pending-unhandled survives process death + *
+ * + *

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 markPendingUnhandled = false; + boolean addErrorsCount = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.getUnhandledException() != null) { + markPendingUnhandled = true; + addErrorsCount = true; + } else if (event.isErrored()) { + addErrorsCount = true; + } + } + } + + if (markPendingUnhandled || addErrorsCount) { + final boolean pending = markPendingUnhandled; + final boolean addErrors = addErrorsCount; + scopes.configureScope( + scope -> { + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + pending + ? session.markPendingUnhandled() + : 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..852758b4201 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 marks it pending unhandled`() { + 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, marked pending unhandled + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(scopeSession.get().isPendingUnhandled).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.isPendingUnhandled).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().isPendingUnhandled).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.isPendingUnhandled).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().isPendingUnhandled).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 cbe60812417..fbcbf21e824 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 From 9fe1d59cf54a7fceea6e936f2a63b2ef2158f912 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:27:33 +0200 Subject: [PATCH 2/8] changelog Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 97cb26432f30581601954497e2539c59a1828530 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:40:20 +0200 Subject: [PATCH 3/8] ref: follow Session rename in InternalSentrySdk Co-authored-by: Cursor --- .../sentry/android/core/InternalSentrySdk.java | 17 +++++++++-------- .../android/core/InternalSentrySdkTest.kt | 14 +++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) 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 227ee2078ce..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 @@ -225,11 +225,12 @@ public static SentryId captureEnvelope( * treat {@code handled=false} as a crash that ends the session. Instead it: * *

    - *
  • marks the current session as pending-unhandled and increments the error count + *
  • flags the current session with a non-terminating unhandled error and increments the error + * count *
  • keeps session status {@code Ok} and the same session id on the scope *
  • does not attach a session update item to this envelope *
  • does not start a new session - *
  • persists the current session so pending-unhandled survives process death + *
  • persists the current session so the flag survives process death *
* *

The session is finalized later by normal lifecycle ({@code endSession} / background / @@ -254,13 +255,13 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel try { final @NotNull ISerializer serializer = options.getSerializer(); - boolean markPendingUnhandled = false; + boolean hasUnhandled = false; boolean addErrorsCount = false; for (SentryEnvelopeItem item : envelope.getItems()) { final SentryEvent event = item.getEvent(serializer); if (event != null) { if (event.getUnhandledException() != null) { - markPendingUnhandled = true; + hasUnhandled = true; addErrorsCount = true; } else if (event.isErrored()) { addErrorsCount = true; @@ -268,8 +269,8 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel } } - if (markPendingUnhandled || addErrorsCount) { - final boolean pending = markPendingUnhandled; + if (hasUnhandled || addErrorsCount) { + final boolean unhandled = hasUnhandled; final boolean addErrors = addErrorsCount; scopes.configureScope( scope -> { @@ -277,8 +278,8 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel session -> { if (session != null) { final boolean updated = - pending - ? session.markPendingUnhandled() + unhandled + ? session.recordNonTerminatingUnhandledError() : session.update(null, null, addErrors, null); if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { ((EnvelopeCache) options.getEnvelopeDiskCache()) 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 852758b4201..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 @@ -470,7 +470,7 @@ class InternalSentrySdkTest { } @Test - fun `captureEnvelopeNonTerminating keeps the session Ok and marks it pending unhandled`() { + fun `captureEnvelopeNonTerminating keeps the session Ok and flags the unhandled error`() { val fixture = Fixture() fixture.init(context) @@ -488,11 +488,11 @@ class InternalSentrySdkTest { assertThat(capturedEnvelopeItems).hasSize(1) assertThat(capturedEnvelopeItems[0].header.type).isEqualTo(SentryItemType.Event) - // and the session stays alive on the scope, same id, marked pending unhandled + // 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().isPendingUnhandled).isTrue() + assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue() assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) // and it is persisted so pending survives process death @@ -500,7 +500,7 @@ class InternalSentrySdkTest { val persistedSession = fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! assertThat(persistedSession.status).isEqualTo(Session.State.Ok) - assertThat(persistedSession.isPendingUnhandled).isTrue() + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue() assertThat(persistedSession.sessionId).isEqualTo(originalSid.get()) } @@ -544,7 +544,7 @@ class InternalSentrySdkTest { val pendingSession = AtomicReference() Sentry.configureScope { scope -> pendingSession.set(scope.session) } val oldSid = pendingSession.get().sessionId - assertThat(pendingSession.get().isPendingUnhandled).isTrue() + assertThat(pendingSession.get().hasNonTerminatingUnhandledError()).isTrue() fixture.capturedEnvelopes.clear() // when a subsequent hard crash is captured through the existing terminating API @@ -562,14 +562,14 @@ class InternalSentrySdkTest { Session::class.java, )!! assertThat(crashedSession.status).isEqualTo(Session.State.Crashed) - assertThat(crashedSession.isPendingUnhandled).isFalse() + 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().isPendingUnhandled).isFalse() + assertThat(activeSession.get().hasNonTerminatingUnhandledError()).isFalse() assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) } From 2ba6b352338a8995198244649916f83b2ebc9009 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:49:55 +0200 Subject: [PATCH 4/8] changelog Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e61f21138f..77294f14336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### 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)) +- 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.52.0 From 594619032bac6b7069d3727099ef2cb086b23a27 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:49:34 +0200 Subject: [PATCH 5/8] ref: drop a redundant comment and stale pending wording Co-authored-by: Cursor --- .../java/io/sentry/android/core/InternalSentrySdk.java | 1 - .../io/sentry/android/core/InternalSentrySdkTest.kt | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) 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 babe34294df..235922b4613 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 @@ -294,7 +294,6 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel }); } - // 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); 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 c7716321212..ea3f7170008 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 @@ -495,7 +495,7 @@ class InternalSentrySdkTest { assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue() assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) - // and it is persisted so pending survives process death + // and it is persisted so the flag survives process death val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) val persistedSession = fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! @@ -541,10 +541,10 @@ class InternalSentrySdkTest { fixture.captureEnvelopeNonTerminatingWithEvent( fixture.createSentryEventWithUnhandledException() ) - val pendingSession = AtomicReference() - Sentry.configureScope { scope -> pendingSession.set(scope.session) } - val oldSid = pendingSession.get().sessionId - assertThat(pendingSession.get().hasNonTerminatingUnhandledError()).isTrue() + 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 From 3124adefd245873c8ec06661d3e192ba5aeb330d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:26:38 +0200 Subject: [PATCH 6/8] ref(android): share one event scan between the two captureEnvelope methods Both methods scanned the envelope's events to derive the same pair of booleans, but wrote it differently - one via isCrashed(), the other via getUnhandledException() != null, which is the same predicate. Extract a single scanEvents returning NONE/ERRORED/UNHANDLED so the two agree by construction and an unhandled-but-not-errored state is unrepresentable. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) 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 235922b4613..39ac2114b6b 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 @@ -176,27 +176,18 @@ public static SentryId captureEnvelope( try { final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull List envelopeItems = new ArrayList<>(); + final @NotNull EnvelopeEvents events = scanEvents(envelope, serializer); - // determine session state based on events inside envelope - @Nullable Session.State status = null; - boolean crashedOrErrored = false; + final @NotNull List envelopeItems = new ArrayList<>(); 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 = + events == EnvelopeEvents.UNHANDLED ? Session.State.Crashed : null; + final @Nullable Session session = + updateSession(scopes, options, status, events != EnvelopeEvents.NONE); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); @@ -255,32 +246,18 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel 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; - } - } - } + final @NotNull EnvelopeEvents events = scanEvents(envelope, serializer); - if (hasUnhandled || addErrorsCount) { - final boolean unhandled = hasUnhandled; - final boolean addErrors = addErrorsCount; + if (events != EnvelopeEvents.NONE) { scopes.configureScope( scope -> { scope.withSession( session -> { if (session != null) { final boolean updated = - unhandled + events == EnvelopeEvents.UNHANDLED ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, addErrors, null); + : session.update(null, null, true, null); if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { ((EnvelopeCache) options.getEnvelopeDiskCache()) .persistCurrentSession(session); @@ -301,6 +278,38 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } + /** What the events inside an envelope amount to, from the session's point of view. */ + private enum EnvelopeEvents { + /** 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 EnvelopeEvents scanEvents( + 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 EnvelopeEvents.UNHANDLED; + } + return errored ? EnvelopeEvents.ERRORED : EnvelopeEvents.NONE; + } + /** * 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 From 2b6d6bb9d2237bcdffbc60131c201fba95544301 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:30:16 +0200 Subject: [PATCH 7/8] ref(session): drop the inert ApiStatus.Internal from IWithSession The annotation had no mechanical effect: apiValidation configures only ignoredPackages/ignoredProjects and no nonPublicMarkers, so the type is tracked in sentry.api either way. Regenerating the dump after removing it produces no diff. The interface still has to be public, since the lambda in InternalSentrySdk.captureEnvelopeNonTerminating targets it from io.sentry.android.core. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Scope.java | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 54e8b893555..734ee5b69b2 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1018,7 +1018,6 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ - @ApiStatus.Internal public interface IWithSession { /** From 9e9e22862b709b141f07fe20bf32176da472b45f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 14:30:24 +0200 Subject: [PATCH 8/8] ref(android): rename scanEvents to eventStateOf Both the method and the enum were plural nouns that read as if they returned the envelope's events, when they return a single summary value. That made "events != EnvelopeEvents.NONE" look like an emptiness check rather than "nothing worth recording happened". EnvelopeEventState also lines up with the Session.State vocabulary already used here. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 39ac2114b6b..9d2bfc19fdb 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 @@ -176,7 +176,7 @@ public static SentryId captureEnvelope( try { final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEvents events = scanEvents(envelope, serializer); + final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); final @NotNull List envelopeItems = new ArrayList<>(); for (SentryEnvelopeItem item : envelope.getItems()) { @@ -185,9 +185,9 @@ public static SentryId captureEnvelope( // update session and add it to envelope if necessary final @Nullable Session.State status = - events == EnvelopeEvents.UNHANDLED ? Session.State.Crashed : null; + eventState == EnvelopeEventState.UNHANDLED ? Session.State.Crashed : null; final @Nullable Session session = - updateSession(scopes, options, status, events != EnvelopeEvents.NONE); + updateSession(scopes, options, status, eventState != EnvelopeEventState.NONE); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); @@ -246,16 +246,16 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel try { final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEvents events = scanEvents(envelope, serializer); + final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); - if (events != EnvelopeEvents.NONE) { + if (eventState != EnvelopeEventState.NONE) { scopes.configureScope( scope -> { scope.withSession( session -> { if (session != null) { final boolean updated = - events == EnvelopeEvents.UNHANDLED + eventState == EnvelopeEventState.UNHANDLED ? session.recordNonTerminatingUnhandledError() : session.update(null, null, true, null); if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { @@ -279,7 +279,7 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel } /** What the events inside an envelope amount to, from the session's point of view. */ - private enum EnvelopeEvents { + private enum EnvelopeEventState { /** No event carried an exception. */ NONE, /** At least one event carried an exception, none of them unhandled. */ @@ -288,7 +288,7 @@ private enum EnvelopeEvents { UNHANDLED } - private static @NotNull EnvelopeEvents scanEvents( + private static @NotNull EnvelopeEventState eventStateOf( final @NotNull SentryEnvelope envelope, final @NotNull ISerializer serializer) throws Exception { boolean unhandled = false; @@ -305,9 +305,9 @@ private enum EnvelopeEvents { } } if (unhandled) { - return EnvelopeEvents.UNHANDLED; + return EnvelopeEventState.UNHANDLED; } - return errored ? EnvelopeEvents.ERRORED : EnvelopeEvents.NONE; + return errored ? EnvelopeEventState.ERRORED : EnvelopeEventState.NONE; } /**