Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0e52a46
collection: Unhandled Sessions
buenaflor Aug 10, 2026
1ae2b46
feat(core): Add Unhandled session state and pending-unhandled marker
buenaflor Aug 10, 2026
e62793f
ref: rename pendingUnhandled to nonTerminatingUnhandledError
buenaflor Aug 10, 2026
af3fd91
ref: drop public setter for the non-terminating unhandled error flag
buenaflor Aug 10, 2026
59717b2
ref: initialize the non-terminating flag through a private constructor
buenaflor Aug 10, 2026
55fc569
ref: prefix the non-terminating flag field with has
buenaflor Aug 11, 2026
407c39d
ref: drop comments that restate the code in Session
buenaflor Aug 11, 2026
8551441
test: move Session serialization cases out of SessionTest
buenaflor Aug 11, 2026
d198a04
test: remove SessionTest
buenaflor Aug 11, 2026
b24a47e
docs(session): describe hasNonTerminatingUnhandledError on the field
buenaflor Aug 11, 2026
aaa4154
docs(session): capitalise the hasNonTerminatingUnhandledError comment
buenaflor Aug 11, 2026
f236516
ref(session): drop the private canonical constructor
buenaflor Aug 11, 2026
2c1d462
test(session): use Truth in the new session serialization tests
buenaflor Aug 11, 2026
d9def2e
fix(core): Make session cache writes session-id aware
buenaflor Aug 10, 2026
17f7923
ref: follow Session rename in EnvelopeCache
buenaflor Aug 10, 2026
153074c
ref: follow the setter removal in EnvelopeCacheTest
buenaflor Aug 10, 2026
c120b31
docs: explain why stale session envelopes must not clobber newer state
buenaflor Aug 11, 2026
d5fec24
ref(session): make the two stale-envelope checks read the same way
buenaflor Aug 11, 2026
f4dfc80
ref(cache): replace the stale-start comparison with an identity check
buenaflor Aug 12, 2026
688a43f
ref(cache): restore catch (Throwable) in the envelope session reader
buenaflor Aug 12, 2026
0a2dda4
ref(cache): delete the current session file unconditionally again
buenaflor Aug 12, 2026
dbfcd3f
ref(cache): fold the session-id comparison into one predicate
buenaflor Aug 13, 2026
b28cadf
test(session): cover the unhandled flag through the previous-session โ€ฆ
buenaflor Aug 13, 2026
305a48d
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 13, 2026
d3af8ad
test(session): cover the unhandled session shape with a JSON fixture
buenaflor Aug 13, 2026
2161da8
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 13, 2026
2e8a06f
ref(cache): track the out-of-band session id instead of re-reading itโ€ฆ
buenaflor Aug 13, 2026
a7f837f
ref(cache): rename isLateDuplicateStart to isAlreadyPersisted
buenaflor Aug 13, 2026
ae99f6c
fix(sessions): don't let the persisted session guard swallow a Sessioโ€ฆ
buenaflor Aug 13, 2026
f8fee2b
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 24, 2026
b81fcdc
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 24, 2026
116abce
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 24, 2026
ef55c1a
Merge branch 'feat/unhandled-sessions-protocol' into feat/unhandled-sโ€ฆ
buenaflor Aug 24, 2026
7a5cd31
fix(cache): Clear the persisted session id when the write fails
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
1 change: 1 addition & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -4880,6 +4880,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache {
public static fun getPreviousSessionFile (Ljava/lang/String;)Ljava/io/File;
public fun iterator ()Ljava/util/Iterator;
public fun movePreviousSession (Ljava/io/File;Ljava/io/File;)V
public fun persistCurrentSession (Lio/sentry/Session;)V
public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V
public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z
public fun waitPreviousSessionFlush ()Z
Expand Down
66 changes: 55 additions & 11 deletions sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ public class EnvelopeCache extends CacheStrategy implements IEnvelopeCache {
protected final @NotNull AutoClosableReentrantLock cacheLock = new AutoClosableReentrantLock();
protected final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock();

/**
* Session id last written to the current session file by {@link #persistCurrentSession(Session)},
* which bypasses the transport queue that every other write to that file goes through.
*/
private @Nullable String lastPersistedSessionId;

public static @NotNull IEnvelopeCache create(final @NotNull SentryOptions options) {
final String cacheDirPath = options.getCacheDirPath();
final int maxCacheItems = options.getMaxCacheItems();
Expand Down Expand Up @@ -118,8 +124,11 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not
final File previousSessionFile = getPreviousSessionFile(directoryPath);

if (HintUtils.hasType(hint, SessionEnd.class)) {
if (!currentSessionFile.delete()) {
options.getLogger().log(WARNING, "Current envelope doesn't exist.");
try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) {
lastPersistedSessionId = null;
if (!currentSessionFile.delete()) {
options.getLogger().log(WARNING, "Current envelope doesn't exist.");
}
}
}

Expand All @@ -129,8 +138,15 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not
}

if (HintUtils.hasType(hint, SessionStart.class)) {
movePreviousSession(currentSessionFile, previousSessionFile);
updateCurrentSession(currentSessionFile, envelope);
try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) {
final @Nullable Session startingSession = readSessionFromEnvelope(envelope);
if (!isAlreadyPersisted(startingSession)) {
movePreviousSession(currentSessionFile, previousSessionFile);
if (startingSession != null) {
writeSessionToDisk(currentSessionFile, startingSession);
}
}
}
Comment on lines +141 to +149

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Today session.json only gets written from the transport queue. This PR adds a second writer, persistCurrentSession, which writes the live session straight to disk. #5921 calls it when Flutter reports an unhandled error that didn't kill the app.

The issue is that the SessionStart envelope is queued, so it can land after that:

session S starts       โ†’ SessionStart(S) goes into the transport queue
unhandled Dart error   โ†’ S gets flagged, persistCurrentSession writes it to session.json
SessionStart(S) drains โ†’ session.json moved to previous_session.json, so last run's session is gone
                       โ†’ envelope's unflagged copy of S written over session.json
app killed             โ†’ S comes in as exited instead of unhandled

So we skip the move and the write if the SessionStart is for a session we already persisted. Nothing else changes.

Comment thread
cursor[bot] marked this conversation as resolved.

boolean crashedLastRun = false;
final File crashMarkerFile = new File(options.getCacheDirPath(), NATIVE_CRASH_MARKER_FILE);
Expand Down Expand Up @@ -274,8 +290,7 @@ private void writeCrashMarkerFile() {
}
}

private void updateCurrentSession(
final @NotNull File currentSessionFile, final @NotNull SentryEnvelope envelope) {
private @Nullable Session readSessionFromEnvelope(final @NotNull SentryEnvelope envelope) {
final Iterable<SentryEnvelopeItem> items = envelope.getItems();

// we know that an envelope with a SessionStart hint has a single item inside
Expand All @@ -295,7 +310,7 @@ private void updateCurrentSession(
"Item of type %s returned null by the parser.",
item.getHeader().getType());
} else {
writeSessionToDisk(currentSessionFile, session);
return session;
}
} catch (Throwable e) {
options.getLogger().log(ERROR, "Item failed to process.", e);
Expand All @@ -309,10 +324,26 @@ private void updateCurrentSession(
item.getHeader().getType());
}
} else {
options
.getLogger()
.log(INFO, "Current envelope %s is empty", currentSessionFile.getAbsolutePath());
options.getLogger().log(INFO, "Current envelope is empty.");
}
return null;
}

