From 439f7046b3bef1c0c12bd78ca8877881348a741f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 12:13:12 +0200 Subject: [PATCH 1/4] 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 --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 1 + .../android/core/InternalSentrySdk.java | 94 ++++++++++++------- .../android/core/InternalSentrySdkTest.kt | 80 ++++++++++++++++ 4 files changed, 144 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e26c34b0b..fa196401f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Internal - 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)) +- Add `InternalSentrySdk.updateSessionForDroppedEventNonTerminating` so hybrid SDKs can still update the session when an error is dropped by sampling or rate limiting ## 8.53.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 70ae34f331..f3c8dd7501 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -323,6 +323,7 @@ public final class io/sentry/android/core/InternalSentrySdk { public static fun getCurrentScope ()Lio/sentry/IScope; public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; public static fun setTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V + public static fun updateSessionForDroppedEventNonTerminating (Z)V } public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index ed18576cba..5e0bb063a5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -30,6 +30,7 @@ import io.sentry.protocol.Device; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.ExceptionUtils; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; import java.io.ByteArrayInputStream; @@ -204,6 +205,9 @@ public static SentryId captureEnvelope( new SentryEnvelope(envelope.getHeader(), envelopeItems); return scopes.captureEnvelope(repackagedEnvelope); } catch (Throwable t) { + // hybrid SDKs call this over JNI while handling a crash, so an unexpected throwable must not + // escape into the host app. Non-recoverable ones still go through. + ExceptionUtils.rethrowIfFatal(t); options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } return null; @@ -232,7 +236,7 @@ public static SentryId captureEnvelope( * {@code beforeSend}, or sample — the caller is responsible for that. * * @param envelopeData the serialized envelope data - * @return the id of the captured envelope, or null if capture failed + * @return the id of the captured envelope, or null if the envelope could not be read */ @Nullable public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { @@ -244,41 +248,67 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } + final @NotNull EnvelopeEventState eventState; try { - final @NotNull ISerializer serializer = options.getSerializer(); - final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); + eventState = eventStateOf(envelope, options.getSerializer()); + } catch (Exception e) { + // as wide as the Callable SentryEnvelopeItem may resolve, even though the items read here + // always carry their bytes eagerly + options.getLogger().log(SentryLevel.ERROR, "Failed to read the envelope events", e); + return null; + } - if (eventState != EnvelopeEventState.NONE) { - scopes.configureScope( - scope -> { - // the write stays inside the callback so the mutation and the persist are one - // critical section. Persisting outside it lets a concurrent caller's older snapshot - // land last and drop the unhandled marker. - scope.withSession( - session -> { - if (session != null) { - final boolean updated = - eventState == EnvelopeEventState.UNHANDLED - ? session.recordNonTerminatingUnhandledError() - : session.update(null, null, true, null); - if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { - ((EnvelopeCache) options.getEnvelopeDiskCache()) - .persistCurrentSession(session); - } - } else { - options - .getLogger() - .log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } - }); - }); - } + updateSessionNonTerminating(scopes, options, eventState); - return scopes.captureEnvelope(envelope); - } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + return scopes.captureEnvelope(envelope); + } + + /** + * Session side effects of {@link #captureEnvelopeNonTerminating(byte[])} without sending the + * event. Hybrid SDKs should call this when an error is dropped by sample rate or rate limiting. + * + *

