From 72d9812bb1e21e5a0609e6b5acc881d3c1b65cb3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 26 Aug 2026 11:33:38 +0200 Subject: [PATCH 1/6] fix(android): mark tombstone and ANR exits as reported when the event is dropped Co-Authored-By: Claude Opus 5 --- .../api/sentry-android-core.api | 3 +++ .../sentry/android/core/AnrV2Integration.java | 5 +++++ .../ApplicationExitInfoHistoryDispatcher.java | 11 +++++++++- .../android/core/TombstoneIntegration.java | 5 +++++ .../core/cache/AndroidEnvelopeCache.java | 21 ++++++++++++------- .../ApplicationExitIntegrationTestBase.kt | 11 ++++++++++ 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 65bf072f0a0..4b8b41d41c1 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -638,6 +638,7 @@ public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/se public fun getLabel ()Ljava/lang/String; public fun getLastReportedTimestamp ()Ljava/lang/Long; public fun getTargetReason ()I + public fun markReported (J)V public fun shouldReportHistorical ()Z } @@ -749,6 +750,8 @@ public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun markAnrReported (Lio/sentry/SentryOptions;J)V + public static fun markTombstoneReported (Lio/sentry/SentryOptions;J)V public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 285c3b77ade..e28a08f7e8d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -132,6 +132,11 @@ public boolean shouldReportHistorical() { return AndroidEnvelopeCache.lastReportedAnr(options); } + @Override + public void markReported(final long timestamp) { + AndroidEnvelopeCache.markAnrReported(options, timestamp); + } + @Override public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java index 79c3f19c6e5..0e83cdcbe79 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -177,6 +177,7 @@ private void reportHistorical( } } + @RequiresApi(api = Build.VERSION_CODES.R) private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean enrich) { final @Nullable Report report = policy.buildReport(exitInfo, enrich); @@ -186,7 +187,12 @@ private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean e final @NotNull SentryId sentryId = scopes.captureEvent(report.getEvent(), report.getHint()); final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); - if (!isEventDropped) { + if (isEventDropped) { + // A dropped event never reaches the envelope disk cache, which is where the last reported + // marker is normally written. Without writing it here, the very same exit would be turned + // into an event again on the next app start, ignoring the user's decision to drop it. + policy.markReported(exitInfo.getTimestamp()); + } else { final @Nullable BlockingFlushHint flushHint = report.getFlushHint(); if (flushHint != null && !flushHint.waitFlush()) { options @@ -211,6 +217,9 @@ interface ApplicationExitInfoPolicy { @Nullable Long getLastReportedTimestamp(); + /** Records {@code timestamp} as the last reported exit, so it is not reported again. */ + void markReported(long timestamp); + @Nullable Report buildReport(@NotNull ApplicationExitInfo exitInfo, boolean enrich); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index 2663051f7e4..d6d6a7767c8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -147,6 +147,11 @@ public boolean shouldReportHistorical() { return AndroidEnvelopeCache.lastReportedTombstone(options); } + @Override + public void markReported(final long timestamp) { + AndroidEnvelopeCache.markTombstoneReported(options, timestamp); + } + @RequiresApi(api = Build.VERSION_CODES.R) @Override public @Nullable ApplicationExitInfoHistoryDispatcher.Report buildReport( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 1ef02dfdd2c..0373c39deea 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -85,7 +85,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull } for (TimestampMarkerHandler handler : TIMESTAMP_MARKER_HANDLERS) { - handler.handle(this, hint, options); + handler.handle(hint, options); } return didStore; @@ -182,7 +182,8 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options return null; } - private void writeLastReportedMarker( + private static void writeLastReportedMarker( + final @NotNull SentryOptions options, final @Nullable Long timestamp, @NotNull String reportFilename, @NotNull String markerCategory) { @@ -215,6 +216,15 @@ private void writeLastReportedMarker( return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); } + public static void markAnrReported(final @NotNull SentryOptions options, final long timestamp) { + writeLastReportedMarker(options, timestamp, LAST_ANR_REPORT, LAST_ANR_MARKER_LABEL); + } + + public static void markTombstoneReported( + final @NotNull SentryOptions options, final long timestamp) { + writeLastReportedMarker(options, timestamp, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); + } + private static final class TimestampMarkerHandler { interface TimestampExtractor { @NotNull @@ -237,10 +247,7 @@ interface TimestampExtractor { this.timestampProvider = timestampProvider; } - void handle( - final @NotNull AndroidEnvelopeCache cache, - final @NotNull Hint hint, - final @NotNull SentryAndroidOptions options) { + void handle(final @NotNull Hint hint, final @NotNull SentryAndroidOptions options) { HintUtils.runIfHasType( hint, type, @@ -253,7 +260,7 @@ void handle( "Writing last reported %s marker with timestamp %d", label, timestamp); - cache.writeLastReportedMarker(timestamp, reportFilename, label); + writeLastReportedMarker(options, timestamp, reportFilename, label); }); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index edb2ce1df24..137add8c8e5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -187,6 +187,17 @@ abstract class ApplicationExitIntegrationTestBase { .log(any(), argThat { startsWith(config.flushLogPrefix) }, any()) } + @Test + fun `when latest event was dropped, marks the exit as reported`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + assertEquals(newTimestamp.toString(), fixture.lastReportedFile.readText()) + } + @Test fun `historical exits are reported non-enriched`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) From 838fc0374f6499ef70526aba42e4b4af1400bf98 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 26 Aug 2026 11:35:36 +0200 Subject: [PATCH 2/6] docs: add changelog entry for #6002 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878238076f3..5abcb0bddcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888)) - Prevent a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965)) - Symbolicate tombstone native frames for libraries loaded directly from APKs ([#5992](https://github.com/getsentry/sentry-java/pull/5992)) +- Keep tombstone and ANR events discarded from `beforeSend` discarded, instead of reporting them again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) ### Features From ab9fa40d96aba182a160ad3236756ae5b02e2f3c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 26 Aug 2026 15:24:44 +0200 Subject: [PATCH 3/6] feat(core): record an EventDropReason on the hint for intentionally dropped events Co-Authored-By: Claude Opus 5 --- sentry/api/sentry.api | 3 +++ sentry/src/main/java/io/sentry/SentryClient.java | 5 +++++ sentry/src/main/java/io/sentry/hints/EventDropReason.java | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fa876b3312f..cf64be19ea9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5197,7 +5197,10 @@ public abstract interface class io/sentry/hints/Enqueable { } public final class io/sentry/hints/EventDropReason : java/lang/Enum { + public static final field BEFORE_SEND Lio/sentry/hints/EventDropReason; + public static final field IGNORED Lio/sentry/hints/EventDropReason; public static final field MULTITHREADED_DEDUPLICATION Lio/sentry/hints/EventDropReason; + public static final field SAMPLE_RATE Lio/sentry/hints/EventDropReason; public static fun valueOf (Ljava/lang/String;)Lio/sentry/hints/EventDropReason; public static fun values ()[Lio/sentry/hints/EventDropReason; } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 18e247c510a..4dd0cb5b4b8 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -7,6 +7,7 @@ import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; import io.sentry.hints.DiskFlushNotification; +import io.sentry.hints.EventDropReason; import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; import io.sentry.logger.NoOpLoggerBatchProcessor; @@ -137,6 +138,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Error); + HintUtils.setEventDropReason(hint, EventDropReason.IGNORED); return SentryId.EMPTY_ID; } @@ -150,6 +152,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Error); + HintUtils.setEventDropReason(hint, EventDropReason.IGNORED); return SentryId.EMPTY_ID; } } @@ -176,6 +179,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Error); + HintUtils.setEventDropReason(hint, EventDropReason.BEFORE_SEND); } } @@ -208,6 +212,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Error); + HintUtils.setEventDropReason(hint, EventDropReason.SAMPLE_RATE); // setting event as null to not be sent as its been discarded by sample rate event = null; } diff --git a/sentry/src/main/java/io/sentry/hints/EventDropReason.java b/sentry/src/main/java/io/sentry/hints/EventDropReason.java index 56a4bbfeabc..bd2564a0134 100644 --- a/sentry/src/main/java/io/sentry/hints/EventDropReason.java +++ b/sentry/src/main/java/io/sentry/hints/EventDropReason.java @@ -5,5 +5,11 @@ /** A reason for which an event was dropped, used for (not to confuse with ClientReports) */ @ApiStatus.Internal public enum EventDropReason { - MULTITHREADED_DEDUPLICATION + MULTITHREADED_DEDUPLICATION, + /** The event matched {@code ignoredExceptionsForType} or {@code ignoredErrors}. */ + IGNORED, + /** The {@code beforeSend} callback returned {@code null}. */ + BEFORE_SEND, + /** The event lost the {@code sampleRate} draw. */ + SAMPLE_RATE } From 4d5a224db53b42fb22818070e3c631581a9d9147 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 26 Aug 2026 15:24:48 +0200 Subject: [PATCH 4/6] fix(android): only mark an exit as reported when the event was dropped on purpose Co-Authored-By: Claude Opus 5 --- .../ApplicationExitInfoHistoryDispatcher.java | 16 ++++++++++- .../ApplicationExitIntegrationTestBase.kt | 27 ++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java index 0e83cdcbe79..88a81022582 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -12,8 +12,10 @@ import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; import io.sentry.hints.BlockingFlushHint; +import io.sentry.hints.EventDropReason; import io.sentry.protocol.SentryId; import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.HintUtils; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -191,7 +193,19 @@ private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean e // A dropped event never reaches the envelope disk cache, which is where the last reported // marker is normally written. Without writing it here, the very same exit would be turned // into an event again on the next app start, ignoring the user's decision to drop it. - policy.markReported(exitInfo.getTimestamp()); + // An empty id alone is not enough: capturing also returns one when building or handing over + // the envelope failed, and such an exit has to stay eligible for the next app start. + final @Nullable EventDropReason dropReason = HintUtils.getEventDropReason(report.getHint()); + if (dropReason != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s event was dropped (%s), marking the exit as reported.", + policy.getLabel(), + dropReason); + policy.markReported(exitInfo.getTimestamp()); + } } else { final @Nullable BlockingFlushHint flushHint = report.getFlushHint(); if (flushHint != null && !flushHint.waitFlush()) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 137add8c8e5..1bcef8b7e7b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -12,6 +12,7 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.cache.EnvelopeCache import io.sentry.hints.DiskFlushNotification +import io.sentry.hints.EventDropReason import io.sentry.hints.SessionStartHint import io.sentry.protocol.SentryId import io.sentry.test.ImmediateExecutorService @@ -188,9 +189,14 @@ abstract class ApplicationExitIntegrationTestBase { } @Test - fun `when latest event was dropped, marks the exit as reported`() { + fun `when latest event was dropped by beforeSend, marks the exit as reported`() { val integration = - fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + lastEventId = SentryId.EMPTY_ID, + eventDropReason = EventDropReason.BEFORE_SEND, + ) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) @@ -198,6 +204,17 @@ abstract class ApplicationExitIntegrationTestBase { assertEquals(newTimestamp.toString(), fixture.lastReportedFile.readText()) } + @Test + fun `when capturing the latest event failed, does not mark the exit as reported`() { + val integration = + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + assertEquals(oldTimestamp.toString(), fixture.lastReportedFile.readText()) + } + @Test fun `historical exits are reported non-enriched`() { val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) @@ -414,6 +431,7 @@ abstract class ApplicationExitIntegrationTestBase { sessionFlushTimeoutMillis: Long = 0L, lastReportedTimestamp: Long? = null, lastEventId: SentryId = SentryId(), + eventDropReason: EventDropReason? = null, sessionTrackingEnabled: Boolean = true, reportHistorical: Boolean = true, extraOptions: (SentryAndroidOptions) -> Unit = {}, @@ -437,7 +455,10 @@ abstract class ApplicationExitIntegrationTestBase { lastReportedFile = File(cacheDir, config.lastReportedFileName) lastReportedFile.writeText(lastReportedTimestamp.toString()) } - whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) + whenever(scopes.captureEvent(any(), anyOrNull())).thenAnswer { invocation -> + eventDropReason?.let { HintUtils.setEventDropReason(invocation.getArgument(1), it) } + lastEventId + } return config.createIntegration(context) } From 4e5f82dbd20c1bf074f12202d76e48f94850df1e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 26 Aug 2026 19:49:20 +0200 Subject: [PATCH 5/6] docs(core): note that BEFORE_SEND also covers a throwing callback --- sentry/src/main/java/io/sentry/hints/EventDropReason.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/hints/EventDropReason.java b/sentry/src/main/java/io/sentry/hints/EventDropReason.java index bd2564a0134..70fb037d6ec 100644 --- a/sentry/src/main/java/io/sentry/hints/EventDropReason.java +++ b/sentry/src/main/java/io/sentry/hints/EventDropReason.java @@ -8,7 +8,7 @@ public enum EventDropReason { MULTITHREADED_DEDUPLICATION, /** The event matched {@code ignoredExceptionsForType} or {@code ignoredErrors}. */ IGNORED, - /** The {@code beforeSend} callback returned {@code null}. */ + /** The {@code beforeSend} callback returned {@code null} or threw. */ BEFORE_SEND, /** The event lost the {@code sampleRate} draw. */ SAMPLE_RATE From 0f0f918041916c24c1cc2c746ac85d16f8c1ad7f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 27 Aug 2026 12:08:49 +0200 Subject: [PATCH 6/6] refactor(core): flag failed captures instead of enumerating drop reasons An exit was only marked as reported when the hint carried one of a handful of drop reasons, so every drop path that forgot to set one - event processors, applyScope, event size limiting - reported the same exit again at every app start. Turn the check around: the hint is now flagged only when handing the event over failed, and any drop, whatever its cause, marks the exit as reported. --- CHANGELOG.md | 2 +- .../ApplicationExitInfoHistoryDispatcher.java | 21 +++++++++-------- .../ApplicationExitIntegrationTestBase.kt | 23 ++++++++++--------- sentry/api/sentry.api | 6 ++--- sentry/src/main/java/io/sentry/Scopes.java | 3 +++ .../src/main/java/io/sentry/SentryClient.java | 6 +---- .../main/java/io/sentry/TypeCheckHint.java | 2 ++ .../java/io/sentry/hints/EventDropReason.java | 8 +------ .../main/java/io/sentry/util/HintUtils.java | 16 +++++++++++++ sentry/src/test/java/io/sentry/ScopesTest.kt | 11 +++++++++ .../test/java/io/sentry/SentryClientTest.kt | 22 ++++++++++++++++++ 11 files changed, 84 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b70b5c358e..c9fd18124a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -- Keep tombstone and ANR events discarded from `beforeSend` discarded, instead of reporting them again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) +- Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002)) ## 8.54.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java index 88a81022582..155c9fab27e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoHistoryDispatcher.java @@ -12,7 +12,6 @@ import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; import io.sentry.hints.BlockingFlushHint; -import io.sentry.hints.EventDropReason; import io.sentry.protocol.SentryId; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.HintUtils; @@ -192,18 +191,22 @@ private void report(final @NotNull ApplicationExitInfo exitInfo, final boolean e if (isEventDropped) { // A dropped event never reaches the envelope disk cache, which is where the last reported // marker is normally written. Without writing it here, the very same exit would be turned - // into an event again on the next app start, ignoring the user's decision to drop it. - // An empty id alone is not enough: capturing also returns one when building or handing over - // the envelope failed, and such an exit has to stay eligible for the next app start. - final @Nullable EventDropReason dropReason = HintUtils.getEventDropReason(report.getHint()); - if (dropReason != null) { + // into an event again on the next app start, no matter why it was dropped. Only a technical + // failure to hand the event over keeps the exit eligible for another attempt. + if (HintUtils.isCaptureFailed(report.getHint())) { options .getLogger() .log( SentryLevel.DEBUG, - "%s event was dropped (%s), marking the exit as reported.", - policy.getLabel(), - dropReason); + "Capturing the %s event failed, leaving the exit for the next app start.", + policy.getLabel()); + } else { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%s event was dropped, marking the exit as reported.", + policy.getLabel()); policy.markReported(exitInfo.getTimestamp()); } } else { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index 1bcef8b7e7b..82cf1c25c9c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -12,7 +12,6 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.cache.EnvelopeCache import io.sentry.hints.DiskFlushNotification -import io.sentry.hints.EventDropReason import io.sentry.hints.SessionStartHint import io.sentry.protocol.SentryId import io.sentry.test.ImmediateExecutorService @@ -189,14 +188,9 @@ abstract class ApplicationExitIntegrationTestBase { } @Test - fun `when latest event was dropped by beforeSend, marks the exit as reported`() { + fun `when latest event was dropped, marks the exit as reported`() { val integration = - fixture.getSut( - tmpDir, - lastReportedTimestamp = oldTimestamp, - lastEventId = SentryId.EMPTY_ID, - eventDropReason = EventDropReason.BEFORE_SEND, - ) + fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) @@ -207,7 +201,12 @@ abstract class ApplicationExitIntegrationTestBase { @Test fun `when capturing the latest event failed, does not mark the exit as reported`() { val integration = - fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp, lastEventId = SentryId.EMPTY_ID) + fixture.getSut( + tmpDir, + lastReportedTimestamp = oldTimestamp, + lastEventId = SentryId.EMPTY_ID, + captureFailed = true, + ) fixture.addAppExitInfo(timestamp = newTimestamp) integration.register(fixture.scopes, fixture.options) @@ -431,7 +430,7 @@ abstract class ApplicationExitIntegrationTestBase { sessionFlushTimeoutMillis: Long = 0L, lastReportedTimestamp: Long? = null, lastEventId: SentryId = SentryId(), - eventDropReason: EventDropReason? = null, + captureFailed: Boolean = false, sessionTrackingEnabled: Boolean = true, reportHistorical: Boolean = true, extraOptions: (SentryAndroidOptions) -> Unit = {}, @@ -456,7 +455,9 @@ abstract class ApplicationExitIntegrationTestBase { lastReportedFile.writeText(lastReportedTimestamp.toString()) } whenever(scopes.captureEvent(any(), anyOrNull())).thenAnswer { invocation -> - eventDropReason?.let { HintUtils.setEventDropReason(invocation.getArgument(1), it) } + if (captureFailed) { + HintUtils.setCaptureFailed(invocation.getArgument(1)) + } lastEventId } return config.createIntegration(context) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9617959a6e9..ec756beb117 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4737,6 +4737,7 @@ public final class io/sentry/TypeCheckHint { public static final field OPEN_FEIGN_REQUEST Ljava/lang/String; public static final field OPEN_FEIGN_RESPONSE Ljava/lang/String; public static final field REPLAY_FRAME_BITMAP Ljava/lang/String; + public static final field SENTRY_CAPTURE_FAILED Ljava/lang/String; public static final field SENTRY_DART_SDK_NAME Ljava/lang/String; public static final field SENTRY_DOTNET_SDK_NAME Ljava/lang/String; public static final field SENTRY_EVENT_DROP_REASON Ljava/lang/String; @@ -5207,10 +5208,7 @@ public abstract interface class io/sentry/hints/Enqueable { } public final class io/sentry/hints/EventDropReason : java/lang/Enum { - public static final field BEFORE_SEND Lio/sentry/hints/EventDropReason; - public static final field IGNORED Lio/sentry/hints/EventDropReason; public static final field MULTITHREADED_DEDUPLICATION Lio/sentry/hints/EventDropReason; - public static final field SAMPLE_RATE Lio/sentry/hints/EventDropReason; public static fun valueOf (Ljava/lang/String;)Lio/sentry/hints/EventDropReason; public static fun values ()[Lio/sentry/hints/EventDropReason; } @@ -7765,11 +7763,13 @@ public final class io/sentry/util/HintUtils { public static fun getEventDropReason (Lio/sentry/Hint;)Lio/sentry/hints/EventDropReason; public static fun getSentrySdkHint (Lio/sentry/Hint;)Ljava/lang/Object; public static fun hasType (Lio/sentry/Hint;Ljava/lang/Class;)Z + public static fun isCaptureFailed (Lio/sentry/Hint;)Z public static fun isFromHybridSdk (Lio/sentry/Hint;)Z public static fun runIfDoesNotHaveType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryNullableConsumer;)V public static fun runIfHasType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryConsumer;)V public static fun runIfHasType (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/util/HintUtils$SentryConsumer;Lio/sentry/util/HintUtils$SentryHintFallback;)V public static fun runIfHasTypeLogIfNot (Lio/sentry/Hint;Ljava/lang/Class;Lio/sentry/ILogger;Lio/sentry/util/HintUtils$SentryConsumer;)V + public static fun setCaptureFailed (Lio/sentry/Hint;)V public static fun setEventDropReason (Lio/sentry/Hint;Lio/sentry/hints/EventDropReason;)V public static fun setIsFromHybridSdk (Lio/sentry/Hint;Ljava/lang/String;)V public static fun setTypeCheckHint (Lio/sentry/Hint;Ljava/lang/Object;)V diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 936a331e3d4..d3926d56993 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -152,8 +152,10 @@ public boolean isEnabled() { .getLogger() .log( SentryLevel.WARNING, "Instance is disabled and this 'captureEvent' call is a no-op."); + HintUtils.setCaptureFailed(hint); } else if (event == null) { getOptions().getLogger().log(SentryLevel.WARNING, "captureEvent called with null parameter."); + HintUtils.setCaptureFailed(hint); } else { try { assignTraceContext(event); @@ -166,6 +168,7 @@ public boolean isEnabled() { .getLogger() .log( SentryLevel.ERROR, "Error while capturing event with id: " + event.getEventId(), e); + HintUtils.setCaptureFailed(hint); } } return sentryId; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 4dd0cb5b4b8..0e44812a491 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -7,7 +7,6 @@ import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; import io.sentry.hints.DiskFlushNotification; -import io.sentry.hints.EventDropReason; import io.sentry.hints.TransactionEnd; import io.sentry.logger.ILoggerBatchProcessor; import io.sentry.logger.NoOpLoggerBatchProcessor; @@ -138,7 +137,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Error); - HintUtils.setEventDropReason(hint, EventDropReason.IGNORED); return SentryId.EMPTY_ID; } @@ -152,7 +150,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Error); - HintUtils.setEventDropReason(hint, EventDropReason.IGNORED); return SentryId.EMPTY_ID; } } @@ -179,7 +176,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Error); - HintUtils.setEventDropReason(hint, EventDropReason.BEFORE_SEND); } } @@ -212,7 +208,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options .getClientReportRecorder() .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Error); - HintUtils.setEventDropReason(hint, EventDropReason.SAMPLE_RATE); // setting event as null to not be sent as its been discarded by sample rate event = null; } @@ -292,6 +287,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); // if there was an error capturing the event, we return an emptyId + HintUtils.setCaptureFailed(hint); sentryId = SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index b3b061e847c..3260b46f16b 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -13,6 +13,8 @@ public final class TypeCheckHint { @ApiStatus.Internal public static final String SENTRY_EVENT_DROP_REASON = "sentry:eventDropReason"; + @ApiStatus.Internal public static final String SENTRY_CAPTURE_FAILED = "sentry:captureFailed"; + @ApiStatus.Internal public static final String SENTRY_REPLAY_NETWORK_DETAILS = "sentry:replayNetworkDetails"; diff --git a/sentry/src/main/java/io/sentry/hints/EventDropReason.java b/sentry/src/main/java/io/sentry/hints/EventDropReason.java index 70fb037d6ec..56a4bbfeabc 100644 --- a/sentry/src/main/java/io/sentry/hints/EventDropReason.java +++ b/sentry/src/main/java/io/sentry/hints/EventDropReason.java @@ -5,11 +5,5 @@ /** A reason for which an event was dropped, used for (not to confuse with ClientReports) */ @ApiStatus.Internal public enum EventDropReason { - MULTITHREADED_DEDUPLICATION, - /** The event matched {@code ignoredExceptionsForType} or {@code ignoredErrors}. */ - IGNORED, - /** The {@code beforeSend} callback returned {@code null} or threw. */ - BEFORE_SEND, - /** The event lost the {@code sampleRate} draw. */ - SAMPLE_RATE + MULTITHREADED_DEDUPLICATION } diff --git a/sentry/src/main/java/io/sentry/util/HintUtils.java b/sentry/src/main/java/io/sentry/util/HintUtils.java index da568c614b4..4ccbce3c33f 100644 --- a/sentry/src/main/java/io/sentry/util/HintUtils.java +++ b/sentry/src/main/java/io/sentry/util/HintUtils.java @@ -1,5 +1,6 @@ package io.sentry.util; +import static io.sentry.TypeCheckHint.SENTRY_CAPTURE_FAILED; import static io.sentry.TypeCheckHint.SENTRY_DART_SDK_NAME; import static io.sentry.TypeCheckHint.SENTRY_DOTNET_SDK_NAME; import static io.sentry.TypeCheckHint.SENTRY_EVENT_DROP_REASON; @@ -45,6 +46,21 @@ public static EventDropReason getEventDropReason(final @NotNull Hint hint) { return hint.getAs(SENTRY_EVENT_DROP_REASON, EventDropReason.class); } + /** + * Marks the event as not captured because of a technical failure, as opposed to being dropped on + * purpose. Callers that keep an event around for a later attempt use this to tell the two apart: + * an empty event id alone does not, because almost every drop also returns one. + */ + public static void setCaptureFailed(final @Nullable Hint hint) { + if (hint != null) { + hint.set(SENTRY_CAPTURE_FAILED, true); + } + } + + public static boolean isCaptureFailed(final @NotNull Hint hint) { + return Boolean.TRUE.equals(hint.getAs(SENTRY_CAPTURE_FAILED, Boolean.class)); + } + public static Hint createWithTypeCheckHint(Object typeCheckHint) { Hint hint = new Hint(); setTypeCheckHint(hint, typeCheckHint); diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 9d598aec885..d1cb38c6495 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -344,6 +344,17 @@ class ScopesTest { verify(mockClient, never()).captureEvent(any(), any()) } + @Test + fun `when captureEvent is called on disabled client, the hint is marked as capture failed`() { + val (sut, _) = getEnabledScopes() + sut.close() + + val hint = Hint() + sut.captureEvent(SentryEvent(), hint) + + assertTrue(HintUtils.isCaptureFailed(hint)) + } + @Test fun `when captureEvent is called with a valid argument, captureEvent on the client should be called`() { val (sut, mockClient) = getEnabledScopes() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index e4c4b447cf6..3066d2c1b53 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1635,6 +1635,28 @@ class SentryClientTest { verify(fixture.transport).send(check { assertNotNull(it.header.sdkVersion) }, anyOrNull()) } + @Test + fun `when capturing the event throws, the hint is marked as capture failed`() { + whenever(fixture.transport.send(any(), anyOrNull())).thenThrow(IOException()) + + val hint = Hint() + val sentryId = fixture.getSut().captureEvent(SentryEvent(), hint) + + assertEquals(SentryId.EMPTY_ID, sentryId) + assertTrue(HintUtils.isCaptureFailed(hint)) + } + + @Test + fun `when the event is dropped, the hint is not marked as capture failed`() { + fixture.sentryOptions.setBeforeSend { _, _ -> null } + + val hint = Hint() + val sentryId = fixture.getSut().captureEvent(SentryEvent(), hint) + + assertEquals(SentryId.EMPTY_ID, sentryId) + assertFalse(HintUtils.isCaptureFailed(hint)) + } + @Test fun `when captureEnvelope and thres an exception, returns empty sentryId`() { whenever(fixture.transport.send(any(), anyOrNull())).thenThrow(IOException())