Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8a95759
feat(core): Add Unhandled session state and pending-unhandled marker
buenaflor Aug 10, 2026
0a6e2c4
ref: rename pendingUnhandled to nonTerminatingUnhandledError
buenaflor Aug 10, 2026
914f836
ref: drop public setter for the non-terminating unhandled error flag
buenaflor Aug 10, 2026
3445b94
ref: initialize the non-terminating flag through a private constructor
buenaflor Aug 10, 2026
6ca65c5
ref: prefix the non-terminating flag field with has
buenaflor Aug 11, 2026
dfdd999
ref: drop comments that restate the code in Session
buenaflor Aug 11, 2026
487daa8
test: move Session serialization cases out of SessionTest
buenaflor Aug 11, 2026
f7575a1
test: remove SessionTest
buenaflor Aug 11, 2026
ae2f5da
docs(session): describe hasNonTerminatingUnhandledError on the field
buenaflor Aug 11, 2026
d0cbd81
docs(session): capitalise the hasNonTerminatingUnhandledError comment
buenaflor Aug 11, 2026
4256a7b
ref(session): drop the private canonical constructor
buenaflor Aug 11, 2026
9b00da8
test(session): use Truth in the new session serialization tests
buenaflor Aug 11, 2026
76f3fa4
test(session): cover the unhandled flag through the previous-session โ€ฆ
buenaflor Aug 13, 2026
8fe5142
test(session): cover the unhandled session shape with a JSON fixture
buenaflor Aug 13, 2026
4c5d7a2
docs(session): Clarify Unhandled state and hybrid-only recording
buenaflor Aug 24, 2026
8437702
docs(session): Reword Unhandled status javadoc
buenaflor Aug 24, 2026
398f0eb
ref(session): Clear the unhandled marker for any terminal status
buenaflor Aug 24, 2026
3347255
ref(session): Name the terminal-status check
buenaflor Aug 24, 2026
7f47fa4
Revert "ref(session): Name the terminal-status check"
buenaflor Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -4304,7 +4304,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
Expand All @@ -4327,6 +4329,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;
Expand All @@ -4342,6 +4345,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;
}
Expand Down
102 changes: 85 additions & 17 deletions sentry/src/main/java/io/sentry/Session.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public enum State {
Ok,
Exited,
Crashed,
Abnormal
Abnormal,
/**
* Final status when an unhandled error did not kill the process, such as a Flutter exception.
* The session stays {@link #Ok} until {@link Session#end()}. Native crashes still end as {@link
* #Crashed}.
*/
Unhandled
Comment thread
buenaflor marked this conversation as resolved.
}

/** started timestamp */
Expand Down Expand Up @@ -66,6 +72,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();

Expand Down Expand Up @@ -188,6 +197,45 @@ public int errorCount() {
return abnormalMechanism;
}

/**
* Whether the session experienced an unhandled error that did <em>not</em> 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.
*
* <p>Never sent as a status while the session is alive; it is only persisted with the session.
*/
@ApiStatus.Internal
public boolean hasNonTerminatingUnhandledError() {
Comment thread
buenaflor marked this conversation as resolved.
return hasNonTerminatingUnhandledError;
}

/**
* Records that an active session experienced an unhandled error which did not terminate the
Comment thread
buenaflor marked this conversation as resolved.
* 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 terminal status such as
* {@link State#Crashed} or {@link State#Abnormal} took over first.
*
* <p>Hybrid SDKs whose unhandled errors do not kill the process. Native Java/Android capture
* should not call this.
*
* @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;
Expand All @@ -209,7 +257,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) {
Expand Down Expand Up @@ -262,6 +310,11 @@ public boolean update(
boolean sessionHasBeenUpdated = false;
if (status != null) {
this.status = status;
// the flag only decides how an Ok session is finalized, so an explicit terminal status
// such as a crash or an ANR takes precedence over a non-terminating error.
if (status != State.Ok) {
hasNonTerminatingUnhandledError = false;
}
sessionHasBeenUpdated = true;
}

Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -318,21 +371,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
Expand All @@ -354,6 +410,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
Expand Down Expand Up @@ -384,6 +441,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);
}
Comment thread
buenaflor marked this conversation as resolved.
writer.name(JsonKeys.ATTRS);
writer.beginObject();
writer.name(JsonKeys.RELEASE).value(logger, release);
Expand Down Expand Up @@ -440,6 +500,7 @@ public static final class Deserializer implements JsonDeserializer<Session> {
String environment = null;
String release = null; // @NotNull
String abnormalMechanism = null;
boolean hasNonTerminatingUnhandledError = false;

Map<String, Object> unknown = null;
while (reader.peek() == JsonToken.NAME) {
Expand Down Expand Up @@ -483,6 +544,12 @@ public static final class Deserializer implements JsonDeserializer<Session> {
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) {
Expand Down Expand Up @@ -542,6 +609,7 @@ public static final class Deserializer implements JsonDeserializer<Session> {
environment,
release,
abnormalMechanism);
session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError;
session.setUnknown(unknown);
reader.endObject();
return session;
Expand Down
45 changes: 45 additions & 0 deletions sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
assertFalse(updatedSession.hasNonTerminatingUnhandledError())
}

@Test
fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() {
val cache = fixture.getSUT()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 terminal status 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()
Expand All @@ -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 =
Expand Down
Loading
Loading