diff --git a/CHANGELOG.md b/CHANGELOG.md index 2592e2d8bb..14714db1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,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 ([#5990](https://github.com/getsentry/sentry-java/pull/5990)) ## 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 40b00c177b..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 @@ -259,6 +259,19 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel return null; } + /** + * Session side effects of {@link #captureEnvelopeNonTerminating(byte[])} without sending the + * 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. + * + * @param crashed {@code true} if the dropped error was unhandled ({@code + * mechanism.handled=false}) + */ + public static void updateSessionForDroppedEventNonTerminating(final boolean crashed) { + updateSessionNonTerminating(crashed); + } + /** * 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 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()