/**
* Whether a {@link SessionStart} envelope refers to the session {@link
* #persistCurrentSession(Session)} already wrote to the current session file. That copy is the
* live session, so it is at least as advanced as this envelope. Rotating and overwriting it would
* file a running session as the previous one and roll back any unhandled error it has recorded
* since.
*
* <p>A null session id never matches, so sessions we cannot tell apart are rotated as before.
*/
private boolean isAlreadyPersisted(final @Nullable Session startingSession) {
if (startingSession == null) {
return false;
}
final @Nullable String startingSessionId = startingSession.getSessionId();
return startingSessionId != null && startingSessionId.equals(lastPersistedSessionId);
Comment thread
buenaflor marked this conversation as resolved.
}

private boolean writeEnvelopeToDisk(
Expand All @@ -337,7 +368,7 @@ private boolean writeEnvelopeToDisk(
return true;
}

private void writeSessionToDisk(final @NotNull File file, final @NotNull Session session) {
private boolean writeSessionToDisk(final @NotNull File file, final @NotNull Session session) {
try (final OutputStream outputStream = new FileOutputStream(file);
final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) {
options
Expand All @@ -349,6 +380,19 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session
options
.getLogger()
.log(ERROR, e, "Error writing Session to offline storage: %s", session.getSessionId());
return false;
}
return true;
}

@ApiStatus.Internal
public void persistCurrentSession(final @NotNull Session session) {
try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) {
final boolean written =
writeSessionToDisk(
getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session);
// a failed write truncates the file, so there is no good copy left to protect
lastPersistedSessionId = written ? session.getSessionId() : null;
}
Comment thread
buenaflor marked this conversation as resolved.
}

Expand Down
194 changes: 192 additions & 2 deletions sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.sentry.cache

import com.google.common.truth.Truth.assertThat
import io.sentry.DateUtils
import io.sentry.Hint
import io.sentry.ILogger
Expand All @@ -23,6 +24,7 @@ import io.sentry.hints.SessionStartHint
import io.sentry.protocol.SentryId
import io.sentry.util.HintUtils
import java.io.File
import java.io.Writer
import java.nio.file.Files
import java.nio.file.Path
import java.util.Date
Expand All @@ -34,8 +36,10 @@ import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.same
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

class EnvelopeCacheTest {
Expand Down Expand Up @@ -160,6 +164,189 @@ class EnvelopeCacheTest {
assertTrue(didStore)
}

@Test
fun `delayed same SID SessionStart preserves newer unhandled snapshot`() {
val cache = fixture.getSUT()
val sid = SentryUUID.generateSentryId()
val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
val newerSession = createSession(sessionId = sid)
newerSession.recordNonTerminatingUnhandledError()
cache.persistCurrentSession(newerSession)

val delayedStart = createSession(sessionId = sid)
val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

val persistedSession =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedSession.sessionId).isEqualTo(sid)
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue()
assertThat(persistedSession.errorCount()).isEqualTo(1)
assertThat(previousSessionFile.exists()).isFalse()
}

@Test
fun `delayed same SID SessionStart preserves newer error count snapshot`() {
val cache = fixture.getSUT()
val sid = SentryUUID.generateSentryId()
val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
val newerSession = createSession(sessionId = sid)
newerSession.update(null, null, true)
cache.persistCurrentSession(newerSession)

val delayedStart = createSession(sessionId = sid)
val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

val persistedSession =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedSession.sessionId).isEqualTo(sid)
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isFalse()
assertThat(persistedSession.errorCount()).isEqualTo(1)
assertThat(previousSessionFile.exists()).isFalse()
}

