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" + } +}