Skip to content

Commit 0df52e7

Browse files
feat(android): [Unhandled Sessions 4] Add session update API for unsampled hybrid errors (#5990)
* feat(android): Add session update for dropped hybrid errors Hybrid SDKs skip captureEnvelopeNonTerminating when an error is unsampled, so the session never records it. Expose the same non-terminating session update without sending the event. Co-authored-by: Cursor <cursoragent@cursor.com> * changelog Co-authored-by: Cursor <cursoragent@cursor.com> * docs(android): Mention only sampling for the dropped-event session API This path is for hybrid errors dropped by sample rate, not rate limiting. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(android): Note that the dropped-event session API writes to disk Persisting the session is synchronous so the unhandled marker is durable by the time the call returns, which means callers must stay off the main thread. Say so where hybrid SDK authors will read it. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 794a14e commit 0df52e7

4 files changed

Lines changed: 99 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
### Internal
1818

1919
- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed` ([#5921](https://github.com/getsentry/sentry-java/pull/5921))
20+
- Add `InternalSentrySdk.updateSessionForDroppedEventNonTerminating` so hybrid SDKs can still update the session when an error is dropped by sampling ([#5990](https://github.com/getsentry/sentry-java/pull/5990))
2021

2122
## 8.54.0
2223

‎sentry-android-core/api/sentry-android-core.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ public final class io/sentry/android/core/InternalSentrySdk {
323323
public static fun getCurrentScope ()Lio/sentry/IScope;
324324
public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map;
325325
public static fun setTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V
326+
public static fun updateSessionForDroppedEventNonTerminating (Z)V
326327
}
327328

328329
public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass {

‎sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,23 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel
264264
return scopes.captureEnvelope(envelope);
265265
}
266266

267+
/**
268+
* Session side effects of {@link #captureEnvelopeNonTerminating(byte[])} without sending the
269+
* event. Hybrid SDKs should call this when an error is dropped by sampling.
270+
*
271+
* <p>Do not call this for events dropped by {@code beforeSend} or ignored exception types.
272+
*
273+
* <p>Persisting the session is a blocking disk write on the calling thread, so call this off the
274+
* main thread as the hybrid SDKs do. It is synchronous on purpose: a deferred write would not be
275+
* on disk yet if the process dies right after this returns.
276+
*
277+
* @param crashed {@code true} if the dropped error was unhandled ({@code
278+
* mechanism.handled=false})
279+
*/
280+
public static void updateSessionForDroppedEventNonTerminating(final boolean crashed) {
281+
updateSessionNonTerminating(crashed);
282+
}
283+
267284
/**
268285
* Flags the current session for a non-terminating hybrid error and persists it before returning,
269286
* so the marker survives an immediate process death.

‎sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,86 @@ class InternalSentrySdkTest {
612612
assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid)
613613
}
614614

615+
@Test
616+
fun `updateSessionForDroppedEventNonTerminating flags an unhandled error without sending an envelope`() {
617+
val fixture = Fixture()
618+
fixture.init(context)
619+
620+
val originalSid = AtomicReference<String>()
621+
Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) }
622+
fixture.capturedEnvelopes.clear()
623+
624+
InternalSentrySdk.updateSessionForDroppedEventNonTerminating(true)
625+
626+
assertThat(fixture.capturedEnvelopes).isEmpty()
627+
628+
val scopeSession = AtomicReference<Session>()
629+
Sentry.configureScope { scope -> scopeSession.set(scope.session) }
630+
assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok)
631+
assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue()
632+
assertThat(scopeSession.get().errorCount()).isEqualTo(1)
633+
assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get())
634+
635+
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
636+
val persistedSession =
637+
fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!!
638+
assertThat(persistedSession.status).isEqualTo(Session.State.Ok)
639+
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue()
640+
assertThat(persistedSession.errorCount()).isEqualTo(1)
641+
assertThat(persistedSession.sessionId).isEqualTo(originalSid.get())
642+
}
643+
644+
@Test
645+
fun `updateSessionForDroppedEventNonTerminating increments errors for a handled error without sending an envelope`() {
646+
val fixture = Fixture()
647+
fixture.init(context)
648+
649+
val originalSid = AtomicReference<String>()
650+
Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) }
651+
fixture.capturedEnvelopes.clear()
652+
653+
InternalSentrySdk.updateSessionForDroppedEventNonTerminating(false)
654+
655+
assertThat(fixture.capturedEnvelopes).isEmpty()
656+
657+
val scopeSession = AtomicReference<Session>()
658+
Sentry.configureScope { scope -> scopeSession.set(scope.session) }
659+
assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok)
660+
assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isFalse()
661+
assertThat(scopeSession.get().errorCount()).isEqualTo(1)
662+
assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get())
663+
664+
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
665+
val persistedSession =
666+
fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!!
667+
assertThat(persistedSession.status).isEqualTo(Session.State.Ok)
668+
assertThat(persistedSession.hasNonTerminatingUnhandledError()).isFalse()
669+
assertThat(persistedSession.errorCount()).isEqualTo(1)
670+
}
671+
672+
@Test
673+
fun `updateSessionForDroppedEventNonTerminating then endSession finalizes the session as unhandled`() {
674+
val fixture = Fixture()
675+
fixture.init(context)
676+
677+
InternalSentrySdk.updateSessionForDroppedEventNonTerminating(true)
678+
fixture.capturedEnvelopes.clear()
679+
680+
Sentry.endSession()
681+
682+
val sessionItems =
683+
fixture.capturedEnvelopes
684+
.flatMap { it.items.toList() }
685+
.filter { it.header.type == SentryItemType.Session }
686+
assertThat(sessionItems).hasSize(1)
687+
val endedSession =
688+
fixture.options.serializer.deserialize(
689+
InputStreamReader(ByteArrayInputStream(sessionItems[0].data)),
690+
Session::class.java,
691+
)!!
692+
assertThat(endedSession.status).isEqualTo(Session.State.Unhandled)
693+
}
694+
615695
@Test
616696
fun `getAppStartMeasurement returns correct serialized data from the app start instance`() {
617697
Fixture().mockFinishedAppStart()

0 commit comments

Comments
 (0)