@Test
fun `failed persist stops the delayed same SID SessionStart from being skipped`() {
val cache = fixture.getSUT()
val sid = SentryUUID.generateSentryId()
val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val newerSession = createSession(sessionId = sid)
newerSession.recordNonTerminatingUnhandledError()
cache.persistCurrentSession(newerSession)

// a directory where the session file belongs makes the write fail
assertTrue(currentSessionFile.delete())
assertTrue(currentSessionFile.mkdir())
cache.persistCurrentSession(newerSession)
assertTrue(currentSessionFile.delete())

val delayedStart = createSession(sessionId = sid)
val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

val persistedSession =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedSession.sessionId).isEqualTo(sid)
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isFalse()
assertThat(persistedSession.errorCount()).isEqualTo(0)
}

@Test
fun `null SIDs on SessionStart rotate instead of preserving as same session`() {
val cache = fixture.getSUT()
val currentSession = createSession(sessionId = null)
currentSession.update(null, null, true)
cache.persistCurrentSession(currentSession)
val startingSession = createSession(sessionId = null)

val envelope = SentryEnvelope.from(fixture.options.serializer, startingSession, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
val persistedCurrent =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
val persistedPrevious =
fixture.options.serializer.deserialize(
previousSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedCurrent.sessionId).isNull()
assertThat(persistedCurrent.errorCount()).isEqualTo(0)
assertThat(persistedPrevious.sessionId).isNull()
assertThat(persistedPrevious.errorCount()).isEqualTo(1)
}

@Test
fun `different SID SessionStart rotates current session`() {
val cache = fixture.getSUT()
val currentSession = createSession()
cache.persistCurrentSession(currentSession)
val nextSession = createSession()

val envelope = SentryEnvelope.from(fixture.options.serializer, nextSession, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!)
val persistedCurrent =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
val persistedPrevious =
fixture.options.serializer.deserialize(
previousSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedCurrent.sessionId).isEqualTo(nextSession.sessionId)
assertThat(persistedPrevious.sessionId).isEqualTo(currentSession.sessionId)
}

@Test
fun `SessionEnd deleting the persisted session lets the delayed SessionStart write it again`() {
val cache = fixture.getSUT()
val sid = SentryUUID.generateSentryId()
val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
cache.persistCurrentSession(createSession(sessionId = sid))

// the previous session's end envelope is still queued and deletes the file the live session
// was just written to
val endedSession = createSession()
cache.storeEnvelope(
SentryEnvelope.from(fixture.options.serializer, endedSession, null),
HintUtils.createWithTypeCheckHint(SessionEndHint()),
)
assertThat(currentSessionFile.exists()).isFalse()

val delayedStart = createSession(sessionId = sid)
cache.storeEnvelope(
SentryEnvelope.from(fixture.options.serializer, delayedStart, null),
HintUtils.createWithTypeCheckHint(SessionStartHint()),
)

val persistedSession =
fixture.options.serializer.deserialize(
currentSessionFile.bufferedReader(),
Session::class.java,
)!!
assertThat(persistedSession.sessionId).isEqualTo(sid)
}

@Test
fun `failed persist lets the delayed SessionStart write the session`() {
val sid = SentryUUID.generateSentryId()
val liveSession = createSession(sessionId = sid)
val delayedStart = createSession(sessionId = sid)
val serializer = mock<ISerializer>()
whenever(serializer.serialize(same(liveSession), any<Writer>()))
.thenThrow(RuntimeException("forced ex"))
whenever(serializer.deserialize(any(), eq(Session::class.java))).thenReturn(delayedStart)
val cache = fixture.getSUT { options -> options.setSerializer(serializer) }

cache.persistCurrentSession(liveSession)

val envelope = SentryEnvelope.from(SentryOptions.empty().serializer, delayedStart, null)
cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint()))

verify(serializer).serialize(same(delayedStart), any<Writer>())
}

@Test
fun `updates current file on session update and read it back`() {
val cache = fixture.getSUT()
Expand Down Expand Up @@ -491,14 +678,17 @@ class EnvelopeCacheTest {
assertFalse(didStore)
}

private fun createSession(started: Date? = null): Session =
private fun createSession(
started: Date? = null,
Comment thread
buenaflor marked this conversation as resolved.
sessionId: String? = SentryUUID.generateSentryId(),
): Session =
Session(
Ok,
started ?: DateUtils.getCurrentDateTime(),
DateUtils.getCurrentDateTime(),
0,
"dis",
SentryUUID.generateSentryId(),
sessionId,
true,
null,
null,
Expand Down
Loading