From 8506cf68f306eaf8b12e02a8cbdab167293cfc8f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:26:52 +0200 Subject: [PATCH 01/25] 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 + .../android/core/InternalSentrySdk.java | 121 ++++++++++++++++-- .../android/core/InternalSentrySdkTest.kt | 121 ++++++++++++++++++ sentry/api/sentry.api | 4 + sentry/src/main/java/io/sentry/Scope.java | 3 +- 5 files changed, 240 insertions(+), 10 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4b8b41d41c1..f2162e72c55 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 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 093c788981e..39fbbe3096d 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 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 53e48ca70632d293602b05bb248eaa97b2bd41ba Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:27:33 +0200 Subject: [PATCH 02/25] changelog Co-authored-by: Cursor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9579bd744f6..58f800e146d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,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 90c8294c574ff07b4f6cd97d8da9be00da30bc20 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:40:20 +0200 Subject: [PATCH 03/25] 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 917d8575296f6a83981b7c5a208bae869a2cbb46 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:49:55 +0200 Subject: [PATCH 04/25] changelog Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f800e146d..eac6f8f9ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,7 +80,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 80ffe96bc96a17b1e1a75c36e87cea972b280bc8 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:49:34 +0200 Subject: [PATCH 05/25] 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 d7245f26de8d437c934e18570a2d514b7ba740ca Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:26:38 +0200 Subject: [PATCH 06/25] 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 b2b3f9d4d403963fc556c83350c53e15f00a170b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:30:16 +0200 Subject: [PATCH 07/25] 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 7f847b5349db040553cf3554cdbac177bc508e78 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 14:30:24 +0200 Subject: [PATCH 08/25] 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; } /** From 22461988dec8ed07d9ba99f6f77ae69fca3ced6d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 10:04:52 +0200 Subject: [PATCH 09/25] ref(android): restore catch (Throwable) in captureEnvelope Same reasoning as the cache change: this catch predates the feature, and narrowing it changed how an Error during capture behaves for every existing caller while leaving the file's three other catch (Throwable) blocks untouched. captureEnvelopeNonTerminating and readEnvelope are new code and keep catch (Exception). Co-authored-by: Cursor --- .../main/java/io/sentry/android/core/InternalSentrySdk.java | 4 ++-- 1 file changed, 2 insertions(+), 2 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 9d2bfc19fdb..8f880977a17 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 @@ -203,8 +203,8 @@ public static SentryId captureEnvelope( final SentryEnvelope repackagedEnvelope = new SentryEnvelope(envelope.getHeader(), envelopeItems); return scopes.captureEnvelope(repackagedEnvelope); - } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } return null; } From f307465ccdf82b578f46fd151c18d4c828152dd9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 17:06:26 +0200 Subject: [PATCH 10/25] ref(scope): mark IWithSession as internal Matches IWithTransaction and IWithPropagationContext. The interface has to be public for InternalSentrySdk to use it, but it is not supported API. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Scope.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 734ee5b69b2..54e8b893555 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1018,6 +1018,7 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ + @ApiStatus.Internal public interface IWithSession { /** From c3ce7cb37c683deaecd2703c259ae3e0c6809bee Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 11:33:56 +0200 Subject: [PATCH 11/25] ref(scope): Drop Internal from IWithSession 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 490e38bcb70e123a64607e9664dfbf9b26fca11d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 11:43:57 +0200 Subject: [PATCH 12/25] ref(scope): Keep IWithSession package-private Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 29 ++++++++----------- sentry/api/sentry.api | 4 --- sentry/src/main/java/io/sentry/Scope.java | 2 +- 3 files changed, 13 insertions(+), 22 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 8f880977a17..c0b7d788e52 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 @@ -251,23 +251,18 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel if (eventState != EnvelopeEventState.NONE) { scopes.configureScope( scope -> { - scope.withSession( - session -> { - if (session != null) { - final boolean updated = - eventState == EnvelopeEventState.UNHANDLED - ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null); - if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()) - .persistCurrentSession(session); - } - } else { - options - .getLogger() - .log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } - }); + final @Nullable Session session = scope.getSession(); + if (session != null) { + final boolean updated = + eventState == EnvelopeEventState.UNHANDLED + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + } + } else { + options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } }); } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 39fbbe3096d..093c788981e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2517,10 +2517,6 @@ 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 734ee5b69b2..25f36cd3f59 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1018,7 +1018,7 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ - public interface IWithSession { + interface IWithSession { /** * The accept method of the callback From 47d9440d430ad30852d57dd25de4bc91d9c8f73c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 12:33:56 +0200 Subject: [PATCH 13/25] ref(scope): Mark IWithSession public internal like IWithTransaction Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 29 +++++++++++-------- sentry/api/sentry.api | 4 +++ sentry/src/main/java/io/sentry/Scope.java | 3 +- 3 files changed, 23 insertions(+), 13 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 c0b7d788e52..8f880977a17 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 @@ -251,18 +251,23 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel if (eventState != EnvelopeEventState.NONE) { scopes.configureScope( scope -> { - final @Nullable Session session = scope.getSession(); - if (session != null) { - final boolean updated = - eventState == EnvelopeEventState.UNHANDLED - ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null); - if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); - } - } else { - options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + eventState == EnvelopeEventState.UNHANDLED + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()) + .persistCurrentSession(session); + } + } else { + options + .getLogger() + .log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); }); } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 093c788981e..39fbbe3096d 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 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 c7524fcb9a17e51fa2c8ec264af1afdfea58881d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 12:54:33 +0200 Subject: [PATCH 14/25] changelog Co-authored-by: Cursor --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eac6f8f9ccb..73512c95d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0164) - [diff](https://github.com/getsentry/sentry-native/compare/0.16.2...0.16.4) +### 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.53.0 ### Features @@ -78,10 +82,6 @@ - [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` ([#5921](https://github.com/getsentry/sentry-java/pull/5921)) - ## 8.52.0 ### Fixes From d43958094b62d72486c03395b0124b34d7a0e975 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:12:33 +0200 Subject: [PATCH 15/25] ref(android): Persist the session snapshot outside the scope lock captureEnvelopeNonTerminating called persistCurrentSession from inside the withSession callback, so synchronous session-file I/O ran while holding the scope sessionLock, on the platform thread for Flutter. Mutate under the lock and write the clone withSession returns, the way SentryClient.updateSessionData uses its snapshot. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 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 8f880977a17..192fa870f14 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 @@ -41,6 +41,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -249,33 +250,51 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); if (eventState != EnvelopeEventState.NONE) { - scopes.configureScope( - scope -> { + final @Nullable Session session = recordSessionError(scopes, options, eventState); + if (session != null && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + } + } + + return scopes.captureEnvelope(envelope); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + } + return null; + } + + /** + * Records the envelope's error on the current session under the scope lock and returns the + * snapshot taken while holding it, so the caller can persist it without keeping the lock during + * disk I/O. Returns null when there is no session or it had already reached a terminal state. + */ + private static @Nullable Session recordSessionError( + final @NotNull IScopes scopes, + final @NotNull SentryOptions options, + final @NotNull EnvelopeEventState eventState) { + final @NotNull AtomicReference snapshotRef = new AtomicReference<>(); + scopes.configureScope( + scope -> { + final @NotNull AtomicBoolean updated = new AtomicBoolean(false); + final @Nullable Session snapshot = scope.withSession( session -> { if (session != null) { - final boolean updated = + updated.set( eventState == EnvelopeEventState.UNHANDLED ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null); - if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()) - .persistCurrentSession(session); - } + : session.update(null, null, true, null)); } else { options .getLogger() .log(INFO, "Session is null on captureEnvelopeNonTerminating"); } }); - }); - } - - return scopes.captureEnvelope(envelope); - } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); - } - return null; + if (updated.get()) { + snapshotRef.set(snapshot); + } + }); + return snapshotRef.get(); } /** What the events inside an envelope amount to, from the session's point of view. */ From cdd99981b05ee9028eef50181ff2d5a7130eda2a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:19:22 +0200 Subject: [PATCH 16/25] Revert "ref(android): Persist the session snapshot outside the scope lock" This reverts commit da288f8168c7d8de6503511630f842bba16eb987. --- .../android/core/InternalSentrySdk.java | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 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 192fa870f14..8f880977a17 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 @@ -41,7 +41,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -250,51 +249,33 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); if (eventState != EnvelopeEventState.NONE) { - final @Nullable Session session = recordSessionError(scopes, options, eventState); - if (session != null && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); - } - } - - return scopes.captureEnvelope(envelope); - } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); - } - return null; - } - - /** - * Records the envelope's error on the current session under the scope lock and returns the - * snapshot taken while holding it, so the caller can persist it without keeping the lock during - * disk I/O. Returns null when there is no session or it had already reached a terminal state. - */ - private static @Nullable Session recordSessionError( - final @NotNull IScopes scopes, - final @NotNull SentryOptions options, - final @NotNull EnvelopeEventState eventState) { - final @NotNull AtomicReference snapshotRef = new AtomicReference<>(); - scopes.configureScope( - scope -> { - final @NotNull AtomicBoolean updated = new AtomicBoolean(false); - final @Nullable Session snapshot = + scopes.configureScope( + scope -> { scope.withSession( session -> { if (session != null) { - updated.set( + final boolean updated = eventState == EnvelopeEventState.UNHANDLED ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null)); + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()) + .persistCurrentSession(session); + } } else { options .getLogger() .log(INFO, "Session is null on captureEnvelopeNonTerminating"); } }); - if (updated.get()) { - snapshotRef.set(snapshot); - } - }); - return snapshotRef.get(); + }); + } + + return scopes.captureEnvelope(envelope); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + } + return null; } /** What the events inside an envelope amount to, from the session's point of view. */ From dea45862489021b2f55c4aa3492b6b6f4048e83b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:33:21 +0200 Subject: [PATCH 17/25] docs(android): Record why the session persist sits inside withSession Three reviewers in a row read the persist as accidentally holding the scope lock, so say why it is deliberate. Also name abnormal alongside crashed as a terminal status that wins over the unhandled marker, following the Session javadoc. Co-authored-by: Cursor --- .../java/io/sentry/android/core/InternalSentrySdk.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 8f880977a17..3a4f53a62a0 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,8 +225,8 @@ public static SentryId captureEnvelope( * * *

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}. + * 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. @@ -251,6 +251,9 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel if (eventState != EnvelopeEventState.NONE) { scopes.configureScope( scope -> { + // the write stays inside the callback so the mutation and the persist are one + // critical section. Persisting outside it lets a concurrent caller's older snapshot + // land last and drop the unhandled marker. scope.withSession( session -> { if (session != null) { From a82807c78136c37dd04579dbd53cd9546da52316 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 09:31:11 +0200 Subject: [PATCH 18/25] ref(android): Catch only what readEnvelope can throw The envelope reader declares IOException and rejects malformed payloads with an unchecked IllegalArgumentException, so name both instead of catching Exception. Co-authored-by: Cursor --- .../java/io/sentry/android/core/InternalSentrySdk.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 3a4f53a62a0..ed18576cba9 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 @@ -314,15 +314,15 @@ private enum EnvelopeEventState { } /** - * 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. + * Reads an envelope from the given bytes. {@link io.sentry.IEnvelopeReader#read(InputStream)} + * declares {@link IOException} and additionally rejects malformed payloads with an unchecked + * {@link IllegalArgumentException}. */ 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) { + } catch (IOException | IllegalArgumentException e) { options.getLogger().log(SentryLevel.ERROR, "Failed to read envelope", e); return null; } From 07c67015b33430e716860e430a255ae5598df804 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 12:58:01 +0200 Subject: [PATCH 19/25] ref(android): Extract non-terminating session update helper Pull the session mutation out of captureEnvelopeNonTerminating so the dropped-event API can reuse it without duplicating the persist path. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 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 ed18576cba9..40b00c177b3 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 @@ -249,29 +249,7 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); if (eventState != EnvelopeEventState.NONE) { - scopes.configureScope( - scope -> { - // the write stays inside the callback so the mutation and the persist are one - // critical section. Persisting outside it lets a concurrent caller's older snapshot - // land last and drop the unhandled marker. - scope.withSession( - session -> { - if (session != null) { - final boolean updated = - eventState == EnvelopeEventState.UNHANDLED - ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null); - if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()) - .persistCurrentSession(session); - } - } else { - options - .getLogger() - .log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } - }); - }); + updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED); } return scopes.captureEnvelope(envelope); @@ -281,6 +259,35 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } + /** + * Mutates and persists the current session for a non-terminating hybrid error. The write stays + * inside {@code withSession} so the mutation and the persist are one critical section. Persisting + * outside it lets a concurrent caller's older snapshot land last and drop the unhandled marker. + * + * @param crashed {@code true} if the error was unhandled ({@code mechanism.handled=false}) + */ + private static void updateSessionNonTerminating(final boolean crashed) { + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); + scopes.configureScope( + scope -> { + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + crashed + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + } + } else { + options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating"); + } + }); + }); + } + /** What the events inside an envelope amount to, from the session's point of view. */ private enum EnvelopeEventState { /** No event carried an exception. */ From c6903a9008d8184b06bf0c9ea2175f218bb3240a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 27 Aug 2026 14:27:05 +0200 Subject: [PATCH 20/25] ref(android): Route non-terminating session updates through withSession Every writer to the live session now holds Scope.sessionLock, and only clones leave it, so the lazy serialization on the transport thread and the session file write can no longer race a later mutation. Persisting moves to the executor service instead of running on the calling thread, an already terminated session is no longer written back to the session file, and the broad catch is narrowed to the one call that forces it. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 122 +++++++++++------- .../android/core/InternalSentrySdkTest.kt | 41 ++++++ sentry/src/main/java/io/sentry/Session.java | 1 + 3 files changed, 115 insertions(+), 49 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 40b00c177b3..834c0514e26 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 @@ -41,6 +41,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -244,48 +245,72 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } + final @NotNull EnvelopeEventState eventState; try { - final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); - - if (eventState != EnvelopeEventState.NONE) { - updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED); - } - - return scopes.captureEnvelope(envelope); + eventState = eventStateOf(envelope, options.getSerializer()); } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + // getEvent reads through a Callable, whose call() declares Exception + options.getLogger().log(SentryLevel.ERROR, "Failed to inspect envelope events", e); + return null; } - return null; + + if (eventState != EnvelopeEventState.NONE) { + updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED); + } + + return scopes.captureEnvelope(envelope); } /** - * Mutates and persists the current session for a non-terminating hybrid error. The write stays - * inside {@code withSession} so the mutation and the persist are one critical section. Persisting - * outside it lets a concurrent caller's older snapshot land last and drop the unhandled marker. + * Flags and persists the current session for a non-terminating hybrid error. * - * @param crashed {@code true} if the error was unhandled ({@code mechanism.handled=false}) + * @param unhandled {@code true} if the error was unhandled ({@code mechanism.handled=false}) */ - private static void updateSessionNonTerminating(final boolean crashed) { + 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) { - final boolean updated = - crashed + 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 (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + if (recorded) { + schedulePersistSession(options, session.clone()); } - } else { - options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating"); - } - }); - }); + })); + } + + /** + * This function is mostly called from Flutter where capturing envelope is run in a background + * thread. + * + *

Nonetheless we should run this in the same executor service as the deleteCurrentSessionFile + * function for consistency. + */ + private static void schedulePersistSession( + final @NotNull SentryOptions options, final @NotNull Session session) { + if (!(options.getEnvelopeDiskCache() instanceof EnvelopeCache)) { + return; + } + final @NotNull EnvelopeCache cache = (EnvelopeCache) options.getEnvelopeDiskCache(); + try { + options.getExecutorService().submit(() -> cache.persistCurrentSession(session)); + } catch (RejectedExecutionException e) { + options.getLogger().log(WARNING, "Submission of session persisting rejected.", e); + } } /** What the events inside an envelope amount to, from the session's point of view. */ @@ -320,11 +345,6 @@ private enum EnvelopeEventState { return errored ? EnvelopeEventState.ERRORED : EnvelopeEventState.NONE; } - /** - * Reads an envelope from the given bytes. {@link io.sentry.IEnvelopeReader#read(InputStream)} - * declares {@link IOException} and additionally rejects malformed payloads with an unchecked - * {@link IllegalArgumentException}. - */ private static @Nullable SentryEnvelope readEnvelope( final @NotNull SentryOptions options, final @NotNull byte[] envelopeData) { try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { @@ -427,22 +447,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 ea3f7170008..f1c534e6604 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 @@ -30,6 +30,7 @@ import io.sentry.protocol.Contexts import io.sentry.protocol.Mechanism import io.sentry.protocol.SentryId import io.sentry.protocol.User +import io.sentry.test.ImmediateExecutorService import io.sentry.test.createTestScopes import io.sentry.transport.ITransport import io.sentry.transport.RateLimiter @@ -61,6 +62,8 @@ class InternalSentrySdkTest { initForTest(context) { options -> this@Fixture.options = options options.dsn = "https://key@host/proj" + // the non-terminating path persists the session off the calling thread + options.executorService = ImmediateExecutorService() options.setTransportFactory { _, _ -> object : ITransport { override fun close(isRestarting: Boolean) { @@ -136,6 +139,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() @@ -504,6 +518,33 @@ class InternalSentrySdkTest { 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() diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 26300f73005..a3d3c207ebf 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -307,6 +307,7 @@ 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; From f0ae91a30a459560d92f350e597661be8f863955 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 27 Aug 2026 14:37:47 +0200 Subject: [PATCH 21/25] docs(session): Say why a terminal status clears the unhandled marker end() already gives a terminal status precedence via its status == Ok guard, so the clearing is about keeping the flag honest: without it a crashed session serializes a marker claiming it did not terminate, and the public accessor reports true for it. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a3d3c207ebf..598fe994fdc 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -311,8 +311,8 @@ public boolean update( 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; } From 4ddbd488340412e3d551b52d53d6fb8b0078e37f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 28 Aug 2026 13:30:32 +0200 Subject: [PATCH 22/25] fix(android): Persist the non-terminating session synchronously Deferring the write to the executor service meant the unhandled marker could be lost if the process died before the task drained, which is the guarantee this path exists for. It also let a queued snapshot land after the session was ended or replaced, rewriting an already-sent session or overwriting a newer current session, both of which surface as a duplicated session on the next launch. Writing under the scope's session lock closes that window, since every scope-side rotation takes the same lock. Also narrow captureEnvelope's blanket catch (Throwable) to the two checked exceptions actually thrown there: Exception from eventStateOf, whose getEvent reads through a Callable, and IOException from SentryEnvelopeItem.fromSession. Everything else in that method already has its own boundary catch in Scopes. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 93 ++++++++----------- .../android/core/InternalSentrySdkTest.kt | 6 +- 2 files changed, 43 insertions(+), 56 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 834c0514e26..2cfdc459e13 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 @@ -41,7 +41,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -175,39 +174,45 @@ public static SentryId captureEnvelope( return null; } + final @NotNull ISerializer serializer = options.getSerializer(); + final @NotNull EnvelopeEventState eventState; try { - final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); + eventState = eventStateOf(envelope, serializer); + } 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; + } - final @NotNull List envelopeItems = new ArrayList<>(); - for (SentryEnvelopeItem item : envelope.getItems()) { - envelopeItems.add(item); - } + final @NotNull List envelopeItems = new ArrayList<>(); + for (SentryEnvelopeItem item : envelope.getItems()) { + envelopeItems.add(item); + } - // update session and add it to envelope if necessary - 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); - deleteCurrentSessionFile( - options, - // should be sync if going to crash or already not a main thread - !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); - if (maybeStartNewSession) { - scopes.startSession(); - } + // update session and add it to envelope if necessary + 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) { + try { + envelopeItems.add(SentryEnvelopeItem.fromSession(serializer, session)); + } catch (IOException e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to add session to envelope", e); + return null; + } + deleteCurrentSessionFile( + options, + // should be sync if going to crash or already not a main thread + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); + if (maybeStartNewSession) { + scopes.startSession(); } - - 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); } - return null; + + final SentryEnvelope repackagedEnvelope = + new SentryEnvelope(envelope.getHeader(), envelopeItems); + return scopes.captureEnvelope(repackagedEnvelope); } /** @@ -262,7 +267,11 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel } /** - * Flags and persists the current session for a non-terminating hybrid error. + * 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}) */ @@ -287,32 +296,12 @@ private static void updateSessionNonTerminating(final boolean unhandled) { unhandled ? session.recordNonTerminatingUnhandledError() : session.update(null, null, true, null); - if (recorded) { - schedulePersistSession(options, session.clone()); + if (recorded && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); } })); } - /** - * This function is mostly called from Flutter where capturing envelope is run in a background - * thread. - * - *

Nonetheless we should run this in the same executor service as the deleteCurrentSessionFile - * function for consistency. - */ - private static void schedulePersistSession( - final @NotNull SentryOptions options, final @NotNull Session session) { - if (!(options.getEnvelopeDiskCache() instanceof EnvelopeCache)) { - return; - } - final @NotNull EnvelopeCache cache = (EnvelopeCache) options.getEnvelopeDiskCache(); - try { - options.getExecutorService().submit(() -> cache.persistCurrentSession(session)); - } catch (RejectedExecutionException e) { - options.getLogger().log(WARNING, "Submission of session persisting rejected.", e); - } - } - /** What the events inside an envelope amount to, from the session's point of view. */ private enum EnvelopeEventState { /** No event carried an exception. */ 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 f1c534e6604..88b54ce7c82 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 @@ -30,7 +30,6 @@ import io.sentry.protocol.Contexts import io.sentry.protocol.Mechanism import io.sentry.protocol.SentryId import io.sentry.protocol.User -import io.sentry.test.ImmediateExecutorService import io.sentry.test.createTestScopes import io.sentry.transport.ITransport import io.sentry.transport.RateLimiter @@ -62,8 +61,6 @@ class InternalSentrySdkTest { initForTest(context) { options -> this@Fixture.options = options options.dsn = "https://key@host/proj" - // the non-terminating path persists the session off the calling thread - options.executorService = ImmediateExecutorService() options.setTransportFactory { _, _ -> object : ITransport { override fun close(isRestarting: Boolean) { @@ -509,7 +506,8 @@ class InternalSentrySdkTest { assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue() assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) - // and it is persisted so the flag survives process death + // 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)!! From aab5791b41cc8fa7bee52ecde73f258ada81b287 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 28 Aug 2026 13:32:01 +0200 Subject: [PATCH 23/25] docs(android): Note that captureEnvelopeNonTerminating writes to disk The caveat that hybrid SDKs call this off the main thread used to live on the persist helper and was lost when that helper was inlined. It matters more now that the write is synchronous, so state it where callers read it. Co-authored-by: Cursor --- .../main/java/io/sentry/android/core/InternalSentrySdk.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 2cfdc459e13..63891d11744 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 @@ -227,9 +227,13 @@ public static SentryId captureEnvelope( *

  • 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 the flag survives process death + *
  • persists the current session before returning, so the flag survives process death * * + *

    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. From ad43885f14b9a62ec4d3998967fdf1a9791d54d9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 28 Aug 2026 16:09:02 +0200 Subject: [PATCH 24/25] docs: Move captureEnvelopeNonTerminating changelog entry to Unreleased Co-authored-by: Cursor --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73512c95d68..acd4e4e534f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,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 @@ -46,10 +50,6 @@ - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0164) - [diff](https://github.com/getsentry/sentry-native/compare/0.16.2...0.16.4) -### 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.53.0 ### Features From c6ab7ad32f03b521bf19c978b1c03f026c674fe9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 28 Aug 2026 18:39:17 +0200 Subject: [PATCH 25/25] ref(android): restore catch (Throwable) in captureEnvelope Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 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 63891d11744..cb612cd7b93 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 @@ -174,45 +174,39 @@ public static SentryId captureEnvelope( return null; } - final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEventState eventState; try { - eventState = eventStateOf(envelope, serializer); - } 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; - } - - final @NotNull List envelopeItems = new ArrayList<>(); - for (SentryEnvelopeItem item : envelope.getItems()) { - envelopeItems.add(item); - } + final @NotNull ISerializer serializer = options.getSerializer(); + final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); - // update session and add it to envelope if necessary - 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) { - try { - envelopeItems.add(SentryEnvelopeItem.fromSession(serializer, session)); - } catch (IOException e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to add session to envelope", e); - return null; + final @NotNull List envelopeItems = new ArrayList<>(); + for (SentryEnvelopeItem item : envelope.getItems()) { + envelopeItems.add(item); } - deleteCurrentSessionFile( - options, - // should be sync if going to crash or already not a main thread - !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); - if (maybeStartNewSession) { - scopes.startSession(); + + // update session and add it to envelope if necessary + 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); + deleteCurrentSessionFile( + options, + // should be sync if going to crash or already not a main thread + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); + if (maybeStartNewSession) { + scopes.startSession(); + } } - } - final SentryEnvelope repackagedEnvelope = - new SentryEnvelope(envelope.getHeader(), envelopeItems); - return scopes.captureEnvelope(repackagedEnvelope); + 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); + } + return null; } /**