Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a4cf413
collection: Unhandled Sessions
buenaflor Aug 28, 2026
3829c2e
feat(core): Add Unhandled session state and pending-unhandled marker
buenaflor Aug 10, 2026
5a1130d
ref: rename pendingUnhandled to nonTerminatingUnhandledError
buenaflor Aug 10, 2026
6441148
ref: drop public setter for the non-terminating unhandled error flag
buenaflor Aug 10, 2026
5977515
ref: initialize the non-terminating flag through a private constructor
buenaflor Aug 10, 2026
1f50e9d
ref: prefix the non-terminating flag field with has
buenaflor Aug 11, 2026
c7e399e
ref: drop comments that restate the code in Session
buenaflor Aug 11, 2026
e35da15
test: move Session serialization cases out of SessionTest
buenaflor Aug 11, 2026
c5e821c
test: remove SessionTest
buenaflor Aug 11, 2026
a03e0e5
docs(session): describe hasNonTerminatingUnhandledError on the field
buenaflor Aug 11, 2026
f067d22
docs(session): capitalise the hasNonTerminatingUnhandledError comment
buenaflor Aug 11, 2026
007d3ed
ref(session): drop the private canonical constructor
buenaflor Aug 11, 2026
a7064ec
test(session): use Truth in the new session serialization tests
buenaflor Aug 11, 2026
e7d8759
test(session): cover the unhandled flag through the previous-session โ€ฆ
buenaflor Aug 13, 2026
03457a7
test(session): cover the unhandled session shape with a JSON fixture
buenaflor Aug 13, 2026
0798336
docs(session): Clarify Unhandled state and hybrid-only recording
buenaflor Aug 24, 2026
23f6118
docs(session): Reword Unhandled status javadoc
buenaflor Aug 24, 2026
bb42dc7
ref(session): Clear the unhandled marker for any terminal status
buenaflor Aug 24, 2026
e9b3b14
ref(session): Name the terminal-status check
buenaflor Aug 24, 2026
cfb652e
Revert "ref(session): Name the terminal-status check"
buenaflor Aug 24, 2026
5faaac8
changelog
buenaflor Aug 28, 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add `Session.State.Unhandled` for unhandled errors that do not terminate the process ([#5919](https://github.com/getsentry/sentry-java/pull/5919))

### Fixes

- Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002))
Expand Down
4 changes: 4 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -4324,7 +4324,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 @@ -4347,6 +4349,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 @@ -4362,6 +4365,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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should be volatile, but it's not done anywhere else in this class, so good to keep it as-is.


/** 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nice!

*/
@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 thread
sentry[bot] marked this conversation as resolved.
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