diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4..8a810918c0 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 hasNonTerminatingUnhandledError ()Z public fun isTerminated ()Z + 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 @@ -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 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; @@ -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 2fdfffb35d..33e9de4289 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,17 @@ 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 nonTerminatingUnhandledError; + /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -188,6 +200,50 @@ public int errorCount() { return abnormalMechanism; } + /** + * 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 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 + * #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 recordNonTerminatingUnhandledError() { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + if (status != State.Ok) { + return false; + } + nonTerminatingUnhandledError = true; + errorCount.incrementAndGet(); + init = null; + timestamp = DateUtils.getCurrentDateTime(); + sequence = getSequenceTimestamp(timestamp); + return true; + } + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; @@ -209,7 +265,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 = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -262,6 +320,10 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; + // a crash terminates the process, so it takes precedence over a non-terminating one. + if (status == State.Crashed) { + nonTerminatingUnhandledError = false; + } sessionHasBeenUpdated = true; } @@ -318,21 +380,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.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + return session; } // JsonSerializable @@ -354,6 +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 NON_TERMINATING_UNHANDLED_ERROR = "non_terminating_unhandled_error"; } @Override @@ -384,6 +450,9 @@ 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); + } writer.name(JsonKeys.ATTRS); writer.beginObject(); writer.name(JsonKeys.RELEASE).value(logger, release); @@ -440,6 +509,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; + boolean nonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -483,6 +553,11 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; + case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: + final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + nonTerminatingUnhandledError = + nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; + break; case JsonKeys.ATTRS: reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -542,6 +617,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); + 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 4b433ffb3e..af2e67b19d 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -200,6 +200,51 @@ class PreviousSessionFinalizerTest { ) } + @Test + 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 { + setNonTerminatingUnhandledError(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.hasNonTerminatingUnhandledError() + } + ) + } + + @Test + 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 { + setNonTerminatingUnhandledError(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 0000000000..6326179a9b --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,145 @@ +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 `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.setNonTerminatingUnhandledError(true) + 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.setNonTerminatingUnhandledError(true) + 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.setNonTerminatingUnhandledError(true) + 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.setNonTerminatingUnhandledError(true) + + 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.setNonTerminatingUnhandledError(true) + + val clone = session.clone() + + assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() + } + + @Test + fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { + val logger = mock() + val session = okSession() + session.setNonTerminatingUnhandledError(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.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 644f57a023..ed85351f83 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 non-terminating flag`() { + val session = Session(null, null, "environment", "release") + 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.hasNonTerminatingUnhandledError()) + } + // Helper private fun sanitizedFile(path: String): String =