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
d9f14eb
feat(core): Add Unhandled session state and pending-unhandled marker
buenaflor Aug 10, 2026
a2f6fbe
ref: rename pendingUnhandled to nonTerminatingUnhandledError
buenaflor Aug 10, 2026
03159d4
ref: drop public setter for the non-terminating unhandled error flag
buenaflor Aug 10, 2026
038bb2f
ref: initialize the non-terminating flag through a private constructor
buenaflor Aug 10, 2026
d5d1c94
ref: prefix the non-terminating flag field with has
buenaflor Aug 11, 2026
d6cd366
ref: drop comments that restate the code in Session
buenaflor Aug 11, 2026
69172e3
test: move Session serialization cases out of SessionTest
buenaflor Aug 11, 2026
21961dd
test: remove SessionTest
buenaflor Aug 11, 2026
54aa13f
docs(session): describe hasNonTerminatingUnhandledError on the field
buenaflor Aug 11, 2026
cddad30
docs(session): capitalise the hasNonTerminatingUnhandledError comment
buenaflor Aug 11, 2026
2cdc4c2
ref(session): drop the private canonical constructor
buenaflor Aug 11, 2026
00f8001
test(session): use Truth in the new session serialization tests
buenaflor Aug 11, 2026
e2532bc
test(session): cover the unhandled flag through the previous-session โ€ฆ
buenaflor Aug 13, 2026
575fc68
test(session): cover the unhandled session shape with a JSON fixture
buenaflor Aug 13, 2026
0503853
docs(session): Clarify Unhandled state and hybrid-only recording
buenaflor Aug 24, 2026
6ca1908
docs(session): Reword Unhandled status javadoc
buenaflor Aug 24, 2026
f9d6315
ref(session): Clear the unhandled marker for any terminal status
buenaflor Aug 24, 2026
769d174
ref(session): Name the terminal-status check
buenaflor Aug 24, 2026
7baac41
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 @@ -4314,7 +4314,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 @@ -4337,6 +4339,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 @@ -4352,6 +4355,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) {
Comment on lines 257 to 263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A persisted session with State.Unhandled has its end() method called again on recovery, incorrectly overwriting its timestamp and duration.
Severity: MEDIUM

Suggested Fix

In PreviousSessionFinalizer, add a condition to prevent calling session.end() on sessions that are already in a terminal state, such as State.Unhandled or State.Exited. The finalizer should only attempt to end sessions that are still in an State.Ok status.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: sentry/src/main/java/io/sentry/Session.java#L257-L263

Potential issue: When a session is marked with a non-terminating unhandled error, its
status becomes `State.Unhandled` after `end()` is called. If the application closes
before this session is sent, it gets persisted to disk. Upon the next application
launch, the `PreviousSessionFinalizer` processes this session. It lacks a specific check
for the `State.Unhandled` status and proceeds to call `session.end()` a second time.
This subsequent call overwrites the original `timestamp` and recalculates the `duration`
based on the current time, leading to incorrect session analytics data being reported.

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