Do not call this for events dropped by {@code beforeSend} or ignored exception types. + * + * @param crashed {@code true} if the dropped error was unhandled ({@code + * mechanism.handled=false}) + */ + public static void updateSessionForDroppedEventNonTerminating(final boolean crashed) { + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + updateSessionNonTerminating( + scopes, + scopes.getOptions(), + crashed ? EnvelopeEventState.UNHANDLED : EnvelopeEventState.ERRORED); + } + + /** + * Mutates and persists the current session for a non-terminating hybrid error. The write stays + * inside {@code withSession} so the mutation and the persist are one critical section. Persisting + * outside it lets a concurrent caller's older snapshot land last and drop the unhandled marker. + */ + private static void updateSessionNonTerminating( + final @NotNull IScopes scopes, + final @NotNull SentryOptions options, + final @NotNull EnvelopeEventState eventState) { + if (eventState == EnvelopeEventState.NONE) { + return; } - return null; + scopes.configureScope( + scope -> { + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + eventState == EnvelopeEventState.UNHANDLED + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); + } + } else { + options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating"); + } + }); + }); } /** What the events inside an envelope amount to, from the session's point of view. */ diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index ea3f717000..5be48060c7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -573,6 +573,86 @@ class InternalSentrySdkTest { assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) } + @Test + fun `updateSessionForDroppedEventNonTerminating flags an unhandled error without sending an envelope`() { + val fixture = Fixture() + fixture.init(context) + + val originalSid = AtomicReference() + Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) } + fixture.capturedEnvelopes.clear() + + InternalSentrySdk.updateSessionForDroppedEventNonTerminating(true) + + assertThat(fixture.capturedEnvelopes).isEmpty() + + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isTrue() + assertThat(scopeSession.get().errorCount()).isEqualTo(1) + assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) + + val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! + assertThat(persistedSession.status).isEqualTo(Session.State.Ok) + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue() + assertThat(persistedSession.errorCount()).isEqualTo(1) + assertThat(persistedSession.sessionId).isEqualTo(originalSid.get()) + } + + @Test + fun `updateSessionForDroppedEventNonTerminating increments errors for a handled error without sending an envelope`() { + val fixture = Fixture() + fixture.init(context) + + val originalSid = AtomicReference() + Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) } + fixture.capturedEnvelopes.clear() + + InternalSentrySdk.updateSessionForDroppedEventNonTerminating(false) + + assertThat(fixture.capturedEnvelopes).isEmpty() + + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertThat(scopeSession.get().status).isEqualTo(Session.State.Ok) + assertThat(scopeSession.get().hasNonTerminatingUnhandledError()).isFalse() + assertThat(scopeSession.get().errorCount()).isEqualTo(1) + assertThat(scopeSession.get().sessionId).isEqualTo(originalSid.get()) + + val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! + assertThat(persistedSession.status).isEqualTo(Session.State.Ok) + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isFalse() + assertThat(persistedSession.errorCount()).isEqualTo(1) + } + + @Test + fun `updateSessionForDroppedEventNonTerminating then endSession finalizes the session as unhandled`() { + val fixture = Fixture() + fixture.init(context) + + InternalSentrySdk.updateSessionForDroppedEventNonTerminating(true) + fixture.capturedEnvelopes.clear() + + Sentry.endSession() + + val sessionItems = + fixture.capturedEnvelopes + .flatMap { it.items.toList() } + .filter { it.header.type == SentryItemType.Session } + assertThat(sessionItems).hasSize(1) + val endedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertThat(endedSession.status).isEqualTo(Session.State.Unhandled) + } + @Test fun `getAppStartMeasurement returns correct serialized data from the app start instance`() { Fixture().mockFinishedAppStart() From 4968ac9be8384e8d1a241e130354022eed169023 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 12:44:15 +0200 Subject: [PATCH 2/4] changelog Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa196401f6..1ae8619874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### Internal - 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)) -- Add `InternalSentrySdk.updateSessionForDroppedEventNonTerminating` so hybrid SDKs can still update the session when an error is dropped by sampling or rate limiting +- Add `InternalSentrySdk.updateSessionForDroppedEventNonTerminating` so hybrid SDKs can still update the session when an error is dropped by sampling or rate limiting ([#5990](https://github.com/getsentry/sentry-java/pull/5990)) ## 8.53.0 From 09097b7f06eb8a16edb9daee2f7e14ce9ae924e5 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 12:54:00 +0200 Subject: [PATCH 3/4] ref(android): Keep captureEnvelope paths unchanged in the dropped-event PR Leave the stack's capture methods as they are and add the dropped-event API as a standalone method so this PR stays additive. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 73 ++++++++++--------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 5e0bb063a5..bd2b941f2f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -30,7 +30,6 @@ import io.sentry.protocol.Device; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; -import io.sentry.util.ExceptionUtils; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; import java.io.ByteArrayInputStream; @@ -205,9 +204,6 @@ public static SentryId captureEnvelope( new SentryEnvelope(envelope.getHeader(), envelopeItems); return scopes.captureEnvelope(repackagedEnvelope); } catch (Throwable t) { - // hybrid SDKs call this over JNI while handling a crash, so an unexpected throwable must not - // escape into the host app. Non-recoverable ones still go through. - ExceptionUtils.rethrowIfFatal(t); options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } return null; @@ -236,7 +232,7 @@ public static SentryId captureEnvelope( * {@code beforeSend}, or sample — the caller is responsible for that. * * @param envelopeData the serialized envelope data - * @return the id of the captured envelope, or null if the envelope could not be read + * @return the id of the captured envelope, or null if capture failed */ @Nullable public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { @@ -248,19 +244,41 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } - final @NotNull EnvelopeEventState eventState; try { - eventState = eventStateOf(envelope, options.getSerializer()); - } catch (Exception e) { - // as wide as the Callable SentryEnvelopeItem may resolve, even though the items read here - // always carry their bytes eagerly - options.getLogger().log(SentryLevel.ERROR, "Failed to read the envelope events", e); - return null; - } + final @NotNull ISerializer serializer = options.getSerializer(); + final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer); - updateSessionNonTerminating(scopes, options, eventState); + if (eventState != EnvelopeEventState.NONE) { + scopes.configureScope( + scope -> { + // the write stays inside the callback so the mutation and the persist are one + // critical section. Persisting outside it lets a concurrent caller's older snapshot + // land last and drop the unhandled marker. + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + eventState == EnvelopeEventState.UNHANDLED + ? session.recordNonTerminatingUnhandledError() + : session.update(null, null, true, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()) + .persistCurrentSession(session); + } + } else { + options + .getLogger() + .log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); + }); + } - return scopes.captureEnvelope(envelope); + return scopes.captureEnvelope(envelope); + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e); + } + return null; } /** @@ -274,38 +292,23 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel */ public static void updateSessionForDroppedEventNonTerminating(final boolean crashed) { final @NotNull IScopes scopes = ScopesAdapter.getInstance(); - updateSessionNonTerminating( - scopes, - scopes.getOptions(), - crashed ? EnvelopeEventState.UNHANDLED : EnvelopeEventState.ERRORED); - } - - /** - * Mutates and persists the current session for a non-terminating hybrid error. The write stays - * inside {@code withSession} so the mutation and the persist are one critical section. Persisting - * outside it lets a concurrent caller's older snapshot land last and drop the unhandled marker. - */ - private static void updateSessionNonTerminating( - final @NotNull IScopes scopes, - final @NotNull SentryOptions options, - final @NotNull EnvelopeEventState eventState) { - if (eventState == EnvelopeEventState.NONE) { - return; - } + final @NotNull SentryOptions options = scopes.getOptions(); scopes.configureScope( scope -> { scope.withSession( session -> { if (session != null) { final boolean updated = - eventState == EnvelopeEventState.UNHANDLED + crashed ? session.recordNonTerminatingUnhandledError() : session.update(null, null, true, null); if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { ((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session); } } else { - options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating"); + options + .getLogger() + .log(INFO, "Session is null on updateSessionForDroppedEventNonTerminating"); } }); }); From b51865540f24aeea96cc337955d2327c588af0c6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 25 Aug 2026 14:29:56 +0200 Subject: [PATCH 4/4] 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 --- CHANGELOG.md | 2 +- .../src/main/java/io/sentry/android/core/InternalSentrySdk.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae8619874..5942790497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### Internal - 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)) -- Add `InternalSentrySdk.updateSessionForDroppedEventNonTerminating` so hybrid SDKs can still update the session when an error is dropped by sampling or rate limiting ([#5990](https://github.com/getsentry/sentry-java/pull/5990)) +- 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)) ## 8.53.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 976556dbfe..d9b943a67d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -261,7 +261,7 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel /** * Session side effects of {@link #captureEnvelopeNonTerminating(byte[])} without sending the - * event. Hybrid SDKs should call this when an error is dropped by sample rate or rate limiting. + * event. Hybrid SDKs should call this when an error is dropped by sampling. * *

Do not call this for events dropped by {@code beforeSend} or ignored exception types. *