From 1ae2b467296c6f96c4a875a69631d53add242fea Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:22:37 +0200 Subject: [PATCH 01/14] feat(core): Add Unhandled session state and pending-unhandled marker Adds Session.State.Unhandled from the session protocol, plus a pending-unhandled marker that survives serialization. A session carrying the marker finalizes as Unhandled instead of Exited on end(), while Crashed and Abnormal keep taking precedence. Co-authored-by: Cursor --- sentry/api/sentry.api | 5 + sentry/src/main/java/io/sentry/Session.java | 97 +++++++++--- .../io/sentry/PreviousSessionFinalizerTest.kt | 41 +++++ sentry/src/test/java/io/sentry/SessionTest.kt | 144 ++++++++++++++++++ .../protocol/SessionSerializationTest.kt | 13 ++ 5 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4f..941463a66f8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4286,9 +4286,12 @@ 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 markPendingUnhandled ()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 @@ -4309,6 +4312,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; @@ -4324,6 +4328,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..a1334ea45f5 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,41 @@ public int errorCount() { return abnormalMechanism; } + /** + * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized + * yet. + */ + @ApiStatus.Internal + 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; + } + + /** 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; @@ -209,7 +253,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 +308,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 +368,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 +407,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 +438,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 +497,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 +541,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 +604,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..36b934b4906 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 = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), + ) + 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..77a16b8625a --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,144 @@ +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 `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() + + 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 `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() + 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 e62793f8693905c33e9fed08e8858136fb3acd7e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:37:48 +0200 Subject: [PATCH 02/14] ref: rename pendingUnhandled to nonTerminatingUnhandledError "Unhandled" alone is ambiguous: a native crash is also an unhandled error, it just terminates the process and so ends the session as crashed rather than unhandled. Name the flag after the property that actually distinguishes the two and match the vocabulary of captureEnvelopeNonTerminating. Also clarify that the setter only restores the flag when rebuilding a session and must not be used to mutate a live one. Co-authored-by: Cursor --- sentry/api/sentry.api | 8 +-- sentry/src/main/java/io/sentry/Session.java | 71 +++++++++++-------- .../io/sentry/PreviousSessionFinalizerTest.kt | 14 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 57 +++++++-------- .../protocol/SessionSerializationTest.kt | 6 +- 5 files changed, 87 insertions(+), 69 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 941463a66f8..8a810918c03 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4286,12 +4286,12 @@ 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 hasNonTerminatingUnhandledError ()Z public fun isTerminated ()Z - public fun markPendingUnhandled ()Z + public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setPendingUnhandled (Z)V + public fun setNonTerminatingUnhandledError (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 @@ -4312,7 +4312,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 NON_TERMINATING_UNHANDLED_ERROR 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; diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a1334ea45f5..33e9de42894 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -68,12 +68,15 @@ public enum State { 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}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process + * and therefore ends the session as {@link State#Crashed} instead. + * + *

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; + private boolean nonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -198,32 +201,41 @@ public int errorCount() { } /** - * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized - * yet. + * Whether the session experienced an unhandled error that did not terminate the process, and so + * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. */ @ApiStatus.Internal - public boolean isPendingUnhandled() { - return pendingUnhandled; + public boolean hasNonTerminatingUnhandledError() { + return nonTerminatingUnhandledError; } /** - * 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. + * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. + * + *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this + * neither counts the error nor advances the session's sequence, so a session mutated through this + * setter would be sent as an out-of-date update. */ @ApiStatus.Internal - public void setPendingUnhandled(final boolean pendingUnhandled) { - this.pendingUnhandled = pendingUnhandled; + public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } - /** Marks an active session as having experienced an unhandled non-terminal exception. */ + /** + * Records that an active session experienced an unhandled error which did not terminate the + * process, counting the error and advancing the session's sequence without ending it. On {@link + * #end()} the session is finalized as {@link State#Unhandled} unless a crash escalated it to + * {@link State#Crashed} first. + * + * @return whether the session was updated, i.e. false if it had already reached a terminal state + */ @ApiStatus.Internal - public boolean markPendingUnhandled() { + public boolean recordNonTerminatingUnhandledError() { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { if (status != State.Ok) { return false; } - pendingUnhandled = true; + nonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -255,7 +267,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = pendingUnhandled ? State.Unhandled : State.Exited; + status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -308,9 +320,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // a real crash takes precedence over a pending unhandled (non-terminal) exception. + // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - pendingUnhandled = false; + nonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -384,7 +396,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); return session; } @@ -407,7 +419,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"; + public static final String NON_TERMINATING_UNHANDLED_ERROR = "non_terminating_unhandled_error"; } @Override @@ -438,8 +450,8 @@ 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); + if (nonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -497,7 +509,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean pendingUnhandled = false; + boolean nonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -541,9 +553,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; + case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: + final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + nonTerminatingUnhandledError = + nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -604,7 +617,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); 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 36b934b4906..af2e67b19d9 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -201,12 +201,14 @@ class PreviousSessionFinalizerTest { } @Test - fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + fun `if previous session has a non-terminating unhandled error 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) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, ) finalizer.run() @@ -216,18 +218,20 @@ class PreviousSessionFinalizerTest { val session = fixture.sessionFromEnvelope(this) session.release == "io.sentry.sample@1.0" && session.status == Session.State.Unhandled && - session.isPendingUnhandled + session.hasNonTerminatingUnhandledError() } ) } @Test - fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + fun `if previous session has a non-terminating unhandled error 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) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) finalizer.run() diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 77a16b8625a..6326179a9be 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -11,15 +11,15 @@ class SessionTest { private fun okSession(): Session = Session(null, null, "environment", "release") @Test - fun `markPendingUnhandled atomically updates an Ok session`() { + fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { val session = okSession() val initialTimestamp = session.timestamp - val updated = session.markPendingUnhandled() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isTrue() assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() assertThat(session.errorCount()).isEqualTo(1) assertThat(session.init).isNull() assertThat(session.timestamp).isNotNull() @@ -28,17 +28,18 @@ class SessionTest { } @Test - fun `markPendingUnhandled does not change terminal sessions`() { + fun `recordNonTerminatingUnhandledError 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() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isFalse() assertThat(session.status).isEqualTo(before.status) - assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.hasNonTerminatingUnhandledError()) + .isEqualTo(before.hasNonTerminatingUnhandledError()) assertThat(session.errorCount()).isEqualTo(before.errorCount()) assertThat(session.init).isEqualTo(before.init) assertThat(session.timestamp).isEqualTo(before.timestamp) @@ -47,7 +48,7 @@ class SessionTest { } @Test - fun `end without pending unhandled finalizes as Exited`() { + fun `end without a non-terminating unhandled error finalizes as Exited`() { val session = okSession() session.end() @@ -56,68 +57,68 @@ class SessionTest { } @Test - fun `end with pending unhandled finalizes as Unhandled`() { + fun `end with a non-terminating unhandled error finalizes as Unhandled`() { val session = okSession() - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Abnormal as Abnormal`() { + fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Abnormal, null, false, "anr") session.end() assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Crashed as Crashed`() { + fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, false) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, true) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `clone preserves pending unhandled`() { + fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) val clone = session.clone() - assertThat(clone.isPendingUnhandled).isTrue() + assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `serialization round-trips pending unhandled and Unhandled status`() { + fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -128,17 +129,17 @@ class SessionTest { Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.isPendingUnhandled).isTrue() + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `pending unhandled defaults to false and is not serialized when unset`() { + fun `a non-terminating unhandled error 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") + assertThat(writer.toString()).doesNotContain("non_terminating_unhandled_error") } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 1280680e7f8..ed85351f83e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -54,16 +54,16 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertEquals(Session.State.Unhandled, session.status) val deserialized = deserialize(serialize(session)) assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.isPendingUnhandled) + assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } // Helper From af3fd9191f53c1d5691bcfcaec3b76b8277f34ff Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:00:56 +0200 Subject: [PATCH 03/14] ref: drop public setter for the non-terminating unhandled error flag clone() and Session.Deserializer are both inside Session, so they can restore the field directly. Dropping the setter keeps it off the public API surface and makes it impossible to flip the flag on a live session without counting the error and advancing the sequence. Co-authored-by: Cursor --- sentry/api/sentry.api | 1 - sentry/src/main/java/io/sentry/Session.java | 16 ++-------------- .../io/sentry/PreviousSessionFinalizerTest.kt | 4 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 12 ++++++------ .../sentry/protocol/SessionSerializationTest.kt | 2 +- 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8a810918c03..b01ffbd0ed9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4291,7 +4291,6 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setNonTerminatingUnhandledError (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 diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 33e9de42894..1fde656ab05 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -209,18 +209,6 @@ public boolean hasNonTerminatingUnhandledError() { return nonTerminatingUnhandledError; } - /** - * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. - * - *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this - * neither counts the error nor advances the session's sequence, so a session mutated through this - * setter would be sent as an out-of-date update. - */ - @ApiStatus.Internal - public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - } - /** * Records that an active session experienced an unhandled error which did not terminate the * process, counting the error and advancing the session's sequence without ending it. On {@link @@ -396,7 +384,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; return session; } @@ -617,7 +605,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; 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 af2e67b19d9..3a730e971bf 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -207,7 +207,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, ) finalizer.run() @@ -230,7 +230,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 6326179a9be..d036805e06b 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -61,7 +61,7 @@ class SessionTest { val session = okSession() assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -71,7 +71,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Abnormal, null, false, "anr") session.end() @@ -83,7 +83,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, false) session.end() @@ -95,7 +95,7 @@ class SessionTest { @Test fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, true) session.end() @@ -107,7 +107,7 @@ class SessionTest { @Test fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() val clone = session.clone() @@ -118,7 +118,7 @@ class SessionTest { fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ed85351f83e..c88e335e1ac 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -56,7 +56,7 @@ class SessionSerializationTest { @Test fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertEquals(Session.State.Unhandled, session.status) From 59717b2d8f3a1a53cc6a306609919f55eede9e73 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:10:54 +0200 Subject: [PATCH 04/14] ref: initialize the non-terminating flag through a private constructor Every other field is set at construction; the flag was the odd one out, assigned afterwards. A private canonical constructor keeps construction complete without putting the flag on the public API, which a 15-arg public overload would do. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 +++++++++++++++------ 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 1fde656ab05..adefa43bd8f 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -99,6 +99,46 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { + this( + status, + started, + timestamp, + errorCount, + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + false); + } + + /** + * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need + * to restore, and a public overload carrying it would let callers fabricate a session claiming an + * unhandled error that was never counted. + */ + private Session( + final @NotNull State status, + final @NotNull Date started, + final @Nullable Date timestamp, + final int errorCount, + final @Nullable String distinctId, + final @Nullable String sessionId, + final @Nullable Boolean init, + final @Nullable Long sequence, + final @Nullable Double duration, + final @Nullable String ipAddress, + final @Nullable String userAgent, + final @Nullable String environment, + final @NotNull String release, + final @Nullable String abnormalMechanism, + final boolean nonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -113,6 +153,7 @@ public Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } public Session( @@ -368,24 +409,22 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - final Session session = - new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - return session; + return new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + nonTerminatingUnhandledError); } // JsonSerializable @@ -604,8 +643,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + abnormalMechanism, + nonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From 55fc5695291b616be9c327d4f80ae2db53ab5686 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:44:26 +0200 Subject: [PATCH 05/14] ref: prefix the non-terminating flag field with has As a bare noun phrase the field read like it held the error rather than a boolean, most visibly where it is passed as a constructor argument. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 33 +++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index adefa43bd8f..ca62627a36a 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -76,7 +76,7 @@ public enum State { * 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 nonTerminatingUnhandledError; + private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -118,7 +118,7 @@ public Session( } /** - * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need * to restore, and a public overload carrying it would let callers fabricate a session claiming an * unhandled error that was never counted. @@ -138,7 +138,7 @@ private Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism, - final boolean nonTerminatingUnhandledError) { + final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -153,7 +153,7 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -247,7 +247,7 @@ public int errorCount() { */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { - return nonTerminatingUnhandledError; + return hasNonTerminatingUnhandledError; } /** @@ -264,7 +264,7 @@ public boolean recordNonTerminatingUnhandledError() { if (status != State.Ok) { return false; } - nonTerminatingUnhandledError = true; + hasNonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -296,7 +296,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; + status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -351,7 +351,7 @@ public boolean update( this.status = status; // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - nonTerminatingUnhandledError = false; + hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -424,7 +424,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); } // JsonSerializable @@ -477,8 +477,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } - if (nonTerminatingUnhandledError) { - writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); + if (hasNonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(hasNonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -536,7 +536,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean nonTerminatingUnhandledError = false; + boolean hasNonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -581,9 +581,10 @@ public static final class Deserializer implements JsonDeserializer { abnormalMechanism = reader.nextStringOrNull(); break; case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: - final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); - nonTerminatingUnhandledError = - nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; + final Boolean hasNonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + hasNonTerminatingUnhandledError = + hasNonTerminatingUnhandledErrorValue != null + && hasNonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -644,7 +645,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From 407c39d07c2e3d7d728be6c3d43eecfeac0fe2d6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:48:40 +0200 Subject: [PATCH 06/14] ref: drop comments that restate the code in Session Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ca62627a36a..8375184e4ae 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,15 +67,6 @@ 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 error that did not terminate the process, - * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process - * and therefore ends the session as {@link State#Crashed} instead. - * - *

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 hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ @@ -242,8 +233,12 @@ public int errorCount() { } /** - * Whether the session experienced an unhandled error that did not terminate the process, and so - * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception, and so finalizes as {@link State#Unhandled} rather than + * {@link State#Exited}. A native crash is also unhandled, but it kills the process and ends the + * session as {@link State#Crashed} instead. + * + *

Never sent as a status while the session is alive; it is only persisted with the session. */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { @@ -294,8 +289,6 @@ 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) { - // a session that experienced an unhandled (but non-terminal) exception is finalized as - // Unhandled rather than Exited. status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } From 8551441e069857b5a6ab494a6ffc6cce36967cad Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:37:50 +0200 Subject: [PATCH 07/14] test: move Session serialization cases out of SessionTest The round-trip case duplicated one already added to SessionSerializationTest. Keep JSON concerns in the serialization test and leave SessionTest to state transitions. Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 32 ------------------- .../protocol/SessionSerializationTest.kt | 8 +++++ 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index d036805e06b..812138c1215 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -1,10 +1,7 @@ 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 { @@ -113,33 +110,4 @@ class SessionTest { assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } - - @Test - fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { - val logger = mock() - val session = okSession() - session.recordNonTerminatingUnhandledError() - 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.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `a non-terminating unhandled error 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("non_terminating_unhandled_error") - } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index c88e335e1ac..663bcae0f06 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -10,6 +10,7 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -66,6 +67,13 @@ class SessionSerializationTest { assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } + @Test + fun `non-terminating flag is omitted when unset`() { + val session = Session(null, null, "environment", "release") + + assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + } + // Helper private fun sanitizedFile(path: String): String = From d198a045206a55d2d4611d572aee6a6238ad3777 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:42:13 +0200 Subject: [PATCH 08/14] test: remove SessionTest Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt deleted file mode 100644 index 812138c1215..00000000000 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.sentry - -import com.google.common.truth.Truth.assertThat -import kotlin.test.Test - -class SessionTest { - - private fun okSession(): Session = Session(null, null, "environment", "release") - - @Test - fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { - val session = okSession() - val initialTimestamp = session.timestamp - - val updated = session.recordNonTerminatingUnhandledError() - - assertThat(updated).isTrue() - assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.hasNonTerminatingUnhandledError()).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 `recordNonTerminatingUnhandledError 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.recordNonTerminatingUnhandledError() - - assertThat(updated).isFalse() - assertThat(session.status).isEqualTo(before.status) - assertThat(session.hasNonTerminatingUnhandledError()) - .isEqualTo(before.hasNonTerminatingUnhandledError()) - 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 a non-terminating unhandled error finalizes as Exited`() { - val session = okSession() - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Exited) - } - - @Test - fun `end with a non-terminating unhandled error finalizes as Unhandled`() { - val session = okSession() - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - - session.recordNonTerminatingUnhandledError() - session.end() - - assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Abnormal, null, false, "anr") - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Crashed, null, false) - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - session.update(Session.State.Crashed, null, true) - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `clone preserves a non-terminating unhandled error`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - val clone = session.clone() - - assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() - } -} From b24a47eac3beae26f3e27b301ab5222966c77f5d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:53:06 +0200 Subject: [PATCH 09/14] docs(session): describe hasNonTerminatingUnhandledError on the field It was the only field in Session without the one-line comment the surrounding declarations all carry. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 8375184e4ae..60987817218 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,6 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From aaa4154e111b317f7af94c79a80d948911da644c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:54:34 +0200 Subject: [PATCH 10/14] docs(session): capitalise the hasNonTerminatingUnhandledError comment Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 60987817218..a4579c1c649 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,7 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; - /** whether an unhandled error occurred that did not terminate the process */ + /** Whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From f236516f93fcb8105b9d92da115e3371fa6b5a83 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:58:23 +0200 Subject: [PATCH 11/14] ref(session): drop the private canonical constructor hasNonTerminatingUnhandledError is not final - recordNonTerminating UnhandledError and update() both write it - so setting it through a constructor established no invariant that a plain assignment does not. Both call sites are inside Session, so clone() and the deserializer can assign the field directly, which is what the deserializer already does for unknown. Removes the 15-parameter overload and the javadoc that existed to justify it. The public constructor is unchanged, so sentry.api is too. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 ++++++--------------- 1 file changed, 20 insertions(+), 59 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a4579c1c649..1434e0ab6a5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -91,46 +91,6 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { - this( - status, - started, - timestamp, - errorCount, - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism, - false); - } - - /** - * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the - * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need - * to restore, and a public overload carrying it would let callers fabricate a session claiming an - * unhandled error that was never counted. - */ - private Session( - final @NotNull State status, - final @NotNull Date started, - final @Nullable Date timestamp, - final int errorCount, - final @Nullable String distinctId, - final @Nullable String sessionId, - final @Nullable Boolean init, - final @Nullable Long sequence, - final @Nullable Double duration, - final @Nullable String ipAddress, - final @Nullable String userAgent, - final @Nullable String environment, - final @NotNull String release, - final @Nullable String abnormalMechanism, - final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -145,7 +105,6 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -403,22 +362,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, - hasNonTerminatingUnhandledError); + final @NotNull Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; + return session; } // JsonSerializable @@ -638,8 +599,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism, - hasNonTerminatingUnhandledError); + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; session.setUnknown(unknown); reader.endObject(); return session; From 2c1d4629f77cd5f1c43a25fc5f3d55b9952bfc1b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 14:06:33 +0200 Subject: [PATCH 12/14] test(session): use Truth in the new session serialization tests Also swaps assertFalse(serialize(...).contains(...)) for Truth's doesNotContain, which reports the offending json on failure instead of just "expected false". The two new PreviousSessionFinalizerTest cases are left on Mockito argThat, which needs a Boolean predicate rather than an assertion. Co-authored-by: Cursor --- .../io/sentry/protocol/SessionSerializationTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 663bcae0f06..ad19b90bb4e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -1,5 +1,6 @@ package io.sentry.protocol +import com.google.common.truth.Truth.assertThat import io.sentry.DateUtils import io.sentry.FileFromResources import io.sentry.ILogger @@ -10,7 +11,6 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals -import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -59,19 +59,19 @@ class SessionSerializationTest { val session = Session(null, null, "environment", "release") session.recordNonTerminatingUnhandledError() session.end() - assertEquals(Session.State.Unhandled, session.status) + assertThat(session.status).isEqualTo(Session.State.Unhandled) val deserialized = deserialize(serialize(session)) - assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test fun `non-terminating flag is omitted when unset`() { val session = Session(null, null, "environment", "release") - assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") } // Helper From b28cadff71bf2cee9cb8db4549fd3a0f7341b0ab Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:01:09 +0200 Subject: [PATCH 13/14] test(session): cover the unhandled flag through the previous-session recovery paths Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index dda06ee7e63..c80117aa99b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -346,6 +346,36 @@ class EnvelopeCacheTest { assertEquals(sessionExitedWithAbnormal, updatedSession!!.timestamp!!.time) } + @Test + fun `AbnormalExit hint keeps persisted unhandled session as abnormal`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { recordNonTerminatingUnhandledError() } + 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.hasNonTerminatingUnhandledError()) + } + @Test fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() { val cache = fixture.getSUT() @@ -400,6 +430,29 @@ class EnvelopeCacheTest { assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) } + @Test + fun `NativeCrashExit hint keeps persisted unhandled session as crashed`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { recordNonTerminatingUnhandledError() } + 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.hasNonTerminatingUnhandledError()) + } + @Test fun `when NativeCrashExit happened before previous session start, does not mark as crashed`() { val cache = fixture.getSUT() From d3af8adc89e5e613706684e325a82cfe8657c26d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:14:27 +0200 Subject: [PATCH 14/14] test(session): cover the unhandled session shape with a JSON fixture Co-authored-by: Cursor --- .../protocol/SessionSerializationTest.kt | 52 ++++++++++++++----- .../resources/json/session_unhandled.json | 18 +++++++ 2 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 sentry/src/test/resources/json/session_unhandled.json diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ad19b90bb4e..ebe108b2fe6 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -35,6 +35,34 @@ class SessionSerializationTest { "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", "anr_foreground", ) + + /** + * An unhandled session cannot be built by mutating [getSut]: the flag is only reachable through + * [Session.recordNonTerminatingUnhandledError], which no-ops unless the session is still `Ok`, + * and a crash would clear it again. Ending on a fixed timestamp keeps `seq` and `duration` + * deterministic. + */ + fun getUnhandledSut() = + Session( + Session.State.Ok, + DateUtils.getDateTime("1945-06-16T06:36:49.000Z"), + DateUtils.getDateTime("1970-04-21T09:32:21.000Z"), + 9001, + "631693c2-3d61-4a93-8fd1-89817426ba5a", + "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + true, + 4, + 5.5, + "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3", + "d732be55-b57e-48ec-afe6-b0040c7f93de", + "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + null, + ) + .apply { + recordNonTerminatingUnhandledError() + end(DateUtils.getDateTime("1970-04-21T09:32:21.000Z")) + } } private val fixture = Fixture() @@ -55,23 +83,19 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { - val session = Session(null, null, "environment", "release") - session.recordNonTerminatingUnhandledError() - session.end() - assertThat(session.status).isEqualTo(Session.State.Unhandled) - - val deserialized = deserialize(serialize(session)) - - assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() + fun serializeUnhandled() { + val expected = sanitizedFile("json/session_unhandled.json") + val actual = serialize(fixture.getUnhandledSut()) + assertThat(actual).isEqualTo(expected) } @Test - fun `non-terminating flag is omitted when unset`() { - val session = Session(null, null, "environment", "release") - - assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") + fun deserializeUnhandled() { + val expectedJson = sanitizedFile("json/session_unhandled.json") + val actual = deserialize(expectedJson) + assertThat(actual.status).isEqualTo(Session.State.Unhandled) + assertThat(actual.hasNonTerminatingUnhandledError()).isTrue() + assertThat(serialize(actual)).isEqualTo(expectedJson) } // Helper diff --git a/sentry/src/test/resources/json/session_unhandled.json b/sentry/src/test/resources/json/session_unhandled.json new file mode 100644 index 00000000000..cd822fee25b --- /dev/null +++ b/sentry/src/test/resources/json/session_unhandled.json @@ -0,0 +1,18 @@ +{ + "sid": "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + "did": "631693c2-3d61-4a93-8fd1-89817426ba5a", + "started": "1945-06-16T06:36:49.000Z", + "status": "unhandled", + "seq": 9538341000, + "errors": 9002, + "duration": 7.84090532E8, + "timestamp": "1970-04-21T09:32:21.000Z", + "non_terminating_unhandled_error": true, + "attrs": + { + "release": "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + "environment": "d732be55-b57e-48ec-afe6-b0040c7f93de", + "ip_address": "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "user_agent": "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3" + } +}