Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
93 changes: 76 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,8 @@ public enum State {
Ok,
Exited,
Crashed,
Abnormal
Abnormal,
Unhandled

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should we javadoc this? i know the other ones don't but this feels like it is worht documenting given the surprising behavior

}

/** started timestamp */
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -188,6 +192,42 @@ 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we need to grab the sessionLock in order to read this?

otherwise if this could be set to true before errorCount is incremented. would that be a valid state?

return hasNonTerminatingUnhandledError;
}

/**
* Records that an active session experienced an unhandled error which did not terminate the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should we document that this is only meant to be called from hybrid sdks and never from java/kotlin/android?

* 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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -440,6 +491,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 +535,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 +600,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)
assertTrue(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 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()
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
18 changes: 18 additions & 0 deletions sentry/src/test/resources/json/session_unhandled.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading