From 468abd9a86f6cb3cff35c81bac890de6e4d66772 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 22 Sep 2026 06:19:23 +0200 Subject: [PATCH 01/37] collection: callback error handling From 28af8f249703ce0a9bd5d528a7e9df7e71f196ff Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 22 Sep 2026 06:38:21 +0200 Subject: [PATCH 02/37] fix(core): [Callback Errors 1] Report callback failures Add the callback_error discard reason and use it when a beforeSend callback throws. Preserve before_send for callbacks that intentionally return null and retain item, span, and byte accounting. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 2 + sentry/api/sentry.api | 1 + .../src/main/java/io/sentry/SentryClient.java | 198 +++++++++--------- .../io/sentry/clientreport/DiscardReason.java | 1 + .../test/java/io/sentry/SentryClientTest.kt | 45 ++-- .../sentry/clientreport/ClientReportTest.kt | 6 + 6 files changed, 138 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59606e335c..eaed5ce3eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,8 @@ ### Fixes +- Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) + - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 53452c4c3b9..c3dcbc730b7 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5148,6 +5148,7 @@ public final class io/sentry/clientreport/DiscardReason : java/lang/Enum { public static final field BACKPRESSURE Lio/sentry/clientreport/DiscardReason; public static final field BEFORE_SEND Lio/sentry/clientreport/DiscardReason; public static final field CACHE_OVERFLOW Lio/sentry/clientreport/DiscardReason; + public static final field CALLBACK_ERROR Lio/sentry/clientreport/DiscardReason; public static final field EVENT_PROCESSOR Lio/sentry/clientreport/DiscardReason; public static final field NETWORK_ERROR Lio/sentry/clientreport/DiscardReason; public static final field QUEUE_OVERFLOW Lio/sentry/clientreport/DiscardReason; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 4bba195feea..a9cc392e711 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -171,9 +171,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul if (event == null) { options.getLogger().log(SentryLevel.DEBUG, "Event was dropped by beforeSend"); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Error); } } @@ -345,9 +342,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin if (event == null) { options.getLogger().log(SentryLevel.DEBUG, "Event was dropped by beforeSendReplay"); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Replay); } } @@ -530,6 +524,24 @@ private SentryEvent processEvent( return event; } + private void recordLostLogEvent( + final @NotNull DiscardReason reason, final @NotNull SentryLogEvent event) { + options.getClientReportRecorder().recordLostEvent(reason, DataCategory.LogItem); + final long numberOfBytes = + JsonSerializationUtils.byteSizeOf(options.getSerializer(), options.getLogger(), event); + options.getClientReportRecorder().recordLostEvent(reason, DataCategory.LogByte, numberOfBytes); + } + + private void recordLostMetricsEvent( + final @NotNull DiscardReason reason, final @NotNull SentryMetricsEvent event) { + options.getClientReportRecorder().recordLostEvent(reason, DataCategory.TraceMetric); + final long numberOfBytes = + JsonSerializationUtils.byteSizeOf(options.getSerializer(), options.getLogger(), event); + options + .getClientReportRecorder() + .recordLostEvent(reason, DataCategory.TraceMetricByte, numberOfBytes); + } + @Nullable private SentryLogEvent processLogEvent( @NotNull SentryLogEvent event, final @NotNull List eventProcessors) { @@ -554,16 +566,7 @@ private SentryLogEvent processLogEvent( SentryLevel.DEBUG, "Log event was dropped by a processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.LogItem); - final long logEventNumberOfBytes = - JsonSerializationUtils.byteSizeOf( - options.getSerializer(), options.getLogger(), eventBeforeProcessor); - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.EVENT_PROCESSOR, DataCategory.LogByte, logEventNumberOfBytes); + recordLostLogEvent(DiscardReason.EVENT_PROCESSOR, eventBeforeProcessor); break; } } @@ -596,18 +599,7 @@ private SentryMetricsEvent processMetricsEvent( SentryLevel.DEBUG, "Metrics event was dropped by a processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.TraceMetric); - final long metricsEventNumberOfBytes = - JsonSerializationUtils.byteSizeOf( - options.getSerializer(), options.getLogger(), eventBeforeProcessor); - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.EVENT_PROCESSOR, - DataCategory.TraceMetricByte, - metricsEventNumberOfBytes); + recordLostMetricsEvent(DiscardReason.EVENT_PROCESSOR, eventBeforeProcessor); break; } } @@ -1049,35 +1041,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return SentryId.EMPTY_ID; } - final int spanCountBeforeCallback = transaction.getSpans().size(); transaction = executeBeforeSendTransaction(transaction, hint); - final int spanCountAfterCallback = transaction == null ? 0 : transaction.getSpans().size(); if (transaction == null) { options .getLogger() .log(SentryLevel.DEBUG, "Transaction was dropped by beforeSendTransaction."); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Transaction); - // If we drop a transaction, we are also dropping all its spans (+1 for the root span) - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.BEFORE_SEND, DataCategory.Span, spanCountBeforeCallback + 1); return SentryId.EMPTY_ID; - } else if (spanCountAfterCallback < spanCountBeforeCallback) { - // If the callback removed some spans, we report it - final int droppedSpanCount = spanCountBeforeCallback - spanCountAfterCallback; - options - .getLogger() - .log( - SentryLevel.DEBUG, - "%d spans were dropped by beforeSendTransaction.", - droppedSpanCount); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Span, droppedSpanCount); } try { @@ -1256,9 +1226,6 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint if (event == null) { options.getLogger().log(SentryLevel.DEBUG, "Event was dropped by beforeSend"); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Feedback); } } @@ -1356,21 +1323,10 @@ public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope } if (logEvent != null) { - final @NotNull SentryLogEvent tmpLogEvent = logEvent; logEvent = executeBeforeSendLog(logEvent); if (logEvent == null) { options.getLogger().log(SentryLevel.DEBUG, "Log Event was dropped by beforeSendLog"); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.LogItem); - final @NotNull long logEventNumberOfBytes = - JsonSerializationUtils.byteSizeOf( - options.getSerializer(), options.getLogger(), tmpLogEvent); - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.BEFORE_SEND, DataCategory.LogByte, logEventNumberOfBytes); return; } @@ -1419,23 +1375,12 @@ public void captureMetric( } if (metricsEvent != null) { - final @NotNull SentryMetricsEvent tmpMetricsEvent = metricsEvent; metricsEvent = executeBeforeSendMetric(metricsEvent, hint); if (metricsEvent == null) { options .getLogger() .log(SentryLevel.DEBUG, "Metrics Event was dropped by beforeSendMetrics"); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.TraceMetric); - final long metricsEventNumberOfBytes = - JsonSerializationUtils.byteSizeOf( - options.getSerializer(), options.getLogger(), tmpMetricsEvent); - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.BEFORE_SEND, DataCategory.TraceMetricByte, metricsEventNumberOfBytes); return; } @@ -1673,11 +1618,17 @@ private void sortBreadcrumbsByDate( .getLogger() .log( SentryLevel.ERROR, - "The BeforeSend callback threw an exception. It will be added as breadcrumb and continue.", + "The beforeSend callback threw an exception. Dropping event.", e); - - // drop event in case of an error in beforeSend due to PII concerns - event = null; + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Error); + return null; + } + if (event == null) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Error); } } return event; @@ -1688,6 +1639,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendTransactionCallback beforeSendTransaction = options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { + final int spanCountBeforeCallback = transaction.getSpans().size(); try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { transaction = beforeSendTransaction.execute(transaction, hint); } catch (Throwable e) { @@ -1695,11 +1647,40 @@ private void sortBreadcrumbsByDate( .getLogger() .log( SentryLevel.ERROR, - "The BeforeSendTransaction callback threw an exception. It will be added as breadcrumb and continue.", + "The beforeSendTransaction callback threw an exception. Dropping transaction.", e); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeCallback + 1); + return null; + } - // drop transaction in case of an error in beforeSend due to PII concerns - transaction = null; + if (transaction == null) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.BEFORE_SEND, DataCategory.Span, spanCountBeforeCallback + 1); + } else { + final int spanCountAfterCallback = transaction.getSpans().size(); + if (spanCountAfterCallback < spanCountBeforeCallback) { + final int droppedSpanCount = spanCountBeforeCallback - spanCountAfterCallback; + options + .getLogger() + .log( + SentryLevel.DEBUG, + "%d spans were dropped by beforeSendTransaction.", + droppedSpanCount); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Span, droppedSpanCount); + } } } return transaction; @@ -1714,10 +1695,19 @@ private void sortBreadcrumbsByDate( } catch (Throwable e) { options .getLogger() - .log(SentryLevel.ERROR, "The BeforeSendFeedback callback threw an exception.", e); - - // drop feedback in case of an error in beforeSend due to PII concerns - event = null; + .log( + SentryLevel.ERROR, + "The beforeSendFeedback callback threw an exception. Dropping feedback.", + e); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback); + return null; + } + if (event == null) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Feedback); } } return event; @@ -1734,11 +1724,17 @@ private void sortBreadcrumbsByDate( .getLogger() .log( SentryLevel.ERROR, - "The BeforeSendReplay callback threw an exception. It will be added as breadcrumb and continue.", + "The beforeSendReplay callback threw an exception. Dropping replay event.", e); - - // drop event in case of an error in beforeSend due to PII concerns - event = null; + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay); + return null; + } + if (event == null) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Replay); } } return event; @@ -1748,6 +1744,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Logs.BeforeSendLogCallback beforeSendLog = options.getLogs().getBeforeSend(); if (beforeSendLog != null) { + final @NotNull SentryLogEvent eventBeforeCallback = event; try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendLog.execute(event); } catch (Throwable e) { @@ -1755,11 +1752,13 @@ private void sortBreadcrumbsByDate( .getLogger() .log( SentryLevel.ERROR, - "The BeforeSendLog callback threw an exception. Dropping log event.", + "The beforeSendLog callback threw an exception. Dropping log event.", e); - - // drop event in case of an error in beforeSendLog due to PII concerns - event = null; + recordLostLogEvent(DiscardReason.CALLBACK_ERROR, eventBeforeCallback); + return null; + } + if (event == null) { + recordLostLogEvent(DiscardReason.BEFORE_SEND, eventBeforeCallback); } } return event; @@ -1770,6 +1769,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Metrics.BeforeSendMetricCallback beforeSendMetric = options.getMetrics().getBeforeSend(); if (beforeSendMetric != null) { + final @NotNull SentryMetricsEvent eventBeforeCallback = event; try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendMetric.execute(event, hint); } catch (Throwable e) { @@ -1777,11 +1777,13 @@ private void sortBreadcrumbsByDate( .getLogger() .log( SentryLevel.ERROR, - "The BeforeSendMetric callback threw an exception. Dropping metrics event.", + "The beforeSendMetric callback threw an exception. Dropping metrics event.", e); - - // drop event in case of an error in beforeSendMetric due to PII concerns - event = null; + recordLostMetricsEvent(DiscardReason.CALLBACK_ERROR, eventBeforeCallback); + return null; + } + if (event == null) { + recordLostMetricsEvent(DiscardReason.BEFORE_SEND, eventBeforeCallback); } } return event; diff --git a/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java b/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java index 98f25386a5f..4fc424dfa1a 100644 --- a/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java +++ b/sentry/src/main/java/io/sentry/clientreport/DiscardReason.java @@ -8,6 +8,7 @@ public enum DiscardReason { SEND_ERROR("send_error"), SAMPLE_RATE("sample_rate"), BEFORE_SEND("before_send"), + CALLBACK_ERROR("callback_error"), EVENT_PROCESSOR("event_processor"), // also for ignored exceptions BACKPRESSURE("backpressure"); diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 61181ee96a6..7b907df1e82 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -261,9 +261,10 @@ class SentryClientTest { @Test fun `when beforeSend throws an exception, event is dropped`() { val exception = Exception("test") + val onDiscardMock = mock() - exception.stackTrace.toString() fixture.sentryOptions.setBeforeSend { _, _ -> throw exception } + fixture.sentryOptions.onDiscard = onDiscardMock val sut = fixture.getSut() val actual = SentryEvent() val id = sut.captureEvent(actual) @@ -272,8 +273,9 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, - listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.Error.category, 1)), + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Error.category, 1)), ) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Error, 1) } @Test @@ -418,9 +420,10 @@ class SentryClientTest { fun `when beforeSendLog throws an exception, log is dropped`() { val scope = createScope() val exception = Exception("test") + val onDiscardMock = mock() - exception.stackTrace.toString() fixture.sentryOptions.logs.setBeforeSend { _ -> throw exception } + fixture.sentryOptions.onDiscard = onDiscardMock val sut = fixture.getSut() sut.captureLog( SentryLogEvent(SentryId(), SentryNanotimeDate(), "message", SentryLogLevel.WARN), @@ -430,10 +433,12 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, listOf( - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogItem.category, 1), - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogByte.category, 109), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.LogItem.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.LogByte.category, 109), ), ) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.LogItem, 1) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.LogByte, 109) } @Test @@ -531,9 +536,10 @@ class SentryClientTest { fun `when beforeSendMetric throws an exception, metric is dropped`() { val scope = createScope() val exception = Exception("test") + val onDiscardMock = mock() - exception.stackTrace.toString() - fixture.sentryOptions.metrics.setBeforeSend { _, hint -> throw exception } + fixture.sentryOptions.metrics.setBeforeSend { _, _ -> throw exception } + fixture.sentryOptions.onDiscard = onDiscardMock val sut = fixture.getSut() sut.captureMetric( SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0), @@ -544,14 +550,16 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, listOf( - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.TraceMetric.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.TraceMetric.category, 1), DiscardedEvent( - DiscardReason.BEFORE_SEND.reason, + DiscardReason.CALLBACK_ERROR.reason, DataCategory.TraceMetricByte.category, 120, ), ), ) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.TraceMetric, 1) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.TraceMetricByte, 120) } @Test @@ -1552,13 +1560,14 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, listOf( - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.Transaction.category, 1), - DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.Span.category, 2), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 2), ), ) - verify(onDiscardMock, times(1)).execute(DiscardReason.BEFORE_SEND, DataCategory.Transaction, 1) - verify(onDiscardMock).execute(DiscardReason.BEFORE_SEND, DataCategory.Span, 2) + verify(onDiscardMock, times(1)) + .execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscardMock).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 2) } @Test @@ -3870,10 +3879,10 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, - listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.Replay.category, 1)), + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Replay.category, 1)), ) - verify(onDiscardMock, times(1)).execute(DiscardReason.BEFORE_SEND, DataCategory.Replay, 1) + verify(onDiscardMock, times(1)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Replay, 1) } // endregion @@ -4050,10 +4059,12 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, - listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.Feedback.category, 1)), + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Feedback.category, 1) + ), ) - verify(onDiscardMock, times(1)).execute(DiscardReason.BEFORE_SEND, DataCategory.Feedback, 1) + verify(onDiscardMock, times(1)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback, 1) } @Test diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index b89b1894f32..165f3c6c030 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -1,5 +1,6 @@ package io.sentry.clientreport +import com.google.common.truth.Truth.assertThat import io.sentry.Attachment import io.sentry.CheckIn import io.sentry.CheckInStatus @@ -64,6 +65,11 @@ class ClientReportTest { lateinit var clientReportRecorder: ClientReportRecorder lateinit var testHelper: ClientReportTestHelper + @Test + fun `callback error has expected discard reason`() { + assertThat(DiscardReason.CALLBACK_ERROR.reason).isEqualTo("callback_error") + } + @Test fun `lost envelope can be recorded`() { givenClientReportRecorder() From d6895262d119b6c31f4b2eaad6e55680f75f7978 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 22 Sep 2026 06:41:32 +0200 Subject: [PATCH 03/37] fix(core): [Callback Errors 2] Drop failed processor data Stop processing and drop telemetry when an event processor throws. Record callback_error outcomes for every supported category instead of sending potentially partially processed data. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 24 ++ .../test/java/io/sentry/SentryClientTest.kt | 218 +++++++++++++++++- 3 files changed, 231 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaed5ce3eb8..7b48c927987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. + - Drop telemetry and record `callback_error` when an event processor throws instead of continuing with a potentially partially processed item. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a9cc392e711..cebc37a3936 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -506,6 +506,10 @@ private SentryEvent processEvent( e, "An exception occurred while processing event by processor: %s", processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Error); + return null; } if (event == null) { @@ -557,6 +561,8 @@ private SentryLogEvent processLogEvent( e, "An exception occurred while processing log event by processor: %s", processor.getClass().getName()); + recordLostLogEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); + return null; } if (event == null) { @@ -590,6 +596,8 @@ private SentryMetricsEvent processMetricsEvent( e, "An exception occurred while processing metrics event by processor: %s", processor.getClass().getName()); + recordLostMetricsEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); + return null; } if (event == null) { @@ -622,6 +630,14 @@ private SentryMetricsEvent processMetricsEvent( e, "An exception occurred while processing transaction by processor: %s", processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeProcessor + 1); + return null; } final int spanCountAfterProcessor = transaction == null ? 0 : transaction.getSpans().size(); @@ -675,6 +691,10 @@ private SentryReplayEvent processReplayEvent( e, "An exception occurred while processing replay event by processor: %s", processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay); + return null; } if (replayEvent == null) { @@ -709,6 +729,10 @@ private SentryEvent processFeedbackEvent( e, "An exception occurred while processing feedback event by processor: %s", processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback); + return null; } if (feedbackEvent == null) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 7b907df1e82..ff6fc57900e 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.Scope.IWithPropagationContext import io.sentry.SentryLevel.WARNING import io.sentry.Session.State.Crashed @@ -474,6 +475,48 @@ class SentryClientTest { ) } + @Test + fun `throwing log processor drops log and stops callbacks`() { + val scope = createScope() + val logEvent = SentryLogEvent(SentryId(), SentryNanotimeDate(), "message", SentryLogLevel.WARN) + val logEventNumberOfBytes = + JsonSerializationUtils.byteSizeOf( + fixture.sentryOptions.serializer, + fixture.sentryOptions.logger, + logEvent, + ) + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any())) + .thenThrow(IllegalStateException("test")) + scope.addEventProcessor(throwingProcessor) + scope.addEventProcessor(nextProcessor) + fixture.sentryOptions.logs.beforeSend = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + fixture.getSut().captureLog(logEvent, scope) + + verify(nextProcessor, never()).process(any()) + verify(beforeSend, never()).execute(any()) + verify(fixture.loggerBatchProcessor, never()).add(any()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.LogItem.category, 1), + DiscardedEvent( + DiscardReason.CALLBACK_ERROR.reason, + DataCategory.LogByte.category, + logEventNumberOfBytes, + ), + ), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.LogItem, 1) + verify(onDiscard) + .execute(DiscardReason.CALLBACK_ERROR, DataCategory.LogByte, logEventNumberOfBytes) + } + @Test fun `when beforeSendLog is returns new instance, new instance is sent`() { val scope = createScope() @@ -595,6 +638,56 @@ class SentryClientTest { ) } + @Test + fun `throwing metric processor drops metric and stops callbacks`() { + val scope = createScope() + val metricsEvent = SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0) + val metricsEventNumberOfBytes = + JsonSerializationUtils.byteSizeOf( + fixture.sentryOptions.serializer, + fixture.sentryOptions.logger, + metricsEvent, + ) + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any(), anyOrNull())) + .thenThrow(IllegalStateException("test")) + scope.addEventProcessor(throwingProcessor) + scope.addEventProcessor(nextProcessor) + fixture.sentryOptions.metrics.beforeSend = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + fixture.getSut().captureMetric(metricsEvent, scope, null) + + verify(nextProcessor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verify(fixture.metricsBatchProcessor, never()).add(any()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf( + DiscardedEvent( + DiscardReason.CALLBACK_ERROR.reason, + DataCategory.TraceMetric.category, + 1, + ), + DiscardedEvent( + DiscardReason.CALLBACK_ERROR.reason, + DataCategory.TraceMetricByte.category, + metricsEventNumberOfBytes, + ), + ), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.TraceMetric, 1) + verify(onDiscard) + .execute( + DiscardReason.CALLBACK_ERROR, + DataCategory.TraceMetricByte, + metricsEventNumberOfBytes, + ) + } + @Test fun `when beforeSendMetric is returns new instance, new instance is sent`() { val scope = createScope() @@ -1242,6 +1335,42 @@ class SentryClientTest { ) } + @Test + fun `throwing transaction processor drops transaction and stops callbacks`() { + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any(), anyOrNull())) + .thenThrow(IllegalStateException("test")) + fixture.sentryOptions.addEventProcessor(throwingProcessor) + fixture.sentryOptions.addEventProcessor(nextProcessor) + fixture.sentryOptions.beforeSendTransaction = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + val id = + fixture + .getSut() + .captureTransaction( + SentryTransaction(fixture.sentryTracer), + fixture.sentryTracer.traceContext(), + ) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 2), + ), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 2) + } + @Test fun `transaction dropped by ignoredTransactions is recorded`() { fixture.sentryOptions.setIgnoredTransactions(listOf("a-transaction")) @@ -1927,10 +2056,29 @@ class SentryClientTest { } @Test - fun `exception thrown by an event processor is handled gracefully`() { - fixture.sentryOptions.addEventProcessor(eventProcessorThrows()) - val sut = fixture.getSut() - sut.captureEvent(SentryEvent()) + fun `exception thrown by an event processor drops event and stops callbacks`() { + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any(), anyOrNull())) + .thenThrow(IllegalStateException("test")) + fixture.sentryOptions.addEventProcessor(throwingProcessor) + fixture.sentryOptions.addEventProcessor(nextProcessor) + fixture.sentryOptions.beforeSend = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + val id = fixture.getSut().captureEvent(SentryEvent()) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Error.category, 1)), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Error, 1) } @Test @@ -3524,6 +3672,32 @@ class SentryClientTest { verify(onDiscardMock, times(1)).execute(DiscardReason.EVENT_PROCESSOR, DataCategory.Replay, 1) } + @Test + fun `throwing replay processor drops replay and stops callbacks`() { + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any(), anyOrNull())) + .thenThrow(IllegalStateException("test")) + fixture.sentryOptions.addEventProcessor(throwingProcessor) + fixture.sentryOptions.addEventProcessor(nextProcessor) + fixture.sentryOptions.beforeSendReplay = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + val id = fixture.getSut().captureReplayEvent(createReplayEvent(), createScope(), null) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Replay.category, 1)), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Replay, 1) + } + @Test fun `calls captureReplay on replay controller for error events`() { var called = false @@ -4086,6 +4260,34 @@ class SentryClientTest { verify(onDiscardMock, times(1)).execute(DiscardReason.EVENT_PROCESSOR, DataCategory.Feedback, 1) } + @Test + fun `throwing feedback processor drops feedback and stops callbacks`() { + val throwingProcessor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + whenever(throwingProcessor.process(any(), anyOrNull())) + .thenThrow(IllegalStateException("test")) + fixture.sentryOptions.addEventProcessor(throwingProcessor) + fixture.sentryOptions.addEventProcessor(nextProcessor) + fixture.sentryOptions.beforeSendFeedback = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + + val id = fixture.getSut().captureFeedback(Feedback("message"), null, createScope()) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Feedback.category, 1) + ), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback, 1) + } + // endregion private fun givenScopeWithStartedSession( @@ -4352,14 +4554,6 @@ class SentryClientTest { override fun timestamp(): Long? = null } - private fun eventProcessorThrows(): EventProcessor { - return object : EventProcessor { - override fun process(event: SentryEvent, hint: Hint): SentryEvent? { - throw Throwable() - } - } - } - private class BackfillableHint : Backfillable { override fun shouldEnrich(): Boolean = false } From fa3ca4520754952a26b9ceccdad13808fc585d91 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 22 Sep 2026 06:43:02 +0200 Subject: [PATCH 04/37] fix(core): [Callback Errors 3] Drop failed breadcrumbs Drop breadcrumbs when beforeBreadcrumb throws instead of storing the breadcrumb with exception details. Keep observer state unchanged and release the callback reentrancy guard for later additions. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/Scope.java | 7 +--- sentry/src/test/java/io/sentry/ScopeTest.kt | 42 +++++++++++++++++--- sentry/src/test/java/io/sentry/ScopesTest.kt | 16 ++++---- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b48c927987..9504883d420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when an event processor throws instead of continuing with a potentially partially processed item. + - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 54e8b893555..42aef2821ac 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -472,12 +472,9 @@ public Queue getBreadcrumbs() { .getLogger() .log( SentryLevel.ERROR, - "The BeforeBreadcrumbCallback callback threw an exception. Exception details will be added to the breadcrumb.", + "The BeforeBreadcrumb callback threw an exception. Dropping breadcrumb.", e); - - if (e.getMessage() != null) { - breadcrumb.setData("sentry:message", e.getMessage()); - } + return null; } return breadcrumb; } diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index be6a22516c0..69f19b6d421 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1,6 +1,8 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.SentryLevel.WARNING +import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.protocol.Request import io.sentry.protocol.SentryId import io.sentry.protocol.User @@ -334,16 +336,44 @@ class ScopeTest { } @Test - fun `when adding breadcrumb, executeBreadcrumb will be executed and throw, but breadcrumb will be added`() { - val exception = Exception("test") + fun `when beforeBreadcrumb throws, breadcrumb is dropped without notifying observers`() { + val observer = mock() + val options = + SentryOptions().apply { + setBeforeBreadcrumb { _, _ -> throw Exception("test") } + addScopeObserver(observer) + } - val options = SentryOptions().apply { setBeforeBreadcrumb { _, _ -> throw exception } } + val scope = Scope(options) + val breadcrumb = Breadcrumb() + scope.addBreadcrumb(breadcrumb) + + assertThat(scope.breadcrumbs).isEmpty() + assertThat(breadcrumb.data).doesNotContainKey("sentry:message") + verifyNoInteractions(observer) + assertClientReport(options.clientReportRecorder, emptyList()) + } + + @Test + fun `when beforeBreadcrumb throws, later breadcrumbs can still be added`() { + var invocationCount = 0 + val options = + SentryOptions().apply { + setBeforeBreadcrumb { breadcrumb, _ -> + invocationCount++ + if (invocationCount == 1) { + throw Exception("test") + } + breadcrumb + } + } val scope = Scope(options) - val actual = Breadcrumb() - scope.addBreadcrumb(actual) + scope.addBreadcrumb(Breadcrumb("dropped")) + scope.addBreadcrumb(Breadcrumb("kept")) - assertEquals("test", actual.data["sentry:message"]) + assertThat(invocationCount).isEqualTo(2) + assertThat(scope.breadcrumbs.single().message).isEqualTo("kept") } @Test diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index d1cb38c6495..e6db21bc791 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.backpressure.IBackpressureMonitor import io.sentry.cache.EnvelopeCache import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport @@ -236,22 +237,23 @@ class ScopesTest { } @Test - fun `when beforeSend throws an exception, breadcrumb adds an entry to the data field with exception message`() { - val exception = Exception("test") - + fun `when beforeBreadcrumb throws an exception, breadcrumb is dropped`() { val options = SentryOptions() options.cacheDirPath = file.absolutePath options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { _: Breadcrumb, _: Any? -> - throw exception + throw Exception("test") } options.dsn = "https://key@sentry.io/proj" options.setSerializer(mock()) val sut = createScopes(options) - val actual = Breadcrumb() - sut.addBreadcrumb(actual) + val breadcrumb = Breadcrumb() + sut.addBreadcrumb(breadcrumb) - assertEquals("test", actual.data["sentry:message"]) + var breadcrumbs: Queue? = null + sut.configureScope { breadcrumbs = it.breadcrumbs } + assertThat(breadcrumbs).isEmpty() + assertThat(breadcrumb.data).doesNotContainKey("sentry:message") } @Test From 434e1db0c66dd3b6770d299b3f17d31a9fdd091a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 14:48:59 +0200 Subject: [PATCH 05/37] ref(core): [Callback Errors 2] Mark SDK-owned event processors Add an internal marker to SDK event processors across core and integrations. Keep customer processors and the backfilling marker independent so later callback handling can distinguish ownership regardless of registration path. Leave exception handling unchanged in this preparatory change. Refs #6081 Co-Authored-By: Claude --- .../api/sentry-android-core.api | 6 +++--- .../ApplicationExitInfoEventProcessor.java | 4 +++- .../core/DefaultAndroidEventProcessor.java | 3 ++- .../PerformanceAndroidEventProcessor.java | 4 ++-- .../core/ScreenshotEventProcessor.java | 4 ++-- .../core/ViewHierarchyEventProcessor.java | 4 ++-- .../api/sentry-opentelemetry-core.api | 2 +- .../OpenTelemetryLinkErrorEventProcessor.java | 4 ++-- .../api/sentry-opentelemetry-otlp.api | 2 +- .../otlp/OpenTelemetryOtlpEventProcessor.java | 4 ++-- ...tryRequestHttpServletRequestProcessor.java | 4 ++-- ...tryRequestHttpServletRequestProcessor.java | 4 ++-- sentry-spring-7/api/sentry-spring-7.api | 6 +++--- .../spring7/ContextTagsEventProcessor.java | 4 ++-- ...tryRequestHttpServletRequestProcessor.java | 4 ++-- .../io/sentry/spring7/SentrySpringFilter.java | 4 ++-- .../spring7/SpringProfilesEventProcessor.java | 4 ++-- .../api/sentry-spring-jakarta.api | 6 +++--- .../jakarta/ContextTagsEventProcessor.java | 4 ++-- ...tryRequestHttpServletRequestProcessor.java | 4 ++-- .../spring/jakarta/SentrySpringFilter.java | 4 ++-- .../jakarta/SpringProfilesEventProcessor.java | 4 ++-- sentry-spring/api/sentry-spring.api | 6 +++--- .../spring/ContextTagsEventProcessor.java | 4 ++-- ...tryRequestHttpServletRequestProcessor.java | 4 ++-- .../io/sentry/spring/SentrySpringFilter.java | 4 ++-- .../spring/SpringProfilesEventProcessor.java | 4 ++-- sentry/api/sentry.api | 9 +++++--- ...eduplicateMultithreadedEventProcessor.java | 3 ++- ...DuplicateEventDetectionEventProcessor.java | 3 ++- .../java/io/sentry/MainEventProcessor.java | 3 ++- .../sentry/SentryRuntimeEventProcessor.java | 3 ++- .../eventprocessor/SentryEventProcessor.java | 8 +++++++ .../SentryEventProcessorTest.kt | 21 +++++++++++++++++++ 34 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/internal/eventprocessor/SentryEventProcessor.java create mode 100644 sentry/src/test/java/io/sentry/internal/eventprocessor/SentryEventProcessorTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 9a8b9d835db..f8b30d05e4e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -233,7 +233,7 @@ public final class io/sentry/android/core/AppState$LifecycleObserver : androidx/ public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V } -public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io/sentry/BackfillingEventProcessor { +public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io/sentry/BackfillingEventProcessor, io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -401,7 +401,7 @@ public class io/sentry/android/core/PerfettoProfiler { public fun endAndCollect (Ljava/util/function/Consumer;)V } -public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;Z)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -678,7 +678,7 @@ public final class io/sentry/android/core/UserInteractionIntegration : android/a public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 400a3293bcb..483259fa8dd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -52,6 +52,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.NativeCrashExit; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugImage; @@ -89,7 +90,8 @@ */ @ApiStatus.Internal @WorkerThread -public final class ApplicationExitInfoEventProcessor implements BackfillingEventProcessor { +public final class ApplicationExitInfoEventProcessor + implements BackfillingEventProcessor, SentryEventProcessor { private final @NotNull Context context; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 84e953ee69e..c7ea0e27e4f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -8,6 +8,7 @@ import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.App; import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.SentryException; @@ -32,7 +33,7 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -final class DefaultAndroidEventProcessor implements EventProcessor { +final class DefaultAndroidEventProcessor implements SentryEventProcessor { @TestOnly final Context context; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 7f81ea1b738..142bb1f4752 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -7,7 +7,6 @@ import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; @@ -20,6 +19,7 @@ import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.App; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; @@ -36,7 +36,7 @@ import org.jetbrains.annotations.Nullable; /** Event Processor responsible for adding Android metrics to transactions */ -final class PerformanceAndroidEventProcessor implements EventProcessor { +final class PerformanceAndroidEventProcessor implements SentryEventProcessor { private static final String APP_METRICS_ORIGIN = "auto.ui"; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 8c4b1feef7c..7783955f42c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -8,7 +8,6 @@ import android.graphics.Bitmap; import android.view.View; import io.sentry.Attachment; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -18,6 +17,7 @@ import io.sentry.android.replay.util.MaskRenderer; import io.sentry.android.replay.util.ViewsKt; import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -34,7 +34,7 @@ * captured. */ @ApiStatus.Internal -public final class ScreenshotEventProcessor implements EventProcessor { +public final class ScreenshotEventProcessor implements SentryEventProcessor { private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 6d21edb3d0c..77e3ad917ea 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -7,7 +7,6 @@ import android.view.ViewGroup; import android.view.Window; import io.sentry.Attachment; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.ISerializer; @@ -18,6 +17,7 @@ import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.ViewHierarchy; @@ -37,7 +37,7 @@ /** ViewHierarchyEventProcessor responsible for taking a snapshot of the current view hierarchy. */ @ApiStatus.Internal -public final class ViewHierarchyEventProcessor implements EventProcessor { +public final class ViewHierarchyEventProcessor implements SentryEventProcessor { private final @NotNull SentryAndroidOptions options; private final @NotNull Debouncer debouncer; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 3ed25d1a9cf..b6a5ab2de48 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -4,7 +4,7 @@ public final class io/sentry/opentelemetry/OpenTelemetryAttributesExtractor { public fun extractUrl (Lio/opentelemetry/api/common/Attributes;Lio/sentry/SentryOptions;)Ljava/lang/String; } -public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun ()V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java index cf1e530cd9e..dde593fa755 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java @@ -3,7 +3,6 @@ import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanId; import io.opentelemetry.api.trace.TraceId; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISpan; @@ -12,6 +11,7 @@ import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.SpanContext; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -21,7 +21,7 @@ * @deprecated this is no longer needed for the latest version of our OpenTelemetry integration. */ @Deprecated -public final class OpenTelemetryLinkErrorEventProcessor implements EventProcessor { +public final class OpenTelemetryLinkErrorEventProcessor implements SentryEventProcessor { private final @NotNull IScopes scopes; diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api index 56e80e60ae6..07af57c2e4e 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api @@ -1,4 +1,4 @@ -public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun ()V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java index ad8b672c7de..0b564bc16d0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java @@ -3,7 +3,6 @@ import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanId; import io.opentelemetry.api.trace.TraceId; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ScopesAdapter; @@ -12,12 +11,13 @@ import io.sentry.SentryLogEvent; import io.sentry.SentryMetricsEvent; import io.sentry.SpanContext; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -public final class OpenTelemetryOtlpEventProcessor implements EventProcessor { +public final class OpenTelemetryOtlpEventProcessor implements SentryEventProcessor { private final @NotNull IScopes scopes; diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java index 1904d2e5cf0..0dea8ee2bfa 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -1,9 +1,9 @@ package io.sentry.servlet.jakarta; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryOptions; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -18,7 +18,7 @@ import org.jetbrains.annotations.Nullable; /** Attaches information about HTTP request to {@link SentryEvent}. */ -final class SentryRequestHttpServletRequestProcessor implements EventProcessor { +final class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull HttpServletRequest httpRequest; private final @NotNull SentryOptions options; diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java index 789ed1b766f..d8a2fcd808c 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java @@ -1,9 +1,9 @@ package io.sentry.servlet; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryOptions; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -18,7 +18,7 @@ import org.jetbrains.annotations.Nullable; /** Attaches information about HTTP request to {@link SentryEvent}. */ -final class SentryRequestHttpServletRequestProcessor implements EventProcessor { +final class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull HttpServletRequest httpRequest; private final @NotNull SentryOptions options; diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index c9250b550fd..3b2872afc67 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -3,7 +3,7 @@ public final class io/sentry/spring7/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } -public final class io/sentry/spring7/ContextTagsEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring7/ContextTagsEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -48,7 +48,7 @@ public class io/sentry/spring7/SentryProfilerConfiguration { public fun sentryOpenTelemetryProfilerConverterConfiguration ()Lio/sentry/IProfileConverter; } -public class io/sentry/spring7/SentryRequestHttpServletRequestProcessor : io/sentry/EventProcessor { +public class io/sentry/spring7/SentryRequestHttpServletRequestProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/spring7/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -92,7 +92,7 @@ public class io/sentry/spring7/SentryWebConfiguration { public fun httpServletRequestSentryUserProvider (Lio/sentry/SentryOptions;)Lio/sentry/spring7/HttpServletRequestSentryUserProvider; } -public final class io/sentry/spring7/SpringProfilesEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring7/SpringProfilesEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lorg/springframework/core/env/Environment;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java index 89fdef8d1b2..812db92d644 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring7; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryOptions; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.util.CollectionUtils; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -14,7 +14,7 @@ * Attaches context tags defined in {@link SentryOptions#getContextTags()} from {@link MDC} to * {@link SentryEvent#getTags()}. */ -public final class ContextTagsEventProcessor implements EventProcessor { +public final class ContextTagsEventProcessor implements SentryEventProcessor { private final SentryOptions options; public ContextTagsEventProcessor(final @NotNull SentryOptions options) { diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java index 2412083812d..680f560fffc 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring7; import com.jakewharton.nopen.annotation.Open; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring7.tracing.TransactionNameProvider; import io.sentry.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -12,7 +12,7 @@ /** Attaches transaction name from the HTTP request to {@link SentryEvent}. */ @Open -public class SentryRequestHttpServletRequestProcessor implements EventProcessor { +public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java index bf2c431a179..de766504aff 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java @@ -6,7 +6,6 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Breadcrumb; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -15,6 +14,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryOptions.RequestSize; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider; import io.sentry.spring7.tracing.TransactionNameProvider; import io.sentry.util.Objects; @@ -140,7 +140,7 @@ private static boolean shouldCacheMimeType(String contentType) { } } - static final class RequestBodyExtractingEventProcessor implements EventProcessor { + static final class RequestBodyExtractingEventProcessor implements SentryEventProcessor { private final @NotNull RequestPayloadExtractor requestPayloadExtractor = new RequestPayloadExtractor(); private final @NotNull HttpServletRequest request; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java index 100bdd6a38b..d22d15fe767 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java @@ -1,10 +1,10 @@ package io.sentry.spring7; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryReplayEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.Spring; import org.jetbrains.annotations.NotNull; @@ -15,7 +15,7 @@ * Attaches the list of active Spring profiles (an empty list if only the default profile is active) * to the {@link io.sentry.TraceContext} associated with the event. */ -public final class SpringProfilesEventProcessor implements EventProcessor { +public final class SpringProfilesEventProcessor implements SentryEventProcessor { private final @NotNull Environment environment; @Override diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 24b9af7e14b..be94039b754 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -3,7 +3,7 @@ public final class io/sentry/spring/jakarta/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } -public final class io/sentry/spring/jakarta/ContextTagsEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring/jakarta/ContextTagsEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -48,7 +48,7 @@ public class io/sentry/spring/jakarta/SentryProfilerConfiguration { public fun sentryOpenTelemetryProfilerConverterConfiguration ()Lio/sentry/IProfileConverter; } -public class io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor : io/sentry/EventProcessor { +public class io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -92,7 +92,7 @@ public class io/sentry/spring/jakarta/SentryWebConfiguration { public fun httpServletRequestSentryUserProvider (Lio/sentry/SentryOptions;)Lio/sentry/spring/jakarta/HttpServletRequestSentryUserProvider; } -public final class io/sentry/spring/jakarta/SpringProfilesEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring/jakarta/SpringProfilesEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lorg/springframework/core/env/Environment;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/ContextTagsEventProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/ContextTagsEventProcessor.java index 94f49d83190..dd5b3684fa4 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/ContextTagsEventProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/ContextTagsEventProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring.jakarta; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryOptions; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.util.CollectionUtils; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -14,7 +14,7 @@ * Attaches context tags defined in {@link SentryOptions#getContextTags()} from {@link MDC} to * {@link SentryEvent#getTags()}. */ -public final class ContextTagsEventProcessor implements EventProcessor { +public final class ContextTagsEventProcessor implements SentryEventProcessor { private final SentryOptions options; public ContextTagsEventProcessor(final @NotNull SentryOptions options) { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java index 91b27ddeac7..b18b5a914e7 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring.jakarta; import com.jakewharton.nopen.annotation.Open; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.jakarta.tracing.TransactionNameProvider; import io.sentry.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -12,7 +12,7 @@ /** Attaches transaction name from the HTTP request to {@link SentryEvent}. */ @Open -public class SentryRequestHttpServletRequestProcessor implements EventProcessor { +public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java index c549223e559..3ce21ed4ce5 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java @@ -6,7 +6,6 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Breadcrumb; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -15,6 +14,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryOptions.RequestSize; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.jakarta.tracing.SpringMvcTransactionNameProvider; import io.sentry.spring.jakarta.tracing.TransactionNameProvider; import io.sentry.util.Objects; @@ -140,7 +140,7 @@ private static boolean shouldCacheMimeType(String contentType) { } } - static final class RequestBodyExtractingEventProcessor implements EventProcessor { + static final class RequestBodyExtractingEventProcessor implements SentryEventProcessor { private final @NotNull RequestPayloadExtractor requestPayloadExtractor = new RequestPayloadExtractor(); private final @NotNull HttpServletRequest request; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringProfilesEventProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringProfilesEventProcessor.java index 48957e88507..48ea9910f32 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringProfilesEventProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringProfilesEventProcessor.java @@ -1,10 +1,10 @@ package io.sentry.spring.jakarta; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryReplayEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.Spring; import org.jetbrains.annotations.NotNull; @@ -15,7 +15,7 @@ * Attaches the list of active Spring profiles (an empty list if only the default profile is active) * to the {@link io.sentry.TraceContext} associated with the event. */ -public final class SpringProfilesEventProcessor implements EventProcessor { +public final class SpringProfilesEventProcessor implements SentryEventProcessor { private final @NotNull Environment environment; @Override diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 4e1bea84288..7c8ff495f3b 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -3,7 +3,7 @@ public final class io/sentry/spring/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } -public final class io/sentry/spring/ContextTagsEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring/ContextTagsEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -48,7 +48,7 @@ public class io/sentry/spring/SentryProfilerConfiguration { public fun sentryOpenTelemetryProfilerConverterConfiguration ()Lio/sentry/IProfileConverter; } -public class io/sentry/spring/SentryRequestHttpServletRequestProcessor : io/sentry/EventProcessor { +public class io/sentry/spring/SentryRequestHttpServletRequestProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/spring/tracing/TransactionNameProvider;Ljavax/servlet/http/HttpServletRequest;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -92,7 +92,7 @@ public class io/sentry/spring/SentryWebConfiguration { public fun httpServletRequestSentryUserProvider (Lio/sentry/SentryOptions;)Lio/sentry/spring/HttpServletRequestSentryUserProvider; } -public final class io/sentry/spring/SpringProfilesEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/spring/SpringProfilesEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lorg/springframework/core/env/Environment;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; diff --git a/sentry-spring/src/main/java/io/sentry/spring/ContextTagsEventProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/ContextTagsEventProcessor.java index 41ff04d0c49..52685b0a700 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/ContextTagsEventProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/ContextTagsEventProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; import io.sentry.SentryOptions; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.util.CollectionUtils; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -14,7 +14,7 @@ * Attaches context tags defined in {@link SentryOptions#getContextTags()} from {@link MDC} to * {@link SentryEvent#getTags()}. */ -public final class ContextTagsEventProcessor implements EventProcessor { +public final class ContextTagsEventProcessor implements SentryEventProcessor { private final SentryOptions options; public ContextTagsEventProcessor(final @NotNull SentryOptions options) { diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java index 426571ba6e1..829e39b8508 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java @@ -1,9 +1,9 @@ package io.sentry.spring; import com.jakewharton.nopen.annotation.Open; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.tracing.TransactionNameProvider; import io.sentry.util.Objects; import javax.servlet.http.HttpServletRequest; @@ -12,7 +12,7 @@ /** Attaches transaction name from the HTTP request to {@link SentryEvent}. */ @Open -public class SentryRequestHttpServletRequestProcessor implements EventProcessor { +public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java index 3fe8ab9e13f..be893b67a5a 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java @@ -6,7 +6,6 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Breadcrumb; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -15,6 +14,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryOptions.RequestSize; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.tracing.SpringMvcTransactionNameProvider; import io.sentry.spring.tracing.TransactionNameProvider; import io.sentry.util.Objects; @@ -140,7 +140,7 @@ private static boolean shouldCacheMimeType(String contentType) { } } - static final class RequestBodyExtractingEventProcessor implements EventProcessor { + static final class RequestBodyExtractingEventProcessor implements SentryEventProcessor { private final @NotNull RequestPayloadExtractor requestPayloadExtractor = new RequestPayloadExtractor(); private final @NotNull HttpServletRequest request; diff --git a/sentry-spring/src/main/java/io/sentry/spring/SpringProfilesEventProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SpringProfilesEventProcessor.java index 30b6483fd31..c233a7e9824 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SpringProfilesEventProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SpringProfilesEventProcessor.java @@ -1,10 +1,10 @@ package io.sentry.spring; -import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryReplayEvent; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.Spring; import org.jetbrains.annotations.NotNull; @@ -15,7 +15,7 @@ * Attaches the list of active Spring profiles (an empty list if only the default profile is active) * to the {@link io.sentry.TraceContext} associated with the event. */ -public final class SpringProfilesEventProcessor implements EventProcessor { +public final class SpringProfilesEventProcessor implements SentryEventProcessor { private final @NotNull Environment environment; @Override diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c3dcbc730b7..de6b48f9db3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -461,7 +461,7 @@ public final class io/sentry/DateUtils { public static fun toUtilDateNotNull (Lio/sentry/SentryDate;)Ljava/util/Date; } -public final class io/sentry/DeduplicateMultithreadedEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/DeduplicateMultithreadedEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -511,7 +511,7 @@ public final class io/sentry/DsnUtil { public static fun urlContainsDsnHost (Lio/sentry/SentryOptions;Ljava/lang/String;)Z } -public final class io/sentry/DuplicateEventDetectionEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/DuplicateEventDetectionEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -1483,7 +1483,7 @@ public final class io/sentry/KeyValueCollectionBehavior$Mode : java/lang/Enum { public static fun values ()[Lio/sentry/KeyValueCollectionBehavior$Mode; } -public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor { +public final class io/sentry/MainEventProcessor : io/sentry/internal/eventprocessor/SentryEventProcessor { public fun (Lio/sentry/SentryOptions;)V public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -5447,6 +5447,9 @@ public final class io/sentry/internal/eventprocessor/EventProcessorAndOrder : ja public fun getOrder ()Ljava/lang/Long; } +public abstract interface class io/sentry/internal/eventprocessor/SentryEventProcessor : io/sentry/EventProcessor { +} + public abstract interface class io/sentry/internal/gestures/GestureTargetLocator { public abstract fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; } diff --git a/sentry/src/main/java/io/sentry/DeduplicateMultithreadedEventProcessor.java b/sentry/src/main/java/io/sentry/DeduplicateMultithreadedEventProcessor.java index b5869a63796..7053259c61c 100644 --- a/sentry/src/main/java/io/sentry/DeduplicateMultithreadedEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DeduplicateMultithreadedEventProcessor.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.hints.EventDropReason; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryException; import io.sentry.util.HintUtils; import java.util.Collections; @@ -14,7 +15,7 @@ * multiple threads. This can be the case for OutOfMemory errors or CursorWindowAllocationException, * basically any error related to allocating memory when it's low. */ -public final class DeduplicateMultithreadedEventProcessor implements EventProcessor { +public final class DeduplicateMultithreadedEventProcessor implements SentryEventProcessor { private final @NotNull Map processedEvents = Collections.synchronizedMap(new HashMap<>()); diff --git a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java index fc178d789f3..a184a21cfb8 100644 --- a/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java +++ b/sentry/src/main/java/io/sentry/DuplicateEventDetectionEventProcessor.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -9,7 +10,7 @@ import org.jetbrains.annotations.Nullable; /** Deduplicates events containing throwable that has been already processed. */ -public final class DuplicateEventDetectionEventProcessor implements EventProcessor { +public final class DuplicateEventDetectionEventProcessor implements SentryEventProcessor { private final @NotNull Map capturedObjects = Collections.synchronizedMap(new WeakHashMap<>()); private final @NotNull SentryOptions options; diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 79b45a30c65..25ec4b6966a 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -2,6 +2,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Cached; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; @@ -16,7 +17,7 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class MainEventProcessor implements EventProcessor { +public final class MainEventProcessor implements SentryEventProcessor { private final @NotNull SentryOptions options; private final @NotNull SentryThreadFactory sentryThreadFactory; diff --git a/sentry/src/main/java/io/sentry/SentryRuntimeEventProcessor.java b/sentry/src/main/java/io/sentry/SentryRuntimeEventProcessor.java index ca19a9ca74d..013cb7ac2b1 100644 --- a/sentry/src/main/java/io/sentry/SentryRuntimeEventProcessor.java +++ b/sentry/src/main/java/io/sentry/SentryRuntimeEventProcessor.java @@ -1,12 +1,13 @@ package io.sentry; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryRuntime; import io.sentry.protocol.SentryTransaction; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** Attaches Java vendor and version to events and transactions. */ -final class SentryRuntimeEventProcessor implements EventProcessor { +final class SentryRuntimeEventProcessor implements SentryEventProcessor { private final @Nullable String javaVersion; private final @Nullable String javaVendor; diff --git a/sentry/src/main/java/io/sentry/internal/eventprocessor/SentryEventProcessor.java b/sentry/src/main/java/io/sentry/internal/eventprocessor/SentryEventProcessor.java new file mode 100644 index 00000000000..160dfe0fd78 --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/eventprocessor/SentryEventProcessor.java @@ -0,0 +1,8 @@ +package io.sentry.internal.eventprocessor; + +import io.sentry.EventProcessor; +import org.jetbrains.annotations.ApiStatus; + +/** Marker interface for event processors implemented by the Sentry SDK. */ +@ApiStatus.Internal +public interface SentryEventProcessor extends EventProcessor {} diff --git a/sentry/src/test/java/io/sentry/internal/eventprocessor/SentryEventProcessorTest.kt b/sentry/src/test/java/io/sentry/internal/eventprocessor/SentryEventProcessorTest.kt new file mode 100644 index 00000000000..5c94108a6ae --- /dev/null +++ b/sentry/src/test/java/io/sentry/internal/eventprocessor/SentryEventProcessorTest.kt @@ -0,0 +1,21 @@ +package io.sentry.internal.eventprocessor + +import com.google.common.truth.Truth.assertThat +import io.sentry.EventProcessor +import io.sentry.SentryOptions +import kotlin.test.Test + +class SentryEventProcessorTest { + @Test + fun `default SDK event processors are marked as Sentry processors`() { + assertThat(SentryOptions().eventProcessors).isNotEmpty() + assertThat(SentryOptions().eventProcessors.all { it is SentryEventProcessor }).isTrue() + } + + @Test + fun `customer event processors are not marked as Sentry processors`() { + val processor = object : EventProcessor {} + + assertThat(processor).isNotInstanceOf(SentryEventProcessor::class.java) + } +} From 6d074267e70eda7c60afe8f4f1c6a5c2ae75801d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 15:04:25 +0200 Subject: [PATCH 06/37] fix(core): [Callback Errors 3] Preserve SDK processor data Use the internal processor marker to continue processing after SDK-owned processor failures without recording callback_error losses. Keep customer processor failures fail-closed across events, transactions, replays, feedback, logs, and metrics. Cover scope and options registration, continued callbacks and delivery, logging without discard notifications, intentional drops, and span loss accounting. Clarify the customer-only failure policy in the changelog. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 2 +- .../src/main/java/io/sentry/SentryClient.java | 61 +++-- .../SentryClientInternalEventProcessorTest.kt | 229 ++++++++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 43 ++++ 4 files changed, 310 insertions(+), 25 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b48c927987..246668e0744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,7 +124,7 @@ - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - - Drop telemetry and record `callback_error` when an event processor throws instead of continuing with a potentially partially processed item. + - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index cebc37a3936..705f6fe3bbb 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -8,6 +8,7 @@ import io.sentry.hints.Cached; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.TransactionEnd; +import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.logger.ILoggerBatchProcessor; import io.sentry.logger.NoOpLoggerBatchProcessor; import io.sentry.metrics.IMetricsBatchProcessor; @@ -506,10 +507,12 @@ private SentryEvent processEvent( e, "An exception occurred while processing event by processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Error); - return null; + if (!(processor instanceof SentryEventProcessor)) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Error); + return null; + } } if (event == null) { @@ -561,8 +564,10 @@ private SentryLogEvent processLogEvent( e, "An exception occurred while processing log event by processor: %s", processor.getClass().getName()); - recordLostLogEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); - return null; + if (!(processor instanceof SentryEventProcessor)) { + recordLostLogEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); + return null; + } } if (event == null) { @@ -596,8 +601,10 @@ private SentryMetricsEvent processMetricsEvent( e, "An exception occurred while processing metrics event by processor: %s", processor.getClass().getName()); - recordLostMetricsEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); - return null; + if (!(processor instanceof SentryEventProcessor)) { + recordLostMetricsEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor); + return null; + } } if (event == null) { @@ -630,14 +637,16 @@ private SentryMetricsEvent processMetricsEvent( e, "An exception occurred while processing transaction by processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction); - options - .getClientReportRecorder() - .recordLostEvent( - DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeProcessor + 1); - return null; + if (!(processor instanceof SentryEventProcessor)) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeProcessor + 1); + return null; + } } final int spanCountAfterProcessor = transaction == null ? 0 : transaction.getSpans().size(); @@ -691,10 +700,12 @@ private SentryReplayEvent processReplayEvent( e, "An exception occurred while processing replay event by processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay); - return null; + if (!(processor instanceof SentryEventProcessor)) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay); + return null; + } } if (replayEvent == null) { @@ -729,10 +740,12 @@ private SentryEvent processFeedbackEvent( e, "An exception occurred while processing feedback event by processor: %s", processor.getClass().getName()); - options - .getClientReportRecorder() - .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback); - return null; + if (!(processor instanceof SentryEventProcessor)) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback); + return null; + } } if (feedbackEvent == null) { diff --git a/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt b/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt new file mode 100644 index 00000000000..59aaaa0a1ae --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt @@ -0,0 +1,229 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent +import io.sentry.internal.eventprocessor.SentryEventProcessor +import io.sentry.protocol.Feedback +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import kotlin.test.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(Parameterized::class) +class SentryClientInternalEventProcessorTest(private val onScope: Boolean) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "onScope={0}") + fun data(): List> = listOf(arrayOf(false), arrayOf(true)) + } + + private val fixture = SentryClientTest.Fixture() + private val options = fixture.sentryOptions + private val scope = Scope(options) + private val processor = mock() + private val nextProcessor = mock() + private val onDiscard = mock() + private val logger = mock() + private val failure = IllegalStateException("SDK processor failed") + + init { + options.eventProcessors.clear() + options.onDiscard = onDiscard + options.setLogger(logger) + options.logs.isEnabled = true + options.metrics.isEnabled = true + if (onScope) { + scope.addEventProcessor(processor) + scope.addEventProcessor(nextProcessor) + } else { + options.addEventProcessor(processor) + options.addEventProcessor(nextProcessor) + } + } + + @Test + fun `SDK event processor failure keeps event and runs remaining callbacks`() { + val event = SentryEvent() + val beforeSend = mock() + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] } + options.beforeSend = beforeSend + + val id = fixture.getSut().captureEvent(event, scope) + + assertThat(id).isEqualTo(event.eventId) + verify(nextProcessor).process(same(event), any()) + verify(beforeSend).execute(same(event), any()) + verify(fixture.transport) + .send(check { assertThat(it.header.eventId).isEqualTo(id) }, anyOrNull()) + assertFailureLoggedWithoutLoss("event") + } + + @Test + fun `SDK transaction processor failure keeps transaction and spans`() { + val transaction = SentryTransaction(fixture.sentryTracer) + val beforeSend = mock() + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] } + options.beforeSendTransaction = beforeSend + + val id = fixture.getSut().captureTransaction(transaction, scope, null) + + assertThat(id).isEqualTo(transaction.eventId) + verify(nextProcessor).process(same(transaction), any()) + verify(beforeSend).execute(same(transaction), any()) + verify(fixture.transport) + .send( + check { + val sent = it.items.first().getTransaction(options.serializer)!! + assertThat(sent.eventId).isEqualTo(id) + assertThat(sent.spans).hasSize(1) + }, + anyOrNull(), + ) + assertFailureLoggedWithoutLoss("transaction") + } + + @Test + fun `SDK feedback processor failure keeps feedback and runs remaining callbacks`() { + val feedback = Feedback("message") + val beforeSend = mock() + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] } + options.beforeSendFeedback = beforeSend + + val id = fixture.getSut().captureFeedback(feedback, null, scope) + + assertThat(id).isNotEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor) + .process( + check { + assertThat(it.contexts.feedback).isSameInstanceAs(feedback) + }, + any(), + ) + verify(beforeSend).execute(check { assertThat(it.eventId).isEqualTo(id) }, any()) + verify(fixture.transport) + .send(check { assertThat(it.header.eventId).isEqualTo(id) }, anyOrNull()) + assertFailureLoggedWithoutLoss("feedback event") + } + + @Test + fun `SDK log processor failure keeps log and runs remaining callbacks`() { + val event = SentryLogEvent(SentryId(), SentryNanotimeDate(), "message", SentryLogLevel.WARN) + val beforeSend = mock() + whenever(processor.process(any())).thenThrow(failure) + whenever(nextProcessor.process(any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any())).thenAnswer { it.arguments[0] } + options.logs.beforeSend = beforeSend + + fixture.getSut().captureLog(event, scope) + + verify(nextProcessor).process(same(event)) + verify(beforeSend).execute(same(event)) + verify(fixture.loggerBatchProcessor).add(same(event)) + assertFailureLoggedWithoutLoss("log event") + } + + @Test + fun `SDK metric processor failure keeps metric and runs remaining callbacks`() { + val event = SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0) + val beforeSend = mock() + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] } + options.metrics.beforeSend = beforeSend + + fixture.getSut().captureMetric(event, scope, null) + + verify(nextProcessor).process(same(event), any()) + verify(beforeSend).execute(same(event), any()) + verify(fixture.metricsBatchProcessor).add(same(event)) + assertFailureLoggedWithoutLoss("metrics event") + } + + @Test + fun `SDK processor returning null still drops event as event_processor`() { + whenever(processor.process(any(), any())).thenReturn(null) + + val id = fixture.getSut().captureEvent(SentryEvent(), scope) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor, never()).process(any(), any()) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + options.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Error.category, 1)), + ) + } + + @Test + fun `customer processor failure after SDK processor failure still drops event`() { + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())) + .thenThrow(IllegalArgumentException("customer")) + val beforeSend = mock() + options.beforeSend = beforeSend + + val id = fixture.getSut().captureEvent(SentryEvent(), scope) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(nextProcessor).process(any(), any()) + verifyNoInteractions(beforeSend) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + options.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Error.category, 1)), + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Error, 1) + } + + @Test + fun `spans removed before SDK processor failure retain event_processor accounting`() { + val transaction = SentryTransaction(fixture.sentryTracer) + whenever(processor.process(any(), any())).doAnswer { + transaction.spans.clear() + throw failure + } + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + + val id = fixture.getSut().captureTransaction(transaction, scope, null) + + assertThat(id).isEqualTo(transaction.eventId) + verify(nextProcessor).process(same(transaction), any()) + verify(fixture.transport).send(any(), anyOrNull()) + assertClientReport( + options.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Span.category, 1)), + ) + } + + private fun assertFailureLoggedWithoutLoss(item: String) { + verify(logger) + .log( + eq(SentryLevel.ERROR), + same(failure), + eq("An exception occurred while processing $item by processor: %s"), + eq(processor.javaClass.name), + ) + assertClientReport(options.clientReportRecorder, emptyList()) + verifyNoInteractions(onDiscard) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index ff6fc57900e..99d12e12184 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -14,6 +14,7 @@ import io.sentry.hints.Backfillable import io.sentry.hints.Cached import io.sentry.hints.DiskFlushNotification import io.sentry.hints.TransactionEnd +import io.sentry.internal.eventprocessor.SentryEventProcessor import io.sentry.logger.ILoggerBatchProcessor import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.metrics.IMetricsBatchProcessor @@ -3698,6 +3699,48 @@ class SentryClientTest { verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Replay, 1) } + @Test + fun `throwing SDK replay processor keeps replay and runs remaining callbacks`() { + val processor = mock() + val nextProcessor = mock() + val beforeSend = mock() + val onDiscard = mock() + val logger = mock() + val failure = IllegalStateException("SDK processor failed") + val replay = createReplayEvent() + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] } + fixture.sentryOptions.addEventProcessor(processor) + fixture.sentryOptions.addEventProcessor(nextProcessor) + fixture.sentryOptions.beforeSendReplay = beforeSend + fixture.sentryOptions.onDiscard = onDiscard + fixture.sentryOptions.setLogger(logger) + + val id = fixture.getSut().captureReplayEvent(replay, createScope(), null) + + assertThat(id).isEqualTo(replay.eventId) + verify(nextProcessor).process(eq(replay), any()) + verify(beforeSend).execute(eq(replay), any()) + verify(fixture.transport) + .send( + check { + assertThat(it.header.eventId).isEqualTo(id) + assertThat(it.items.first().header.type).isEqualTo(SentryItemType.ReplayVideo) + }, + anyOrNull(), + ) + verify(logger) + .log( + eq(SentryLevel.ERROR), + eq(failure), + eq("An exception occurred while processing replay event by processor: %s"), + eq(processor.javaClass.name), + ) + assertClientReport(fixture.sentryOptions.clientReportRecorder, emptyList()) + verifyNoInteractions(onDiscard) + } + @Test fun `calls captureReplay on replay controller for error events`() { var called = false From 9320f73263c5e74f578aeffe7d74b73214882bc7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 15:46:36 +0200 Subject: [PATCH 07/37] fix(core): [Callback Errors 5] Handle tracesSampler failures Inherit the parent sampling decision when tracesSampler throws. Without a parent decision, leave the trace unsampled instead of applying the static tracesSampleRate, which can override the failed sampling policy. Keep normal null-result fallback, profiling callbacks, and catch types unchanged. Cover parent inheritance, backfilling, and static-rate bypass. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../main/java/io/sentry/TracesSampler.java | 5 +- .../test/java/io/sentry/TracesSamplerTest.kt | 78 +++++++++++++++++-- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17952821933..1b643f449e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. + - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate`. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index 5430b9242ac..e981bb6f66f 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -39,11 +39,13 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont } Boolean profilesSampled = profilesSampleRate != null && sample(profilesSampleRate, sampleRand); + boolean tracesSamplerFailed = false; if (options.getTracesSampler() != null) { Double samplerResult = null; try { samplerResult = options.getTracesSampler().sample(samplingContext); } catch (Throwable t) { + tracesSamplerFailed = true; options .getLogger() .log(SentryLevel.ERROR, "Error in the 'TracesSamplerCallback' callback.", t); @@ -64,7 +66,8 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont return SampleRateUtils.backfilledSampleRand(parentSamplingDecision); } - final @Nullable Double tracesSampleRateFromOptions = options.getTracesSampleRate(); + final @Nullable Double tracesSampleRateFromOptions = + tracesSamplerFailed ? null : options.getTracesSampleRate(); final @NotNull Double downsampleFactor = Math.pow(2, options.getBackpressureMonitor().getDownsampleFactor()); final @Nullable Double downsampledTracesSampleRate = diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index 4266061353c..bb257a9c46e 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -1,5 +1,7 @@ package io.sentry +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryId import io.sentry.util.SentryRandom import kotlin.test.Test import kotlin.test.assertEquals @@ -175,7 +177,7 @@ class TracesSamplerTest { @Test fun `when tracesSampler returns null and parentSampled is set sampler uses it as a sampling decision`() { - val sampler = fixture.getSut(tracesSamplerCallback = null) + val sampler = fixture.getSut(tracesSamplerCallback = { null }) val transactionContextParentSampled = TransactionContext("name", "op") transactionContextParentSampled.parentSampled = true val samplingDecision = @@ -204,7 +206,7 @@ class TracesSamplerTest { @Test fun `when tracesSampler returns null and tracesSampleRate is set sampler uses it as a sampling decision`() { - val sampler = fixture.getSut(tracesSampleRate = 0.2, tracesSamplerCallback = null) + val sampler = fixture.getSut(tracesSampleRate = 0.2, tracesSamplerCallback = { null }) val samplingDecision = sampler.sample( SamplingContext(TransactionContext("name", "op"), CustomSamplingContext(), 0.1, null) @@ -381,14 +383,74 @@ class TracesSamplerTest { } @Test - fun `when a tracesSampleRate and a TracesSamplerCallback is set but the callback throws an exception then tracing should still be enabled`() { + fun `when tracesSampler throws without a parent then static rates are ignored`() { val exception = Exception("faulty TracesSamplerCallback") + for (tracesSampleRate in listOf(null, 0.0, 1.0)) { + val sampler = + fixture.getSut( + tracesSampleRate = tracesSampleRate, + profilesSampleRate = 1.0, + tracesSamplerCallback = { throw exception }, + ) + val decision = + sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.0, null)) + + assertThat(decision.sampled).isFalse() + assertThat(decision.sampleRate).isNull() + assertThat(decision.sampleRand).isEqualTo(0.0) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() + } + } + + @Test + fun `when tracesSampler throws then a sampled parent decision is inherited`() { val sampler = - fixture.getSut(tracesSampleRate = 1.0, tracesSamplerCallback = { throw exception }) - val decision = - sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.0, null)) - assertTrue(decision.sampled) - assertEquals(0.0, decision.sampleRand) + fixture.getSut( + tracesSampleRate = 0.0, + tracesSamplerCallback = { throw IllegalStateException("faulty TracesSamplerCallback") }, + ) + val parentDecision = TracesSamplingDecision(true, 0.5, 0.1, true, 0.5) + val transactionContext = + TransactionContext(SentryId(), SpanId(), SpanId(), parentDecision, null) + + val decision = sampler.sample(SamplingContext(transactionContext, null, 0.1, null)) + + assertThat(decision).isSameInstanceAs(parentDecision) + } + + @Test + fun `when tracesSampler throws then an unsampled parent decision is inherited`() { + val sampler = + fixture.getSut( + tracesSampleRate = 1.0, + profilesSampleRate = 1.0, + tracesSamplerCallback = { throw IllegalStateException("faulty TracesSamplerCallback") }, + ) + val parentDecision = TracesSamplingDecision(false, 0.5, 0.9) + val transactionContext = + TransactionContext(SentryId(), SpanId(), SpanId(), parentDecision, null) + + val decision = sampler.sample(SamplingContext(transactionContext, null, 0.9, null)) + + assertThat(decision).isSameInstanceAs(parentDecision) + } + + @Test + fun `when tracesSampler throws then a parent decision without sampleRand is backfilled`() { + val sampler = + fixture.getSut( + tracesSampleRate = 0.0, + tracesSamplerCallback = { throw IllegalStateException("faulty TracesSamplerCallback") }, + ) + val transactionContext = TransactionContext("name", "op") + transactionContext.parentSampled = true + + val decision = sampler.sample(SamplingContext(transactionContext, null, 0.1, null)) + + assertThat(decision.sampled).isTrue() + assertThat(decision.sampleRate).isNull() + assertThat(decision.sampleRand).isNotNull() } @Test From 60d1e152d1217bde586e8cd76d3fe6e0f8d33d88 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 15:49:21 +0200 Subject: [PATCH 08/37] changelog Link the tracesSampler failure fallback entry to #6163 in the callback error handling stack. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b643f449e1..79f7b506447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,7 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate`. + - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) From 2c84527d946bb56b1f462959c8de26ad22a1f305 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:16:08 +0200 Subject: [PATCH 09/37] fix(core): [Callback Errors 6] Handle profilesSampler failures Disable profiling when profilesSampler throws instead of applying the static profilesSampleRate or inheriting a parent profiling decision. Preserve trace sampling metadata and normal null-result fallback. Cover static-rate bypass, parent inheritance, and failures in both samplers without changing existing catch types. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../main/java/io/sentry/TracesSampler.java | 11 ++- .../test/java/io/sentry/TracesSamplerTest.kt | 93 +++++++++++++++++-- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f7b506447..5df28f856c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,7 @@ - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) + - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged. - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index e981bb6f66f..cb73310e694 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -25,16 +25,18 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont } Double profilesSampleRate = null; + boolean profilesSamplerFailed = false; if (options.getProfilesSampler() != null) { try { profilesSampleRate = options.getProfilesSampler().sample(samplingContext); } catch (Throwable t) { + profilesSamplerFailed = true; options .getLogger() .log(SentryLevel.ERROR, "Error in the 'ProfilesSamplerCallback' callback.", t); } } - if (profilesSampleRate == null) { + if (profilesSampleRate == null && !profilesSamplerFailed) { profilesSampleRate = options.getProfilesSampleRate(); } Boolean profilesSampled = profilesSampleRate != null && sample(profilesSampleRate, sampleRand); @@ -63,6 +65,13 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont final TracesSamplingDecision parentSamplingDecision = samplingContext.getTransactionContext().getParentSamplingDecision(); if (parentSamplingDecision != null) { + if (profilesSamplerFailed) { + return SampleRateUtils.backfilledSampleRand( + new TracesSamplingDecision( + parentSamplingDecision.getSampled(), + parentSamplingDecision.getSampleRate(), + parentSamplingDecision.getSampleRand())); + } return SampleRateUtils.backfilledSampleRand(parentSamplingDecision); } diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index bb257a9c46e..e5b97b34e7c 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -191,7 +191,7 @@ class TracesSamplerTest { @Test fun `when profilesSampler returns null and parentSampled is set sampler uses it as a sampling decision`() { - val sampler = fixture.getSut(tracesSampleRate = 1.0, profilesSamplerCallback = null) + val sampler = fixture.getSut(tracesSampleRate = 1.0, profilesSamplerCallback = { null }) val transactionContextParentSampled = TransactionContext("name", "op") transactionContextParentSampled.setParentSampled(true, true) val samplingDecision = @@ -222,7 +222,7 @@ class TracesSamplerTest { fixture.getSut( tracesSampleRate = 1.0, profilesSampleRate = 0.2, - profilesSamplerCallback = null, + profilesSamplerCallback = { null }, ) val samplingDecision = sampler.sample( @@ -355,18 +355,93 @@ class TracesSamplerTest { } @Test - fun `when a profilingRate and a ProfilesSamplerCallback is set but the callback throws an exception then profiling should still be enabled`() { - val exception = Exception("faulty ProfilesSamplerCallback") + fun `when profilesSampler throws then static profile rates are ignored`() { + for (profilesSampleRate in listOf(null, 0.0, 1.0)) { + val sampler = + fixture.getSut( + tracesSampleRate = 1.0, + profilesSampleRate = profilesSampleRate, + profilesSamplerCallback = { + throw IllegalStateException("faulty ProfilesSamplerCallback") + }, + ) + val decision = + sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.0, null)) + + assertThat(decision.sampled).isTrue() + assertThat(decision.sampleRate).isEqualTo(1.0) + assertThat(decision.sampleRand).isEqualTo(0.0) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() + } + } + + @Test + fun `when profilesSampler throws then tracesSampler still determines trace sampling`() { val sampler = fixture.getSut( - tracesSampleRate = 1.0, + tracesSampleRate = 0.0, profilesSampleRate = 1.0, - profilesSamplerCallback = { throw exception }, + tracesSamplerCallback = { 0.5 }, + profilesSamplerCallback = { throw IllegalStateException("faulty ProfilesSamplerCallback") }, ) val decision = - sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.0, null)) - assertTrue(decision.profileSampled) - assertEquals(0.0, decision.sampleRand) + sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.1, null)) + + assertThat(decision.sampled).isTrue() + assertThat(decision.sampleRate).isEqualTo(0.5) + assertThat(decision.sampleRand).isEqualTo(0.1) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() + } + + @Test + fun `when profilesSampler throws then parent trace sampling is preserved without profiling`() { + val sampler = + fixture.getSut( + tracesSampleRate = 1.0, + profilesSampleRate = 1.0, + profilesSamplerCallback = { throw IllegalStateException("faulty ProfilesSamplerCallback") }, + ) + for (sampled in listOf(true, false)) { + val sampleRand = if (sampled) 0.1 else 0.9 + val parentDecision = TracesSamplingDecision(sampled, 0.5, sampleRand, true, 1.0) + val transactionContext = + TransactionContext(SentryId(), SpanId(), SpanId(), parentDecision, null) + + val decision = sampler.sample(SamplingContext(transactionContext, null, sampleRand, null)) + + assertThat(decision.sampled).isEqualTo(sampled) + assertThat(decision.sampleRate).isEqualTo(0.5) + assertThat(decision.sampleRand).isEqualTo(sampleRand) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() + assertThat(parentDecision.profileSampled).isEqualTo(sampled) + assertThat(parentDecision.profileSampleRate).isEqualTo(1.0) + } + } + + @Test + fun `when both samplers throw then parent trace sampling is backfilled without profiling`() { + val sampler = + fixture.getSut( + tracesSampleRate = 0.0, + profilesSampleRate = 1.0, + tracesSamplerCallback = { throw IllegalStateException("faulty TracesSamplerCallback") }, + profilesSamplerCallback = { throw IllegalStateException("faulty ProfilesSamplerCallback") }, + ) + val parentDecision = TracesSamplingDecision(true, 0.5, true, 1.0) + val transactionContext = + TransactionContext(SentryId(), SpanId(), SpanId(), parentDecision, null) + + val decision = sampler.sample(SamplingContext(transactionContext, null, 0.9, null)) + + assertThat(decision.sampled).isTrue() + assertThat(decision.sampleRate).isEqualTo(0.5) + assertThat(decision.sampleRand).isAtLeast(0.0) + assertThat(decision.sampleRand).isLessThan(0.5) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() } @Test From aa1516454d40b256dec624b256bc14ac33a2d745 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:35:20 +0200 Subject: [PATCH 10/37] changelog Link the profilesSampler failure handling entry to #6164 in the callback error handling stack. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df28f856c2..81f3c86b49a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,7 +127,7 @@ - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged. + - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) From 49f8b9cfbafa1d6c6c67175775137e61d84ef078 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:46:16 +0200 Subject: [PATCH 11/37] fix(core): [Callback Errors 7] Handle replay sampling failures Skip replay capture when beforeErrorSampling throws instead of capturing replay after a failed user filter. Continue sending the error event and retain existing exception logging and catch types. Cover handled errors, crashes, and successful replay capture after a previous callback failure. Verify error delivery and failure logging. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 4 +- .../test/java/io/sentry/SentryClientTest.kt | 67 ++++++++++++++++--- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f3c86b49a..d316af0066a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. + - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event. - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 705f6fe3bbb..e409603f80c 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -244,9 +244,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul .getLogger() .log( SentryLevel.ERROR, - "The beforeErrorSampling callback threw an exception. Proceeding with replay capture.", + "The beforeErrorSampling callback threw an exception. Skipping replay capture.", e); - shouldCaptureReplay = true; + shouldCaptureReplay = false; } } if (shouldCaptureReplay) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 99d12e12184..dc59e767758 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3962,24 +3962,71 @@ class SentryClientTest { } @Test - fun `beforeErrorSampling throwing exception proceeds with captureReplay`() { - var called = false - fixture.sentryOptions.setReplayController( - object : ReplayController by NoOpReplayController.getInstance() { - override fun captureReplay(isTerminating: Boolean?): SentryId { - called = true - return SentryId.EMPTY_ID + fun `beforeErrorSampling throwing exception skips replay but still sends errors and crashes`() { + val replayController = mock() + whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) + fixture.sentryOptions.setReplayController(replayController) + val logger = mock() + fixture.sentryOptions.setLogger(logger) + val exception = RuntimeException("test") + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + throw exception + } + val sut = fixture.getSut() + val events = + listOf(true, false).map { handled -> + SentryEvent().apply { + exceptions = + listOf( + SentryException().apply { mechanism = Mechanism().apply { isHandled = handled } } + ) } } - ) + + events.forEach { event -> + assertThat(sut.captureEvent(event)).isEqualTo(event.eventId) + } + + verify(replayController, never()).captureReplay(anyOrNull()) + val envelopes = argumentCaptor() + verify(fixture.transport, times(2)).send(envelopes.capture(), anyOrNull()) + assertThat(envelopes.allValues.map { getEventFromData(it.items.first().data).eventId }) + .containsExactlyElementsIn(events.map { it.eventId }) + .inOrder() + verify(logger, times(2)) + .log( + SentryLevel.ERROR, + "The beforeErrorSampling callback threw an exception. Skipping replay capture.", + exception, + ) + } + + @Test + fun `beforeErrorSampling throwing exception does not prevent replay capture for subsequent events`() { + val replayController = mock() + whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) + fixture.sentryOptions.setReplayController(replayController) + var invocations = 0 fixture.sentryOptions.sessionReplay.beforeErrorSampling = SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> - throw RuntimeException("test") + invocations++ + if (invocations == 1) { + throw RuntimeException("test") + } + true } val sut = fixture.getSut() sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) - assertTrue(called) + verify(replayController, never()).captureReplay(anyOrNull()) + + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + assertThat(sut.captureEvent(event)).isEqualTo(event.eventId) + + assertThat(invocations).isEqualTo(2) + verify(replayController).captureReplay(false) + verify(fixture.transport, times(2)).send(any(), anyOrNull()) } @Test From b62c0944c212952006541394fef8f72b4213cc67 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 24 Sep 2026 16:53:53 +0200 Subject: [PATCH 12/37] changelog Link the beforeErrorSampling failure entry to PR #6165. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d316af0066a..b77d26210c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,7 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event. + - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) From efb5dfb87f8e75aebba45d8b28a4fbe482cd9806 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 05:47:32 +0200 Subject: [PATCH 13/37] fix(core): [Callback Errors 8] Report dropped profiles Record callback_error/profile when beforeSendTransaction or a customer transaction processor throws and an attached profile is dropped. Notify OnDiscardCallback through the same client-report accounting path. Cover scope and options processors, absent profiles, and SDK processor failures that retain profiles. Leave catch types, intentional drops, and profile-file cleanup unchanged. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 25 ++++-- .../SentryClientInternalEventProcessorTest.kt | 29 +++++++ .../SentryClientTransactionProfileTest.kt | 84 +++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b77d26210c3..36f2af7015b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,7 @@ - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. + - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback`. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index e409603f80c..75b2b474413 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -624,7 +624,8 @@ private SentryMetricsEvent processMetricsEvent( private @Nullable SentryTransaction processTransaction( @NotNull SentryTransaction transaction, final @NotNull Hint hint, - final @NotNull List eventProcessors) { + final @NotNull List eventProcessors, + final boolean hasProfile) { for (final EventProcessor processor : eventProcessors) { final int spanCountBeforeProcessor = transaction.getSpans().size(); try { @@ -645,6 +646,11 @@ private SentryMetricsEvent processMetricsEvent( .getClientReportRecorder() .recordLostEvent( DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeProcessor + 1); + if (hasProfile) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Profile); + } return null; } } @@ -1061,7 +1067,9 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint transaction = applyScope(transaction, scope, HintUtils.hasType(hint, Cached.class)); if (transaction != null && scope != null) { - transaction = processTransaction(transaction, hint, scope.getEventProcessors()); + transaction = + processTransaction( + transaction, hint, scope.getEventProcessors(), profilingTraceData != null); } if (transaction == null) { @@ -1070,7 +1078,9 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint } if (transaction != null) { - transaction = processTransaction(transaction, hint, options.getEventProcessors()); + transaction = + processTransaction( + transaction, hint, options.getEventProcessors(), profilingTraceData != null); } if (transaction == null) { @@ -1078,7 +1088,7 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return SentryId.EMPTY_ID; } - transaction = executeBeforeSendTransaction(transaction, hint); + transaction = executeBeforeSendTransaction(transaction, hint, profilingTraceData != null); if (transaction == null) { options @@ -1672,7 +1682,7 @@ private void sortBreadcrumbsByDate( } private @Nullable SentryTransaction executeBeforeSendTransaction( - @NotNull SentryTransaction transaction, final @NotNull Hint hint) { + @NotNull SentryTransaction transaction, final @NotNull Hint hint, final boolean hasProfile) { final SentryOptions.BeforeSendTransactionCallback beforeSendTransaction = options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { @@ -1693,6 +1703,11 @@ private void sortBreadcrumbsByDate( .getClientReportRecorder() .recordLostEvent( DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeCallback + 1); + if (hasProfile) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Profile); + } return null; } diff --git a/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt b/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt index 59aaaa0a1ae..6b7934e3685 100644 --- a/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientInternalEventProcessorTest.kt @@ -100,6 +100,35 @@ class SentryClientInternalEventProcessorTest(private val onScope: Boolean) { assertFailureLoggedWithoutLoss("transaction") } + @Test + fun `SDK transaction processor failure keeps attached profile without reporting loss`() { + val transaction = SentryTransaction(fixture.sentryTracer) + whenever(processor.process(any(), any())).thenThrow(failure) + whenever(nextProcessor.process(any(), any())).thenAnswer { it.arguments[0] } + + val id = + fixture + .getSut() + .captureTransaction( + transaction, + fixture.sentryTracer.traceContext(), + scope, + null, + fixture.profilingTraceData, + ) + + assertThat(id).isEqualTo(transaction.eventId) + verify(fixture.transport) + .send( + check { + assertThat(it.items.map { item -> item.header.type }) + .containsExactly(SentryItemType.Transaction, SentryItemType.Profile) + }, + anyOrNull(), + ) + assertFailureLoggedWithoutLoss("transaction") + } + @Test fun `SDK feedback processor failure keeps feedback and runs remaining callbacks`() { val feedback = Feedback("message") diff --git a/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt b/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt new file mode 100644 index 00000000000..dc85ca3817c --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt @@ -0,0 +1,84 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import kotlin.test.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@RunWith(Parameterized::class) +class SentryClientTransactionProfileTest( + private val callback: String, + private val hasProfile: Boolean, +) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "callback={0}, hasProfile={1}") + fun data(): List> = + listOf("scope processor", "options processor", "beforeSendTransaction").flatMap { callback -> + listOf(false, true).map { hasProfile -> arrayOf(callback, hasProfile) } + } + } + + @Test + fun `transaction callback failure reports attached profile exactly once`() { + val fixture = SentryClientTest.Fixture() + val options = fixture.sentryOptions + options.eventProcessors.clear() + val scope = Scope(options) + val onDiscard = mock() + options.onDiscard = onDiscard + val failure = IllegalStateException("callback failed") + if (callback == "beforeSendTransaction") { + options.setBeforeSendTransaction { _, _ -> throw failure } + } else { + val processor = mock() + whenever(processor.process(any(), any())).thenThrow(failure) + if (callback == "scope processor") { + scope.addEventProcessor(processor) + } else { + options.addEventProcessor(processor) + } + } + + val id = + fixture + .getSut() + .captureTransaction( + SentryTransaction(fixture.sentryTracer), + fixture.sentryTracer.traceContext(), + scope, + null, + if (hasProfile) fixture.profilingTraceData else null, + ) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(fixture.transport, never()).send(any(), anyOrNull()) + val expected = + mutableListOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 2), + ) + if (hasProfile) { + expected.add( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Profile.category, 1) + ) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Profile, 1) + } + assertClientReport(options.clientReportRecorder, expected) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 2) + verifyNoMoreInteractions(onDiscard) + } +} From 7a471df67add3421669720a49f84464630160ce3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 05:49:22 +0200 Subject: [PATCH 14/37] changelog Link the transaction profile loss accounting entry to PR #6166. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f2af7015b..414585486f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,7 +124,7 @@ - Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback`. + - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166)) - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) From 8845736834a33b83ce97884ee5b1cab9be8e2cd1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 06:45:26 +0200 Subject: [PATCH 15/37] fix(integrations): [Callback Errors 9] Guard custom callbacks Skip Android screenshot and view hierarchy capture when their callbacks throw, while retaining the error event. Drop spans on beforeSpan failures in OkHttp, OpenFeign, GraphQL, Ktor, and Apollo without disrupting requests or replacing the original request error. Preserve normal callback results and existing catch types. Finish failed spans and retain request cleanup and breadcrumbs. Add regression coverage for callback failures, partial mutations, original request errors, asynchronous GraphQL results, and subsequent Android captures. Correct callback wiring in the Ktor and screenshot test fixtures. Verify 18 regression cases fail before the fix and pass afterward; all 454 tests in the affected suites pass, along with formatting and API checks. Refs #6081 Co-Authored-By: Claude --- CHANGELOG.md | 2 + .../core/ScreenshotEventProcessor.java | 12 ++- .../core/ViewHierarchyEventProcessor.java | 12 ++- .../core/ScreenshotEventProcessorTest.kt | 33 ++++++- .../core/ViewHierarchyEventProcessorTest.kt | 27 ++++++ sentry-apollo-3/build.gradle.kts | 1 + .../apollo3/SentryApollo3HttpInterceptor.kt | 1 + .../apollo3/SentryApollo3InterceptorTest.kt | 35 +++++++- sentry-apollo-4/build.gradle.kts | 1 + .../apollo4/SentryApollo4HttpInterceptor.kt | 1 + .../SentryApollo4HttpInterceptorTest.kt | 35 +++++++- sentry-apollo/build.gradle.kts | 1 + .../sentry/apollo/SentryApolloInterceptor.kt | 1 + .../apollo/SentryApolloInterceptorTest.kt | 32 ++++++- sentry-graphql-22/build.gradle.kts | 1 + .../graphql22/SentryInstrumentationTest.kt | 86 ++++++++++++++++++- .../graphql/SentryGraphqlInstrumentation.java | 15 +++- sentry-graphql/build.gradle.kts | 1 + .../graphql/SentryInstrumentationTest.kt | 86 ++++++++++++++++++- sentry-ktor-client/build.gradle.kts | 1 + .../ktorClient/SentryKtorClientPlugin.kt | 16 +++- .../ktorClient/SentryKtorClientPluginTest.kt | 60 +++++++++++++ .../sentry/okhttp/SentryOkHttpInterceptor.kt | 13 ++- .../okhttp/SentryOkHttpInterceptorTest.kt | 74 ++++++++++++++++ sentry-openfeign/build.gradle.kts | 1 + .../sentry/openfeign/SentryFeignClient.java | 15 +++- .../sentry/openfeign/SentryFeignClientTest.kt | 61 +++++++++++++ 27 files changed, 602 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 414585486f6..607d76b6fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,8 @@ - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166)) - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. + - Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event. + - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request. - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 7783955f42c..6932b6be3f6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -112,7 +112,17 @@ private boolean isMaskingEnabled() { final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback = options.getBeforeScreenshotCaptureCallback(); if (beforeCaptureCallback != null) { - if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { + try { + if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { + return event; + } + } catch (Exception e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "The beforeScreenshotCapture callback threw an exception. Skipping screenshot capture.", + e); return event; } } else if (shouldDebounce) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 77e3ad917ea..00753ee4c90 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -91,7 +91,17 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback = options.getBeforeViewHierarchyCaptureCallback(); if (beforeCaptureCallback != null) { - if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { + try { + if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { + return event; + } + } catch (Exception e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "The beforeViewHierarchyCapture callback threw an exception. Skipping view hierarchy capture.", + e); return event; } } else if (shouldDebounce) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index b8e223f08e9..635d7a308c5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -29,11 +29,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Attachment import io.sentry.Hint +import io.sentry.ILogger import io.sentry.MainEventProcessor import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel import io.sentry.TypeCheckHint.ANDROID_ACTIVITY import io.sentry.protocol.SentryException import io.sentry.util.thread.IThreadChecker @@ -48,6 +51,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity import org.robolectric.Shadows.shadowOf @@ -310,11 +314,38 @@ class ScreenshotEventProcessorTest { assertNull(hint.screenshot) } + @Test + fun `when capture callback throws, skips screenshot and retains event`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + val logger = mock() + fixture.options.isDebug = true + fixture.options.setLogger(logger) + val failure = IllegalStateException("callback failed") + fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> throw failure } + val processor = fixture.getSut(true) + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + val hint = Hint() + + assertThat(processor.process(event, hint)).isSameInstanceAs(event) + assertThat(hint.screenshot).isNull() + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeScreenshotCapture callback threw an exception. Skipping screenshot capture.", + failure, + ) + + fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> true } + val nextHint = Hint() + assertThat(processor.process(event, nextHint)).isSameInstanceAs(event) + assertThat(nextHint.screenshot).isNotNull() + } + @Test fun `when capture callback returns true, a screenshot should be captured`() { CurrentActivityHolder.getInstance().setActivity(fixture.activity) - fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true } + fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> true } val processor = fixture.getSut(true) val event = SentryEvent().apply { exceptions = listOf(SentryException()) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt index 4d908fcac1c..6f19035a8d9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt @@ -5,11 +5,13 @@ import android.view.View import android.view.ViewGroup import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Hint import io.sentry.JsonSerializable import io.sentry.JsonSerializer import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel import io.sentry.TypeCheckHint import io.sentry.protocol.SentryException import io.sentry.util.thread.IThreadChecker @@ -342,6 +344,31 @@ class ViewHierarchyEventProcessorTest { assertNull(hint.viewHierarchy) } + @Test + fun `when capture callback throws, skips view hierarchy and retains event`() { + fixture.options.isDebug = true + fixture.options.setLogger(fixture.logger) + val failure = IllegalStateException("callback failed") + fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> throw failure } + val processor = fixture.getSut(true) + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + val hint = Hint() + + assertThat(processor.process(event, hint)).isSameInstanceAs(event) + assertThat(hint.viewHierarchy).isNull() + verify(fixture.logger) + .log( + SentryLevel.ERROR, + "The beforeViewHierarchyCapture callback threw an exception. Skipping view hierarchy capture.", + failure, + ) + + fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true } + val nextHint = Hint() + assertThat(processor.process(event, nextHint)).isSameInstanceAs(event) + assertThat(nextHint.viewHierarchy).isNotNull() + } + @Test fun `when capture callback returns true, a view hierarchy should be captured`() { fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true } diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 70f43d946ef..d143e30c508 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.apollo3.kotlin) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 94ba52cb592..c276f56db9c 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -223,6 +223,7 @@ constructor( span.spanContext.sampled = false } } catch (e: Throwable) { + span.spanContext.sampled = false scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 8316f6c0f33..4c43f320e04 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -7,12 +7,15 @@ import com.apollographql.apollo3.exception.ApolloException import com.apollographql.apollo3.exception.ApolloHttpException import com.apollographql.apollo3.network.http.HttpInterceptor import com.apollographql.apollo3.network.http.HttpInterceptorChain +import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SentryTraceHeader @@ -303,16 +306,42 @@ class SentryApollo3InterceptorTest { } @Test - fun `when customizer throws, exception is handled`() { - executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> throw RuntimeException() })) + fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { + val failure = IllegalStateException("callback failed") + val logger = mock() + fixture.options.isDebug = true + fixture.options.setLogger(logger) + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + val response = sut.query(LaunchDetailsQuery("83")).execute() + assertThat(response.data).isNotNull() + val span = tx.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + tx.finish() verify(fixture.scopes) .captureTransaction( - check { assertEquals(1, it.spans.size) }, + check { assertThat(it.spans).isEmpty() }, anyOrNull(), anyOrNull(), anyOrNull(), ) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "An error occurred while executing beforeSpan on ApolloInterceptor", + failure, + ) } @Test diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 4f1276f0bf4..304f34fb74d 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.apollo4.kotlin) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.kotlin) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 54ad1d50fdb..a75743d4565 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -222,6 +222,7 @@ constructor( span.spanContext.sampled = false } } catch (e: Throwable) { + span.spanContext.sampled = false scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan in ApolloInterceptor", diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index d92cefe9772..e1d6a61a07a 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -10,12 +10,15 @@ import com.apollographql.apollo.exception.ApolloException import com.apollographql.apollo.exception.ApolloHttpException import com.apollographql.apollo.network.http.HttpInterceptor import com.apollographql.apollo.network.http.HttpInterceptorChain +import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SentryTraceHeader @@ -317,16 +320,42 @@ abstract class SentryApollo4HttpInterceptorTest( } @Test - fun `when customizer throws, exception is handled`() { - executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> throw RuntimeException() })) + fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { + val failure = IllegalStateException("callback failed") + val logger = mock() + fixture.options.isDebug = true + fixture.options.setLogger(logger) + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + val response = executeQueryImplementation(sut.query(LaunchDetailsQuery("83"))) + assertThat(response.data).isNotNull() + val span = tx.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + tx.finish() verify(fixture.scopes) .captureTransaction( - check { assertEquals(1, it.spans.size) }, + check { assertThat(it.spans).isEmpty() }, anyOrNull(), anyOrNull(), anyOrNull(), ) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "An error occurred while executing beforeSpan in ApolloInterceptor", + failure, + ) } @Test diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 2da8d8b20c1..534a5d92e70 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(libs.apollo2.coroutines) testImplementation(libs.apollo2.runtime) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.kotlinx.coroutines) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index cb7df6472dd..52ac48f9e50 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -178,6 +178,7 @@ class SentryApolloInterceptor( try { newSpan = beforeSpan.execute(span, request, response) } catch (e: Exception) { + span.spanContext.sampled = false scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index d43fe40c9e4..3e5986ef8a0 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -3,12 +3,15 @@ package io.sentry.apollo import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.coroutines.await import com.apollographql.apollo.exception.ApolloException +import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -241,16 +244,39 @@ class SentryApolloInterceptorTest { } @Test - fun `when customizer throws, exception is handled`() { - executeQuery(fixture.getSut { _, _, _ -> throw RuntimeException() }) + fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { + val failure = IllegalStateException("callback failed") + val logger = mock() + fixture.options.isDebug = true + fixture.options.setLogger(logger) + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val sut = fixture.getSut { span, _, _ -> + span.description = "partially modified" + throw failure + } + val response = sut.query(LaunchDetailsQuery.builder().id("83").build()).await() + assertThat(response.data).isNotNull() + val span = tx.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + tx.finish() verify(fixture.scopes) .captureTransaction( - check { assertEquals(1, it.spans.size) }, + check { assertThat(it.spans).isEmpty() }, anyOrNull(), anyOrNull(), anyOrNull(), ) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "An error occurred while executing beforeSpan on ApolloInterceptor", + failure, + ) } @Test diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index 32db28fae8f..def6708f672 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.graphql.java22) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt index c684688299e..16dbdd685c3 100644 --- a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -1,5 +1,6 @@ package io.sentry.graphql22 +import com.google.common.truth.Truth.assertThat import graphql.GraphQL import graphql.GraphQLContext import graphql.execution.ExecutionContextBuilder @@ -18,8 +19,10 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Sentry +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus @@ -29,6 +32,7 @@ import io.sentry.graphql.NoOpSubscriptionHandler import io.sentry.graphql.SentryGraphqlInstrumentation import io.sentry.graphql.SentrySubscriptionHandler import java.lang.RuntimeException +import java.util.concurrent.CompletableFuture import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals @@ -38,6 +42,7 @@ import kotlin.test.assertTrue import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class SentryInstrumentationTest { @@ -48,6 +53,7 @@ class SentryInstrumentationTest { fun getSut( isTransactionActive: Boolean = true, dataFetcherThrows: Boolean = false, + async: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null, ): GraphQL { whenever(scopes.options).thenReturn(SentryOptions()) @@ -66,7 +72,10 @@ class SentryInstrumentationTest { val graphQLSchema = SchemaGenerator() - .makeExecutableSchema(SchemaParser().parse(schema), buildRuntimeWiring(dataFetcherThrows)) + .makeExecutableSchema( + SchemaParser().parse(schema), + buildRuntimeWiring(dataFetcherThrows, async), + ) val graphQL = GraphQL.newGraphQL(graphQLSchema) .instrumentation( @@ -83,14 +92,15 @@ class SentryInstrumentationTest { return graphQL } - private fun buildRuntimeWiring(dataFetcherThrows: Boolean) = + private fun buildRuntimeWiring(dataFetcherThrows: Boolean, async: Boolean) = RuntimeWiring.newRuntimeWiring() .type("Query") { it.dataFetcher("shows") { if (dataFetcherThrows) { throw RuntimeException("error") } else { - listOf(Show(Random.nextInt()), Show(Random.nextInt())) + val shows = listOf(Show(Random.nextInt()), Show(Random.nextInt())) + if (async) CompletableFuture.completedFuture(shows) else shows } } } @@ -165,6 +175,76 @@ class SentryInstrumentationTest { } } + @Test + fun `when beforeSpan throws, drops span and preserves result`() { + val failure = IllegalStateException("callback failed") + val logger = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + fixture.scopes.options.isDebug = true + fixture.scopes.options.setLogger(logger) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).isEmpty() + assertThat(result.getData>()).containsKey("shows") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryGraphqlInstrumentation. Dropping span.", + failure, + ) + } + } + + @Test + fun `when beforeSpan throws, drops async span and preserves result`() { + val sut = + fixture.getSut( + async = true, + beforeSpan = { _, _, _ -> + throw IllegalStateException("callback failed") + }, + ) + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).isEmpty() + assertThat(result.getData>()).containsKey("shows") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + } + } + + @Test + fun `when beforeSpan throws, preserves data fetcher error`() { + val sut = + fixture.getSut( + dataFetcherThrows = true, + beforeSpan = { _, _, _ -> + throw IllegalStateException("callback failed") + }, + ) + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).hasSize(1) + assertThat(result.errors.single().message).contains("error") + assertThat(result.errors.single().message).doesNotContain("callback failed") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + } + @Test fun `beforeSpan can modify spans`() { val sut = diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java index c316774c045..5e71b14647b 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -22,6 +22,7 @@ import io.sentry.ISpan; import io.sentry.NoOpScopes; import io.sentry.Sentry; +import io.sentry.SentryLevel; import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.TypeCheckHint; @@ -283,7 +284,19 @@ private void finish( final @NotNull DataFetchingEnvironment environment, final @Nullable Object result) { if (beforeSpan != null) { - final ISpan newSpan = beforeSpan.execute(span, environment, result); + ISpan newSpan = span; + try { + newSpan = beforeSpan.execute(span, environment, result); + } catch (Exception e) { + span.getSpanContext().setSampled(false); + scopesFromContext(environment.getGraphQlContext()) + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryGraphqlInstrumentation. Dropping span.", + e); + } if (newSpan == null) { // span is dropped span.getSpanContext().setSampled(false); diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index d92dc52c6d7..f996e0310a1 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.graphql.java17) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.okhttp) diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 972b091a226..bc76286abe4 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -1,5 +1,6 @@ package io.sentry.graphql +import com.google.common.truth.Truth.assertThat import graphql.GraphQL import graphql.GraphQLContext import graphql.execution.ExecutionContextBuilder @@ -18,13 +19,16 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Sentry +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus import io.sentry.TransactionContext import java.lang.RuntimeException +import java.util.concurrent.CompletableFuture import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals @@ -34,6 +38,7 @@ import kotlin.test.assertTrue import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class SentryInstrumentationTest { @@ -44,6 +49,7 @@ class SentryInstrumentationTest { fun getSut( isTransactionActive: Boolean = true, dataFetcherThrows: Boolean = false, + async: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null, ): GraphQL { whenever(scopes.options).thenReturn(SentryOptions()) @@ -62,7 +68,10 @@ class SentryInstrumentationTest { val graphQLSchema = SchemaGenerator() - .makeExecutableSchema(SchemaParser().parse(schema), buildRuntimeWiring(dataFetcherThrows)) + .makeExecutableSchema( + SchemaParser().parse(schema), + buildRuntimeWiring(dataFetcherThrows, async), + ) val graphQL = GraphQL.newGraphQL(graphQLSchema) .instrumentation( @@ -79,14 +88,15 @@ class SentryInstrumentationTest { return graphQL } - private fun buildRuntimeWiring(dataFetcherThrows: Boolean) = + private fun buildRuntimeWiring(dataFetcherThrows: Boolean, async: Boolean) = RuntimeWiring.newRuntimeWiring() .type("Query") { it.dataFetcher("shows") { if (dataFetcherThrows) { throw RuntimeException("error") } else { - listOf(Show(Random.nextInt()), Show(Random.nextInt())) + val shows = listOf(Show(Random.nextInt()), Show(Random.nextInt())) + if (async) CompletableFuture.completedFuture(shows) else shows } } } @@ -161,6 +171,76 @@ class SentryInstrumentationTest { } } + @Test + fun `when beforeSpan throws, drops span and preserves result`() { + val failure = IllegalStateException("callback failed") + val logger = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + fixture.scopes.options.isDebug = true + fixture.scopes.options.setLogger(logger) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).isEmpty() + assertThat(result.getData>()).containsKey("shows") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryGraphqlInstrumentation. Dropping span.", + failure, + ) + } + } + + @Test + fun `when beforeSpan throws, drops async span and preserves result`() { + val sut = + fixture.getSut( + async = true, + beforeSpan = { _, _, _ -> + throw IllegalStateException("callback failed") + }, + ) + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).isEmpty() + assertThat(result.getData>()).containsKey("shows") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + } + } + + @Test + fun `when beforeSpan throws, preserves data fetcher error`() { + val sut = + fixture.getSut( + dataFetcherThrows = true, + beforeSpan = { _, _, _ -> + throw IllegalStateException("callback failed") + }, + ) + withMockScopes { + val result = sut.execute("{ shows { id } }") + assertThat(result.errors).hasSize(1) + assertThat(result.errors.single().message).contains("error") + assertThat(result.errors.single().message).doesNotContain("callback failed") + val span = fixture.activeSpan.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + } + @Test fun `beforeSpan can modify spans`() { val sut = diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index fefcdbfebaf..ecd48c5f1df 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(libs.ktor.client.core) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index 95cdfb5fdae..80e9c3e0ddc 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -16,6 +16,7 @@ import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.kotlin.SentryContext @@ -186,7 +187,20 @@ public val SentryKtorClientPlugin: ClientPlugin = var result: ISpan? = span if (beforeSpan != null) { - result = beforeSpan.execute(span, request) + result = + try { + beforeSpan.execute(span, request) + } catch (e: Exception) { + (if (forceScopes) scopes else Sentry.getCurrentScopes()) + .options + .logger + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryKtorClientPlugin. Dropping span.", + e, + ) + null + } } if (result == null) { diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 38ffe609b2c..5cf1257019f 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -1,17 +1,20 @@ package io.sentry.ktorClient +import com.google.common.truth.Truth.assertThat import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.java.Java import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange +import io.sentry.ILogger import io.sentry.IScope import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior @@ -19,6 +22,7 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry import io.sentry.SentryEvent +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -112,6 +116,7 @@ class SentryKtorClientPluginTest { return HttpClient(httpClientEngine) { install(SentryKtorClientPlugin) { this.scopes = this@Fixture.scopes + this.beforeSpan = beforeSpan this.captureFailedRequests = captureFailedRequests this.failedRequestTargets = failedRequestTargets this.failedRequestStatusCodes = failedRequestStatusCodes @@ -434,6 +439,61 @@ class SentryKtorClientPluginTest { ) } + @Test + fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { + val failure = IllegalStateException("callback failed") + val logger = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _ -> + span.description = "partially modified" + throw failure + } + ) + fixture.options.isDebug = true + fixture.options.setLogger(logger) + sut.use { + val response = sut.get(fixture.server.url("/hello").toString()) + assertThat(response.status.value).isEqualTo(201) + assertThat(response.bodyAsText()).isEqualTo("success") + } + val span = fixture.sentryTracer.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + assertThat(span.status).isEqualTo(SpanStatus.OK) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryKtorClientPlugin. Dropping span.", + failure, + ) + } + + @Test + fun `beforeSpan can drop span`(): Unit = runBlocking { + val sut = fixture.getSut(beforeSpan = { _, _ -> null }) + sut.use { sut.get(fixture.server.url("/hello").toString()) } + val span = fixture.sentryTracer.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + } + + @Test + fun `beforeSpan can modify span`(): Unit = runBlocking { + val sut = + fixture.getSut( + beforeSpan = { span, _ -> + span.description = "changed" + span + } + ) + sut.use { sut.get(fixture.server.url("/hello").toString()) } + val span = fixture.sentryTracer.children.single() + assertThat(span.description).isEqualTo("changed") + assertThat(span.isFinished).isTrue() + } + @Test fun `creates a span around the request`(): Unit = runBlocking { val sut = fixture.getSut() diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index ed704966610..1adc59386aa 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -9,6 +9,7 @@ import io.sentry.IScopes import io.sentry.ISpan import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SentryReplayOptions import io.sentry.SpanDataConvention @@ -364,7 +365,17 @@ public open class SentryOkHttpInterceptor( return } if (beforeSpan != null) { - val result = beforeSpan.execute(span, request, response) + val result = + try { + beforeSpan.execute(span, request, response) + } catch (e: Exception) { + scopes.options.logger.log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryOkHttpInterceptor. Dropping span.", + e, + ) + null + } if (result == null) { // span is dropped span.spanContext.sampled = false diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 750406f3d22..0499b5273dc 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -2,16 +2,19 @@ package io.sentry.okhttp +import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange +import io.sentry.ILogger import io.sentry.IScope import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -28,6 +31,7 @@ import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -434,6 +438,76 @@ class SentryOkHttpInterceptorTest { assertNotNull(httpClientSpan.spanContext.sampled) { assertFalse(it) } } + @Test + fun `when beforeSpan throws, drops span and preserves response`() { + val failure = IllegalStateException("callback failed") + val logger = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + fixture.options.isDebug = true + fixture.options.setLogger(logger) + + sut.newCall(getRequest()).execute().use { response -> + assertThat(response.code).isEqualTo(201) + assertThat(response.body!!.string()).isEqualTo("success") + } + val span = fixture.sentryTracer.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryOkHttpInterceptor. Dropping span.", + failure, + ) + } + + @Test + fun `when beforeSpan throws, event listener still finishes span`() { + val sut = + fixture.getSut( + beforeSpan = { _, _, _ -> throw IllegalStateException("callback failed") }, + eventListener = SentryOkHttpEventListener(fixture.scopes), + ) + val call = sut.newCall(getRequest()) + call.execute().use { response -> + assertThat(response.code).isEqualTo(201) + assertThat(SentryOkHttpEventListener.eventMap[call]!!.isEventFinished.get()).isTrue() + } + val span = fixture.sentryTracer.children.first { it.operation == "http.client" } + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + assertThat(SentryOkHttpEventListener.eventMap).doesNotContainKey(call) + } + + @Test + fun `when beforeSpan throws, preserves original request exception`() { + val requestFailure = IOException("request failed") + val sut = + fixture + .getSut( + beforeSpan = { _, _, _ -> + throw IllegalStateException("callback failed") + } + ) + .newBuilder() + .addInterceptor { throw requestFailure } + .build() + + val thrown = assertFailsWith { sut.newCall(getRequest()).execute() } + assertThat(thrown).isSameInstanceAs(requestFailure) + val span = fixture.sentryTracer.children.single() + assertThat(span.throwable).isSameInstanceAs(requestFailure) + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + } + @Test fun `captures failed requests by default`() { val sut = fixture.getSut(httpStatusCode = 500, captureFailedRequests = null) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 3baa85dee26..8b7adc97544 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { testImplementation(libs.awaitility.kotlin) testImplementation(libs.feign.core) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.okhttp.mockwebserver) } diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index 520828c0a75..4700884c87d 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -14,6 +14,7 @@ import io.sentry.IScopes; import io.sentry.ISpan; import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; import io.sentry.SpanDataConvention; import io.sentry.SpanOptions; import io.sentry.SpanStatus; @@ -95,7 +96,19 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O throw e; } finally { if (beforeSpan != null) { - final ISpan result = beforeSpan.execute(span, request, response); + ISpan result = span; + try { + result = beforeSpan.execute(span, request, response); + } catch (Exception e) { + span.getSpanContext().setSampled(false); + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryFeignClient. Dropping span.", + e); + } if (result == null) { // span is dropped diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index c25a81f9501..d76b0276351 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -1,15 +1,19 @@ package io.sentry.openfeign +import com.google.common.truth.Truth.assertThat import feign.Client import feign.Feign import feign.FeignException import feign.HeaderMap +import feign.Request import feign.RequestLine import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -18,10 +22,12 @@ import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader import io.sentry.mockServerRequestTimeoutMillis +import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -284,6 +290,61 @@ class SentryFeignClientTest { assertTrue(httpClientSpan.throwable is Exception) } + @Test + fun `when beforeSpan throws, drops span and preserves response`() { + val failure = IllegalStateException("callback failed") + val logger = mock() + fixture.sentryOptions.isDebug = true + fixture.sentryOptions.setLogger(logger) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + + assertThat(sut.getOk()).isEqualTo("success") + val span = fixture.sentryTracer.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + verify(logger) + .log( + SentryLevel.ERROR, + "The beforeSpan callback threw an exception in SentryFeignClient. Dropping span.", + failure, + ) + } + + @Test + fun `when beforeSpan throws, preserves original request exception`() { + val requestFailure = IOException("request failed") + val delegate = mock() + whenever(delegate.execute(any(), any())).thenThrow(requestFailure) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + val sut = + SentryFeignClient(delegate, fixture.scopes) { _, _, _ -> + throw IllegalStateException("callback failed") + } + val request = + Request.create( + Request.HttpMethod.GET, + "https://example.com", + emptyMap>(), + null as ByteArray?, + null, + ) + + val thrown = assertFailsWith { sut.execute(request, Request.Options()) } + assertThat(thrown).isSameInstanceAs(requestFailure) + val span = fixture.sentryTracer.children.single() + assertThat(span.throwable).isSameInstanceAs(requestFailure) + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + } + @Test fun `customizer modifies span`() { val sut = fixture.getSut { span, _, _ -> From 44ed7c6bcf38e335a362ec011df496b5a13eddda Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 07:00:42 +0200 Subject: [PATCH 16/37] changelog Link the Android capture and integration beforeSpan callback fixes to Callback Errors 9 (#6167). Co-Authored-By: Claude --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 607d76b6fd8..6ec43fa46f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,8 +127,8 @@ - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166)) - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event. - - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request. + - Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) + - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) From fcbf95f0dd5708937bde0e18f77b9e4b2eb6bd64 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 10:46:32 +0200 Subject: [PATCH 17/37] fix(core): Drop transactions when tracesSampler fails Replace parent-decision fallback after tracesSampler errors with an unsampled decision and callback_error reports for the transaction and root span. Preserve deliberate null-result fallback and existing catches. Report callback errors directly in the sampler and retain existing sample_rate and backpressure accounting, accepting duplicate loss reports instead of adding discard-reason propagation. Cover core and OpenTelemetry paths with regression tests and update the changelog. Refs #6163 Co-Authored-By: Claude --- CHANGELOG.md | 2 +- .../src/test/kotlin/SentrySamplerTest.kt | 176 ++++++++++++++++++ .../main/java/io/sentry/TracesSampler.java | 15 +- sentry/src/test/java/io/sentry/ScopesTest.kt | 115 ++++++++++++ .../test/java/io/sentry/TracesSamplerTest.kt | 64 ++++++- 5 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySamplerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f7b506447..79616655acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,7 +126,7 @@ - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - When `tracesSampler` throws, inherit the parent sampling decision or leave the trace unsampled if there is no parent decision, instead of falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) + - When `tracesSampler` throws, drop the transaction and record `callback_error` instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySamplerTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySamplerTest.kt new file mode 100644 index 00000000000..8a8ec0c8e0d --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySamplerTest.kt @@ -0,0 +1,176 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.samplers.Sampler +import io.opentelemetry.sdk.trace.samplers.SamplingDecision +import io.sentry.DataCategory +import io.sentry.IScopes +import io.sentry.SamplingContext +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SpanId +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.clientreport.DiscardReason +import io.sentry.protocol.SentryId +import kotlin.test.AfterTest +import kotlin.test.Test +import org.mockito.AdditionalAnswers.delegatesTo +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class SentrySamplerTest { + private val onDiscard = mock() + private val options = + SentryOptions().apply { + tracesSampleRate = 1.0 + profilesSampleRate = 1.0 + tracesSampler = SentryOptions.TracesSamplerCallback { throw IllegalStateException("sampler") } + this.onDiscard = this@SentrySamplerTest.onDiscard + } + private val scopes = mock().also { whenever(it.options).thenReturn(options) } + private val sampler = SentrySampler(scopes) + + @AfterTest + fun tearDown() { + SentryWeakSpanStorage.getInstance().clear() + } + + @Test + fun `throwing tracesSampler drops root and reports callback errors alongside sample rate losses`() { + for (parentSampled in listOf(null, false, true)) { + val traceId = SentryId() + val context = + if (parentSampled == null) Context.root() + else + Context.root() + .with( + SentryOtelKeys.SENTRY_TRACE_KEY, + SentryTraceHeader(traceId, SpanId(), parentSampled), + ) + val result = + sampler.shouldSample( + context, + traceId.toString(), + "root", + SpanKind.INTERNAL, + Attributes.empty(), + emptyList(), + ) as SentrySamplingResult + + assertThat(result.decision).isEqualTo(SamplingDecision.RECORD_ONLY) + assertThat(result.sentryDecision.sampled).isFalse() + assertThat(result.sentryDecision.profileSampled).isFalse() + } + + verify(onDiscard, times(3)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard, times(3)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + verify(onDiscard, times(3)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1) + verify(onDiscard, times(3)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + } + + @Test + fun `children of failed sampling decisions retain sample rate accounting`() { + val rootResult = + sampler.shouldSample( + Context.root(), + SentryId().toString(), + "root", + SpanKind.INTERNAL, + Attributes.empty(), + emptyList(), + ) as SentrySamplingResult + val restored = OtelSamplingUtil.extractSamplingDecision(rootResult.attributes)!! + assertThat(restored.sampled).isFalse() + assertThat(restored.sampleRand).isEqualTo(rootResult.sentryDecision.sampleRand) + + val parentContext = + io.opentelemetry.api.trace.SpanContext.create( + SentryId().toString(), + SpanId().toString(), + TraceFlags.getDefault(), + TraceState.getDefault(), + ) + val parent = + mock().also { + whenever(it.samplingDecision).thenReturn(restored) + } + SentryWeakSpanStorage.getInstance().storeSentrySpan(parentContext, parent) + val childResult = + sampler.shouldSample( + Span.wrap(parentContext).storeInContext(Context.root()), + parentContext.traceId, + "child", + SpanKind.INTERNAL, + Attributes.empty(), + emptyList(), + ) as SentrySamplingResult + + assertThat(childResult.decision).isEqualTo(SamplingDecision.RECORD_ONLY) + assertThat(childResult.sentryDecision.sampled).isFalse() + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1) + verify(onDiscard, times(2)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + } + + @Test + fun `Sentry API sampling failure reports before forwarding through span factory`() { + val context = TransactionContext("root", "op") + context.samplingDecision = + options.internalTracesSampler.sample(SamplingContext(context, null, 0.0, null)) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + val recordingSampler = mock(defaultAnswer = delegatesTo(sampler)) + SdkTracerProvider.builder().setSampler(recordingSampler).build().use { provider -> + val openTelemetry = + mock().also { + whenever(it.tracerProvider).thenReturn(provider) + } + OtelSpanFactory(openTelemetry).createTransaction(context, scopes, TransactionOptions(), null) + val attributes = argumentCaptor() + verify(recordingSampler).shouldSample(any(), any(), any(), any(), attributes.capture(), any()) + val restored = OtelSamplingUtil.extractSamplingDecision(attributes.firstValue)!! + assertThat(restored.sampled).isFalse() + assertThat(restored.profileSampled).isFalse() + } + + verifyNoMoreInteractions(onDiscard) + } + + @Test + fun `null tracesSampler result uses normal sample rate accounting`() { + options.tracesSampler = SentryOptions.TracesSamplerCallback { null } + options.tracesSampleRate = 0.0 + val result = + sampler.shouldSample( + Context.root(), + SentryId().toString(), + "root", + SpanKind.INTERNAL, + Attributes.empty(), + emptyList(), + ) as SentrySamplingResult + + assertThat(result.sentryDecision.sampled).isFalse() + verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + } +} diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index e981bb6f66f..729de9bf185 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.clientreport.DiscardReason; import io.sentry.util.Objects; import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; @@ -39,16 +40,21 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont } Boolean profilesSampled = profilesSampleRate != null && sample(profilesSampleRate, sampleRand); - boolean tracesSamplerFailed = false; if (options.getTracesSampler() != null) { - Double samplerResult = null; + final Double samplerResult; try { samplerResult = options.getTracesSampler().sample(samplingContext); } catch (Throwable t) { - tracesSamplerFailed = true; options .getLogger() .log(SentryLevel.ERROR, "Error in the 'TracesSamplerCallback' callback.", t); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Span); + return new TracesSamplingDecision(false, null, sampleRand, false, null); } if (samplerResult != null) { return new TracesSamplingDecision( @@ -66,8 +72,7 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont return SampleRateUtils.backfilledSampleRand(parentSamplingDecision); } - final @Nullable Double tracesSampleRateFromOptions = - tracesSamplerFailed ? null : options.getTracesSampleRate(); + final @Nullable Double tracesSampleRateFromOptions = options.getTracesSampleRate(); final @NotNull Double downsampleFactor = Math.pow(2, options.getBackpressureMonitor().getDownsampleFactor()); final @Nullable Double downsampledTracesSampleRate = diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index e6db21bc791..50a05025ea2 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1660,6 +1660,121 @@ class ScopesTest { ) } + @Test + fun `tracesSampler failures report callback errors and retain normal sampling loss accounting`() { + for (parentSampled in listOf(null, false, true)) { + for (downsampleFactor in listOf(0, 1)) { + val onDiscard = mock() + val profiler = mock() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + profilesSampleRate = 1.0 + tracesSampler = SentryOptions.TracesSamplerCallback { + throw IllegalStateException("sampler") + } + this.onDiscard = onDiscard + setTransactionProfiler(profiler) + backpressureMonitor = + mock().also { + whenever(it.downsampleFactor).thenReturn(downsampleFactor) + } + } + val scopes = createScopes(options) + val client = createSentryClientMock() + scopes.bindClient(client) + val context = + TransactionContext("name", "op").apply { + setParentSampled(parentSampled, true) + } + + val transaction = scopes.startTransaction(context) + assertThat(transaction.isSampled).isFalse() + assertThat(transaction.isProfileSampled).isFalse() + transaction.startChild("child").finish() + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1) + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + transaction.finish() + transaction.finish() + + verify(client, never()) + .captureTransaction(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + verify(profiler, never()).start() + val samplingReason = + if (downsampleFactor > 0) DiscardReason.BACKPRESSURE else DiscardReason.SAMPLE_RATE + verify(onDiscard).execute(samplingReason, DataCategory.Transaction, 1) + verify(onDiscard).execute(samplingReason, DataCategory.Span, 1) + verifyNoMoreInteractions(onDiscard) + assertClientReport( + options.clientReportRecorder, + listOf( + DiscardedEvent( + DiscardReason.CALLBACK_ERROR.reason, + DataCategory.Transaction.category, + 1, + ), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 1), + DiscardedEvent(samplingReason.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(samplingReason.reason, DataCategory.Span.category, 1), + ), + ) + } + } + } + + @Test + fun `tracesSampler failure does not affect subsequent successful sampling`() { + var fail = true + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampler = SentryOptions.TracesSamplerCallback { + if (fail) throw IllegalStateException("sampler") else 1.0 + } + } + val scopes = createScopes(options) + val client = createSentryClientMock() + scopes.bindClient(client) + scopes.startTransaction("failed", "op").finish() + fail = false + val transaction = scopes.startTransaction("successful", "op") + transaction.finish() + + assertThat(transaction.isSampled).isTrue() + verify(client).captureTransaction(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + assertClientReport( + options.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 1), + DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Span.category, 1), + ), + ) + } + + @Test + fun `null tracesSampler results still use normal sampling loss accounting`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 0.0 + tracesSampler = SentryOptions.TracesSamplerCallback { null } + } + val scopes = createScopes(options) + scopes.startTransaction("name", "op").finish() + + assertClientReport( + options.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Span.category, 1), + ), + ) + } + @Test fun `transactions lost due to sampling caused by backpressure are recorded as lost`() { val options = SentryOptions() diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index bb257a9c46e..9b3c60cd996 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -1,6 +1,9 @@ package io.sentry import com.google.common.truth.Truth.assertThat +import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport +import io.sentry.clientreport.DiscardReason +import io.sentry.clientreport.DiscardedEvent import io.sentry.protocol.SentryId import io.sentry.util.SentryRandom import kotlin.test.Test @@ -404,7 +407,7 @@ class TracesSamplerTest { } @Test - fun `when tracesSampler throws then a sampled parent decision is inherited`() { + fun `when tracesSampler throws then a sampled parent decision is ignored`() { val sampler = fixture.getSut( tracesSampleRate = 0.0, @@ -416,11 +419,17 @@ class TracesSamplerTest { val decision = sampler.sample(SamplingContext(transactionContext, null, 0.1, null)) - assertThat(decision).isSameInstanceAs(parentDecision) + assertThat(decision.sampled).isFalse() + assertThat(decision.sampleRate).isNull() + assertThat(decision.sampleRand).isEqualTo(0.1) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() + assertThat(parentDecision.sampled).isTrue() + assertThat(parentDecision.profileSampled).isTrue() } @Test - fun `when tracesSampler throws then an unsampled parent decision is inherited`() { + fun `when tracesSampler throws then an unsampled parent decision is ignored`() { val sampler = fixture.getSut( tracesSampleRate = 1.0, @@ -433,11 +442,15 @@ class TracesSamplerTest { val decision = sampler.sample(SamplingContext(transactionContext, null, 0.9, null)) - assertThat(decision).isSameInstanceAs(parentDecision) + assertThat(decision.sampled).isFalse() + assertThat(decision.sampleRate).isNull() + assertThat(decision.sampleRand).isEqualTo(0.9) + assertThat(decision.profileSampled).isFalse() + assertThat(decision.profileSampleRate).isNull() } @Test - fun `when tracesSampler throws then a parent decision without sampleRand is backfilled`() { + fun `when tracesSampler throws then a parent decision without sampleRand is ignored`() { val sampler = fixture.getSut( tracesSampleRate = 0.0, @@ -448,9 +461,46 @@ class TracesSamplerTest { val decision = sampler.sample(SamplingContext(transactionContext, null, 0.1, null)) - assertThat(decision.sampled).isTrue() + assertThat(decision.sampled).isFalse() assertThat(decision.sampleRate).isNull() - assertThat(decision.sampleRand).isNotNull() + assertThat(decision.sampleRand).isEqualTo(0.1) + } + + @Test + fun `tracesSampler failure reports callback errors immediately`() { + val options = + SentryOptions().apply { + tracesSampler = SentryOptions.TracesSamplerCallback { + throw IllegalStateException("sampler") + } + } + val decision = + TracesSampler(options) + .sample(SamplingContext(TransactionContext("name", "op"), null, 0.0, null)) + + assertThat(decision.sampled).isFalse() + assertClientReport( + options.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 1), + ), + ) + } + + @Test + fun `explicit sampling decisions bypass a throwing tracesSampler without reporting losses`() { + val options = + SentryOptions().apply { + tracesSampler = SentryOptions.TracesSamplerCallback { + throw IllegalStateException("sampler") + } + } + val context = TransactionContext("name", "op", TracesSamplingDecision(true)) + val decision = TracesSampler(options).sample(SamplingContext(context, null, 0.1, null)) + + assertThat(decision.sampled).isTrue() + assertClientReport(options.clientReportRecorder, emptyList()) } @Test From 2a38eeb86b23409d6d8ae20d795393be06e7428f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 25 Sep 2026 12:21:22 +0200 Subject: [PATCH 18/37] fix(integrations): Report beforeSpan callback losses Record callback_error/span when a throwing beforeSpan callback drops a sampled span in OkHttp, OpenFeign, GraphQL, Ktor, and Apollo. Use the pre-callback sampling decision so partial callback mutations do not hide losses, and leave unsampled spans and intentional null drops uncounted. Add regression coverage for discard notifications, sampling states, and intentional drops while preserving request and span completion behavior. Refs #6167 Co-Authored-By: Claude --- CHANGELOG.md | 2 +- .../apollo3/SentryApollo3HttpInterceptor.kt | 9 +++++ .../apollo3/SentryApollo3InterceptorTest.kt | 32 +++++++++++++++++ .../apollo4/SentryApollo4HttpInterceptor.kt | 9 +++++ .../SentryApollo4HttpInterceptorTest.kt | 32 +++++++++++++++++ .../sentry/apollo/SentryApolloInterceptor.kt | 9 +++++ .../apollo/SentryApolloInterceptorTest.kt | 29 +++++++++++++++ .../graphql22/SentryInstrumentationTest.kt | 36 ++++++++++++++++++- .../graphql/SentryGraphqlInstrumentation.java | 9 +++++ .../graphql/SentryInstrumentationTest.kt | 36 ++++++++++++++++++- .../ktorClient/SentryKtorClientPlugin.kt | 10 ++++++ .../ktorClient/SentryKtorClientPluginTest.kt | 32 +++++++++++++++++ .../sentry/okhttp/SentryOkHttpInterceptor.kt | 10 ++++++ .../okhttp/SentryOkHttpInterceptorTest.kt | 32 +++++++++++++++++ .../sentry/openfeign/SentryFeignClient.java | 9 +++++ .../sentry/openfeign/SentryFeignClientTest.kt | 32 +++++++++++++++++ 16 files changed, 325 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 357c40c30bf..309d16238ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,7 +128,7 @@ - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) - - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) + - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request. Report lost sampled spans as `callback_error` in client reports and `OnDiscardCallback` ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - When `tracesSampler` throws, drop the transaction and record `callback_error` instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index c276f56db9c..080afc5af7c 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -10,6 +10,7 @@ import com.apollographql.apollo3.network.http.HttpInterceptor import com.apollographql.apollo3.network.http.HttpInterceptorChain import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.IScopes import io.sentry.ISpan @@ -23,6 +24,7 @@ import io.sentry.SpanDataConvention.HTTP_METHOD_KEY import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.clientreport.DiscardReason import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request @@ -216,6 +218,7 @@ constructor( span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) } if (beforeSpan != null) { + val wasSampled = span.isSampled == true try { val result = beforeSpan.execute(span, request, response) if (result == null) { @@ -224,6 +227,12 @@ constructor( } } catch (e: Throwable) { span.spanContext.sampled = false + if (wasSampled) { + scopes.options.clientReportRecorder.recordLostEvent( + DiscardReason.CALLBACK_ERROR, + DataCategory.Span, + ) + } scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 4c43f320e04..09d298744bd 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -10,6 +10,7 @@ import com.apollographql.apollo3.network.http.HttpInterceptorChain import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction @@ -28,6 +29,7 @@ import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader import io.sentry.apollo3.SentryApollo3HttpInterceptor.BeforeSpanCallback +import io.sentry.clientreport.DiscardReason import io.sentry.mockServerRequestTimeoutMillis import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction @@ -50,6 +52,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryApollo3InterceptorTest { @@ -294,7 +297,10 @@ class SentryApollo3InterceptorTest { @Test fun `returning null in beforeSpan callback drops span`() { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> null })) + verifyNoMoreInteractions(onDiscard) verify(fixture.scopes) .captureTransaction( @@ -305,6 +311,32 @@ class SentryApollo3InterceptorTest { ) } + @Test + fun `reports callback errors only for sampled spans`(): Unit = runBlocking { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + val tx = SentryTracer(TransactionContext("op", "desc"), fixture.scopes) + tx.spanContext.sampled = sampled + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + + assertThat(sut.query(LaunchDetailsQuery("83")).execute().data).isNotNull() + tx.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { val failure = IllegalStateException("callback failed") diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index a75743d4565..11eed8ab9d3 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -8,6 +8,7 @@ import com.apollographql.apollo.network.http.HttpInterceptor import com.apollographql.apollo.network.http.HttpInterceptorChain import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.IScopes import io.sentry.ISpan @@ -21,6 +22,7 @@ import io.sentry.SpanDataConvention.HTTP_METHOD_KEY import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.clientreport.DiscardReason import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request @@ -215,6 +217,7 @@ constructor( span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) } if (beforeSpan != null) { + val wasSampled = span.isSampled == true try { val result = beforeSpan.execute(span, request, response) if (result == null) { @@ -223,6 +226,12 @@ constructor( } } catch (e: Throwable) { span.spanContext.sampled = false + if (wasSampled) { + scopes.options.clientReportRecorder.recordLostEvent( + DiscardReason.CALLBACK_ERROR, + DataCategory.Span, + ) + } scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan in ApolloInterceptor", diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index e1d6a61a07a..ce94160303a 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -13,6 +13,7 @@ import com.apollographql.apollo.network.http.HttpInterceptorChain import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction @@ -32,6 +33,7 @@ import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader import io.sentry.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.clientreport.DiscardReason import io.sentry.mockServerRequestTimeoutMillis import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction @@ -55,6 +57,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryApollo4HttpInterceptorTestWithV4Implementation : @@ -308,7 +311,10 @@ abstract class SentryApollo4HttpInterceptorTest( @Test fun `returning null in beforeSpan callback drops span`() { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> null })) + verifyNoMoreInteractions(onDiscard) verify(fixture.scopes) .captureTransaction( @@ -319,6 +325,32 @@ abstract class SentryApollo4HttpInterceptorTest( ) } + @Test + fun `reports callback errors only for sampled spans`(): Unit = runBlocking { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + val tx = SentryTracer(TransactionContext("op", "desc"), fixture.scopes) + tx.spanContext.sampled = sampled + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + + assertThat(executeQueryImplementation(sut.query(LaunchDetailsQuery("83"))).data).isNotNull() + tx.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { val failure = IllegalStateException("callback failed") diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index 52ac48f9e50..3524d32dc1f 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -14,6 +14,7 @@ import com.apollographql.apollo.interceptor.ApolloInterceptorChain import com.apollographql.apollo.request.RequestHeaders import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.IScopes import io.sentry.ISpan @@ -24,6 +25,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.clientreport.DiscardReason import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils @@ -175,10 +177,17 @@ class SentryApolloInterceptor( ) { var newSpan: ISpan? = span if (beforeSpan != null) { + val wasSampled = span.isSampled == true try { newSpan = beforeSpan.execute(span, request, response) } catch (e: Exception) { span.spanContext.sampled = false + if (wasSampled) { + scopes.options.clientReportRecorder.recordLostEvent( + DiscardReason.CALLBACK_ERROR, + DataCategory.Span, + ) + } scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index 3e5986ef8a0..bc09f86a4e6 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -6,6 +6,7 @@ import com.apollographql.apollo.exception.ApolloException import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction @@ -20,6 +21,7 @@ import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext +import io.sentry.clientreport.DiscardReason import io.sentry.mockServerRequestTimeoutMillis import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction @@ -42,6 +44,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryApolloInterceptorTest { @@ -232,7 +235,10 @@ class SentryApolloInterceptorTest { @Test fun `when beforeSpan callback returns null, span is dropped`() { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard executeQuery(fixture.getSut { _, _, _ -> null }) + verifyNoMoreInteractions(onDiscard) verify(fixture.scopes) .captureTransaction( @@ -243,6 +249,29 @@ class SentryApolloInterceptorTest { ) } + @Test + fun `reports callback errors only for sampled spans`(): Unit = runBlocking { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + val tx = SentryTracer(TransactionContext("op", "desc"), fixture.scopes) + tx.spanContext.sampled = sampled + whenever(fixture.scopes.span).thenReturn(tx) + val sut = fixture.getSut { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + + assertThat(sut.query(LaunchDetailsQuery.builder().id("83").build()).await().data).isNotNull() + tx.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { val failure = IllegalStateException("callback failed") diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt index 16dbdd685c3..2331ab17aaf 100644 --- a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -19,6 +19,7 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Sentry @@ -27,6 +28,7 @@ import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus import io.sentry.TransactionContext +import io.sentry.clientreport.DiscardReason import io.sentry.graphql.ExceptionReporter import io.sentry.graphql.NoOpSubscriptionHandler import io.sentry.graphql.SentryGraphqlInstrumentation @@ -43,6 +45,7 @@ import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryInstrumentationTest { @@ -56,7 +59,8 @@ class SentryInstrumentationTest { async: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null, ): GraphQL { - whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.options) + .thenReturn(SentryOptions().apply { dsn = "https://key@sentry.io/proj" }) activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ @@ -162,6 +166,9 @@ class SentryInstrumentationTest { fixture.getSut( beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { _, _, _ -> null } ) + val onDiscard = mock() + fixture.scopes.options.onDiscard = onDiscard + fixture.activeSpan.spanContext.sampled = true withMockScopes { val result = sut.execute("{ shows { id } }") @@ -172,6 +179,33 @@ class SentryInstrumentationTest { assertEquals("graphql", span.operation) assertEquals("Query.shows", span.description) assertNotNull(span.isSampled) { assertFalse(it) } + verifyNoMoreInteractions(onDiscard) + } + } + + @Test + fun `reports callback errors only for sampled spans`() { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + fixture.activeSpan.spanContext.sampled = sampled + fixture.scopes.options.onDiscard = onDiscard + + withMockScopes { + assertThat(sut.execute("{ shows { id } }").errors).isEmpty() + fixture.activeSpan.finish() + } + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) } } diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java index 5e71b14647b..34044aea435 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -17,6 +17,7 @@ import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; import io.sentry.Breadcrumb; +import io.sentry.DataCategory; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISpan; @@ -26,6 +27,7 @@ import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.TypeCheckHint; +import io.sentry.clientreport.DiscardReason; import io.sentry.util.StringUtils; import java.util.Arrays; import java.util.List; @@ -284,11 +286,18 @@ private void finish( final @NotNull DataFetchingEnvironment environment, final @Nullable Object result) { if (beforeSpan != null) { + final boolean wasSampled = Boolean.TRUE.equals(span.isSampled()); ISpan newSpan = span; try { newSpan = beforeSpan.execute(span, environment, result); } catch (Exception e) { span.getSpanContext().setSampled(false); + if (wasSampled) { + scopesFromContext(environment.getGraphQlContext()) + .getOptions() + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Span); + } scopesFromContext(environment.getGraphQlContext()) .getOptions() .getLogger() diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index bc76286abe4..2652694b8f9 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -19,6 +19,7 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Sentry @@ -27,6 +28,7 @@ import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanStatus import io.sentry.TransactionContext +import io.sentry.clientreport.DiscardReason import java.lang.RuntimeException import java.util.concurrent.CompletableFuture import kotlin.random.Random @@ -39,6 +41,7 @@ import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryInstrumentationTest { @@ -52,7 +55,8 @@ class SentryInstrumentationTest { async: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null, ): GraphQL { - whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.options) + .thenReturn(SentryOptions().apply { dsn = "https://key@sentry.io/proj" }) activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ @@ -158,6 +162,9 @@ class SentryInstrumentationTest { fixture.getSut( beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { _, _, _ -> null } ) + val onDiscard = mock() + fixture.scopes.options.onDiscard = onDiscard + fixture.activeSpan.spanContext.sampled = true withMockScopes { val result = sut.execute("{ shows { id } }") @@ -168,6 +175,33 @@ class SentryInstrumentationTest { assertEquals("graphql", span.operation) assertEquals("Query.shows", span.description) assertNotNull(span.isSampled) { assertFalse(it) } + verifyNoMoreInteractions(onDiscard) + } + } + + @Test + fun `reports callback errors only for sampled spans`() { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + fixture.activeSpan.spanContext.sampled = sampled + fixture.scopes.options.onDiscard = onDiscard + + withMockScopes { + assertThat(sut.execute("{ shows { id } }").errors).isEmpty() + fixture.activeSpan.finish() + } + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) } } diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index 80e9c3e0ddc..609ed239961 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -9,6 +9,7 @@ import io.ktor.util.* import io.ktor.util.pipeline.* import io.sentry.BaggageHeader import io.sentry.BuildConfig +import io.sentry.DataCategory import io.sentry.HttpStatusCodeRange import io.sentry.IScopes import io.sentry.ISpan @@ -19,6 +20,7 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SpanStatus +import io.sentry.clientreport.DiscardReason import io.sentry.kotlin.SentryContext import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -187,10 +189,18 @@ public val SentryKtorClientPlugin: ClientPlugin = var result: ISpan? = span if (beforeSpan != null) { + val wasSampled = span.isSampled == true result = try { beforeSpan.execute(span, request) } catch (e: Exception) { + span.spanContext.sampled = false + if (wasSampled) { + (if (forceScopes) scopes else Sentry.getCurrentScopes()) + .options + .clientReportRecorder + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Span) + } (if (forceScopes) scopes else Sentry.getCurrentScopes()) .options .logger diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 5cf1257019f..4aadfe823a0 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -12,6 +12,7 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.ILogger @@ -30,6 +31,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader +import io.sentry.clientreport.DiscardReason import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis import java.util.concurrent.TimeUnit @@ -50,6 +52,7 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryKtorClientPluginTest { @@ -439,6 +442,31 @@ class SentryKtorClientPluginTest { ) } + @Test + fun `reports callback errors only for sampled spans`(): Unit = runBlocking { + for (sampled in listOf(true, false, null)) { + val fixture = Fixture() + val onDiscard = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + fixture.sentryTracer.spanContext.sampled = sampled + fixture.options.onDiscard = onDiscard + + sut.use { sut.get(fixture.server.url("/hello").toString()) } + fixture.sentryTracer.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { val failure = IllegalStateException("callback failed") @@ -473,7 +501,11 @@ class SentryKtorClientPluginTest { @Test fun `beforeSpan can drop span`(): Unit = runBlocking { val sut = fixture.getSut(beforeSpan = { _, _ -> null }) + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + fixture.sentryTracer.spanContext.sampled = true sut.use { sut.get(fixture.server.url("/hello").toString()) } + verifyNoMoreInteractions(onDiscard) val span = fixture.sentryTracer.children.single() assertThat(span.isSampled).isFalse() assertThat(span.isFinished).isTrue() diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 1adc59386aa..6a5f6348dd1 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -2,6 +2,7 @@ package io.sentry.okhttp import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.ILogger @@ -17,6 +18,7 @@ import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS +import io.sentry.clientreport.DiscardReason import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -365,10 +367,18 @@ public open class SentryOkHttpInterceptor( return } if (beforeSpan != null) { + val wasSampled = span.isSampled == true val result = try { beforeSpan.execute(span, request, response) } catch (e: Exception) { + span.spanContext.sampled = false + if (wasSampled) { + scopes.options.clientReportRecorder.recordLostEvent( + DiscardReason.CALLBACK_ERROR, + DataCategory.Span, + ) + } scopes.options.logger.log( SentryLevel.ERROR, "The beforeSpan callback threw an exception in SentryOkHttpInterceptor. Dropping span.", diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 0499b5273dc..a3ba0073f8a 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -5,6 +5,7 @@ package io.sentry.okhttp import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.ILogger @@ -24,6 +25,7 @@ import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.W3CTraceparentHeader +import io.sentry.clientreport.DiscardReason import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis import io.sentry.util.network.NetworkRequestData @@ -57,6 +59,7 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryOkHttpInterceptorTest { @@ -432,12 +435,41 @@ class SentryOkHttpInterceptorTest { @Test fun `customizer can drop the span`() { val sut = fixture.getSut(beforeSpan = { _, _, _ -> null }) + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + fixture.sentryTracer.spanContext.sampled = true sut.newCall(getRequest()).execute() + verifyNoMoreInteractions(onDiscard) val httpClientSpan = fixture.sentryTracer.children.first() assertTrue(httpClientSpan.isFinished) assertNotNull(httpClientSpan.spanContext.sampled) { assertFalse(it) } } + @Test + fun `reports callback errors only for sampled spans`() { + for (sampled in listOf(true, false, null)) { + val fixture = Fixture() + val onDiscard = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + fixture.sentryTracer.spanContext.sampled = sampled + fixture.options.onDiscard = onDiscard + + sut.newCall(Request.Builder().url(fixture.server.url("/hello")).build()).execute().close() + fixture.sentryTracer.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`() { val failure = IllegalStateException("callback failed") diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index 4700884c87d..7ed48d0dc10 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -10,6 +10,7 @@ import io.sentry.BaggageHeader; import io.sentry.Breadcrumb; import io.sentry.BuildConfig; +import io.sentry.DataCategory; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISpan; @@ -19,6 +20,7 @@ import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.W3CTraceparentHeader; +import io.sentry.clientreport.DiscardReason; import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; @@ -96,11 +98,18 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O throw e; } finally { if (beforeSpan != null) { + final boolean wasSampled = Boolean.TRUE.equals(span.isSampled()); ISpan result = span; try { result = beforeSpan.execute(span, request, response); } catch (Exception e) { span.getSpanContext().setSampled(false); + if (wasSampled) { + scopes + .getOptions() + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Span); + } scopes .getOptions() .getLogger() diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index d76b0276351..9a2bd98d365 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -9,6 +9,7 @@ import feign.Request import feign.RequestLine import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Scope @@ -21,6 +22,7 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader +import io.sentry.clientreport.DiscardReason import io.sentry.mockServerRequestTimeoutMillis import java.io.IOException import java.util.concurrent.TimeUnit @@ -41,6 +43,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryFeignClientTest { @@ -290,6 +293,31 @@ class SentryFeignClientTest { assertTrue(httpClientSpan.throwable is Exception) } + @Test + fun `reports callback errors only for sampled spans`() { + for (sampled in listOf(true, false, null)) { + val fixture = Fixture() + val onDiscard = mock() + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + fixture.sentryTracer.spanContext.sampled = sampled + fixture.sentryOptions.onDiscard = onDiscard + + assertThat(sut.getOk()).isEqualTo("success") + fixture.sentryTracer.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test fun `when beforeSpan throws, drops span and preserves response`() { val failure = IllegalStateException("callback failed") @@ -371,7 +399,11 @@ class SentryFeignClientTest { @Test fun `customizer can drop the span`() { val sut = fixture.getSut { _, _, _ -> null } + val onDiscard = mock() + fixture.sentryOptions.onDiscard = onDiscard + fixture.sentryTracer.spanContext.sampled = true sut.getOk() + verifyNoMoreInteractions(onDiscard) val httpClientSpan = fixture.sentryTracer.children.first() assertNotNull(httpClientSpan.spanContext.sampled) { assertFalse(it) } } From 0414429b2705f212a54e2a8aef87d945354696de Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 5 Oct 2026 15:19:52 +0200 Subject: [PATCH 19/37] docs(core): Document SDK backfilling processor marker Clarify that SDK-owned backfilling processors must also use the Sentry processor marker so callback error handling can identify their ownership. Refs #6081 Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/BackfillingEventProcessor.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java b/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java index 2d8d7bc5575..7a936be511f 100644 --- a/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java +++ b/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java @@ -4,5 +4,8 @@ * Marker interface for event processors that process events that have to be backfilled, i.e. * currently stored in-memory data (like Scope or SentryOptions) is irrelevant, because the event * happened in the past. + * + *

