From 8dd05ff18c564f7eed528a590b7fed2534c9d701 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 16 Jul 2026 13:53:11 +0200 Subject: [PATCH 01/10] feat: add InternalSentrySdk.captureEnvelopeNonTerminating for pending-unhandled sessions Hybrid unhandled exceptions that don't terminate the process (e.g. Flutter) no longer end the session as crashed. The session is kept alive and marked pending-unhandled, finalized as unhandled on session end or escalated to crashed on a native crash. Existing captureEnvelope(byte[], boolean) behavior (React Native) is unchanged. Co-authored-by: Cursor --- CHANGELOG.md | 6 + .../api/sentry-android-core.api | 1 + .../android/core/InternalSentrySdk.java | 110 ++++++++++++++++-- .../android/core/InternalSentrySdkTest.kt | 109 +++++++++++++++++ sentry/api/sentry.api | 4 + sentry/src/main/java/io/sentry/Session.java | 80 ++++++++++--- .../io/sentry/PreviousSessionFinalizerTest.kt | 41 +++++++ sentry/src/test/java/io/sentry/SessionTest.kt | 84 +++++++++++++ .../protocol/SessionSerializationTest.kt | 13 +++ 9 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a7..b9e2d2175f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- 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`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) + ## 8.49.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..d9d3b859219 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/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 2779f803a69..77058f2a9e0 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 @@ -32,9 +32,15 @@ import io.sentry.protocol.User; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; +import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -49,6 +55,9 @@ @ApiStatus.Internal public final class InternalSentrySdk { + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8_CHARSET = Charset.forName("UTF-8"); + /** * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled */ @@ -153,13 +162,55 @@ 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 */ @Nullable public static SentryId captureEnvelope( final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { + return captureEnvelope(envelopeData, maybeStartNewSession, true); + } + + /** + * 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) { + return captureEnvelope(envelopeData, false, false); + } + + @Nullable + private static SentryId captureEnvelope( + final @NotNull byte[] envelopeData, + final boolean maybeStartNewSession, + final boolean isTerminating) { final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); @@ -176,13 +227,19 @@ public static SentryId captureEnvelope( // determine session state based on events inside envelope @Nullable Session.State status = null; boolean crashedOrErrored = false; + boolean markPendingUnhandled = false; for (SentryEnvelopeItem item : envelope.getItems()) { envelopeItems.add(item); final SentryEvent event = item.getEvent(serializer); if (event != null) { if (event.isCrashed()) { - status = Session.State.Crashed; + if (isTerminating) { + status = Session.State.Crashed; + } else { + // non-terminating: don't crash the session, only remember the unhandled exception. + markPendingUnhandled = true; + } } if (event.isCrashed() || event.isErrored()) { crashedOrErrored = true; @@ -191,16 +248,24 @@ public static SentryId captureEnvelope( } // update session and add it to envelope if necessary - final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); + final @Nullable Session session = + updateSession(scopes, options, status, crashedOrErrored, markPendingUnhandled); 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(); + if (isTerminating) { + 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(); + } + } else { + // non-terminating: keep the (still Ok) session on the scope, don't attach a session + // update, and persist it so the pending-unhandled flag survives process death. + persistCurrentSession(options, session); } } @@ -296,17 +361,40 @@ private static void deleteCurrentSessionFile(final @NotNull SentryOptions option } } + private static void persistCurrentSession( + final @NotNull SentryOptions options, final @NotNull Session session) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options.getLogger().log(INFO, "Cache dir is not set, not persisting the current session."); + return; + } + + final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); + try (final OutputStream outputStream = new FileOutputStream(sessionFile); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8_CHARSET))) { + options.getSerializer().serialize(session, writer); + } catch (Throwable e) { + options.getLogger().log(WARNING, "Failed to persist the current session.", e); + } + } + @Nullable private static Session updateSession( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @Nullable Session.State status, - final boolean crashedOrErrored) { + final boolean crashedOrErrored, + final boolean markPendingUnhandled) { final @NotNull AtomicReference sessionRef = new AtomicReference<>(); scopes.configureScope( scope -> { final @Nullable Session session = scope.getSession(); if (session != null) { + if (markPendingUnhandled) { + // remember the unhandled (but non-terminal) exception; finalized later as Unhandled. + session.setPendingUnhandled(true); + } final boolean updated = session.update(status, null, crashedOrErrored, null); // if we have an uncaughtExceptionHint we can end the session. if (updated) { 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..bb0bd0f2407 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 @@ -22,6 +22,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 +108,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 +468,99 @@ 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 + assertEquals(1, fixture.capturedEnvelopes.size) + val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList() + assertEquals(1, capturedEnvelopeItems.size) + assertEquals(SentryItemType.Event, capturedEnvelopeItems[0].header.type) + + // and the session stays alive on the scope, same id, marked pending unhandled + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertEquals(Session.State.Ok, scopeSession.get().status) + assertTrue(scopeSession.get().isPendingUnhandled) + assertEquals(originalSid.get(), scopeSession.get().sessionId) + + // 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)!! + assertEquals(Session.State.Ok, persistedSession.status) + assertTrue(persistedSession.isPendingUnhandled) + assertEquals(originalSid.get(), persistedSession.sessionId) + } + + @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 + } + assertEquals(1, sessionItems.size) + val endedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertEquals(Session.State.Unhandled, endedSession.status) + } + + @Test + fun `captureEnvelopeNonTerminating then a crash finalizes the session as crashed`() { + val fixture = Fixture() + fixture.init(context) + + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + fixture.capturedEnvelopes.clear() + + // when a subsequent hard crash is captured through the terminating API + fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException()) + + // then the crash wins over the pending unhandled state + val sessionItems = + fixture.capturedEnvelopes + .flatMap { it.items.toList() } + .filter { + it.header.type == SentryItemType.Session + } + assertEquals(1, sessionItems.size) + val crashedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertEquals(Session.State.Crashed, crashedSession.status) + } + @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 0e5aad4826b..da08dbce407 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4273,9 +4273,11 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getTimestamp ()Ljava/util/Date; public fun getUnknown ()Ljava/util/Map; public fun getUserAgent ()Ljava/lang/String; + public fun isPendingUnhandled ()Z public fun isTerminated ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V + public fun setPendingUnhandled (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z @@ -4296,6 +4298,7 @@ public final class io/sentry/Session$JsonKeys { public static final field ERRORS Ljava/lang/String; public static final field INIT Ljava/lang/String; public static final field IP_ADDRESS Ljava/lang/String; + public static final field PENDING_UNHANDLED Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SEQ Ljava/lang/String; public static final field SID Ljava/lang/String; @@ -4311,6 +4314,7 @@ public final class io/sentry/Session$State : java/lang/Enum { public static final field Crashed Lio/sentry/Session$State; public static final field Exited Lio/sentry/Session$State; public static final field Ok Lio/sentry/Session$State; + public static final field Unhandled Lio/sentry/Session$State; public static fun valueOf (Ljava/lang/String;)Lio/sentry/Session$State; public static fun values ()[Lio/sentry/Session$State; } diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 2fdfffb35d9..ac775f04623 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -21,7 +21,8 @@ public enum State { Ok, Exited, Crashed, - Abnormal + Abnormal, + Unhandled } /** started timestamp */ @@ -66,6 +67,14 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** + * Whether the session experienced an unhandled (but non-terminal) exception. Kept locally and + * persisted with the session, but never sent as a status while the session is alive. On end() the + * session is finalized as {@link State#Unhandled} instead of {@link State#Exited} unless a crash + * escalated it to {@link State#Crashed}. + */ + private boolean pendingUnhandled; + /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -188,6 +197,24 @@ public int errorCount() { return abnormalMechanism; } + /** + * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized + * yet. + */ + public boolean isPendingUnhandled() { + return pendingUnhandled; + } + + /** + * Marks the session as having experienced an unhandled (non-terminal) exception without ending + * it. On {@link #end()} the session will be finalized as {@link State#Unhandled} unless a crash + * escalated it to {@link State#Crashed} first. + */ + @ApiStatus.Internal + public void setPendingUnhandled(final boolean pendingUnhandled) { + this.pendingUnhandled = pendingUnhandled; + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; @@ -209,7 +236,9 @@ public void end(final @Nullable Date timestamp) { // at this state it might be Crashed already, so we don't check for it. if (status == State.Ok) { - status = State.Exited; + // a session that experienced an unhandled (but non-terminal) exception is finalized as + // Unhandled rather than Exited. + status = pendingUnhandled ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -262,6 +291,10 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; + // a real crash takes precedence over a pending unhandled (non-terminal) exception. + if (status == State.Crashed) { + pendingUnhandled = false; + } sessionHasBeenUpdated = true; } @@ -318,21 +351,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - return new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); + final Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); + return session; } // JsonSerializable @@ -354,6 +390,7 @@ public static final class JsonKeys { public static final String IP_ADDRESS = "ip_address"; public static final String USER_AGENT = "user_agent"; public static final String ABNORMAL_MECHANISM = "abnormal_mechanism"; + public static final String PENDING_UNHANDLED = "pending_unhandled"; } @Override @@ -384,6 +421,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } + if (pendingUnhandled) { + writer.name(JsonKeys.PENDING_UNHANDLED).value(pendingUnhandled); + } writer.name(JsonKeys.ATTRS); writer.beginObject(); writer.name(JsonKeys.RELEASE).value(logger, release); @@ -440,6 +480,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; + boolean pendingUnhandled = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -483,6 +524,10 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; + case JsonKeys.PENDING_UNHANDLED: + final Boolean pendingUnhandledValue = reader.nextBooleanOrNull(); + pendingUnhandled = pendingUnhandledValue != null && pendingUnhandledValue; + break; case JsonKeys.ATTRS: reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -542,6 +587,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 4b433ffb3e1..1c73cf58d75 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -200,6 +200,47 @@ class PreviousSessionFinalizerTest { ) } + @Test + fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && + session.status == Session.State.Unhandled && + session.isPendingUnhandled + } + ) + } + + @Test + fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + nativeCrashTimestamp = Date(2023, 10, 1), + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && session.status == Crashed + } + ) + } + @Test fun `if previous session file exists, deletes previous session file`() { val finalizer = fixture.getSut(tmpDir, sessionFileExists = true) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt new file mode 100644 index 00000000000..b1232f74d8f --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,84 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.Test +import org.mockito.kotlin.mock + +class SessionTest { + + private fun okSession(): Session = Session(null, null, "environment", "release") + + @Test + fun `end without pending unhandled finalizes as Exited`() { + val session = okSession() + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Exited) + } + + @Test + fun `end with pending unhandled finalizes as Unhandled`() { + val session = okSession() + assertThat(session.isPendingUnhandled).isFalse() + + session.setPendingUnhandled(true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Unhandled) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + + session.update(Session.State.Crashed, null, true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + + @Test + fun `clone preserves pending unhandled`() { + val session = okSession() + session.setPendingUnhandled(true) + + val clone = session.clone() + + assertThat(clone.isPendingUnhandled).isTrue() + } + + @Test + fun `serialization round-trips pending unhandled and Unhandled status`() { + val logger = mock() + val session = okSession() + session.setPendingUnhandled(true) + session.end() + assertThat(session.status).isEqualTo(Session.State.Unhandled) + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + val deserialized = + Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) + + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.isPendingUnhandled).isTrue() + } + + @Test + fun `pending unhandled defaults to false and is not serialized when unset`() { + val logger = mock() + val session = okSession() + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + assertThat(writer.toString()).doesNotContain("pending_unhandled") + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 644f57a0232..1280680e7f8 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -53,6 +53,19 @@ class SessionSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + val session = Session(null, null, "environment", "release") + session.setPendingUnhandled(true) + session.end() + assertEquals(Session.State.Unhandled, session.status) + + val deserialized = deserialize(serialize(session)) + + assertEquals(Session.State.Unhandled, deserialized.status) + assertEquals(true, deserialized.isPendingUnhandled) + } + // Helper private fun sanitizedFile(path: String): String = From d72305049aed5ccda8469706ef62318a31bf757d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 16 Jul 2026 16:07:38 +0200 Subject: [PATCH 02/10] fix(android): Preserve terminating envelope capture behavior Keep the existing hybrid capture path isolated from non-terminating Flutter session handling while retaining pending-unhandled session persistence. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 134 +++++++++++------- 1 file changed, 83 insertions(+), 51 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 77058f2a9e0..3c54e6bd6b6 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 @@ -40,7 +40,7 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; -import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -55,9 +55,6 @@ @ApiStatus.Internal public final class InternalSentrySdk { - @SuppressWarnings("CharsetObjectCanBeUsed") - private static final Charset UTF_8_CHARSET = Charset.forName("UTF-8"); - /** * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled */ @@ -174,7 +171,58 @@ public static Map serializeScope( @Nullable public static SentryId captureEnvelope( final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { - return captureEnvelope(envelopeData, maybeStartNewSession, true); + 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 @NotNull List envelopeItems = new ArrayList<>(); + + // determine session state based on events inside envelope + @Nullable Session.State status = null; + boolean crashedOrErrored = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + envelopeItems.add(item); + + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.isCrashed()) { + status = Session.State.Crashed; + } + if (event.isCrashed() || event.isErrored()) { + crashedOrErrored = true; + } + } + } + + // update session and add it to envelope if necessary + final @Nullable Session session = + updateSession(scopes, options, status, crashedOrErrored, false); + 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); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); + } + return null; } /** @@ -203,14 +251,6 @@ public static SentryId captureEnvelope( */ @Nullable public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { - return captureEnvelope(envelopeData, false, false); - } - - @Nullable - private static SentryId captureEnvelope( - final @NotNull byte[] envelopeData, - final boolean maybeStartNewSession, - final boolean isTerminating) { final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); @@ -222,56 +262,48 @@ private static SentryId captureEnvelope( return null; } - final @NotNull List envelopeItems = new ArrayList<>(); - - // determine session state based on events inside envelope - @Nullable Session.State status = null; - boolean crashedOrErrored = false; boolean markPendingUnhandled = false; + boolean addErrorsCount = false; for (SentryEnvelopeItem item : envelope.getItems()) { - envelopeItems.add(item); - final SentryEvent event = item.getEvent(serializer); if (event != null) { + // isCrashed() means mechanism.handled=false (unhandled exception), not that the + // process terminated. For e.g Flutter that distinction is why we only mark pending here. if (event.isCrashed()) { - if (isTerminating) { - status = Session.State.Crashed; - } else { - // non-terminating: don't crash the session, only remember the unhandled exception. - markPendingUnhandled = true; - } - } - if (event.isCrashed() || event.isErrored()) { - crashedOrErrored = true; + markPendingUnhandled = true; + addErrorsCount = true; + } else if (event.isErrored()) { + addErrorsCount = true; } } } - // update session and add it to envelope if necessary - final @Nullable Session session = - updateSession(scopes, options, status, crashedOrErrored, markPendingUnhandled); - if (session != null) { - if (isTerminating) { - 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(); - } - } else { - // non-terminating: keep the (still Ok) session on the scope, don't attach a session - // update, and persist it so the pending-unhandled flag survives process death. + if (markPendingUnhandled || addErrorsCount) { + final @NotNull AtomicReference sessionRef = new AtomicReference<>(); + final boolean pending = markPendingUnhandled; + final boolean addErrors = addErrorsCount; + scopes.configureScope( + scope -> { + final @Nullable Session session = scope.getSession(); + if (session != null) { + if (pending) { + session.setPendingUnhandled(true); + } + if (session.update(null, null, addErrors, null) || pending) { + sessionRef.set(session); + } + } else { + options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); + final @Nullable Session session = sessionRef.get(); + if (session != null) { persistCurrentSession(options, session); } } - final SentryEnvelope repackagedEnvelope = - new SentryEnvelope(envelope.getHeader(), envelopeItems); - return scopes.captureEnvelope(repackagedEnvelope); + // Capture the original envelope as-is (no session item attached). + return scopes.captureEnvelope(envelope); } catch (Throwable t) { options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } @@ -372,7 +404,7 @@ private static void persistCurrentSession( final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); try (final OutputStream outputStream = new FileOutputStream(sessionFile); final Writer writer = - new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8_CHARSET))) { + new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { options.getSerializer().serialize(session, writer); } catch (Throwable e) { options.getLogger().log(WARNING, "Failed to persist the current session.", e); From e8f1ef9670bf9768ac4e5446634b9a87ce32ce05 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:33:25 +0200 Subject: [PATCH 03/10] fix(android): Harden pending unhandled session persistence Serialize pending session mutations with scope and cache lifecycle operations so delayed session updates cannot overwrite or delete newer session state. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 67 ++---- .../android/core/InternalSentrySdkTest.kt | 36 ++- sentry/api/sentry.api | 6 + sentry/src/main/java/io/sentry/Scope.java | 3 +- sentry/src/main/java/io/sentry/Session.java | 17 ++ .../java/io/sentry/cache/EnvelopeCache.java | 72 +++++- sentry/src/test/java/io/sentry/SessionTest.kt | 60 +++++ .../java/io/sentry/cache/EnvelopeCacheTest.kt | 212 +++++++++++++++++- 8 files changed, 400 insertions(+), 73 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 3c54e6bd6b6..f89e312efac 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 @@ -32,15 +32,9 @@ import io.sentry.protocol.User; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; -import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -202,8 +196,7 @@ public static SentryId captureEnvelope( } // update session and add it to envelope if necessary - final @Nullable Session session = - updateSession(scopes, options, status, crashedOrErrored, false); + final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); @@ -279,27 +272,28 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel } if (markPendingUnhandled || addErrorsCount) { - final @NotNull AtomicReference sessionRef = new AtomicReference<>(); final boolean pending = markPendingUnhandled; final boolean addErrors = addErrorsCount; scopes.configureScope( scope -> { - final @Nullable Session session = scope.getSession(); - if (session != null) { - if (pending) { - session.setPendingUnhandled(true); - } - if (session.update(null, null, addErrors, null) || pending) { - sessionRef.set(session); - } - } else { - options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } + 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"); + } + }); }); - final @Nullable Session session = sessionRef.get(); - if (session != null) { - persistCurrentSession(options, session); - } } // Capture the original envelope as-is (no session item attached). @@ -393,40 +387,17 @@ private static void deleteCurrentSessionFile(final @NotNull SentryOptions option } } - private static void persistCurrentSession( - final @NotNull SentryOptions options, final @NotNull Session session) { - final String cacheDirPath = options.getCacheDirPath(); - if (cacheDirPath == null) { - options.getLogger().log(INFO, "Cache dir is not set, not persisting the current session."); - return; - } - - final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); - try (final OutputStream outputStream = new FileOutputStream(sessionFile); - final Writer writer = - new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { - options.getSerializer().serialize(session, writer); - } catch (Throwable e) { - options.getLogger().log(WARNING, "Failed to persist the current session.", e); - } - } - @Nullable private static Session updateSession( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @Nullable Session.State status, - final boolean crashedOrErrored, - final boolean markPendingUnhandled) { + final boolean crashedOrErrored) { final @NotNull AtomicReference sessionRef = new AtomicReference<>(); scopes.configureScope( scope -> { final @Nullable Session session = scope.getSession(); if (session != null) { - if (markPendingUnhandled) { - // remember the unhandled (but non-terminal) exception; finalized later as Unhandled. - session.setPendingUnhandled(true); - } final boolean updated = session.update(status, null, crashedOrErrored, null); // if we have an uncaughtExceptionHint we can end the session. if (updated) { 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 bb0bd0f2407..e5bb2013c2f 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 @@ -39,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -533,32 +534,43 @@ class InternalSentrySdkTest { } @Test - fun `captureEnvelopeNonTerminating then a crash finalizes the session as crashed`() { + 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 + assertTrue(pendingSession.get().isPendingUnhandled) fixture.capturedEnvelopes.clear() - // when a subsequent hard crash is captured through the terminating API - fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException()) + // when a subsequent hard crash is captured through the existing terminating API + fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true) - // then the crash wins over the pending unhandled state - val sessionItems = - fixture.capturedEnvelopes - .flatMap { it.items.toList() } - .filter { - it.header.type == SentryItemType.Session - } - assertEquals(1, sessionItems.size) + // then the crash envelope contains the finalized old session + assertEquals(2, fixture.capturedEnvelopes.size) + val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList() + assertEquals(2, crashEnvelopeItems.size) + assertEquals(SentryItemType.Event, crashEnvelopeItems[0].header.type) + assertEquals(SentryItemType.Session, crashEnvelopeItems[1].header.type) val crashedSession = fixture.options.serializer.deserialize( - InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)), Session::class.java, )!! assertEquals(Session.State.Crashed, crashedSession.status) + assertFalse(crashedSession.isPendingUnhandled) + assertEquals(oldSid, crashedSession.sessionId) + + // and a new Ok session with a different id is active + val activeSession = AtomicReference() + Sentry.configureScope { scope -> activeSession.set(scope.session) } + assertEquals(Session.State.Ok, activeSession.get().status) + assertFalse(activeSession.get().isPendingUnhandled) + assertNotEquals(oldSid, activeSession.get().sessionId) } @Test diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index da08dbce407..e32429d5966 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2492,6 +2492,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 } @@ -4275,6 +4279,7 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getUserAgent ()Ljava/lang/String; public fun isPendingUnhandled ()Z public fun isTerminated ()Z + public fun markPendingUnhandled ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V public fun setPendingUnhandled (Z)V @@ -4843,6 +4848,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static fun getPreviousSessionFile (Ljava/lang/String;)Ljava/io/File; public fun iterator ()Ljava/util/Iterator; public fun movePreviousSession (Ljava/io/File;Ljava/io/File;)V + public fun persistCurrentSession (Lio/sentry/Session;)V public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z public fun waitPreviousSessionFlush ()Z diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..8e06c6bf594 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1008,7 +1008,8 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ - interface IWithSession { + @ApiStatus.Internal + public interface IWithSession { /** * The accept method of the callback diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ac775f04623..a1334ea45f5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -201,6 +201,7 @@ public int errorCount() { * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized * yet. */ + @ApiStatus.Internal public boolean isPendingUnhandled() { return pendingUnhandled; } @@ -215,6 +216,22 @@ public void setPendingUnhandled(final boolean pendingUnhandled) { this.pendingUnhandled = pendingUnhandled; } + /** Marks an active session as having experienced an unhandled non-terminal exception. */ + @ApiStatus.Internal + public boolean markPendingUnhandled() { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + if (status != State.Ok) { + return false; + } + pendingUnhandled = true; + errorCount.incrementAndGet(); + init = null; + timestamp = DateUtils.getCurrentDateTime(); + sequence = getSequenceTimestamp(timestamp); + return true; + } + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 475993bbc1d..c3ccb29b709 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -115,8 +115,15 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); if (HintUtils.hasType(hint, SessionEnd.class)) { - if (!currentSessionFile.delete()) { - options.getLogger().log(WARNING, "Current envelope doesn't exist."); + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + final @Nullable Session endingSession = readSessionFromEnvelope(envelope); + final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); + if (endingSession != null + && currentSession != null + && hasSameSessionId(endingSession, currentSession) + && !currentSessionFile.delete()) { + options.getLogger().log(WARNING, "Current envelope doesn't exist."); + } } } @@ -126,8 +133,22 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not } if (HintUtils.hasType(hint, SessionStart.class)) { - movePreviousSession(currentSessionFile, previousSessionFile); - updateCurrentSession(currentSessionFile, envelope); + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + final @Nullable Session startingSession = readSessionFromEnvelope(envelope); + if (startingSession != null) { + final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); + if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { + if (!isNewerPendingOrErrorSnapshot(currentSession, startingSession)) { + writeSessionToDisk(currentSessionFile, startingSession); + } + } else { + movePreviousSession(currentSessionFile, previousSessionFile); + writeSessionToDisk(currentSessionFile, startingSession); + } + } else { + movePreviousSession(currentSessionFile, previousSessionFile); + } + } boolean crashedLastRun = false; final File crashMarkerFile = new File(options.getCacheDirPath(), NATIVE_CRASH_MARKER_FILE); @@ -271,8 +292,7 @@ private void writeCrashMarkerFile() { } } - private void updateCurrentSession( - final @NotNull File currentSessionFile, final @NotNull SentryEnvelope envelope) { + private @Nullable Session readSessionFromEnvelope(final @NotNull SentryEnvelope envelope) { final Iterable items = envelope.getItems(); // we know that an envelope with a SessionStart hint has a single item inside @@ -292,7 +312,7 @@ private void updateCurrentSession( "Item of type %s returned null by the parser.", item.getHeader().getType()); } else { - writeSessionToDisk(currentSessionFile, session); + return session; } } catch (Throwable e) { options.getLogger().log(ERROR, "Item failed to process.", e); @@ -306,10 +326,35 @@ private void updateCurrentSession( item.getHeader().getType()); } } else { - options - .getLogger() - .log(INFO, "Current envelope %s is empty", currentSessionFile.getAbsolutePath()); + options.getLogger().log(INFO, "Current envelope is empty."); } + return null; + } + + private @Nullable Session readSessionFromDisk(final @NotNull File sessionFile) { + if (!sessionFile.exists()) { + return null; + } + try (final Reader reader = + new BufferedReader(new InputStreamReader(new FileInputStream(sessionFile), UTF_8))) { + return serializer.getValue().deserialize(reader, Session.class); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Failed to read session from disk.", e); + return null; + } + } + + private boolean isNewerPendingOrErrorSnapshot( + final @NotNull Session currentSession, final @NotNull Session startingSession) { + return (currentSession.isPendingUnhandled() && !startingSession.isPendingUnhandled()) + || currentSession.errorCount() > startingSession.errorCount(); + } + + private boolean hasSameSessionId( + final @NotNull Session firstSession, final @NotNull Session secondSession) { + return firstSession.getSessionId() != null + && secondSession.getSessionId() != null + && Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); } private boolean writeEnvelopeToDisk( @@ -349,6 +394,13 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session } } + @ApiStatus.Internal + public void persistCurrentSession(final @NotNull Session session) { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + writeSessionToDisk(getCurrentSessionFile(directory.getAbsolutePath()), session); + } + } + @Override public void discard(final @NotNull SentryEnvelope envelope) { Objects.requireNonNull(envelope, "Envelope is required."); diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index b1232f74d8f..77a16b8625a 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -10,6 +10,42 @@ class SessionTest { private fun okSession(): Session = Session(null, null, "environment", "release") + @Test + fun `markPendingUnhandled atomically updates an Ok session`() { + val session = okSession() + val initialTimestamp = session.timestamp + + val updated = session.markPendingUnhandled() + + assertThat(updated).isTrue() + assertThat(session.status).isEqualTo(Session.State.Ok) + assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.errorCount()).isEqualTo(1) + assertThat(session.init).isNull() + assertThat(session.timestamp).isNotNull() + assertThat(session.timestamp!!.time).isAtLeast(initialTimestamp!!.time) + assertThat(session.sequence).isEqualTo(session.timestamp!!.time) + } + + @Test + fun `markPendingUnhandled does not change terminal sessions`() { + for (state in Session.State.entries.filter { it != Session.State.Ok }) { + val session = okSession() + session.update(state, null, false) + val before = session.clone() + + val updated = session.markPendingUnhandled() + + assertThat(updated).isFalse() + assertThat(session.status).isEqualTo(before.status) + assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.errorCount()).isEqualTo(before.errorCount()) + assertThat(session.init).isEqualTo(before.init) + assertThat(session.timestamp).isEqualTo(before.timestamp) + assertThat(session.sequence).isEqualTo(before.sequence) + } + } + @Test fun `end without pending unhandled finalizes as Exited`() { val session = okSession() @@ -31,6 +67,30 @@ class SessionTest { assertThat(session.isPendingUnhandled).isTrue() } + @Test + fun `end with pending unhandled keeps Abnormal as Abnormal`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Abnormal, null, false, "anr") + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Abnormal) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `end with pending unhandled keeps Crashed as Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Crashed, null, false) + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + @Test fun `updating to Crashed clears pending unhandled and end stays Crashed`() { val session = okSession() diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 973070e0afe..c4aaaf5cef0 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -1,5 +1,6 @@ package io.sentry.cache +import com.google.common.truth.Truth.assertThat import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger @@ -131,6 +132,157 @@ class EnvelopeCacheTest { assertTrue(didStore) } + @Test + fun `delayed same SID SessionStart preserves newer pending snapshot`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val newerSession = createSession(sessionId = sid) + newerSession.markPendingUnhandled() + cache.persistCurrentSession(newerSession) + + val delayedStart = createSession(sessionId = sid) + val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(sid) + assertThat(persistedSession.isPendingUnhandled).isTrue() + assertThat(persistedSession.errorCount()).isEqualTo(1) + assertThat(previousSessionFile.exists()).isFalse() + } + + @Test + fun `delayed same SID SessionStart preserves newer error count snapshot`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val newerSession = createSession(sessionId = sid) + newerSession.update(null, null, true) + cache.persistCurrentSession(newerSession) + + val delayedStart = createSession(sessionId = sid) + val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(sid) + assertThat(persistedSession.isPendingUnhandled).isFalse() + assertThat(persistedSession.errorCount()).isEqualTo(1) + assertThat(previousSessionFile.exists()).isFalse() + } + + @Test + fun `null SIDs on SessionStart rotate instead of preserving as same session`() { + val cache = fixture.getSUT() + val currentSession = createSession(sessionId = null) + currentSession.update(null, null, true) + cache.persistCurrentSession(currentSession) + val startingSession = createSession(sessionId = null) + + val envelope = SentryEnvelope.from(fixture.options.serializer, startingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val persistedCurrent = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + val persistedPrevious = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedCurrent.sessionId).isNull() + assertThat(persistedCurrent.errorCount()).isEqualTo(0) + assertThat(persistedPrevious.sessionId).isNull() + assertThat(persistedPrevious.errorCount()).isEqualTo(1) + } + + @Test + fun `different SID SessionStart rotates current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + val nextSession = createSession() + + val envelope = SentryEnvelope.from(fixture.options.serializer, nextSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val persistedCurrent = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + val persistedPrevious = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedCurrent.sessionId).isEqualTo(nextSession.sessionId) + assertThat(persistedPrevious.sessionId).isEqualTo(currentSession.sessionId) + } + + @Test + fun `matching SessionEnd deletes current session`() { + val cache = fixture.getSUT() + val session = createSession() + cache.persistCurrentSession(session) + + val envelope = SentryEnvelope.from(fixture.options.serializer, session, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `mismatching SessionEnd preserves newer current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + val endingSession = createSession() + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(currentSession.sessionId) + } + + @Test + fun `null SIDs on SessionEnd preserve current session`() { + val cache = fixture.getSUT() + val currentSession = createSession(sessionId = null) + cache.persistCurrentSession(currentSession) + val endingSession = createSession(sessionId = null) + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + assertThat(currentSessionFile.exists()).isTrue() + } + @Test fun `updates current file on session update and read it back`() { val cache = fixture.getSUT() @@ -317,6 +469,36 @@ class EnvelopeCacheTest { assertEquals(sessionExitedWithAbnormal, updatedSession!!.timestamp!!.time) } + @Test + fun `AbnormalExit hint keeps persisted pending session as abnormal`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { setPendingUnhandled(true) } + fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) + + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + val abnormalHint = + object : AbnormalExit { + override fun mechanism(): String = "abnormal_mechanism" + + override fun ignoreCurrentThread(): Boolean = false + + override fun timestamp(): Long = session.started!!.time + TimeUnit.HOURS.toMillis(1) + } + val hints = HintUtils.createWithTypeCheckHint(abnormalHint) + cache.storeEnvelope(envelope, hints) + + val updatedSession = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + ) + assertEquals(State.Abnormal, updatedSession!!.status) + assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism) + assertTrue(updatedSession.isPendingUnhandled) + } + @Test fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() { val cache = fixture.getSUT() @@ -371,6 +553,29 @@ class EnvelopeCacheTest { assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) } + @Test + fun `NativeCrashExit hint keeps persisted pending session as crashed`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { setPendingUnhandled(true) } + fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) + + val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1) + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + val hints = HintUtils.createWithTypeCheckHint(NativeCrashExit { nativeCrashTimestamp }) + cache.storeEnvelope(envelope, hints) + + val updatedSession = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + ) + assertEquals(State.Crashed, updatedSession!!.status) + assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) + assertFalse(updatedSession.isPendingUnhandled) + } + @Test fun `when NativeCrashExit happened before previous session start, does not mark as crashed`() { val cache = fixture.getSUT() @@ -409,14 +614,17 @@ class EnvelopeCacheTest { assertFalse(didStore) } - private fun createSession(started: Date? = null): Session = + private fun createSession( + started: Date? = null, + sessionId: String? = SentryUUID.generateSentryId(), + ): Session = Session( Ok, started ?: DateUtils.getCurrentDateTime(), DateUtils.getCurrentDateTime(), 0, "dis", - SentryUUID.generateSentryId(), + sessionId, true, null, null, From 3603211b13e08963d62625c036ed84f34b19a148 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:34:30 +0200 Subject: [PATCH 04/10] changelog Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e2d2175f0..188585c83a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- 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`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) +- 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`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#5795](https://github.com/getsentry/sentry-java/pull/5795)) ## 8.49.0 From c6f705ca63a0d8c42ab4eb8978a965861d2351db Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:40:31 +0200 Subject: [PATCH 05/10] Update CHANGELOG with unreleased fixes Added unreleased section with recent fixes for ANR and crash events. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d843ca5eb65..421ba724948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## Unreleased + ### Fixes - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) From c38c9f93d5f81dbbe021b8cf192dcf660b0bcabf Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:53:25 +0200 Subject: [PATCH 06/10] ref(android): Clarify non-terminating unhandled detection Check for an unhandled exception directly instead of using the crash-named predicate in a path where the process intentionally continues. Co-authored-by: Cursor --- .../main/java/io/sentry/android/core/InternalSentrySdk.java | 4 +--- 1 file changed, 1 insertion(+), 3 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 f89e312efac..61499032e25 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 @@ -260,9 +260,7 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel for (SentryEnvelopeItem item : envelope.getItems()) { final SentryEvent event = item.getEvent(serializer); if (event != null) { - // isCrashed() means mechanism.handled=false (unhandled exception), not that the - // process terminated. For e.g Flutter that distinction is why we only mark pending here. - if (event.isCrashed()) { + if (event.getUnhandledException() != null) { markPendingUnhandled = true; addErrorsCount = true; } else if (event.isErrored()) { From 184e89a0169d3695ae1b7c520c298508ba57eba7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 11:57:16 +0200 Subject: [PATCH 07/10] fix(sessions): Scope delayed end protection to pending sessions Only preserve a mismatched current session when it carries pending unhandled state, retaining existing deletion behavior for other sessions. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 12 +++-- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 47 +++++++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index c3ccb29b709..cd82b3bcb19 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -118,10 +118,14 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session endingSession = readSessionFromEnvelope(envelope); final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - if (endingSession != null - && currentSession != null - && hasSameSessionId(endingSession, currentSession) - && !currentSessionFile.delete()) { + final boolean preservePendingSession = + endingSession != null + && currentSession != null + && currentSession.isPendingUnhandled() + && endingSession.getSessionId() != null + && currentSession.getSessionId() != null + && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()); + if (!preservePendingSession && !currentSessionFile.delete()) { options.getLogger().log(WARNING, "Current envelope doesn't exist."); } } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index c4aaaf5cef0..d94db2dca4b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -251,9 +251,10 @@ class EnvelopeCacheTest { } @Test - fun `mismatching SessionEnd preserves newer current session`() { + fun `mismatching SessionEnd preserves newer pending current session`() { val cache = fixture.getSUT() val currentSession = createSession() + currentSession.markPendingUnhandled() cache.persistCurrentSession(currentSession) val endingSession = createSession() @@ -270,7 +271,21 @@ class EnvelopeCacheTest { } @Test - fun `null SIDs on SessionEnd preserve current session`() { + fun `mismatching SessionEnd deletes non-pending current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + val endingSession = createSession() + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `null SIDs on SessionEnd delete current session`() { val cache = fixture.getSUT() val currentSession = createSession(sessionId = null) cache.persistCurrentSession(currentSession) @@ -279,8 +294,34 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `malformed SessionEnd deletes current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + + val envelope = SentryEnvelope(null, null, emptyList()) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `unreadable current session on SessionEnd is deleted`() { + val cache = fixture.getSUT() val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) - assertThat(currentSessionFile.exists()).isTrue() + currentSessionFile.writeText("not-a-session") + + val endingSession = createSession() + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(currentSessionFile.exists()).isFalse() } @Test From 9954dec6a15ee73f3f56eb2f134649ea1d3bebd9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 12:42:06 +0200 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 1c73cf58d75..36b934b4906 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -228,7 +228,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, - nativeCrashTimestamp = Date(2023, 10, 1), + nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) finalizer.run() From 2af889121c08b4136b9bdf3f99bad0bf7723701a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 12:49:37 +0200 Subject: [PATCH 09/10] fix(sessions): Verify pending session ordering before preservation Only retain a mismatched pending session when its start time proves it is newer than the delayed session end. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 6 +++++- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index cd82b3bcb19..a98badec84b 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -106,6 +106,7 @@ public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotN return storeInternal(envelope, hint); } + @SuppressWarnings("JavaUtilDate") private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); @@ -124,7 +125,10 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not && currentSession.isPendingUnhandled() && endingSession.getSessionId() != null && currentSession.getSessionId() != null - && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()); + && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()) + && endingSession.getStarted() != null + && currentSession.getStarted() != null + && currentSession.getStarted().after(endingSession.getStarted()); if (!preservePendingSession && !currentSessionFile.delete()) { options.getLogger().log(WARNING, "Current envelope doesn't exist."); } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index d94db2dca4b..d2f753e1200 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -253,10 +253,10 @@ class EnvelopeCacheTest { @Test fun `mismatching SessionEnd preserves newer pending current session`() { val cache = fixture.getSUT() - val currentSession = createSession() + val endingSession = createSession(started = Date(1_000)) + val currentSession = createSession(started = Date(2_000)) currentSession.markPendingUnhandled() cache.persistCurrentSession(currentSession) - val endingSession = createSession() val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) @@ -270,6 +270,21 @@ class EnvelopeCacheTest { assertThat(persistedSession.sessionId).isEqualTo(currentSession.sessionId) } + @Test + fun `mismatching newer SessionEnd deletes stale pending current session`() { + val cache = fixture.getSUT() + val currentSession = createSession(started = Date(1_000)) + currentSession.markPendingUnhandled() + cache.persistCurrentSession(currentSession) + val endingSession = createSession(started = Date(2_000)) + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + @Test fun `mismatching SessionEnd deletes non-pending current session`() { val cache = fixture.getSUT() From 359f6ad237b73696db57b05205c72db8bf2d60f4 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:17:52 +0200 Subject: [PATCH 10/10] ref(android): Share envelope reading and narrow broad catches Extract a common readEnvelope helper for both InternalSentrySdk capture paths and replace catch (Throwable) with catch (Exception) so OOM and other Errors are no longer swallowed. Convert the new tests to Truth. Co-authored-by: Cursor --- sentry-android-core/build.gradle.kts | 1 + .../android/core/InternalSentrySdk.java | 50 ++++++++++++------- .../android/core/InternalSentrySdkTest.kt | 46 ++++++++--------- .../java/io/sentry/cache/EnvelopeCache.java | 2 +- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f92876530fd..9af0a714b25 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 61499032e25..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; @@ -168,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 @@ -212,8 +212,8 @@ 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; } @@ -247,14 +247,13 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel 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(); boolean markPendingUnhandled = false; boolean addErrorsCount = false; for (SentryEnvelopeItem item : envelope.getItems()) { @@ -296,12 +295,27 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel // Capture the original envelope as-is (no session item attached). return scopes.captureEnvelope(envelope); - } 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; } + /** + * 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 e5bb2013c2f..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 @@ -39,7 +40,6 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -483,25 +483,25 @@ class InternalSentrySdkTest { ) // then only the original event envelope is captured, without a session item - assertEquals(1, fixture.capturedEnvelopes.size) + assertThat(fixture.capturedEnvelopes).hasSize(1) val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList() - assertEquals(1, capturedEnvelopeItems.size) - assertEquals(SentryItemType.Event, capturedEnvelopeItems[0].header.type) + 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) } - assertEquals(Session.State.Ok, scopeSession.get().status) - assertTrue(scopeSession.get().isPendingUnhandled) - assertEquals(originalSid.get(), scopeSession.get().sessionId) + 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)!! - assertEquals(Session.State.Ok, persistedSession.status) - assertTrue(persistedSession.isPendingUnhandled) - assertEquals(originalSid.get(), persistedSession.sessionId) + assertThat(persistedSession.status).isEqualTo(Session.State.Ok) + assertThat(persistedSession.isPendingUnhandled).isTrue() + assertThat(persistedSession.sessionId).isEqualTo(originalSid.get()) } @Test @@ -524,13 +524,13 @@ class InternalSentrySdkTest { .filter { it.header.type == SentryItemType.Session } - assertEquals(1, sessionItems.size) + assertThat(sessionItems).hasSize(1) val endedSession = fixture.options.serializer.deserialize( InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), Session::class.java, )!! - assertEquals(Session.State.Unhandled, endedSession.status) + assertThat(endedSession.status).isEqualTo(Session.State.Unhandled) } @Test @@ -544,33 +544,33 @@ class InternalSentrySdkTest { val pendingSession = AtomicReference() Sentry.configureScope { scope -> pendingSession.set(scope.session) } val oldSid = pendingSession.get().sessionId - assertTrue(pendingSession.get().isPendingUnhandled) + 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 - assertEquals(2, fixture.capturedEnvelopes.size) + assertThat(fixture.capturedEnvelopes).hasSize(2) val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList() - assertEquals(2, crashEnvelopeItems.size) - assertEquals(SentryItemType.Event, crashEnvelopeItems[0].header.type) - assertEquals(SentryItemType.Session, crashEnvelopeItems[1].header.type) + 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, )!! - assertEquals(Session.State.Crashed, crashedSession.status) - assertFalse(crashedSession.isPendingUnhandled) - assertEquals(oldSid, crashedSession.sessionId) + 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) } - assertEquals(Session.State.Ok, activeSession.get().status) - assertFalse(activeSession.get().isPendingUnhandled) - assertNotEquals(oldSid, activeSession.get().sessionId) + assertThat(activeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(activeSession.get().isPendingUnhandled).isFalse() + assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) } @Test diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index a98badec84b..597a801c4a5 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -346,7 +346,7 @@ private void writeCrashMarkerFile() { try (final Reader reader = new BufferedReader(new InputStreamReader(new FileInputStream(sessionFile), UTF_8))) { return serializer.getValue().deserialize(reader, Session.class); - } catch (Throwable e) { + } catch (Exception e) { options.getLogger().log(ERROR, "Failed to read session from disk.", e); return null; }