Skip to content

Commit 0699a94

Browse files
feat(core): [Unhandled Sessions 1] Add Unhandled session state and non-terminating error flag (#5919)
* collection: Unhandled Sessions Co-authored-by: Cursor <cursoragent@cursor.com> * feat(core): Add Unhandled session state and pending-unhandled marker Adds Session.State.Unhandled from the session protocol, plus a pending-unhandled marker that survives serialization. A session carrying the marker finalizes as Unhandled instead of Exited on end(), while Crashed and Abnormal keep taking precedence. Co-authored-by: Cursor <cursoragent@cursor.com> * ref: rename pendingUnhandled to nonTerminatingUnhandledError "Unhandled" alone is ambiguous: a native crash is also an unhandled error, it just terminates the process and so ends the session as crashed rather than unhandled. Name the flag after the property that actually distinguishes the two and match the vocabulary of captureEnvelopeNonTerminating. Also clarify that the setter only restores the flag when rebuilding a session and must not be used to mutate a live one. Co-authored-by: Cursor <cursoragent@cursor.com> * ref: drop public setter for the non-terminating unhandled error flag clone() and Session.Deserializer are both inside Session, so they can restore the field directly. Dropping the setter keeps it off the public API surface and makes it impossible to flip the flag on a live session without counting the error and advancing the sequence. Co-authored-by: Cursor <cursoragent@cursor.com> * ref: initialize the non-terminating flag through a private constructor Every other field is set at construction; the flag was the odd one out, assigned afterwards. A private canonical constructor keeps construction complete without putting the flag on the public API, which a 15-arg public overload would do. Co-authored-by: Cursor <cursoragent@cursor.com> * ref: prefix the non-terminating flag field with has As a bare noun phrase the field read like it held the error rather than a boolean, most visibly where it is passed as a constructor argument. Co-authored-by: Cursor <cursoragent@cursor.com> * ref: drop comments that restate the code in Session Co-authored-by: Cursor <cursoragent@cursor.com> * test: move Session serialization cases out of SessionTest The round-trip case duplicated one already added to SessionSerializationTest. Keep JSON concerns in the serialization test and leave SessionTest to state transitions. Co-authored-by: Cursor <cursoragent@cursor.com> * test: remove SessionTest Co-authored-by: Cursor <cursoragent@cursor.com> * docs(session): describe hasNonTerminatingUnhandledError on the field It was the only field in Session without the one-line comment the surrounding declarations all carry. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(session): capitalise the hasNonTerminatingUnhandledError comment Co-authored-by: Cursor <cursoragent@cursor.com> * ref(session): drop the private canonical constructor hasNonTerminatingUnhandledError is not final - recordNonTerminating UnhandledError and update() both write it - so setting it through a constructor established no invariant that a plain assignment does not. Both call sites are inside Session, so clone() and the deserializer can assign the field directly, which is what the deserializer already does for unknown. Removes the 15-parameter overload and the javadoc that existed to justify it. The public constructor is unchanged, so sentry.api is too. Co-authored-by: Cursor <cursoragent@cursor.com> * test(session): use Truth in the new session serialization tests Also swaps assertFalse(serialize(...).contains(...)) for Truth's doesNotContain, which reports the offending json on failure instead of just "expected false". The two new PreviousSessionFinalizerTest cases are left on Mockito argThat, which needs a Boolean predicate rather than an assertion. Co-authored-by: Cursor <cursoragent@cursor.com> * test(session): cover the unhandled flag through the previous-session recovery paths Co-authored-by: Cursor <cursoragent@cursor.com> * test(session): cover the unhandled session shape with a JSON fixture Co-authored-by: Cursor <cursoragent@cursor.com> * docs(session): Clarify Unhandled state and hybrid-only recording Co-authored-by: Cursor <cursoragent@cursor.com> * docs(session): Reword Unhandled status javadoc Co-authored-by: Cursor <cursoragent@cursor.com> * ref(session): Clear the unhandled marker for any terminal status update() only cleared hasNonTerminatingUnhandledError for Crashed, so an ANR arriving after a non-terminating unhandled error left the marker set on an Abnormal session and serialized it into previous_session.json. The marker only decides how an Ok session is finalized, so any explicit terminal status clears it now. Co-authored-by: Cursor <cursoragent@cursor.com> * ref(session): Name the terminal-status check `status != State.Ok` stated the mechanism while the comment carried the meaning. An isTerminal helper says it directly and reads the same in recordNonTerminatingUnhandledError, whose javadoc already spoke of terminal states. Co-authored-by: Cursor <cursoragent@cursor.com> * Revert "ref(session): Name the terminal-status check" This reverts commit 3347255. * changelog Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent af612e9 commit 0699a94

7 files changed

Lines changed: 254 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add `Session.State.Unhandled` for unhandled errors that do not terminate the process ([#5919](https://github.com/getsentry/sentry-java/pull/5919))
8+
59
### Fixes
610

711
- 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))

‎sentry/api/sentry.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4324,7 +4324,9 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso
43244324
public fun getTimestamp ()Ljava/util/Date;
43254325
public fun getUnknown ()Ljava/util/Map;
43264326
public fun getUserAgent ()Ljava/lang/String;
4327+
public fun hasNonTerminatingUnhandledError ()Z
43274328
public fun isTerminated ()Z
4329+
public fun recordNonTerminatingUnhandledError ()Z
43284330
public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
43294331
public fun setInitAsTrue ()V
43304332
public fun setUnknown (Ljava/util/Map;)V
@@ -4347,6 +4349,7 @@ public final class io/sentry/Session$JsonKeys {
43474349
public static final field ERRORS Ljava/lang/String;
43484350
public static final field INIT Ljava/lang/String;
43494351
public static final field IP_ADDRESS Ljava/lang/String;
4352+
public static final field NON_TERMINATING_UNHANDLED_ERROR Ljava/lang/String;
43504353
public static final field RELEASE Ljava/lang/String;
43514354
public static final field SEQ Ljava/lang/String;
43524355
public static final field SID Ljava/lang/String;
@@ -4362,6 +4365,7 @@ public final class io/sentry/Session$State : java/lang/Enum {
43624365
public static final field Crashed Lio/sentry/Session$State;
43634366
public static final field Exited Lio/sentry/Session$State;
43644367
public static final field Ok Lio/sentry/Session$State;
4368+
public static final field Unhandled Lio/sentry/Session$State;
43654369
public static fun valueOf (Ljava/lang/String;)Lio/sentry/Session$State;
43664370
public static fun values ()[Lio/sentry/Session$State;
43674371
}

‎sentry/src/main/java/io/sentry/Session.java‎

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ public enum State {
2121
Ok,
2222
Exited,
2323
Crashed,
24-
Abnormal
24+
Abnormal,
25+
/**
26+
* Final status when an unhandled error did not kill the process, such as a Flutter exception.
27+
* The session stays {@link #Ok} until {@link Session#end()}. Native crashes still end as {@link
28+
* #Crashed}.
29+
*/
30+
Unhandled
2531
}
2632

2733
/** started timestamp */
@@ -66,6 +72,9 @@ public enum State {
6672
/** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */
6773
private @Nullable String abnormalMechanism;
6874

75+
/** Whether an unhandled error occurred that did not terminate the process */
76+
private boolean hasNonTerminatingUnhandledError;
77+
6978
/** The session lock, ops should be atomic */
7079
private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock();
7180

@@ -188,6 +197,45 @@ public int errorCount() {
188197
return abnormalMechanism;
189198
}
190199

200+
/**
201+
* Whether the session experienced an unhandled error that did <em>not</em> terminate the process,
202+
* e.g. an unhandled Flutter exception, and so finalizes as {@link State#Unhandled} rather than
203+
* {@link State#Exited}. A native crash is also unhandled, but it kills the process and ends the
204+
* session as {@link State#Crashed} instead.
205+
*
206+
* <p>Never sent as a status while the session is alive; it is only persisted with the session.
207+
*/
208+
@ApiStatus.Internal
209+
public boolean hasNonTerminatingUnhandledError() {
210+
return hasNonTerminatingUnhandledError;
211+
}
212+
213+
/**
214+
* Records that an active session experienced an unhandled error which did not terminate the
215+
* process, counting the error and advancing the session's sequence without ending it. On {@link
216+
* #end()} the session is finalized as {@link State#Unhandled} unless a terminal status such as
217+
* {@link State#Crashed} or {@link State#Abnormal} took over first.
218+
*
219+
* <p>Hybrid SDKs whose unhandled errors do not kill the process. Native Java/Android capture
220+
* should not call this.
221+
*
222+
* @return whether the session was updated, i.e. false if it had already reached a terminal state
223+
*/
224+
@ApiStatus.Internal
225+
public boolean recordNonTerminatingUnhandledError() {
226+
try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) {
227+
if (status != State.Ok) {
228+
return false;
229+
}
230+
hasNonTerminatingUnhandledError = true;
231+
errorCount.incrementAndGet();
232+
init = null;
233+
timestamp = DateUtils.getCurrentDateTime();
234+
sequence = getSequenceTimestamp(timestamp);
235+
return true;
236+
}
237+
}
238+
191239
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
192240
public @Nullable Date getTimestamp() {
193241
return timestamp;
@@ -209,7 +257,7 @@ public void end(final @Nullable Date timestamp) {
209257

210258
// at this state it might be Crashed already, so we don't check for it.
211259
if (status == State.Ok) {
212-
status = State.Exited;
260+
status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited;
213261
}
214262

215263
if (timestamp != null) {
@@ -262,6 +310,11 @@ public boolean update(
262310
boolean sessionHasBeenUpdated = false;
263311
if (status != null) {
264312
this.status = status;
313+
// the flag only decides how an Ok session is finalized, so an explicit terminal status
314+
// such as a crash or an ANR takes precedence over a non-terminating error.
315+
if (status != State.Ok) {
316+
hasNonTerminatingUnhandledError = false;
317+
}
265318
sessionHasBeenUpdated = true;
266319
}
267320

@@ -318,21 +371,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) {
318371
*/
319372
@SuppressWarnings("MissingOverride")
320373
public @NotNull Session clone() {
321-
return new Session(
322-
status,
323-
started,
324-
timestamp,
325-
errorCount.get(),
326-
distinctId,
327-
sessionId,
328-
init,
329-
sequence,
330-
duration,
331-
ipAddress,
332-
userAgent,
333-
environment,
334-
release,
335-
abnormalMechanism);
374+
final @NotNull Session session =
375+
new Session(
376+
status,
377+
started,
378+
timestamp,
379+
errorCount.get(),
380+
distinctId,
381+
sessionId,
382+
init,
383+
sequence,
384+
duration,
385+
ipAddress,
386+
userAgent,
387+
environment,
388+
release,
389+
abnormalMechanism);
390+
session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError;
391+
return session;
336392
}
337393

338394
// JsonSerializable
@@ -354,6 +410,7 @@ public static final class JsonKeys {
354410
public static final String IP_ADDRESS = "ip_address";
355411
public static final String USER_AGENT = "user_agent";
356412
public static final String ABNORMAL_MECHANISM = "abnormal_mechanism";
413+
public static final String NON_TERMINATING_UNHANDLED_ERROR = "non_terminating_unhandled_error";
357414
}
358415

359416
@Override
@@ -384,6 +441,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
384441
if (abnormalMechanism != null) {
385442
writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism);
386443
}
444+
if (hasNonTerminatingUnhandledError) {
445+
writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(hasNonTerminatingUnhandledError);
446+
}
387447
writer.name(JsonKeys.ATTRS);
388448
writer.beginObject();
389449
writer.name(JsonKeys.RELEASE).value(logger, release);
@@ -440,6 +500,7 @@ public static final class Deserializer implements JsonDeserializer<Session> {
440500
String environment = null;
441501
String release = null; // @NotNull
442502
String abnormalMechanism = null;
503+
boolean hasNonTerminatingUnhandledError = false;
443504

444505
Map<String, Object> unknown = null;
445506
while (reader.peek() == JsonToken.NAME) {
@@ -483,6 +544,12 @@ public static final class Deserializer implements JsonDeserializer<Session> {
483544
case JsonKeys.ABNORMAL_MECHANISM:
484545
abnormalMechanism = reader.nextStringOrNull();
485546
break;
547+
case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR:
548+
final Boolean hasNonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull();
549+
hasNonTerminatingUnhandledError =
550+
hasNonTerminatingUnhandledErrorValue != null
551+
&& hasNonTerminatingUnhandledErrorValue;
552+
break;
486553
case JsonKeys.ATTRS:
487554
reader.beginObject();
488555
while (reader.peek() == JsonToken.NAME) {
@@ -542,6 +609,7 @@ public static final class Deserializer implements JsonDeserializer<Session> {
542609
environment,
543610
release,
544611
abnormalMechanism);
612+
session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError;
545613
session.setUnknown(unknown);
546614
reader.endObject();
547615
return session;

‎sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,51 @@ class PreviousSessionFinalizerTest {
200200
)
201201
}
202202

203+
@Test
204+
fun `if previous session has a non-terminating unhandled error and no crash marker, finalizes as unhandled`() {
205+
val finalizer =
206+
fixture.getSut(
207+
tmpDir,
208+
session =
209+
Session(null, null, null, "io.sentry.sample@1.0").apply {
210+
recordNonTerminatingUnhandledError()
211+
},
212+
)
213+
finalizer.run()
214+
215+
verify(fixture.scopes)
216+
.captureEnvelope(
217+
argThat {
218+
val session = fixture.sessionFromEnvelope(this)
219+
session.release == "io.sentry.sample@1.0" &&
220+
session.status == Session.State.Unhandled &&
221+
session.hasNonTerminatingUnhandledError()
222+
}
223+
)
224+
}
225+
226+
@Test
227+
fun `if previous session has a non-terminating unhandled error but a native crash marker exists, finalizes as crashed`() {
228+
val finalizer =
229+
fixture.getSut(
230+
tmpDir,
231+
session =
232+
Session(null, null, null, "io.sentry.sample@1.0").apply {
233+
recordNonTerminatingUnhandledError()
234+
},
235+
nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"),
236+
)
237+
finalizer.run()
238+
239+
verify(fixture.scopes)
240+
.captureEnvelope(
241+
argThat {
242+
val session = fixture.sessionFromEnvelope(this)
243+
session.release == "io.sentry.sample@1.0" && session.status == Crashed
244+
}
245+
)
246+
}
247+
203248
@Test
204249
fun `if previous session file exists, deletes previous session file`() {
205250
val finalizer = fixture.getSut(tmpDir, sessionFileExists = true)

‎sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,36 @@ class EnvelopeCacheTest {
346346
assertEquals(sessionExitedWithAbnormal, updatedSession!!.timestamp!!.time)
347347
}
348348

349+
@Test
350+
fun `AbnormalExit hint keeps persisted unhandled session as abnormal`() {
351+
val cache = fixture.getSUT()
352+
353+
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
354+
val session = createSession().apply { recordNonTerminatingUnhandledError() }
355+
fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter())
356+
357+
val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null)
358+
val abnormalHint =
359+
object : AbnormalExit {
360+
override fun mechanism(): String = "abnormal_mechanism"
361+
362+
override fun ignoreCurrentThread(): Boolean = false
363+
364+
override fun timestamp(): Long = session.started!!.time + TimeUnit.HOURS.toMillis(1)
365+
}
366+
val hints = HintUtils.createWithTypeCheckHint(abnormalHint)
367+
cache.storeEnvelope(envelope, hints)
368+
369+
val updatedSession =
370+
fixture.options.serializer.deserialize(
371+
previousSessionFile.bufferedReader(),
372+
Session::class.java,
373+
)
374+
assertEquals(State.Abnormal, updatedSession!!.status)
375+
assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism)
376+
assertFalse(updatedSession.hasNonTerminatingUnhandledError())
377+
}
378+
349379
@Test
350380
fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() {
351381
val cache = fixture.getSUT()
@@ -400,6 +430,29 @@ class EnvelopeCacheTest {
400430
assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time)
401431
}
402432

433+
@Test
434+
fun `NativeCrashExit hint keeps persisted unhandled session as crashed`() {
435+
val cache = fixture.getSUT()
436+
437+
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
438+
val session = createSession().apply { recordNonTerminatingUnhandledError() }
439+
fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter())
440+
441+
val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1)
442+
val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null)
443+
val hints = HintUtils.createWithTypeCheckHint(NativeCrashExit { nativeCrashTimestamp })
444+
cache.storeEnvelope(envelope, hints)
445+
446+
val updatedSession =
447+
fixture.options.serializer.deserialize(
448+
previousSessionFile.bufferedReader(),
449+
Session::class.java,
450+
)
451+
assertEquals(State.Crashed, updatedSession!!.status)
452+
assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time)
453+
assertFalse(updatedSession.hasNonTerminatingUnhandledError())
454+
}
455+
403456
@Test
404457
fun `when NativeCrashExit happened before previous session start, does not mark as crashed`() {
405458
val cache = fixture.getSUT()

‎sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.sentry.protocol
22

3+
import com.google.common.truth.Truth.assertThat
34
import io.sentry.DateUtils
45
import io.sentry.FileFromResources
56
import io.sentry.ILogger
@@ -34,6 +35,34 @@ class SessionSerializationTest {
3435
"b2d0224b-4b1f-49db-94c9-fd4a439b3ef5",
3536
"anr_foreground",
3637
)
38+
39+
/**
40+
* An unhandled session cannot be built by mutating [getSut]: the flag is only reachable through
41+
* [Session.recordNonTerminatingUnhandledError], which no-ops unless the session is still `Ok`,
42+
* and a terminal status would clear it again. Ending on a fixed timestamp keeps `seq` and
43+
* `duration` deterministic.
44+
*/
45+
fun getUnhandledSut() =
46+
Session(
47+
Session.State.Ok,
48+
DateUtils.getDateTime("1945-06-16T06:36:49.000Z"),
49+
DateUtils.getDateTime("1970-04-21T09:32:21.000Z"),
50+
9001,
51+
"631693c2-3d61-4a93-8fd1-89817426ba5a",
52+
"3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17",
53+
true,
54+
4,
55+
5.5,
56+
"5a174e69-a297-4ba4-b6e1-2244a8299ec8",
57+
"790da4ae-50ca-48a2-98f6-9b7f4e05a8c3",
58+
"d732be55-b57e-48ec-afe6-b0040c7f93de",
59+
"b2d0224b-4b1f-49db-94c9-fd4a439b3ef5",
60+
null,
61+
)
62+
.apply {
63+
recordNonTerminatingUnhandledError()
64+
end(DateUtils.getDateTime("1970-04-21T09:32:21.000Z"))
65+
}
3766
}
3867

3968
private val fixture = Fixture()
@@ -53,6 +82,22 @@ class SessionSerializationTest {
5382
assertEquals(expectedJson, actualJson)
5483
}
5584

85+
@Test
86+
fun serializeUnhandled() {
87+
val expected = sanitizedFile("json/session_unhandled.json")
88+
val actual = serialize(fixture.getUnhandledSut())
89+
assertThat(actual).isEqualTo(expected)
90+
}
91+
92+
@Test
93+
fun deserializeUnhandled() {
94+
val expectedJson = sanitizedFile("json/session_unhandled.json")
95+
val actual = deserialize(expectedJson)
96+
assertThat(actual.status).isEqualTo(Session.State.Unhandled)
97+
assertThat(actual.hasNonTerminatingUnhandledError()).isTrue()
98+
assertThat(serialize(actual)).isEqualTo(expectedJson)
99+
}
100+
56101
// Helper
57102

58103
private fun sanitizedFile(path: String): String =

0 commit comments

Comments
 (0)