SDK-owned implementations must also implement {@link + * io.sentry.internal.eventprocessor.SentryEventProcessor}. */ public interface BackfillingEventProcessor extends EventProcessor {} From a2b48474b7c0d797a1e75fed363f74c9a81f2e8d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 8 Oct 2026 10:00:00 +0200 Subject: [PATCH 20/37] fix: Report profiles dropped with transactions Record attached profiles as lost when transaction event processors or beforeSendTransaction intentionally discard the transaction. Keep client reports and discard callbacks aligned with dropped profile payloads. Co-Authored-By: Claude --- .../src/main/java/io/sentry/SentryClient.java | 10 ++++ .../SentryClientTransactionProfileTest.kt | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 75b2b474413..cd7fdb0451e 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -671,6 +671,11 @@ private SentryMetricsEvent processMetricsEvent( .getClientReportRecorder() .recordLostEvent( DiscardReason.EVENT_PROCESSOR, DataCategory.Span, spanCountBeforeProcessor + 1); + if (hasProfile) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Profile); + } break; } else if (spanCountAfterProcessor < spanCountBeforeProcessor) { // If the callback removed some spans, we report it @@ -1719,6 +1724,11 @@ private void sortBreadcrumbsByDate( .getClientReportRecorder() .recordLostEvent( DiscardReason.BEFORE_SEND, DataCategory.Span, spanCountBeforeCallback + 1); + if (hasProfile) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.Profile); + } } else { final int spanCountAfterCallback = transaction.getSpans().size(); if (spanCountAfterCallback < spanCountBeforeCallback) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt b/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt index dc85ca3817c..24761837516 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTransactionProfileTest.kt @@ -81,4 +81,55 @@ class SentryClientTransactionProfileTest( verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 2) verifyNoMoreInteractions(onDiscard) } + + @Test + fun `intentional transaction drop reports attached profile exactly once`() { + val fixture = SentryClientTest.Fixture() + val options = fixture.sentryOptions + options.eventProcessors.clear() + val scope = Scope(options) + val onDiscard = mock() + options.onDiscard = onDiscard + val discardReason = + if (callback == "beforeSendTransaction") { + options.setBeforeSendTransaction { _, _ -> null } + DiscardReason.BEFORE_SEND + } else { + val processor = mock() + whenever(processor.process(any(), any())).thenReturn(null) + if (callback == "scope processor") { + scope.addEventProcessor(processor) + } else { + options.addEventProcessor(processor) + } + DiscardReason.EVENT_PROCESSOR + } + + val id = + fixture + .getSut() + .captureTransaction( + SentryTransaction(fixture.sentryTracer), + fixture.sentryTracer.traceContext(), + scope, + null, + if (hasProfile) fixture.profilingTraceData else null, + ) + + assertThat(id).isEqualTo(SentryId.EMPTY_ID) + verify(fixture.transport, never()).send(any(), anyOrNull()) + val expected = + mutableListOf( + DiscardedEvent(discardReason.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(discardReason.reason, DataCategory.Span.category, 2), + ) + if (hasProfile) { + expected.add(DiscardedEvent(discardReason.reason, DataCategory.Profile.category, 1)) + verify(onDiscard).execute(discardReason, DataCategory.Profile, 1) + } + assertClientReport(options.clientReportRecorder, expected) + verify(onDiscard).execute(discardReason, DataCategory.Transaction, 1) + verify(onDiscard).execute(discardReason, DataCategory.Span, 2) + verifyNoMoreInteractions(onDiscard) + } } From 518d48a61995700d8a33588dac8d0dc911f05b1d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 8 Oct 2026 11:38:07 +0200 Subject: [PATCH 21/37] fix(core): Report replay sampling callback errors Record callback_error/replay when beforeErrorSampling throws while the replay controller has an active replay ID. Do not report a loss when no replay is active. Refs GH-6081 Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/SentryClient.java | 5 +++++ sentry/src/test/java/io/sentry/SentryClientTest.kt | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index e409603f80c..01e828c2338 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -246,6 +246,11 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul SentryLevel.ERROR, "The beforeErrorSampling callback threw an exception. Skipping replay capture.", e); + if (!SentryId.EMPTY_ID.equals(options.getReplayController().getReplayId())) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay); + } shouldCaptureReplay = false; } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index dc59e767758..b9f0ec869d0 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3964,6 +3964,7 @@ class SentryClientTest { @Test fun `beforeErrorSampling throwing exception skips replay but still sends errors and crashes`() { val replayController = mock() + whenever(replayController.replayId).thenReturn(SentryId()) whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) fixture.sentryOptions.setReplayController(replayController) val logger = mock() @@ -4000,11 +4001,16 @@ class SentryClientTest { "The beforeErrorSampling callback threw an exception. Skipping replay capture.", exception, ) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Replay.category, 2)), + ) } @Test fun `beforeErrorSampling throwing exception does not prevent replay capture for subsequent events`() { val replayController = mock() + whenever(replayController.replayId).thenReturn(SentryId.EMPTY_ID) whenever(replayController.captureReplay(anyOrNull())).thenReturn(SentryId.EMPTY_ID) fixture.sentryOptions.setReplayController(replayController) var invocations = 0 @@ -4027,6 +4033,7 @@ class SentryClientTest { assertThat(invocations).isEqualTo(2) verify(replayController).captureReplay(false) verify(fixture.transport, times(2)).send(any(), anyOrNull()) + assertClientReport(fixture.sentryOptions.clientReportRecorder, emptyList()) } @Test From 39ac3403d2b77156d46fc57606b39c0ec7a086fb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:24:02 +0200 Subject: [PATCH 22/37] fix(apollo5): Handle beforeSpan callback failures Drop spans when the Apollo 5 beforeSpan callback throws while preserving the GraphQL response. Report sampled spans as callback errors so client reports and discard callbacks remain accurate. Co-Authored-By: Claude --- .../apollo5/SentryApollo5HttpInterceptor.kt | 10 +++ .../SentryApollo5HttpInterceptorTest.kt | 77 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt index 7240f2d87a4..f74d0fec111 100644 --- a/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt +++ b/sentry-apollo-5/src/main/java/io/sentry/apollo5/SentryApollo5HttpInterceptor.kt @@ -8,6 +8,7 @@ import com.apollographql.apollo.network.http.HttpInterceptor import com.apollographql.apollo.network.http.HttpInterceptorChain import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.IScopes import io.sentry.ISpan @@ -21,6 +22,7 @@ import io.sentry.SpanDataConvention.HTTP_METHOD_KEY import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.clientreport.DiscardReason import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request @@ -199,6 +201,7 @@ constructor( span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) } if (beforeSpan != null) { + val wasSampled = span.isSampled == true try { val result = beforeSpan.execute(span, request, response) if (result == null) { @@ -207,6 +210,13 @@ constructor( } } catch (e: Throwable) { ExceptionUtils.rethrowIfFatal(e) + span.spanContext.sampled = false + if (wasSampled) { + scopes.options.clientReportRecorder.recordLostEvent( + DiscardReason.CALLBACK_ERROR, + DataCategory.Span, + ) + } scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan in ApolloInterceptor", diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt index ce0df3a55a0..399667a7647 100644 --- a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt @@ -14,6 +14,7 @@ import com.apollographql.apollo.network.http.HttpInterceptorChain import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb +import io.sentry.DataCategory import io.sentry.Hint import io.sentry.IScopes import io.sentry.ITransaction @@ -32,6 +33,7 @@ import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader import io.sentry.apollo5.SentryApollo5HttpInterceptor.BeforeSpanCallback import io.sentry.apollo5.generated.LaunchDetailsQuery +import io.sentry.clientreport.DiscardReason import io.sentry.mockServerRequestTimeoutMillis import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryTransaction @@ -40,6 +42,7 @@ import java.util.concurrent.TimeUnit import kotlin.reflect.KSuspendFunction1 import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -60,6 +63,7 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever class SentryApollo5HttpInterceptorTestWithV5Implementation : @@ -421,16 +425,83 @@ abstract class SentryApollo5HttpInterceptorTest( } @Test - fun `when customizer throws, exception is handled`() { - executeQuery(fixture.getSut(beforeSpan = { _, _, _ -> throw RuntimeException() })) + fun `reports callback errors only for sampled spans`(): Unit = runBlocking { + for (sampled in listOf(true, false, null)) { + val onDiscard = mock() + fixture.options.onDiscard = onDiscard + val tx = SentryTracer(TransactionContext("op", "desc"), fixture.scopes) + tx.spanContext.sampled = sampled + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.spanContext.sampled = false + throw IllegalStateException("callback failed") + } + ) + + assertThat(executeQueryImplementation(sut.query(LaunchDetailsQuery("83"))).data).isNotNull() + tx.finish() + + if (sampled == true) { + verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1) + } + verifyNoMoreInteractions(onDiscard) + } + } + @Test + fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { + val failure = IllegalStateException("callback failed") + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val sut = + fixture.getSut( + beforeSpan = { span, _, _ -> + span.description = "partially modified" + throw failure + } + ) + + val response = executeQueryImplementation(sut.query(LaunchDetailsQuery("83"))) + assertThat(response.data).isNotNull() + val span = tx.children.single() + assertThat(span.isSampled).isFalse() + assertThat(span.isFinished).isTrue() + tx.finish() verify(fixture.scopes) .captureTransaction( - check { assertEquals(1, it.spans.size) }, + check { assertThat(it.spans).isEmpty() }, anyOrNull(), anyOrNull(), anyOrNull(), ) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) + } + + @Test + fun `fatal error from beforeSpan is not swallowed`(): Unit = runBlocking { + val failure = OutOfMemoryError("callback failed") + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val request = HttpRequest.Builder(HttpMethod.Post, "https://example.com/graphql").build() + val response = HttpResponse.Builder(200).build() + val chain = + object : HttpInterceptorChain { + override suspend fun proceed(request: HttpRequest): HttpResponse = response + } + val interceptor = + SentryApollo5HttpInterceptor( + fixture.scopes, + beforeSpan = { _, _, _ -> throw failure }, + captureFailedRequests = false, + ) + + val thrown = assertFailsWith { interceptor.intercept(request, chain) } + + assertThat(thrown).isSameInstanceAs(failure) } @Test From 754ace34b6a9609cafb302926bb5bfb13b46d54a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:24:35 +0200 Subject: [PATCH 23/37] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc054e0be18..95f947f594d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Drop Apollo 5 spans when `beforeSpan` throws without disrupting the GraphQL request ([#6238](https://github.com/getsentry/sentry-java/pull/6238)) + ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) From c74fa58b23024fae99deed003b065d4ec89ac1f5 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:25:08 +0200 Subject: [PATCH 24/37] fix(android): Handle feedback configurator failures Keep the feedback form usable when an options configuration callback throws. Apply callback changes transactionally so partially mutated feedback options are discarded. Co-Authored-By: Claude --- .../android/core/SentryUserFeedbackForm.java | 31 ++++++++++-- .../core/SentryUserFeedbackFormTest.kt | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 611db3c1345..7da33575de4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -62,14 +62,37 @@ public class SentryUserFeedbackForm extends AlertDialog { final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { super(context, themeResId); this.associatedEventId = associatedEventId; - this.resolvedFeedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + @NotNull + SentryFeedbackOptions feedbackOptions = new SentryFeedbackOptions(options.getFeedbackOptions()); if (configuration != null) { - configuration.configure(context, resolvedFeedbackOptions); + final @NotNull SentryFeedbackOptions configuredOptions = + new SentryFeedbackOptions(feedbackOptions); + try { + configuration.configure(context, configuredOptions); + feedbackOptions = configuredOptions; + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + options + .getLogger() + .log( + SentryLevel.ERROR, "Feedback OptionsConfiguration callback threw an exception.", e); + } } if (configurator != null) { - configurator.configure(resolvedFeedbackOptions); + final @NotNull SentryFeedbackOptions configuredOptions = + new SentryFeedbackOptions(feedbackOptions); + try { + configurator.configure(configuredOptions); + feedbackOptions = configuredOptions; + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + options + .getLogger() + .log(SentryLevel.ERROR, "Feedback OptionsConfigurator callback threw an exception.", e); + } } + this.resolvedFeedbackOptions = feedbackOptions; SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); maybeStartShakeDetection(context); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index 95e50a85b14..b28cb9197cc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -164,6 +164,56 @@ class SentryUserFeedbackFormTest { assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle) } + @Test + fun `when configuration throws, its partial changes are ignored`() { + fixture.options.isEnabled = true + val originalTitle = fixture.options.feedbackOptions.formTitle + val failure = IllegalStateException("configuration failed") + + val sut = + fixture.getSut( + configuration = { _, options -> + options.formTitle = "partial title" + throw failure + } + ) + sut.show() + + assertThat(sut.findViewById(R.id.sentry_dialog_user_feedback_title).text) + .isEqualTo(originalTitle) + } + + @Test + fun `when configuration throws a fatal error, it is not swallowed`() { + val failure = OutOfMemoryError("configuration failed") + + val thrown = + assertFailsWith { + fixture.getSut(configuration = { _, _ -> throw failure }) + } + + assertThat(thrown).isSameInstanceAs(failure) + } + + @Test + fun `when configurator throws, its partial changes are ignored`() { + fixture.options.isEnabled = true + val failure = IllegalStateException("configurator failed") + + val sut = + fixture.getSut( + configuration = { _, options -> options.formTitle = "configured title" }, + configurator = { options -> + options.formTitle = "partial title" + throw failure + }, + ) + sut.show() + + assertThat(sut.findViewById(R.id.sentry_dialog_user_feedback_title).text) + .isEqualTo("configured title") + } + @Test fun `dialog window does not have FLAG_ALT_FOCUSABLE_IM so soft keyboard can appear`() { fixture.options.isEnabled = true From 6f75a53b4a031522373122b650007afce62f8791 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:25:31 +0200 Subject: [PATCH 25/37] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc054e0be18..5a778c8b032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Keep the Android feedback form available when an options configurator throws ([#6239](https://github.com/getsentry/sentry-java/pull/6239)) + ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) From 805d7ea4c4bf72d968f9ca98090c67eda2d4c399 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:26:19 +0200 Subject: [PATCH 26/37] fix(spring): Handle user provider failures Keep requests running when a Spring user provider throws. Continue invoking later providers, but discard all provider-derived identity and install an empty user so inherited identity cannot leak into the event. Co-Authored-By: Claude --- .../io/sentry/spring7/SentryUserFilter.java | 18 +++++++-- .../io/sentry/spring7/SentryUserFilterTest.kt | 39 +++++++++++++++++++ .../spring/jakarta/SentryUserFilter.java | 18 +++++++-- .../spring/jakarta/SentryUserFilterTest.kt | 39 +++++++++++++++++++ .../io/sentry/spring/SentryUserFilter.java | 18 +++++++-- .../io/sentry/spring/SentryUserFilterTest.kt | 39 +++++++++++++++++++ 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java index da9d2f0f77e..ac592cb9ae8 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java @@ -4,7 +4,9 @@ import io.sentry.IScope; import io.sentry.IScopes; import io.sentry.IpAddressUtils; +import io.sentry.SentryLevel; import io.sentry.protocol.User; +import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -43,16 +45,26 @@ protected void doFilterInternal( final @NotNull FilterChain chain) throws ServletException, IOException { final User user = new User(); + boolean providerFailed = false; for (final SentryUserProvider provider : sentryUserProviders) { - apply(user, provider.provideUser()); + try { + apply(user, provider.provideUser()); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + providerFailed = true; + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + } } - if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { + if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); } } - scopes.setUser(user); + scopes.setUser(providerFailed ? new User() : user); chain.doFilter(request, response); } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt index 92327456e13..8d6a2fbcf69 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt @@ -6,9 +6,12 @@ import io.sentry.protocol.User import jakarta.servlet.FilterChain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.check import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.mock.web.MockHttpServletRequest @@ -151,6 +154,42 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `provider failure discards user data while later providers and request continue`() { + val failure = RuntimeException("provider failed") + val laterProvider = mock() + whenever(laterProvider.provideUser()).thenReturn(sampleUser) + val filter = + fixture.getSut( + userProviders = + listOf( + SentryUserProvider { sampleUser }, + SentryUserProvider { throw failure }, + laterProvider, + ) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(User(), it) }) + verify(laterProvider).provideUser() + verify(fixture.chain).doFilter(fixture.request, fixture.response) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val filter = fixture.getSut(userProviders = listOf(SentryUserProvider { throw failure })) + + assertThat( + assertFailsWith { + filter.doFilter(fixture.request, fixture.response, fixture.chain) + } + ) + .isSameAs(failure) + verify(fixture.chain, never()).doFilter(fixture.request, fixture.response) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java index 23a77f79f0d..ab05404fb18 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java @@ -4,7 +4,9 @@ import io.sentry.IScope; import io.sentry.IScopes; import io.sentry.IpAddressUtils; +import io.sentry.SentryLevel; import io.sentry.protocol.User; +import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -43,16 +45,26 @@ protected void doFilterInternal( final @NotNull FilterChain chain) throws ServletException, IOException { final User user = new User(); + boolean providerFailed = false; for (final SentryUserProvider provider : sentryUserProviders) { - apply(user, provider.provideUser()); + try { + apply(user, provider.provideUser()); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + providerFailed = true; + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + } } - if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { + if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); } } - scopes.setUser(user); + scopes.setUser(providerFailed ? new User() : user); chain.doFilter(request, response); } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt index 15a7bf377cd..1cb91b1445f 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt @@ -6,9 +6,12 @@ import io.sentry.protocol.User import jakarta.servlet.FilterChain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.check import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.mock.web.MockHttpServletRequest @@ -151,6 +154,42 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `provider failure discards user data while later providers and request continue`() { + val failure = RuntimeException("provider failed") + val laterProvider = mock() + whenever(laterProvider.provideUser()).thenReturn(sampleUser) + val filter = + fixture.getSut( + userProviders = + listOf( + SentryUserProvider { sampleUser }, + SentryUserProvider { throw failure }, + laterProvider, + ) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(User(), it) }) + verify(laterProvider).provideUser() + verify(fixture.chain).doFilter(fixture.request, fixture.response) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val filter = fixture.getSut(userProviders = listOf(SentryUserProvider { throw failure })) + + assertThat( + assertFailsWith { + filter.doFilter(fixture.request, fixture.response, fixture.chain) + } + ) + .isSameAs(failure) + verify(fixture.chain, never()).doFilter(fixture.request, fixture.response) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java index 18e1c0d2875..ed7f8071076 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java @@ -4,7 +4,9 @@ import io.sentry.IScope; import io.sentry.IScopes; import io.sentry.IpAddressUtils; +import io.sentry.SentryLevel; import io.sentry.protocol.User; +import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import java.io.IOException; import java.util.List; @@ -43,16 +45,26 @@ protected void doFilterInternal( final @NotNull FilterChain chain) throws ServletException, IOException { final User user = new User(); + boolean providerFailed = false; for (final SentryUserProvider provider : sentryUserProviders) { - apply(user, provider.provideUser()); + try { + apply(user, provider.provideUser()); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + providerFailed = true; + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + } } - if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { + if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); } } - scopes.setUser(user); + scopes.setUser(providerFailed ? new User() : user); chain.doFilter(request, response); } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt index 07283bd5b95..8c0ed0f2f92 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt @@ -6,9 +6,12 @@ import io.sentry.protocol.User import javax.servlet.FilterChain import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.check import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.mock.web.MockHttpServletRequest @@ -151,6 +154,42 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `provider failure discards user data while later providers and request continue`() { + val failure = RuntimeException("provider failed") + val laterProvider = mock() + whenever(laterProvider.provideUser()).thenReturn(sampleUser) + val filter = + fixture.getSut( + userProviders = + listOf( + SentryUserProvider { sampleUser }, + SentryUserProvider { throw failure }, + laterProvider, + ) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(User(), it) }) + verify(laterProvider).provideUser() + verify(fixture.chain).doFilter(fixture.request, fixture.response) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val filter = fixture.getSut(userProviders = listOf(SentryUserProvider { throw failure })) + + assertThat( + assertFailsWith { + filter.doFilter(fixture.request, fixture.response, fixture.chain) + } + ) + .isSameAs(failure) + verify(fixture.chain, never()).doFilter(fixture.request, fixture.response) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) From 22bc0e6a56541c05b866c4dfd5cb210f360de479 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:26:45 +0200 Subject: [PATCH 27/37] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc054e0be18..40bffe0ec5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Keep Spring requests running when a `SentryUserProvider` throws and discard the incomplete user identity ([#6240](https://github.com/getsentry/sentry-java/pull/6240)) + ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) From 49e29406d98aaccb1302060fe831d430dacac3cc Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:27:45 +0200 Subject: [PATCH 28/37] fix(spring): Handle transaction name provider failures Keep Spring requests and event capture running when a transaction name provider throws. Use an unlabeled custom transaction as the privacy-safe fallback and continue to later providers when resolving combined names. Apply the same behavior across the Spring, Spring Jakarta, and Spring 7 integrations. Co-Authored-By: Claude --- sentry-spring-7/api/sentry-spring-7.api | 5 ++ .../spring7/SentryExceptionResolver.java | 4 +- ...tryRequestHttpServletRequestProcessor.java | 15 ++++- .../io/sentry/spring7/SentrySpringFilter.java | 3 +- .../spring7/TransactionNameProviderUtils.java | 51 ++++++++++++++++ .../CombinedTransactionNameProvider.java | 55 +++++++++++++---- .../spring7/tracing/SentryTracingFilter.java | 11 +++- .../spring7/SentryExceptionResolverTest.kt | 20 +++++++ ...yRequestHttpServletRequestProcessorTest.kt | 33 ++++++++++- .../TransactionNameProviderUtilsTest.kt | 38 ++++++++++++ .../CombinedTransactionNameProviderTest.kt | 59 +++++++++++++++++++ .../tracing/SentryTracingFilterTest.kt | 20 +++++++ .../api/sentry-spring-jakarta.api | 5 ++ .../jakarta/SentryExceptionResolver.java | 4 +- ...tryRequestHttpServletRequestProcessor.java | 15 ++++- .../spring/jakarta/SentrySpringFilter.java | 3 +- .../jakarta/TransactionNameProviderUtils.java | 51 ++++++++++++++++ .../CombinedTransactionNameProvider.java | 55 +++++++++++++---- .../jakarta/tracing/SentryTracingFilter.java | 11 +++- .../jakarta/SentryExceptionResolverTest.kt | 20 +++++++ ...yRequestHttpServletRequestProcessorTest.kt | 33 ++++++++++- .../TransactionNameProviderUtilsTest.kt | 38 ++++++++++++ .../CombinedTransactionNameProviderTest.kt | 59 +++++++++++++++++++ .../tracing/SentryTracingFilterTest.kt | 20 +++++++ sentry-spring/api/sentry-spring.api | 5 ++ .../spring/SentryExceptionResolver.java | 4 +- ...tryRequestHttpServletRequestProcessor.java | 15 ++++- .../io/sentry/spring/SentrySpringFilter.java | 3 +- .../spring/TransactionNameProviderUtils.java | 51 ++++++++++++++++ .../CombinedTransactionNameProvider.java | 53 +++++++++++++---- .../spring/tracing/SentryTracingFilter.java | 11 +++- .../spring/SentryExceptionResolverTest.kt | 20 +++++++ ...yRequestHttpServletRequestProcessorTest.kt | 33 ++++++++++- .../TransactionNameProviderUtilsTest.kt | 38 ++++++++++++ .../CombinedTransactionNameProviderTest.kt | 59 +++++++++++++++++++ .../spring/tracing/SentryTracingFilterTest.kt | 20 +++++++ 36 files changed, 887 insertions(+), 53 deletions(-) create mode 100644 sentry-spring-7/src/main/java/io/sentry/spring7/TransactionNameProviderUtils.java create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/TransactionNameProviderUtilsTest.kt create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/CombinedTransactionNameProviderTest.kt create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/TransactionNameProviderUtils.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/TransactionNameProviderUtilsTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProviderTest.kt create mode 100644 sentry-spring/src/main/java/io/sentry/spring/TransactionNameProviderUtils.java create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/TransactionNameProviderUtilsTest.kt create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/tracing/CombinedTransactionNameProviderTest.kt diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index 3b2872afc67..9c55ded782d 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -104,6 +104,11 @@ public final class io/sentry/spring7/SpringSecuritySentryUserProvider : io/sentr public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring7/TransactionNameProviderUtils { + public static fun provideTransactionName (Lio/sentry/spring7/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;Lio/sentry/ILogger;)Ljava/lang/String; + public static fun provideTransactionNameAndSource (Lio/sentry/spring7/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;Lio/sentry/ILogger;Lio/sentry/spring7/tracing/TransactionNameWithSource;)Lio/sentry/spring7/tracing/TransactionNameWithSource; +} + public final class io/sentry/spring7/cache/SentryCacheBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { public fun ()V public fun getOrder ()I diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java index f7110aa0f21..4fe1f0c84e5 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java @@ -75,7 +75,9 @@ protected SentryEvent createEvent( new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); final SentryEvent event = new SentryEvent(throwable); event.setLevel(SentryLevel.FATAL); - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, scopes.getOptions().getLogger())); return event; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java index 680f560fffc..2154539c8f5 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java @@ -2,6 +2,8 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.ScopesAdapter; import io.sentry.SentryEvent; import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring7.tracing.TransactionNameProvider; @@ -15,19 +17,30 @@ public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; + private final @NotNull ILogger logger; public SentryRequestHttpServletRequestProcessor( final @NotNull TransactionNameProvider transactionNameProvider, final @NotNull HttpServletRequest request) { + this(transactionNameProvider, request, ScopesAdapter.getInstance().getOptions().getLogger()); + } + + SentryRequestHttpServletRequestProcessor( + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { this.transactionNameProvider = Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); this.request = Objects.requireNonNull(request, "request is required"); + this.logger = Objects.requireNonNull(logger, "logger is required"); } @Override public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { if (event.getTransaction() == null) { - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, logger)); } return event; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java index de766504aff..869eb1e3f5b 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java @@ -91,7 +91,8 @@ private void configureScope( // transaction name is known at the later stage of request processing, thus it cannot // be set on the scope scope.addEventProcessor( - new SentryRequestHttpServletRequestProcessor(transactionNameProvider, request)); + new SentryRequestHttpServletRequestProcessor( + transactionNameProvider, request, scopes.getOptions().getLogger())); // only if request caches body, add an event processor that sets body on the event // body is not on the scope, to avoid using memory when no event is triggered during // request processing diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/TransactionNameProviderUtils.java b/sentry-spring-7/src/main/java/io/sentry/spring7/TransactionNameProviderUtils.java new file mode 100644 index 00000000000..2bff1737d6b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/TransactionNameProviderUtils.java @@ -0,0 +1,51 @@ +package io.sentry.spring7; + +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.spring7.tracing.TransactionNameProvider; +import io.sentry.spring7.tracing.TransactionNameWithSource; +import io.sentry.util.ExceptionUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class TransactionNameProviderUtils { + private TransactionNameProviderUtils() {} + + public static @Nullable String provideTransactionName( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { + try { + return provider.provideTransactionName(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return DEFAULT_TRANSACTION_NAME; + } + } + + public static @NotNull TransactionNameWithSource provideTransactionNameAndSource( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger, + final @NotNull TransactionNameWithSource fallbackOnError) { + try { + return provider.provideTransactionNameAndSource(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return fallbackOnError; + } + } + + private static void logCallbackError( + final @NotNull ILogger logger, final @NotNull Throwable throwable) { + logger.log( + SentryLevel.ERROR, "The TransactionNameProvider callback threw an exception.", throwable); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java index a211ee6c759..f60f98a013d 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java @@ -1,6 +1,11 @@ package io.sentry.spring7.tracing; +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.ExceptionUtils; import jakarta.servlet.http.HttpServletRequest; import java.util.List; import org.jetbrains.annotations.ApiStatus; @@ -21,15 +26,21 @@ public CombinedTransactionNameProvider(final @NotNull List() private val transactionNameProvider = mock() + private val options = SentryOptions() private val request = mock() private val response = mock() + init { + whenever(scopes.options).thenReturn(options) + } + @Test fun `when handles exception, sets wrapped exception for event`() { val eventCaptor = argumentCaptor() @@ -70,6 +77,19 @@ class SentryExceptionResolverTest { verify(transactionNameProvider).provideTransactionName(request) } + @Test + fun `when transaction name provider fails, captures event with safe fallback`() { + val failure = RuntimeException("provider failed") + whenever(transactionNameProvider.provideTransactionName(any())).thenThrow(failure) + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("request failed")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + @Test fun `when handles exception, provides spring resolver hint`() { val hintCaptor = argumentCaptor() diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt index 4c2276275f7..6270e8e045f 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,15 +1,20 @@ package io.sentry.spring7 import io.sentry.Hint +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider +import io.sentry.spring7.tracing.TransactionNameProvider import jakarta.servlet.http.HttpServletRequest import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.springframework.mock.web.MockServletContext @@ -19,13 +24,15 @@ import org.springframework.web.servlet.HandlerMapping class SentryRequestHttpServletRequestProcessorTest { private class Fixture { val scopes = mock() + val logger = mock() fun getSut( request: HttpServletRequest, options: SentryOptions = SentryOptions(), + provider: TransactionNameProvider = SpringMvcTransactionNameProvider(), ): SentryRequestHttpServletRequestProcessor { whenever(scopes.options).thenReturn(options) - return SentryRequestHttpServletRequestProcessor(SpringMvcTransactionNameProvider(), request) + return SentryRequestHttpServletRequestProcessor(provider, request, logger) } } @@ -46,6 +53,30 @@ class SentryRequestHttpServletRequestProcessorTest { assertEquals("GET /some-path", event.transaction) } + @Test + fun `provider failure uses safe transaction name`() { + val failure = RuntimeException("provider failed") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + val event = SentryEvent() + + eventProcessor.process(event, Hint()) + + assertThat(event.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + + assertThat(assertFailsWith { eventProcessor.process(SentryEvent(), Hint()) }) + .isSameAs(failure) + } + @Test fun `when event has transaction name set, does not overwrite transaction name with value from the current request`() { val request = diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/TransactionNameProviderUtilsTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/TransactionNameProviderUtilsTest.kt new file mode 100644 index 00000000000..80b275d8cca --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/TransactionNameProviderUtilsTest.kt @@ -0,0 +1,38 @@ +package io.sentry.spring7 + +import io.sentry.ILogger +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.spring7.tracing.TransactionNameProvider +import jakarta.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class TransactionNameProviderUtilsTest { + private val request = mock() + private val logger = mock() + + @Test + fun `single provider failure uses error fallback`() { + val failure = RuntimeException("provider failed") + val provider = TransactionNameProvider { throw failure } + + val result = TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + + assertThat(result).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `single provider fatal failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = TransactionNameProvider { throw failure } + + assertThat( + assertFailsWith { + TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + } + ) + .isSameAs(failure) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/CombinedTransactionNameProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/CombinedTransactionNameProviderTest.kt new file mode 100644 index 00000000000..3b8af5066a3 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/CombinedTransactionNameProviderTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring7.tracing + +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.protocol.TransactionNameSource +import jakarta.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class CombinedTransactionNameProviderTest { + private val request = mock() + + @Test + fun `continues after provider failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val successfulProvider = + object : TransactionNameProvider { + override fun provideTransactionName(request: HttpServletRequest) = "GET /users/{id}" + + override fun provideTransactionSource() = TransactionNameSource.ROUTE + } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, successfulProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo("GET /users/{id}") + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.ROUTE) + } + + @Test + fun `uses safe fallback when none succeed after failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val emptyProvider = TransactionNameProvider { null } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, emptyProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo(DEFAULT_TRANSACTION_NAME) + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.CUSTOM) + } + + @Test + fun `preserves null when none resolve without failure`() { + val provider = CombinedTransactionNameProvider(listOf(TransactionNameProvider { null })) + + assertThat(provider.provideTransactionName(request)).isNull() + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = + CombinedTransactionNameProvider(listOf(TransactionNameProvider { throw failure })) + + assertThat(assertFailsWith { provider.provideTransactionName(request) }) + .isSameAs(failure) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt index 9778a6d0154..fae0f19ebe6 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TransactionContext +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.TransactionOptions import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction @@ -140,6 +141,25 @@ class SentryTracingFilterTest { ) } + @Test + fun `transaction name provider failure uses safe fallback and request continues`() { + val filter = fixture.getSut() + val failure = RuntimeException("provider failed") + whenever(fixture.transactionNameProvider.provideTransactionNameAndSource(fixture.request)) + .thenThrow(failure) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index be94039b754..0bdcca50de8 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -104,6 +104,11 @@ public final class io/sentry/spring/jakarta/SpringSecuritySentryUserProvider : i public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/jakarta/TransactionNameProviderUtils { + public static fun provideTransactionName (Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;Lio/sentry/ILogger;)Ljava/lang/String; + public static fun provideTransactionNameAndSource (Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;Lio/sentry/ILogger;Lio/sentry/spring/jakarta/tracing/TransactionNameWithSource;)Lio/sentry/spring/jakarta/tracing/TransactionNameWithSource; +} + public final class io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { public fun ()V public fun getOrder ()I diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java index efa98ef581b..9beba4d94fd 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java @@ -75,7 +75,9 @@ protected SentryEvent createEvent( new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); final SentryEvent event = new SentryEvent(throwable); event.setLevel(SentryLevel.FATAL); - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, scopes.getOptions().getLogger())); return event; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java index b18b5a914e7..e87aff165d7 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -2,6 +2,8 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.ScopesAdapter; import io.sentry.SentryEvent; import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.jakarta.tracing.TransactionNameProvider; @@ -15,19 +17,30 @@ public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; + private final @NotNull ILogger logger; public SentryRequestHttpServletRequestProcessor( final @NotNull TransactionNameProvider transactionNameProvider, final @NotNull HttpServletRequest request) { + this(transactionNameProvider, request, ScopesAdapter.getInstance().getOptions().getLogger()); + } + + SentryRequestHttpServletRequestProcessor( + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { this.transactionNameProvider = Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); this.request = Objects.requireNonNull(request, "request is required"); + this.logger = Objects.requireNonNull(logger, "logger is required"); } @Override public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { if (event.getTransaction() == null) { - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, logger)); } return event; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java index 3ce21ed4ce5..ae26af643ad 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java @@ -91,7 +91,8 @@ private void configureScope( // transaction name is known at the later stage of request processing, thus it cannot // be set on the scope scope.addEventProcessor( - new SentryRequestHttpServletRequestProcessor(transactionNameProvider, request)); + new SentryRequestHttpServletRequestProcessor( + transactionNameProvider, request, scopes.getOptions().getLogger())); // only if request caches body, add an event processor that sets body on the event // body is not on the scope, to avoid using memory when no event is triggered during // request processing diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/TransactionNameProviderUtils.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/TransactionNameProviderUtils.java new file mode 100644 index 00000000000..35035cb7737 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/TransactionNameProviderUtils.java @@ -0,0 +1,51 @@ +package io.sentry.spring.jakarta; + +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.spring.jakarta.tracing.TransactionNameProvider; +import io.sentry.spring.jakarta.tracing.TransactionNameWithSource; +import io.sentry.util.ExceptionUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class TransactionNameProviderUtils { + private TransactionNameProviderUtils() {} + + public static @Nullable String provideTransactionName( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { + try { + return provider.provideTransactionName(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return DEFAULT_TRANSACTION_NAME; + } + } + + public static @NotNull TransactionNameWithSource provideTransactionNameAndSource( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger, + final @NotNull TransactionNameWithSource fallbackOnError) { + try { + return provider.provideTransactionNameAndSource(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return fallbackOnError; + } + } + + private static void logCallbackError( + final @NotNull ILogger logger, final @NotNull Throwable throwable) { + logger.log( + SentryLevel.ERROR, "The TransactionNameProvider callback threw an exception.", throwable); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProvider.java index 1e142e958dc..e9ca80b4f21 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProvider.java @@ -1,6 +1,11 @@ package io.sentry.spring.jakarta.tracing; +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.ExceptionUtils; import jakarta.servlet.http.HttpServletRequest; import java.util.List; import org.jetbrains.annotations.ApiStatus; @@ -21,15 +26,21 @@ public CombinedTransactionNameProvider(final @NotNull List() private val transactionNameProvider = mock() + private val options = SentryOptions() private val request = mock() private val response = mock() + init { + whenever(scopes.options).thenReturn(options) + } + @Test fun `when handles exception, sets wrapped exception for event`() { val eventCaptor = argumentCaptor() @@ -70,6 +77,19 @@ class SentryExceptionResolverTest { verify(transactionNameProvider).provideTransactionName(request) } + @Test + fun `when transaction name provider fails, captures event with safe fallback`() { + val failure = RuntimeException("provider failed") + whenever(transactionNameProvider.provideTransactionName(any())).thenThrow(failure) + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("request failed")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + @Test fun `when handles exception, provides spring resolver hint`() { val hintCaptor = argumentCaptor() diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessorTest.kt index a63f00a788f..818090afc25 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,15 +1,20 @@ package io.sentry.spring.jakarta import io.sentry.Hint +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.spring.jakarta.tracing.SpringMvcTransactionNameProvider +import io.sentry.spring.jakarta.tracing.TransactionNameProvider import jakarta.servlet.http.HttpServletRequest import java.net.URI import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.springframework.mock.web.MockServletContext @@ -19,13 +24,15 @@ import org.springframework.web.servlet.HandlerMapping class SentryRequestHttpServletRequestProcessorTest { private class Fixture { val scopes = mock() + val logger = mock() fun getSut( request: HttpServletRequest, options: SentryOptions = SentryOptions(), + provider: TransactionNameProvider = SpringMvcTransactionNameProvider(), ): SentryRequestHttpServletRequestProcessor { whenever(scopes.options).thenReturn(options) - return SentryRequestHttpServletRequestProcessor(SpringMvcTransactionNameProvider(), request) + return SentryRequestHttpServletRequestProcessor(provider, request, logger) } } @@ -46,6 +53,30 @@ class SentryRequestHttpServletRequestProcessorTest { assertEquals("GET /some-path", event.transaction) } + @Test + fun `provider failure uses safe transaction name`() { + val failure = RuntimeException("provider failed") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + val event = SentryEvent() + + eventProcessor.process(event, Hint()) + + assertThat(event.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + + assertThat(assertFailsWith { eventProcessor.process(SentryEvent(), Hint()) }) + .isSameAs(failure) + } + @Test fun `when event has transaction name set, does not overwrite transaction name with value from the current request`() { val request = diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/TransactionNameProviderUtilsTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/TransactionNameProviderUtilsTest.kt new file mode 100644 index 00000000000..c1a91c15503 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/TransactionNameProviderUtilsTest.kt @@ -0,0 +1,38 @@ +package io.sentry.spring.jakarta + +import io.sentry.ILogger +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.spring.jakarta.tracing.TransactionNameProvider +import jakarta.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class TransactionNameProviderUtilsTest { + private val request = mock() + private val logger = mock() + + @Test + fun `single provider failure uses error fallback`() { + val failure = RuntimeException("provider failed") + val provider = TransactionNameProvider { throw failure } + + val result = TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + + assertThat(result).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `single provider fatal failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = TransactionNameProvider { throw failure } + + assertThat( + assertFailsWith { + TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + } + ) + .isSameAs(failure) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProviderTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProviderTest.kt new file mode 100644 index 00000000000..29b17c2a4b5 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/CombinedTransactionNameProviderTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.jakarta.tracing + +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.protocol.TransactionNameSource +import jakarta.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class CombinedTransactionNameProviderTest { + private val request = mock() + + @Test + fun `continues after provider failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val successfulProvider = + object : TransactionNameProvider { + override fun provideTransactionName(request: HttpServletRequest) = "GET /users/{id}" + + override fun provideTransactionSource() = TransactionNameSource.ROUTE + } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, successfulProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo("GET /users/{id}") + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.ROUTE) + } + + @Test + fun `uses safe fallback when none succeed after failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val emptyProvider = TransactionNameProvider { null } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, emptyProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo(DEFAULT_TRANSACTION_NAME) + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.CUSTOM) + } + + @Test + fun `preserves null when none resolve without failure`() { + val provider = CombinedTransactionNameProvider(listOf(TransactionNameProvider { null })) + + assertThat(provider.provideTransactionName(request)).isNull() + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = + CombinedTransactionNameProvider(listOf(TransactionNameProvider { throw failure })) + + assertThat(assertFailsWith { provider.provideTransactionName(request) }) + .isSameAs(failure) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt index ffdd2b9ad75..61fabb6d478 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TransactionContext +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.TransactionOptions import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction @@ -140,6 +141,25 @@ class SentryTracingFilterTest { ) } + @Test + fun `transaction name provider failure uses safe fallback and request continues`() { + val filter = fixture.getSut() + val failure = RuntimeException("provider failed") + whenever(fixture.transactionNameProvider.provideTransactionNameAndSource(fixture.request)) + .thenThrow(failure) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 7c8ff495f3b..7b36f268231 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -104,6 +104,11 @@ public final class io/sentry/spring/SpringSecuritySentryUserProvider : io/sentry public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/TransactionNameProviderUtils { + public static fun provideTransactionName (Lio/sentry/spring/tracing/TransactionNameProvider;Ljavax/servlet/http/HttpServletRequest;Lio/sentry/ILogger;)Ljava/lang/String; + public static fun provideTransactionNameAndSource (Lio/sentry/spring/tracing/TransactionNameProvider;Ljavax/servlet/http/HttpServletRequest;Lio/sentry/ILogger;Lio/sentry/spring/tracing/TransactionNameWithSource;)Lio/sentry/spring/tracing/TransactionNameWithSource; +} + public final class io/sentry/spring/cache/SentryCacheBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { public fun ()V public fun getOrder ()I diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java index ba0586eade5..968c64ff353 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java @@ -75,7 +75,9 @@ protected SentryEvent createEvent( new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); final SentryEvent event = new SentryEvent(throwable); event.setLevel(SentryLevel.FATAL); - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, scopes.getOptions().getLogger())); return event; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java index 829e39b8508..047572bd46b 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java @@ -2,6 +2,8 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.ScopesAdapter; import io.sentry.SentryEvent; import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.spring.tracing.TransactionNameProvider; @@ -15,19 +17,30 @@ public class SentryRequestHttpServletRequestProcessor implements SentryEventProcessor { private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull HttpServletRequest request; + private final @NotNull ILogger logger; public SentryRequestHttpServletRequestProcessor( final @NotNull TransactionNameProvider transactionNameProvider, final @NotNull HttpServletRequest request) { + this(transactionNameProvider, request, ScopesAdapter.getInstance().getOptions().getLogger()); + } + + SentryRequestHttpServletRequestProcessor( + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { this.transactionNameProvider = Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); this.request = Objects.requireNonNull(request, "request is required"); + this.logger = Objects.requireNonNull(logger, "logger is required"); } @Override public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { if (event.getTransaction() == null) { - event.setTransaction(transactionNameProvider.provideTransactionName(request)); + event.setTransaction( + TransactionNameProviderUtils.provideTransactionName( + transactionNameProvider, request, logger)); } return event; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java index be893b67a5a..5108238f4ad 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java @@ -91,7 +91,8 @@ private void configureScope( // transaction name is known at the later stage of request processing, thus it cannot // be set on the scope scope.addEventProcessor( - new SentryRequestHttpServletRequestProcessor(transactionNameProvider, request)); + new SentryRequestHttpServletRequestProcessor( + transactionNameProvider, request, scopes.getOptions().getLogger())); // only if request caches body, add an event processor that sets body on the event // body is not on the scope, to avoid using memory when no event is triggered during // request processing diff --git a/sentry-spring/src/main/java/io/sentry/spring/TransactionNameProviderUtils.java b/sentry-spring/src/main/java/io/sentry/spring/TransactionNameProviderUtils.java new file mode 100644 index 00000000000..9e726ae98a7 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/TransactionNameProviderUtils.java @@ -0,0 +1,51 @@ +package io.sentry.spring; + +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.spring.tracing.TransactionNameProvider; +import io.sentry.spring.tracing.TransactionNameWithSource; +import io.sentry.util.ExceptionUtils; +import javax.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class TransactionNameProviderUtils { + private TransactionNameProviderUtils() {} + + public static @Nullable String provideTransactionName( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger) { + try { + return provider.provideTransactionName(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return DEFAULT_TRANSACTION_NAME; + } + } + + public static @NotNull TransactionNameWithSource provideTransactionNameAndSource( + final @NotNull TransactionNameProvider provider, + final @NotNull HttpServletRequest request, + final @NotNull ILogger logger, + final @NotNull TransactionNameWithSource fallbackOnError) { + try { + return provider.provideTransactionNameAndSource(request); + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); + logCallbackError(logger, e); + return fallbackOnError; + } + } + + private static void logCallbackError( + final @NotNull ILogger logger, final @NotNull Throwable throwable) { + logger.log( + SentryLevel.ERROR, "The TransactionNameProvider callback threw an exception.", throwable); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/CombinedTransactionNameProvider.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/CombinedTransactionNameProvider.java index 384000b36b5..12b24963d5c 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/CombinedTransactionNameProvider.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/CombinedTransactionNameProvider.java @@ -1,6 +1,11 @@ package io.sentry.spring.tracing; +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.ExceptionUtils; import java.util.List; import javax.servlet.http.HttpServletRequest; import org.jetbrains.annotations.ApiStatus; @@ -22,14 +27,20 @@ public CombinedTransactionNameProvider(final @NotNull List() private val transactionNameProvider = mock() + private val options = SentryOptions() private val request = mock() private val response = mock() + init { + whenever(scopes.options).thenReturn(options) + } + @Test fun `when handles exception, sets wrapped exception for event`() { val eventCaptor = argumentCaptor() @@ -70,6 +77,19 @@ class SentryExceptionResolverTest { verify(transactionNameProvider).provideTransactionName(request) } + @Test + fun `when transaction name provider fails, captures event with safe fallback`() { + val failure = RuntimeException("provider failed") + whenever(transactionNameProvider.provideTransactionName(any())).thenThrow(failure) + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("request failed")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + @Test fun `when handles exception, provides spring resolver hint`() { val hintCaptor = argumentCaptor() diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt index 6ef40aaf3df..acccca29955 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,15 +1,20 @@ package io.sentry.spring import io.sentry.Hint +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.spring.tracing.SpringMvcTransactionNameProvider +import io.sentry.spring.tracing.TransactionNameProvider import java.net.URI import javax.servlet.http.HttpServletRequest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import org.assertj.core.api.Assertions.assertThat import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.springframework.mock.web.MockServletContext @@ -19,13 +24,15 @@ import org.springframework.web.servlet.HandlerMapping class SentryRequestHttpServletRequestProcessorTest { private class Fixture { val scopes = mock() + val logger = mock() fun getSut( request: HttpServletRequest, options: SentryOptions = SentryOptions(), + provider: TransactionNameProvider = SpringMvcTransactionNameProvider(), ): SentryRequestHttpServletRequestProcessor { whenever(scopes.options).thenReturn(options) - return SentryRequestHttpServletRequestProcessor(SpringMvcTransactionNameProvider(), request) + return SentryRequestHttpServletRequestProcessor(provider, request, logger) } } @@ -46,6 +53,30 @@ class SentryRequestHttpServletRequestProcessorTest { assertEquals("GET /some-path", event.transaction) } + @Test + fun `provider failure uses safe transaction name`() { + val failure = RuntimeException("provider failed") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + val event = SentryEvent() + + eventProcessor.process(event, Hint()) + + assertThat(event.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val request = mock() + val eventProcessor = + fixture.getSut(request, provider = TransactionNameProvider { throw failure }) + + assertThat(assertFailsWith { eventProcessor.process(SentryEvent(), Hint()) }) + .isSameAs(failure) + } + @Test fun `when event has transaction name set, does not overwrite transaction name with value from the current request`() { val request = diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/TransactionNameProviderUtilsTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/TransactionNameProviderUtilsTest.kt new file mode 100644 index 00000000000..550a1438e82 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/TransactionNameProviderUtilsTest.kt @@ -0,0 +1,38 @@ +package io.sentry.spring + +import io.sentry.ILogger +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.spring.tracing.TransactionNameProvider +import javax.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class TransactionNameProviderUtilsTest { + private val request = mock() + private val logger = mock() + + @Test + fun `single provider failure uses error fallback`() { + val failure = RuntimeException("provider failed") + val provider = TransactionNameProvider { throw failure } + + val result = TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + + assertThat(result).isEqualTo(DEFAULT_TRANSACTION_NAME) + } + + @Test + fun `single provider fatal failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = TransactionNameProvider { throw failure } + + assertThat( + assertFailsWith { + TransactionNameProviderUtils.provideTransactionName(provider, request, logger) + } + ) + .isSameAs(failure) + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/CombinedTransactionNameProviderTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/CombinedTransactionNameProviderTest.kt new file mode 100644 index 00000000000..0c61b5f9a60 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/CombinedTransactionNameProviderTest.kt @@ -0,0 +1,59 @@ +package io.sentry.spring.tracing + +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME +import io.sentry.protocol.TransactionNameSource +import javax.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.mock + +class CombinedTransactionNameProviderTest { + private val request = mock() + + @Test + fun `continues after provider failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val successfulProvider = + object : TransactionNameProvider { + override fun provideTransactionName(request: HttpServletRequest) = "GET /users/{id}" + + override fun provideTransactionSource() = TransactionNameSource.ROUTE + } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, successfulProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo("GET /users/{id}") + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.ROUTE) + } + + @Test + fun `uses safe fallback when none succeed after failure`() { + val failingProvider = TransactionNameProvider { throw RuntimeException("provider failed") } + val emptyProvider = TransactionNameProvider { null } + val provider = CombinedTransactionNameProvider(listOf(failingProvider, emptyProvider)) + + val result = provider.provideTransactionNameAndSource(request) + + assertThat(result.transactionName).isEqualTo(DEFAULT_TRANSACTION_NAME) + assertThat(result.transactionNameSource).isEqualTo(TransactionNameSource.CUSTOM) + } + + @Test + fun `preserves null when none resolve without failure`() { + val provider = CombinedTransactionNameProvider(listOf(TransactionNameProvider { null })) + + assertThat(provider.provideTransactionName(request)).isNull() + } + + @Test + fun `fatal provider failure propagates`() { + val failure = OutOfMemoryError("fatal") + val provider = + CombinedTransactionNameProvider(listOf(TransactionNameProvider { throw failure })) + + assertThat(assertFailsWith { provider.provideTransactionName(request) }) + .isSameAs(failure) + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt index f942da342c5..acfcdb4ebab 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext import io.sentry.TransactionContext +import io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME import io.sentry.TransactionOptions import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction @@ -140,6 +141,25 @@ class SentryTracingFilterTest { ) } + @Test + fun `transaction name provider failure uses safe fallback and request continues`() { + val filter = fixture.getSut() + val failure = RuntimeException("provider failed") + whenever(fixture.transactionNameProvider.provideTransactionNameAndSource(fixture.request)) + .thenThrow(failure) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.transaction).isEqualTo(DEFAULT_TRANSACTION_NAME) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) From 109d07e4b48bd07f18b96efd2d079b3b3e69c437 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:28:29 +0200 Subject: [PATCH 29/37] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc054e0be18..aa0177caf71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Keep Spring requests and event capture running when a `TransactionNameProvider` throws ([#6241](https://github.com/getsentry/sentry-java/pull/6241)) + ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) From 92e9ef5307681148df48da9198334dc484bb2bee Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 06:45:28 +0200 Subject: [PATCH 30/37] test(apollo): Fix callback test lint violations Use Kotlin's error helper in callback failure tests so Detekt accepts the intentional IllegalStateException across Apollo 2, 3, and 4. Co-Authored-By: Claude --- .../test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt | 2 +- .../java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt | 2 +- .../test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 09d298744bd..49d7d790a53 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -323,7 +323,7 @@ class SentryApollo3InterceptorTest { fixture.getSut( beforeSpan = { span, _, _ -> span.spanContext.sampled = false - throw IllegalStateException("callback failed") + error("callback failed") } ) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index ce94160303a..210cab90327 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -337,7 +337,7 @@ abstract class SentryApollo4HttpInterceptorTest( fixture.getSut( beforeSpan = { span, _, _ -> span.spanContext.sampled = false - throw IllegalStateException("callback failed") + error("callback failed") } ) diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index bc09f86a4e6..498b4663cac 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -259,7 +259,7 @@ class SentryApolloInterceptorTest { whenever(fixture.scopes.span).thenReturn(tx) val sut = fixture.getSut { span, _, _ -> span.spanContext.sampled = false - throw IllegalStateException("callback failed") + error("callback failed") } assertThat(sut.query(LaunchDetailsQuery.builder().id("83").build()).await().data).isNotNull() From 27184c688ea4ceafb071861c53ed02bcad4daf7f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 9 Oct 2026 16:35:27 +0200 Subject: [PATCH 31/37] fix(spring): Include user provider name in error log Identify which SentryUserProvider failed when multiple providers are configured, making callback failures actionable across all Spring variants. Refs GH-6240 Co-Authored-By: Claude --- .../src/main/java/io/sentry/spring7/SentryUserFilter.java | 6 +++++- .../java/io/sentry/spring/jakarta/SentryUserFilter.java | 6 +++++- .../src/main/java/io/sentry/spring/SentryUserFilter.java | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java index ac592cb9ae8..5b4afe069df 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java @@ -55,7 +55,11 @@ protected void doFilterInternal( scopes .getOptions() .getLogger() - .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + .log( + SentryLevel.ERROR, + e, + "The SentryUserProvider callback %s threw an exception.", + provider.getClass().getName()); } } if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java index ab05404fb18..796dee9da7e 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java @@ -55,7 +55,11 @@ protected void doFilterInternal( scopes .getOptions() .getLogger() - .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + .log( + SentryLevel.ERROR, + e, + "The SentryUserProvider callback %s threw an exception.", + provider.getClass().getName()); } } if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java index ed7f8071076..00b2ed9b62f 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java @@ -55,7 +55,11 @@ protected void doFilterInternal( scopes .getOptions() .getLogger() - .log(SentryLevel.ERROR, "The SentryUserProvider callback threw an exception.", e); + .log( + SentryLevel.ERROR, + e, + "The SentryUserProvider callback %s threw an exception.", + provider.getClass().getName()); } } if (!providerFailed && scopes.getOptions().getDataCollectionResolver().isUserInfo()) { From e32efe0d3f76d7432656d40b3b3415ae84ac820a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 09:42:25 +0200 Subject: [PATCH 32/37] test(android): Verify configurator runs after configuration failure Ensure a failed legacy configuration does not prevent the configurator from receiving the original feedback options and applying its changes. Co-Authored-By: Claude --- .../android/core/SentryUserFeedbackFormTest.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index b28cb9197cc..6115ac335f9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -165,22 +165,31 @@ class SentryUserFeedbackFormTest { } @Test - fun `when configuration throws, its partial changes are ignored`() { + fun `when configuration throws, configurator still runs with original options`() { fixture.options.isEnabled = true val originalTitle = fixture.options.feedbackOptions.formTitle + val customNameLabel = "custom name label" val failure = IllegalStateException("configuration failed") + var titleReceivedByConfigurator: CharSequence? = null val sut = fixture.getSut( configuration = { _, options -> options.formTitle = "partial title" throw failure - } + }, + configurator = { options -> + titleReceivedByConfigurator = options.formTitle + options.nameLabel = customNameLabel + }, ) sut.show() + assertThat(titleReceivedByConfigurator).isEqualTo(originalTitle) assertThat(sut.findViewById(R.id.sentry_dialog_user_feedback_title).text) .isEqualTo(originalTitle) + assertThat(sut.findViewById(R.id.sentry_dialog_user_feedback_txt_name).text) + .isEqualTo(customNameLabel) } @Test From 08377ad4e4a92023676015d56024d37562f1e7be Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 09:46:18 +0200 Subject: [PATCH 33/37] test(android): Verify configurator propagates fatal errors Ensure fatal errors from the feedback options configurator are rethrown instead of being handled as recoverable callback failures. Co-Authored-By: Claude --- .../sentry/android/core/SentryUserFeedbackFormTest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt index 6115ac335f9..19414515b87 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackFormTest.kt @@ -223,6 +223,16 @@ class SentryUserFeedbackFormTest { .isEqualTo("configured title") } + @Test + fun `when configurator throws a fatal error, it is not swallowed`() { + val failure = OutOfMemoryError("configurator failed") + + val thrown = + assertFailsWith { fixture.getSut(configurator = { throw failure }) } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `dialog window does not have FLAG_ALT_FOCUSABLE_IM so soft keyboard can appear`() { fixture.options.isEnabled = true From 6e599191d190fbbdae5d5455e5f057e009aecdf3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 12:25:02 +0200 Subject: [PATCH 34/37] docs(changelog): Consolidate callback error fixes Move the callback error handling entry back to Unreleased and group the related changes by impact so the full behavior change is documented in one place. Co-Authored-By: Claude --- CHANGELOG.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 730a320156d..39c9d0d06e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,19 @@ ### Fixes -- Keep Spring requests and event capture running when a `TransactionNameProvider` throws ([#6241](https://github.com/getsentry/sentry-java/pull/6241)) -- Keep the Android feedback form available when an options configurator throws ([#6239](https://github.com/getsentry/sentry-java/pull/6239)) -- Keep Spring requests running when a `SentryUserProvider` throws and discard the incomplete user identity ([#6240](https://github.com/getsentry/sentry-java/pull/6240)) -- Drop Apollo 5 spans when `beforeSpan` throws without disrupting the GraphQL request ([#6238](https://github.com/getsentry/sentry-java/pull/6238)) +- Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) + - Drop telemetry when a `beforeSend*` callback throws and report it using `DiscardReason.CALLBACK_ERROR`. + - Drop transactions when `tracesSampler` throws instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)). + - Drop spans when `beforeSpan` throws without disrupting requests, covering OkHttp, OpenFeign, GraphQL, Ktor, and Apollo—including Apollo 5 ([#6167](https://github.com/getsentry/sentry-java/pull/6167), [#6238](https://github.com/getsentry/sentry-java/pull/6238)). + - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues. + - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. + - No longer fail Spring requests when a `TransactionNameProvider` throws, falling back to `` ([#6241](https://github.com/getsentry/sentry-java/pull/6241)). + - No longer fail Spring requests when a `SentryUserProvider` throws and overwrite the user with an empty user object ([#6240](https://github.com/getsentry/sentry-java/pull/6240)). + - Disable profiling when `profilesSampler` throws instead of falling back to `profilesSampleRate` or inheriting the parent profiling decision ([#6164](https://github.com/getsentry/sentry-java/pull/6164)). + - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166)). + - Skip replay capture when `beforeErrorSampling` throws while retaining the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)). + - Skip Android screenshots or view hierarchies when their capture callbacks throw while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167)). + - Keep the Android feedback form available when its options configurator throws ([#6239](https://github.com/getsentry/sentry-java/pull/6239)). ### Dependencies @@ -75,16 +84,6 @@ ### Fixes -- Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140)) - - Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value. - - Report attached profiles dropped by transaction callback errors as `callback_error` in client reports and `OnDiscardCallback` ([#6166](https://github.com/getsentry/sentry-java/pull/6166)) - - Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report. - - Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb. - - Skip Android screenshot or view hierarchy capture when its capture callback throws, while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) - - Drop spans when `beforeSpan` throws in OkHttp, OpenFeign, GraphQL, Ktor, or Apollo, without disrupting the request. Report lost sampled spans as `callback_error` in client reports and `OnDiscardCallback` ([#6167](https://github.com/getsentry/sentry-java/pull/6167)) - - Skip replay capture when `beforeErrorSampling` throws, while still sending the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)) - - When `tracesSampler` throws, drop the transaction and record `callback_error` instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)) - - When `profilesSampler` throws, disable profiling instead of falling back to `profilesSampleRate` or inheriting the parent's profiling decision. Trace sampling is unchanged ([#6164](https://github.com/getsentry/sentry-java/pull/6164)) - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) - Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003)) From cda00a7d4ca3e41e539b87dd772f789a1e9d0733 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 12:30:05 +0200 Subject: [PATCH 35/37] fix(callbacks): Handle nonfatal callback throwables Catch nonfatal Throwable failures from newly guarded customer callbacks while continuing to propagate fatal JVM errors. This keeps callback isolation consistent without introducing new fatal-error swallowing. Add regression coverage for nonfatal fallback behavior and fatal error propagation across the affected integrations. Refs #6081 Co-Authored-By: Claude --- .../core/ScreenshotEventProcessor.java | 4 ++- .../core/ViewHierarchyEventProcessor.java | 4 ++- .../core/ScreenshotEventProcessorTest.kt | 18 +++++++++-- .../core/ViewHierarchyEventProcessorTest.kt | 17 ++++++++-- .../sentry/apollo/SentryApolloInterceptor.kt | 4 ++- .../apollo/SentryApolloInterceptorTest.kt | 31 ++++++++++++++++++- .../graphql22/SentryInstrumentationTest.kt | 18 +++++++++-- .../graphql/SentryGraphqlInstrumentation.java | 4 ++- .../graphql/SentryInstrumentationTest.kt | 18 +++++++++-- .../ktorClient/SentryKtorClientPlugin.kt | 4 ++- .../ktorClient/SentryKtorClientPluginTest.kt | 18 +++++++++-- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 4 ++- .../okhttp/SentryOkHttpInterceptorTest.kt | 17 ++++++++-- .../sentry/openfeign/SentryFeignClient.java | 4 ++- .../sentry/openfeign/SentryFeignClientTest.kt | 14 +++++++-- 15 files changed, 156 insertions(+), 23 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 6932b6be3f6..6283b47f95d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -19,6 +19,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode; import io.sentry.internal.eventprocessor.SentryEventProcessor; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.ExceptionUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.util.concurrent.CountDownLatch; @@ -116,7 +117,8 @@ private boolean isMaskingEnabled() { if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { return event; } - } catch (Exception e) { + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); options .getLogger() .log( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 00753ee4c90..ffd3323e601 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -22,6 +22,7 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.util.ExceptionUtils; import io.sentry.util.HintUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; @@ -95,7 +96,8 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) { return event; } - } catch (Exception e) { + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); options .getLogger() .log( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index 635d7a308c5..0abb232b807 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -44,6 +44,7 @@ import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -315,12 +316,12 @@ class ScreenshotEventProcessorTest { } @Test - fun `when capture callback throws, skips screenshot and retains event`() { + fun `when capture callback throws a nonfatal throwable, skips screenshot and retains event`() { CurrentActivityHolder.getInstance().setActivity(fixture.activity) val logger = mock() fixture.options.isDebug = true fixture.options.setLogger(logger) - val failure = IllegalStateException("callback failed") + val failure = AssertionError("callback failed") fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> throw failure } val processor = fixture.getSut(true) val event = SentryEvent().apply { exceptions = listOf(SentryException()) } @@ -341,6 +342,19 @@ class ScreenshotEventProcessorTest { assertThat(nextHint.screenshot).isNotNull() } + @Test + fun `when capture callback throws a fatal error, it is not swallowed`() { + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + val failure = OutOfMemoryError("callback failed") + fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ -> throw failure } + val processor = fixture.getSut(true) + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + + val thrown = assertFailsWith { processor.process(event, Hint()) } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `when capture callback returns true, a screenshot should be captured`() { CurrentActivityHolder.getInstance().setActivity(fixture.activity) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt index 6f19035a8d9..1396ae09e3c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt @@ -19,6 +19,7 @@ import java.io.Writer import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -345,10 +346,10 @@ class ViewHierarchyEventProcessorTest { } @Test - fun `when capture callback throws, skips view hierarchy and retains event`() { + fun `when capture callback throws a nonfatal throwable, skips view hierarchy and retains event`() { fixture.options.isDebug = true fixture.options.setLogger(fixture.logger) - val failure = IllegalStateException("callback failed") + val failure = AssertionError("callback failed") fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> throw failure } val processor = fixture.getSut(true) val event = SentryEvent().apply { exceptions = listOf(SentryException()) } @@ -369,6 +370,18 @@ class ViewHierarchyEventProcessorTest { assertThat(nextHint.viewHierarchy).isNotNull() } + @Test + fun `when capture callback throws a fatal error, it is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> throw failure } + val processor = fixture.getSut(true) + val event = SentryEvent().apply { exceptions = listOf(SentryException()) } + + val thrown = assertFailsWith { processor.process(event, Hint()) } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `when capture callback returns true, a view hierarchy should be captured`() { fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ -> true } diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index 3524d32dc1f..dcefb0dd5f0 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -26,6 +26,7 @@ import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE import io.sentry.clientreport.DiscardReason +import io.sentry.util.ExceptionUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils @@ -180,7 +181,8 @@ class SentryApolloInterceptor( val wasSampled = span.isSampled == true try { newSpan = beforeSpan.execute(span, request, response) - } catch (e: Exception) { + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) span.spanContext.sampled = false if (wasSampled) { scopes.options.clientReportRecorder.recordLostEvent( diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index 498b4663cac..dd7991b1f88 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -3,6 +3,10 @@ package io.sentry.apollo import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.coroutines.await import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.interceptor.ApolloInterceptor.CallBack +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorRequest +import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorResponse +import com.apollographql.apollo.interceptor.ApolloInterceptorChain import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb @@ -29,6 +33,7 @@ import io.sentry.util.ApolloPlatformTestManipulator import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -274,7 +279,7 @@ class SentryApolloInterceptorTest { @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { - val failure = IllegalStateException("callback failed") + val failure = AssertionError("callback failed") val logger = mock() fixture.options.isDebug = true fixture.options.setLogger(logger) @@ -308,6 +313,30 @@ class SentryApolloInterceptorTest { ) } + @Test + fun `fatal error from beforeSpan is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + val tx = + SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + val interceptor = SentryApolloInterceptor(fixture.scopes) { _, _, _ -> throw failure } + val request = InterceptorRequest.builder(LaunchDetailsQuery.builder().id("83").build()).build() + val chain = mock() + doAnswer { + it.getArgument(2).onResponse(InterceptorResponse(null)) + null + } + .whenever(chain) + .proceedAsync(any(), any(), any()) + + val thrown = + assertFailsWith { + interceptor.interceptAsync(request, chain, { it.run() }, mock()) + } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `adds breadcrumb when http calls succeeds`() { executeQuery() diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt index 2331ab17aaf..a28ff2af582 100644 --- a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -35,9 +35,11 @@ import io.sentry.graphql.SentryGraphqlInstrumentation import io.sentry.graphql.SentrySubscriptionHandler import java.lang.RuntimeException import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -210,8 +212,8 @@ class SentryInstrumentationTest { } @Test - fun `when beforeSpan throws, drops span and preserves result`() { - val failure = IllegalStateException("callback failed") + fun `when beforeSpan throws a nonfatal throwable, drops span and preserves result`() { + val failure = AssertionError("callback failed") val logger = mock() val sut = fixture.getSut( @@ -239,6 +241,18 @@ class SentryInstrumentationTest { } } + @Test + fun `fatal error from beforeSpan is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + val sut = fixture.getSut(beforeSpan = { _, _, _ -> throw failure }) + + withMockScopes { + val thrown = assertFailsWith { sut.execute("{ shows { id } }") } + + assertThat(thrown.cause).isSameInstanceAs(failure) + } + } + @Test fun `when beforeSpan throws, drops async span and preserves result`() { val sut = diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java index 34044aea435..b1f2892500a 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -28,6 +28,7 @@ import io.sentry.SpanStatus; import io.sentry.TypeCheckHint; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.ExceptionUtils; import io.sentry.util.StringUtils; import java.util.Arrays; import java.util.List; @@ -290,7 +291,8 @@ private void finish( ISpan newSpan = span; try { newSpan = beforeSpan.execute(span, environment, result); - } catch (Exception e) { + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); span.getSpanContext().setSampled(false); if (wasSampled) { scopesFromContext(environment.getGraphQlContext()) diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 2652694b8f9..5f61ccfe912 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -31,9 +31,11 @@ import io.sentry.TransactionContext import io.sentry.clientreport.DiscardReason import java.lang.RuntimeException import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -206,8 +208,8 @@ class SentryInstrumentationTest { } @Test - fun `when beforeSpan throws, drops span and preserves result`() { - val failure = IllegalStateException("callback failed") + fun `when beforeSpan throws a nonfatal throwable, drops span and preserves result`() { + val failure = AssertionError("callback failed") val logger = mock() val sut = fixture.getSut( @@ -235,6 +237,18 @@ class SentryInstrumentationTest { } } + @Test + fun `fatal error from beforeSpan is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + val sut = fixture.getSut(beforeSpan = { _, _, _ -> throw failure }) + + withMockScopes { + val thrown = assertFailsWith { sut.execute("{ shows { id } }") } + + assertThat(thrown.cause).isSameInstanceAs(failure) + } + } + @Test fun `when beforeSpan throws, drops async span and preserves result`() { val sut = diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index 609ed239961..0e4538fb418 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -22,6 +22,7 @@ import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.clientreport.DiscardReason import io.sentry.kotlin.SentryContext +import io.sentry.util.ExceptionUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils @@ -193,7 +194,8 @@ public val SentryKtorClientPlugin: ClientPlugin = result = try { beforeSpan.execute(span, request) - } catch (e: Exception) { + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) span.spanContext.sampled = false if (wasSampled) { (if (forceScopes) scopes else Sentry.getCurrentScopes()) diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 4aadfe823a0..87d607f2553 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -38,6 +38,7 @@ import java.util.concurrent.TimeUnit import kotlin.Unit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -451,7 +452,7 @@ class SentryKtorClientPluginTest { fixture.getSut( beforeSpan = { span, _ -> span.spanContext.sampled = false - throw IllegalStateException("callback failed") + error("callback failed") } ) fixture.sentryTracer.spanContext.sampled = sampled @@ -469,7 +470,7 @@ class SentryKtorClientPluginTest { @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { - val failure = IllegalStateException("callback failed") + val failure = AssertionError("callback failed") val logger = mock() val sut = fixture.getSut( @@ -498,6 +499,19 @@ class SentryKtorClientPluginTest { ) } + @Test + fun `fatal error from beforeSpan is not swallowed`(): Unit = runBlocking { + val failure = OutOfMemoryError("callback failed") + val sut = fixture.getSut(beforeSpan = { _, _ -> throw failure }) + + val thrown = + assertFailsWith { + sut.use { sut.get(fixture.server.url("/hello").toString()) } + } + + assertThat(generateSequence(thrown) { it.cause }.last()).isSameInstanceAs(failure) + } + @Test fun `beforeSpan can drop span`(): Unit = runBlocking { val sut = fixture.getSut(beforeSpan = { _, _ -> null }) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 2f07a1de240..f29396d1068 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -22,6 +22,7 @@ import io.sentry.TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS import io.sentry.clientreport.DiscardReason import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider +import io.sentry.util.ExceptionUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils @@ -375,7 +376,8 @@ public open class SentryOkHttpInterceptor( val result = try { beforeSpan.execute(span, request, response) - } catch (e: Exception) { + } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { + ExceptionUtils.rethrowIfFatal(e) span.spanContext.sampled = false if (wasSampled) { scopes.options.clientReportRecorder.recordLostEvent( diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 59a8f121f59..7d099f8a971 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -62,6 +62,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +@Suppress("LargeClass") class SentryOkHttpInterceptorTest { class Fixture { val scopes = mock() @@ -463,7 +464,7 @@ class SentryOkHttpInterceptorTest { fixture.getSut( beforeSpan = { span, _, _ -> span.spanContext.sampled = false - throw IllegalStateException("callback failed") + error("callback failed") } ) fixture.sentryTracer.spanContext.sampled = sampled @@ -480,8 +481,8 @@ class SentryOkHttpInterceptorTest { } @Test - fun `when beforeSpan throws, drops span and preserves response`() { - val failure = IllegalStateException("callback failed") + fun `when beforeSpan throws a nonfatal throwable, drops span and preserves response`() { + val failure = AssertionError("callback failed") val logger = mock() val sut = fixture.getSut( @@ -509,6 +510,16 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `fatal error from beforeSpan is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + val sut = fixture.getSut(beforeSpan = { _, _, _ -> throw failure }) + + val thrown = assertFailsWith { sut.newCall(getRequest()).execute() } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `when beforeSpan throws, event listener still finishes span`() { val sut = diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index 7ed48d0dc10..25c93a15640 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -21,6 +21,7 @@ import io.sentry.SpanStatus; import io.sentry.W3CTraceparentHeader; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; @@ -102,7 +103,8 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O ISpan result = span; try { result = beforeSpan.execute(span, request, response); - } catch (Exception e) { + } catch (Throwable e) { + ExceptionUtils.rethrowIfFatal(e); span.getSpanContext().setSampled(false); if (wasSampled) { scopes diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 9a2bd98d365..5771802c5b8 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -319,8 +319,8 @@ class SentryFeignClientTest { } @Test - fun `when beforeSpan throws, drops span and preserves response`() { - val failure = IllegalStateException("callback failed") + fun `when beforeSpan throws a nonfatal throwable, drops span and preserves response`() { + val failure = AssertionError("callback failed") val logger = mock() fixture.sentryOptions.isDebug = true fixture.sentryOptions.setLogger(logger) @@ -345,6 +345,16 @@ class SentryFeignClientTest { ) } + @Test + fun `fatal error from beforeSpan is not swallowed`() { + val failure = OutOfMemoryError("callback failed") + val sut = fixture.getSut(beforeSpan = { _, _, _ -> throw failure }) + + val thrown = assertFailsWith { sut.getOk() } + + assertThat(thrown).isSameInstanceAs(failure) + } + @Test fun `when beforeSpan throws, preserves original request exception`() { val requestFailure = IOException("request failed") From d04cc24045c1f2c798f45a6a1e4e999cb3a473e1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 12:32:29 +0200 Subject: [PATCH 36/37] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c9d0d06e9..f9f54939727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Skip replay capture when `beforeErrorSampling` throws while retaining the error event ([#6165](https://github.com/getsentry/sentry-java/pull/6165)). - Skip Android screenshots or view hierarchies when their capture callbacks throw while retaining the error event ([#6167](https://github.com/getsentry/sentry-java/pull/6167)). - Keep the Android feedback form available when its options configurator throws ([#6239](https://github.com/getsentry/sentry-java/pull/6239)). + - Isolate nonfatal callback throwables while continuing to propagate fatal JVM errors ([#6251](https://github.com/getsentry/sentry-java/pull/6251)). ### Dependencies From 55fd40d72081bdacfd7923cfb50a19f3bf43eabb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 13 Oct 2026 15:50:17 +0200 Subject: [PATCH 37/37] fix(callbacks): Preserve Apollo 2 error handling Keep Apollo 2's existing Exception boundary unchanged so non-Exception errors retain their released behavior. Limit the broader nonfatal Throwable handling to callback guards introduced by the unreleased stack. Refs #6081 Co-Authored-By: Claude --- .../sentry/apollo/SentryApolloInterceptor.kt | 4 +-- .../apollo/SentryApolloInterceptorTest.kt | 31 +------------------ 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index dcefb0dd5f0..3524d32dc1f 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -26,7 +26,6 @@ import io.sentry.SpanStatus import io.sentry.TypeCheckHint.APOLLO_REQUEST import io.sentry.TypeCheckHint.APOLLO_RESPONSE import io.sentry.clientreport.DiscardReason -import io.sentry.util.ExceptionUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils @@ -181,8 +180,7 @@ class SentryApolloInterceptor( val wasSampled = span.isSampled == true try { newSpan = beforeSpan.execute(span, request, response) - } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { - ExceptionUtils.rethrowIfFatal(e) + } catch (e: Exception) { span.spanContext.sampled = false if (wasSampled) { scopes.options.clientReportRecorder.recordLostEvent( diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index dd7991b1f88..498b4663cac 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -3,10 +3,6 @@ package io.sentry.apollo import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.coroutines.await import com.apollographql.apollo.exception.ApolloException -import com.apollographql.apollo.interceptor.ApolloInterceptor.CallBack -import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorRequest -import com.apollographql.apollo.interceptor.ApolloInterceptor.InterceptorResponse -import com.apollographql.apollo.interceptor.ApolloInterceptorChain import com.google.common.truth.Truth.assertThat import io.sentry.BaggageHeader import io.sentry.Breadcrumb @@ -33,7 +29,6 @@ import io.sentry.util.ApolloPlatformTestManipulator import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -279,7 +274,7 @@ class SentryApolloInterceptorTest { @Test fun `when beforeSpan throws, drops span and preserves response`(): Unit = runBlocking { - val failure = AssertionError("callback failed") + val failure = IllegalStateException("callback failed") val logger = mock() fixture.options.isDebug = true fixture.options.setLogger(logger) @@ -313,30 +308,6 @@ class SentryApolloInterceptorTest { ) } - @Test - fun `fatal error from beforeSpan is not swallowed`() { - val failure = OutOfMemoryError("callback failed") - val tx = - SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) - whenever(fixture.scopes.span).thenReturn(tx) - val interceptor = SentryApolloInterceptor(fixture.scopes) { _, _, _ -> throw failure } - val request = InterceptorRequest.builder(LaunchDetailsQuery.builder().id("83").build()).build() - val chain = mock() - doAnswer { - it.getArgument(2).onResponse(InterceptorResponse(null)) - null - } - .whenever(chain) - .proceedAsync(any(), any(), any()) - - val thrown = - assertFailsWith { - interceptor.interceptAsync(request, chain, { it.run() }, mock()) - } - - assertThat(thrown).isSameInstanceAs(failure) - } - @Test fun `adds breadcrumb when http calls succeeds`() { executeQuery()