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 =