diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index 43de4164a4f..b01ffbd0ed9 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -4286,7 +4286,9 @@ 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 setUnknown (Ljava/util/Map;)V
@@ -4309,6 +4311,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 +4327,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..1434e0ab6a5 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,9 @@ 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 */
private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock();
@@ -188,6 +192,42 @@ public int errorCount() {
return abnormalMechanism;
}
+ /**
+ * 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() {
+ return hasNonTerminatingUnhandledError;
+ }
+
+ /**
+ * 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;
+ }
+ hasNonTerminatingUnhandledError = true;
+ errorCount.incrementAndGet();
+ init = null;
+ timestamp = DateUtils.getCurrentDateTime();
+ sequence = getSequenceTimestamp(timestamp);
+ return true;
+ }
+ }
+
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
public @Nullable Date getTimestamp() {
return timestamp;
@@ -209,7 +249,7 @@ 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;
+ status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited;
}
if (timestamp != null) {
@@ -262,6 +302,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) {
+ hasNonTerminatingUnhandledError = false;
+ }
sessionHasBeenUpdated = true;
}
@@ -318,21 +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);
+ 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
@@ -354,6 +401,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 +432,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
if (abnormalMechanism != null) {
writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism);
}
+ if (hasNonTerminatingUnhandledError) {
+ writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(hasNonTerminatingUnhandledError);
+ }
writer.name(JsonKeys.ATTRS);
writer.beginObject();
writer.name(JsonKeys.RELEASE).value(logger, release);
@@ -440,6 +491,7 @@ public static final class Deserializer implements JsonDeserializer {
String environment = null;
String release = null; // @NotNull
String abnormalMechanism = null;
+ boolean hasNonTerminatingUnhandledError = false;
Map unknown = null;
while (reader.peek() == JsonToken.NAME) {
@@ -483,6 +535,12 @@ public static final class Deserializer implements JsonDeserializer {
case JsonKeys.ABNORMAL_MECHANISM:
abnormalMechanism = reader.nextStringOrNull();
break;
+ case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR:
+ final Boolean hasNonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull();
+ hasNonTerminatingUnhandledError =
+ hasNonTerminatingUnhandledErrorValue != null
+ && hasNonTerminatingUnhandledErrorValue;
+ break;
case JsonKeys.ATTRS:
reader.beginObject();
while (reader.peek() == JsonToken.NAME) {
@@ -542,6 +600,7 @@ public static final class Deserializer implements JsonDeserializer {
environment,
release,
abnormalMechanism);
+ session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError;
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..3a730e971bf 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 {
+ recordNonTerminatingUnhandledError()
+ },
+ )
+ 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 {
+ recordNonTerminatingUnhandledError()
+ },
+ 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/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()
diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt
index 644f57a0232..ebe108b2fe6 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
@@ -34,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()
@@ -53,6 +82,22 @@ class SessionSerializationTest {
assertEquals(expectedJson, actualJson)
}
+ @Test
+ fun serializeUnhandled() {
+ val expected = sanitizedFile("json/session_unhandled.json")
+ val actual = serialize(fixture.getUnhandledSut())
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ 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
private fun sanitizedFile(path: String): String =
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"
+ }
+}