From 263875a0478aec61786feacf5dee19baa0b6a82c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 15:43:10 +0200 Subject: [PATCH 01/12] fix(core): Prevent recursion when a callback triggers another capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user beforeSend/beforeBreadcrumb/beforeSendLog callback that itself captures — directly, or transitively through a logging integration that routes back into Sentry (e.g. Timber, or the Gradle plugin's logcat instrumentation) — recursed until a StackOverflowError, because the callbacks run synchronously on the caller thread with no re-entrancy protection. Add a shared, thread-local SentryCallbackReentrancyGuard that is set only while a callback's execute() runs. Capture entry points (captureEvent, captureTransaction, captureLog, Scope.addBreadcrumb) drop nested captures while the guard is active, breaking the loop for every capture type at once. Dropping (rather than sending) the nested capture is intentional: bypassing beforeSend would send unscrubbed data. The nested capture is dropped, not sent. This also makes the per-integration guard in SentryLogcatAdapter redundant. Co-Authored-By: Claude Opus 4.8 --- .../timber/SentryTimberIntegrationTest.kt | 45 +++++++++++++ sentry/api/sentry.api | 6 ++ sentry/src/main/java/io/sentry/Scope.java | 8 +++ .../src/main/java/io/sentry/SentryClient.java | 45 +++++++++++++ .../util/SentryCallbackReentrancyGuard.java | 41 ++++++++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 22 +++++++ .../test/java/io/sentry/SentryClientTest.kt | 64 +++++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 43a45da7bb3..3c2cabfd5cc 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,10 +1,14 @@ package io.sentry.android.timber import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryLogLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion +import io.sentry.transport.ITransport import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -12,6 +16,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import timber.log.Timber class SentryTimberIntegrationTest { @@ -112,4 +117,44 @@ class SentryTimberIntegrationTest { assertTrue(fixture.options.sdkVersion!!.integrationSet.contains("Timber")) } + + @Test + fun `a beforeSend callback that logs via Timber does not recurse`() { + // End-to-end guard against SDK-CRASHES-JAVA-3T3H style recursion: with a real Sentry instance, + // a beforeSend callback that logs through the planted SentryTimberTree must not loop back into + // capture forever. + val transport = mock() + val transportFactory = mock() + whenever(transportFactory.create(any(), any())).thenReturn(transport) + + var beforeSendInvocations = 0 + Sentry.init { options -> + options.dsn = "https://key@sentry.io/123" + options.setTransportFactory(transportFactory) + options.beforeSend = + SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } + } + Timber.plant( + SentryTimberTree( + ScopesAdapter.getInstance(), + SentryLevel.ERROR, + SentryLevel.INFO, + SentryLogLevel.INFO, + ) + ) + + try { + Timber.e("outer error") + + // Without the core re-entrancy guard this recurses until a StackOverflowError. The nested + // Timber.e is dropped before its own beforeSend, so the callback runs exactly once. + assertEquals(1, beforeSendInvocations) + } finally { + Sentry.close() + } + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d1aecf5ccfd..582d1a7a10e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7924,6 +7924,12 @@ public final class io/sentry/util/ScopesUtil { public static fun printScopesChain (Lio/sentry/IScopes;)V } +public final class io/sentry/util/SentryCallbackReentrancyGuard { + public static fun enter ()V + public static fun exit ()V + public static fun isActive ()Z +} + public final class io/sentry/util/SentryRandom { public fun ()V public static fun current ()Lio/sentry/util/Random; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..ad5acd17ee8 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -16,6 +16,7 @@ import io.sentry.util.ExceptionUtils; import io.sentry.util.Objects; import io.sentry.util.Pair; +import io.sentry.util.SentryCallbackReentrancyGuard; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collection; @@ -465,6 +466,7 @@ public Queue getBreadcrumbs() { @NotNull Breadcrumb breadcrumb, final @NotNull Hint hint) { try { + SentryCallbackReentrancyGuard.enter(); breadcrumb = callback.execute(breadcrumb, hint); } catch (Throwable e) { options @@ -477,6 +479,8 @@ public Queue getBreadcrumbs() { if (e.getMessage() != null) { breadcrumb.setData("sentry:message", e.getMessage()); } + } finally { + SentryCallbackReentrancyGuard.exit(); } return breadcrumb; } @@ -493,6 +497,10 @@ public void addBreadcrumb(@NotNull Breadcrumb breadcrumb, @Nullable Hint hint) { if (breadcrumb == null || breadcrumbs instanceof DisabledQueue) { return; } + // Drop breadcrumbs added from within a user callback to prevent recursion. + if (SentryCallbackReentrancyGuard.isActive()) { + return; + } SentryOptions.BeforeBreadcrumbCallback callback = options.getBeforeBreadcrumb(); if (callback != null) { if (hint == null) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a0ff98a9af7..c37a40fd03c 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -107,6 +107,15 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @NotNull SentryEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SentryEvent is required."); + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Event captured from within a callback (beforeSend/beforeBreadcrumb/beforeSendLog) was dropped to prevent recursion."); + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -969,6 +978,15 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint final @Nullable ProfilingTraceData profilingTraceData) { Objects.requireNonNull(transaction, "Transaction is required."); + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction captured from within a callback was dropped to prevent recursion."); + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -1297,6 +1315,15 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @ApiStatus.Experimental @Override public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope) { + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Log captured from within a callback was dropped to prevent recursion."); + return; + } + if (logEvent != null && scope != null) { logEvent = processLogEvent(logEvent, scope.getEventProcessors()); if (logEvent == null) { @@ -1613,6 +1640,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendCallback beforeSend = options.getBeforeSend(); if (beforeSend != null) { try { + SentryCallbackReentrancyGuard.enter(); event = beforeSend.execute(event, hint); } catch (Throwable e) { options @@ -1624,6 +1652,8 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSend due to PII concerns event = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1635,6 +1665,7 @@ private void sortBreadcrumbsByDate( options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { try { + SentryCallbackReentrancyGuard.enter(); transaction = beforeSendTransaction.execute(transaction, hint); } catch (Throwable e) { options @@ -1646,6 +1677,8 @@ private void sortBreadcrumbsByDate( // drop transaction in case of an error in beforeSend due to PII concerns transaction = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return transaction; @@ -1656,6 +1689,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendCallback beforeSendFeedback = options.getBeforeSendFeedback(); if (beforeSendFeedback != null) { try { + SentryCallbackReentrancyGuard.enter(); event = beforeSendFeedback.execute(event, hint); } catch (Throwable e) { options @@ -1664,6 +1698,8 @@ private void sortBreadcrumbsByDate( // drop feedback in case of an error in beforeSend due to PII concerns event = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1674,6 +1710,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendReplayCallback beforeSendReplay = options.getBeforeSendReplay(); if (beforeSendReplay != null) { try { + SentryCallbackReentrancyGuard.enter(); event = beforeSendReplay.execute(event, hint); } catch (Throwable e) { options @@ -1685,6 +1722,8 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSend due to PII concerns event = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1695,6 +1734,7 @@ private void sortBreadcrumbsByDate( options.getLogs().getBeforeSend(); if (beforeSendLog != null) { try { + SentryCallbackReentrancyGuard.enter(); event = beforeSendLog.execute(event); } catch (Throwable e) { options @@ -1706,6 +1746,8 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSendLog due to PII concerns event = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1717,6 +1759,7 @@ private void sortBreadcrumbsByDate( options.getMetrics().getBeforeSend(); if (beforeSendMetric != null) { try { + SentryCallbackReentrancyGuard.enter(); event = beforeSendMetric.execute(event, hint); } catch (Throwable e) { options @@ -1728,6 +1771,8 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSendMetric due to PII concerns event = null; + } finally { + SentryCallbackReentrancyGuard.exit(); } } return event; diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java new file mode 100644 index 00000000000..afd303731fa --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -0,0 +1,41 @@ +package io.sentry.util; + +import org.jetbrains.annotations.ApiStatus; + +/** + * Thread-local re-entrancy guard that marks whether a user-supplied {@code before*} callback + * ({@code beforeSend}, {@code beforeBreadcrumb}, {@code beforeSendLog}, ...) is currently executing + * on the current thread. + * + *

A callback that itself triggers another SDK capture on the same thread — directly, or + * transitively through a logging integration that routes back into Sentry (e.g. Timber or the + * Gradle plugin's logcat instrumentation) — would otherwise recurse indefinitely and throw {@link + * StackOverflowError}. Capture entry points consult {@link #isActive()} and drop the nested capture + * while a callback is running. + * + *

The flag is set ONLY around each callback's {@code execute(...)} invocation, never around the + * whole capture pipeline, so captures made by event processors (which run outside the callback) are + * not affected. + */ +@ApiStatus.Internal +public final class SentryCallbackReentrancyGuard { + + private static final ThreadLocal isRunning = new ThreadLocal<>(); + + private SentryCallbackReentrancyGuard() {} + + /** Whether a user callback is currently executing on this thread. */ + public static boolean isActive() { + return Boolean.TRUE.equals(isRunning.get()); + } + + /** Marks that a user callback is starting to execute on this thread. */ + public static void enter() { + isRunning.set(Boolean.TRUE); + } + + /** Marks that the user callback finished executing on this thread. */ + public static void exit() { + isRunning.set(Boolean.FALSE); + } +} diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 86aaf6f8f24..240f3d0d1a3 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -371,6 +371,28 @@ class ScopeTest { assertFalse(called) } + @Test + fun `when beforeBreadcrumb adds another breadcrumb, the nested breadcrumb is dropped and does not recurse`() { + var invocations = 0 + lateinit var scope: Scope + val options = + SentryOptions().apply { + beforeBreadcrumb = + SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + invocations++ + scope.addBreadcrumb(Breadcrumb()) + breadcrumb + } + } + + scope = Scope(options) + scope.addBreadcrumb(Breadcrumb()) + + // Callback runs only for the outer breadcrumb; the nested one is dropped before its callback. + assertEquals(1, invocations) + assertEquals(1, scope.breadcrumbs.count()) + } + @Test fun `when adding breadcrumb and maxBreadcrumb is not 0, beforeBreadcrumb is executed`() { var called = false diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5cfa4b6b619..76c1deef5b7 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -284,6 +284,70 @@ class SentryClientTest { ) } + @Test + fun `when beforeSend captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // Callback runs only for the outer event; the nested capture is dropped before its callback. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSend captures a log, the nested log is dropped`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + // The shared guard spans capture types: a log emitted from beforeSend is dropped too. + verify(fixture.loggerBatchProcessor, never()).add(any()) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendLog logs again, the nested log is dropped and does not recurse`() { + val scope = createScope() + fixture.sentryOptions.logs.isEnabled = true + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.logs.setBeforeSend { l -> + invocations++ + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "nested", SentryLogLevel.WARN), + scope, + ) + l + } + sut = fixture.getSut() + + sut.captureLog( + SentryLogEvent(SentryId(), SentryNanotimeDate(), "outer", SentryLogLevel.WARN), + scope, + ) + + assertEquals(1, invocations) + verify(fixture.loggerBatchProcessor, times(1)).add(any()) + } + @Test fun `when beforeSendLog is set, callback is invoked`() { val scope = createScope() From da1637c3c5e8f0d07ee73028866c3b082fe3bce0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 15:44:03 +0200 Subject: [PATCH 02/12] docs(changelog): Add entry for callback re-entrancy guard Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d98e41ebc..62bfce62a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,8 @@ - Fix `NoSuchMethodError` from using `Math.floorDiv`/`Math.floorMod` overloads that are unavailable on Java 8 ([#5743](https://github.com/getsentry/sentry-java/pull/5743)) - Fix main thread identification parsing for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) - Do not send threads without stacktraces for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, or `beforeSendLog` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, or log) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. - Record byte-level client reports when event processors discard logs or trace metrics ([#5718](https://github.com/getsentry/sentry-java/pull/5718)) - Name the device-info caching thread `SentryDeviceInfoCache` so all threads spawned by the SDK are identifiable ([#5684](https://github.com/getsentry/sentry-java/pull/5684)) - Apply byte-category rate limits to log and trace metric envelope items ([#5716](https://github.com/getsentry/sentry-java/pull/5716)) From db3e4c7a03c120c612f10ec60bff03d9caf29655 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 15:59:02 +0200 Subject: [PATCH 03/12] fix(core): Guard captureFeedback, captureReplayEvent, captureMetric too These three capture methods had their beforeSend* executors wrapped by the re-entrancy guard but no entry check, so they were never dropped when a callback was active. That broke the "callbacks never nest" invariant: a callback that captured feedback/replay/metric ran that executor, whose exit() cleared the shared flag mid-callback, re-enabling recursion for any captureEvent/captureLog that followed in the same callback. They also recursed directly (e.g. beforeSendFeedback -> captureFeedback). Add the same isActive() entry guard to all three so every executor-wrapped capture path also drops while a callback runs. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/io/sentry/SentryClient.java | 27 +++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index c37a40fd03c..29792fbd981 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -321,6 +321,15 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Replay event captured from within a callback was dropped to prevent recursion."); + return SentryId.EMPTY_ID; + } + if (hint == null) { hint = new Hint(); } @@ -1205,6 +1214,15 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @Override public @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, @Nullable Hint hint, final @NotNull IScope scope) { + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Feedback captured from within a callback was dropped to prevent recursion."); + return SentryId.EMPTY_ID; + } + SentryEvent event = new SentryEvent(); event.getContexts().setFeedback(feedback); @@ -1378,6 +1396,15 @@ public void captureMetric( @Nullable SentryMetricsEvent metricsEvent, final @Nullable IScope scope, @Nullable Hint hint) { + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Metric captured from within a callback was dropped to prevent recursion."); + return; + } + if (hint == null) { hint = new Hint(); } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 76c1deef5b7..5aeaa09f9cb 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -348,6 +348,44 @@ class SentryClientTest { verify(fixture.loggerBatchProcessor, times(1)).add(any()) } + @Test + fun `when beforeSend captures feedback before an event, the guard is not cleared prematurely`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSend { e, _ -> + invocations++ + // Capturing feedback must not clear the re-entrancy guard for captures that follow it in the + // same callback, otherwise the captureEvent below would recurse. + sut.captureFeedback(Feedback("feedback"), null, scope) + sut.captureEvent(SentryEvent()) + e + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeSendFeedback captures feedback again, the nested capture is dropped and does not recurse`() { + val scope = createScope() + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.setBeforeSendFeedback { e, _ -> + invocations++ + sut.captureFeedback(Feedback("nested"), null, scope) + e + } + sut = fixture.getSut() + + sut.captureFeedback(Feedback("outer"), null, scope) + + assertEquals(1, invocations) + } + @Test fun `when beforeSendLog is set, callback is invoked`() { val scope = createScope() From 60f7033a36b6608c94d3c52e4fea67f12a48a693 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 17:22:28 +0200 Subject: [PATCH 04/12] fix(core): Harden re-entrancy guard with depth counter, wrap remaining callbacks Two robustness fixes for the callback re-entrancy guard: Replace the boolean flag with a depth counter so a nested exit() cannot disarm the guard while an outer callback is still running. The boolean relied on the "callbacks never nest" invariant, which every capture entry point must uphold by convention - a future capture path added without an entry check would silently re-open the recursion hole. The counter makes that failure mode structurally impossible, and lets exit() remove() the thread-local entry instead of parking a stale value on pooled threads. Wrap beforeErrorSampling and beforeEnvelopeCallback, the two remaining user callbacks in SentryClient without enter()/exit(). A capture from within beforeErrorSampling recursed unguarded (captureEvent -> beforeErrorSampling -> captureEvent -> ...). Both regression tests were verified to fail with StackOverflowError without the wraps. Co-Authored-By: Claude Fable 5 --- .../src/main/java/io/sentry/SentryClient.java | 6 +++ .../util/SentryCallbackReentrancyGuard.java | 23 +++++++++--- .../test/java/io/sentry/SentryClientTest.kt | 37 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 29792fbd981..7651a95bed6 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -246,6 +246,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options.getSessionReplay().getBeforeErrorSampling(); if (beforeErrorSampling != null) { try { + SentryCallbackReentrancyGuard.enter(); shouldCaptureReplay = beforeErrorSampling.execute(event, hint); } catch (Throwable e) { options @@ -255,6 +256,8 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul "The beforeErrorSampling callback threw an exception. Proceeding with replay capture.", e); shouldCaptureReplay = true; + } finally { + SentryCallbackReentrancyGuard.exit(); } } if (shouldCaptureReplay) { @@ -959,11 +962,14 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint options.getBeforeEnvelopeCallback(); if (beforeEnvelopeCallback != null) { try { + SentryCallbackReentrancyGuard.enter(); beforeEnvelopeCallback.execute(envelope, hint); } catch (Throwable e) { options .getLogger() .log(SentryLevel.ERROR, "The BeforeEnvelope callback threw an exception.", e); + } finally { + SentryCallbackReentrancyGuard.exit(); } } diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java index afd303731fa..50af3fbb07e 100644 --- a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -1,6 +1,7 @@ package io.sentry.util; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; /** * Thread-local re-entrancy guard that marks whether a user-supplied {@code before*} callback @@ -13,29 +14,41 @@ * StackOverflowError}. Capture entry points consult {@link #isActive()} and drop the nested capture * while a callback is running. * - *

The flag is set ONLY around each callback's {@code execute(...)} invocation, never around the + *

The guard is set ONLY around each callback's {@code execute(...)} invocation, never around the * whole capture pipeline, so captures made by event processors (which run outside the callback) are * not affected. + * + *

The guard is a depth counter rather than a boolean so that nested {@link #exit()} calls cannot + * clear it while an outer callback is still running. Capture entry points drop while a callback is + * active, so callbacks should never nest — but a capture path lacking an entry check must not + * silently disarm the guard for the rest of the outer callback. */ @ApiStatus.Internal public final class SentryCallbackReentrancyGuard { - private static final ThreadLocal isRunning = new ThreadLocal<>(); + private static final ThreadLocal depth = new ThreadLocal<>(); private SentryCallbackReentrancyGuard() {} /** Whether a user callback is currently executing on this thread. */ public static boolean isActive() { - return Boolean.TRUE.equals(isRunning.get()); + final @Nullable Integer current = depth.get(); + return current != null && current > 0; } /** Marks that a user callback is starting to execute on this thread. */ public static void enter() { - isRunning.set(Boolean.TRUE); + final @Nullable Integer current = depth.get(); + depth.set(current == null ? 1 : current + 1); } /** Marks that the user callback finished executing on this thread. */ public static void exit() { - isRunning.set(Boolean.FALSE); + final @Nullable Integer current = depth.get(); + if (current == null || current <= 1) { + depth.remove(); + } else { + depth.set(current - 1); + } } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5aeaa09f9cb..777fbb90fb9 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3290,6 +3290,25 @@ class SentryClientTest { assertTrue(beforeEnvelopeCalled) } + @Test + fun `when beforeEnvelopeCallback captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = + SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { val sut = fixture.getSut { options -> @@ -3552,6 +3571,24 @@ class SentryClientTest { assertFalse(called) } + @Test + fun `when beforeErrorSampling captures another event, the nested capture is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + fixture.sentryOptions.sessionReplay.beforeErrorSampling = + SentryReplayOptions.BeforeErrorSamplingCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + true + } + sut = fixture.getSut() + + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeErrorSampling returning false skips captureReplay`() { var called = false From 5e005bf34a88ace6df288109109cff639f05ca8f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 11:38:35 +0200 Subject: [PATCH 05/12] refactor(core): Hand out AutoCloseable token from re-entrancy guard Replace the manual enter()/finally-exit() pattern at every callback site with an ISentryLifecycleToken returned from enter(), used via try-with-resources. This removes nine copies of the finally boilerplate and the risk of forgetting the exit() call. The depth counter stays as the guard's state model - the token's close() just decrements it - so the nesting correctness guarantee is unchanged. The token is a shared static singleton, so no allocation happens per callback, matching the AutoClosableReentrantLock idiom already in the codebase. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/src/main/java/io/sentry/Scope.java | 5 +-- .../src/main/java/io/sentry/SentryClient.java | 40 ++++--------------- .../util/SentryCallbackReentrancyGuard.java | 19 +++++++-- 3 files changed, 24 insertions(+), 40 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index ad5acd17ee8..60430a9b037 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -465,8 +465,7 @@ public Queue getBreadcrumbs() { final @NotNull SentryOptions.BeforeBreadcrumbCallback callback, @NotNull Breadcrumb breadcrumb, final @NotNull Hint hint) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { breadcrumb = callback.execute(breadcrumb, hint); } catch (Throwable e) { options @@ -479,8 +478,6 @@ public Queue getBreadcrumbs() { if (e.getMessage() != null) { breadcrumb.setData("sentry:message", e.getMessage()); } - } finally { - SentryCallbackReentrancyGuard.exit(); } return breadcrumb; } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 7651a95bed6..d205f0472b0 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -245,8 +245,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul final SentryReplayOptions.BeforeErrorSamplingCallback beforeErrorSampling = options.getSessionReplay().getBeforeErrorSampling(); if (beforeErrorSampling != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { shouldCaptureReplay = beforeErrorSampling.execute(event, hint); } catch (Throwable e) { options @@ -256,8 +255,6 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul "The beforeErrorSampling callback threw an exception. Proceeding with replay capture.", e); shouldCaptureReplay = true; - } finally { - SentryCallbackReentrancyGuard.exit(); } } if (shouldCaptureReplay) { @@ -961,15 +958,12 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint final @Nullable SentryOptions.BeforeEnvelopeCallback beforeEnvelopeCallback = options.getBeforeEnvelopeCallback(); if (beforeEnvelopeCallback != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { beforeEnvelopeCallback.execute(envelope, hint); } catch (Throwable e) { options .getLogger() .log(SentryLevel.ERROR, "The BeforeEnvelope callback threw an exception.", e); - } finally { - SentryCallbackReentrancyGuard.exit(); } } @@ -1672,8 +1666,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSend = options.getBeforeSend(); if (beforeSend != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSend.execute(event, hint); } catch (Throwable e) { options @@ -1685,8 +1678,6 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSend due to PII concerns event = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1697,8 +1688,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.BeforeSendTransactionCallback beforeSendTransaction = options.getBeforeSendTransaction(); if (beforeSendTransaction != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { transaction = beforeSendTransaction.execute(transaction, hint); } catch (Throwable e) { options @@ -1710,8 +1700,6 @@ private void sortBreadcrumbsByDate( // drop transaction in case of an error in beforeSend due to PII concerns transaction = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return transaction; @@ -1721,8 +1709,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendCallback beforeSendFeedback = options.getBeforeSendFeedback(); if (beforeSendFeedback != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendFeedback.execute(event, hint); } catch (Throwable e) { options @@ -1731,8 +1718,6 @@ private void sortBreadcrumbsByDate( // drop feedback in case of an error in beforeSend due to PII concerns event = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1742,8 +1727,7 @@ private void sortBreadcrumbsByDate( @NotNull SentryReplayEvent event, final @NotNull Hint hint) { final SentryOptions.BeforeSendReplayCallback beforeSendReplay = options.getBeforeSendReplay(); if (beforeSendReplay != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendReplay.execute(event, hint); } catch (Throwable e) { options @@ -1755,8 +1739,6 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSend due to PII concerns event = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1766,8 +1748,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Logs.BeforeSendLogCallback beforeSendLog = options.getLogs().getBeforeSend(); if (beforeSendLog != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendLog.execute(event); } catch (Throwable e) { options @@ -1779,8 +1760,6 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSendLog due to PII concerns event = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return event; @@ -1791,8 +1770,7 @@ private void sortBreadcrumbsByDate( final SentryOptions.Metrics.BeforeSendMetricCallback beforeSendMetric = options.getMetrics().getBeforeSend(); if (beforeSendMetric != null) { - try { - SentryCallbackReentrancyGuard.enter(); + try (final @NotNull ISentryLifecycleToken ignored = SentryCallbackReentrancyGuard.enter()) { event = beforeSendMetric.execute(event, hint); } catch (Throwable e) { options @@ -1804,8 +1782,6 @@ private void sortBreadcrumbsByDate( // drop event in case of an error in beforeSendMetric due to PII concerns event = null; - } finally { - SentryCallbackReentrancyGuard.exit(); } } return event; diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java index 50af3fbb07e..b4066f297a0 100644 --- a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -1,6 +1,8 @@ package io.sentry.util; +import io.sentry.ISentryLifecycleToken; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** @@ -22,12 +24,18 @@ * clear it while an outer callback is still running. Capture entry points drop while a callback is * active, so callbacks should never nest — but a capture path lacking an entry check must not * silently disarm the guard for the rest of the outer callback. + * + *

{@link #enter()} returns an {@link ISentryLifecycleToken} so callers can use try-with-resources + * instead of a manual {@code finally exit()}. The token is a shared singleton (its {@code close()} + * just decrements the counter), so no allocation happens per callback. */ @ApiStatus.Internal public final class SentryCallbackReentrancyGuard { private static final ThreadLocal depth = new ThreadLocal<>(); + private static final ISentryLifecycleToken TOKEN = SentryCallbackReentrancyGuard::exit; + private SentryCallbackReentrancyGuard() {} /** Whether a user callback is currently executing on this thread. */ @@ -36,14 +44,17 @@ public static boolean isActive() { return current != null && current > 0; } - /** Marks that a user callback is starting to execute on this thread. */ - public static void enter() { + /** + * Marks that a user callback is starting to execute on this thread. Close the returned token (via + * try-with-resources) once the callback returns. + */ + public static @NotNull ISentryLifecycleToken enter() { final @Nullable Integer current = depth.get(); depth.set(current == null ? 1 : current + 1); + return TOKEN; } - /** Marks that the user callback finished executing on this thread. */ - public static void exit() { + private static void exit() { final @Nullable Integer current = depth.get(); if (current == null || current <= 1) { depth.remove(); From e6566b785867a674f05f17b0115b287977eb39e9 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 20 Jul 2026 09:42:42 +0000 Subject: [PATCH 06/12] Format code --- .../android/timber/SentryTimberIntegrationTest.kt | 11 +++++------ .../io/sentry/util/SentryCallbackReentrancyGuard.java | 6 +++--- sentry/src/test/java/io/sentry/ScopeTest.kt | 11 +++++------ sentry/src/test/java/io/sentry/SentryClientTest.kt | 9 ++++----- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index 3c2cabfd5cc..7c21eca8ef0 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -131,12 +131,11 @@ class SentryTimberIntegrationTest { Sentry.init { options -> options.dsn = "https://key@sentry.io/123" options.setTransportFactory(transportFactory) - options.beforeSend = - SentryOptions.BeforeSendCallback { event, _ -> - beforeSendInvocations++ - Timber.e("logging from beforeSend") - event - } + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + beforeSendInvocations++ + Timber.e("logging from beforeSend") + event + } } Timber.plant( SentryTimberTree( diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java index b4066f297a0..e4b885707e9 100644 --- a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -25,9 +25,9 @@ * active, so callbacks should never nest — but a capture path lacking an entry check must not * silently disarm the guard for the rest of the outer callback. * - *

{@link #enter()} returns an {@link ISentryLifecycleToken} so callers can use try-with-resources - * instead of a manual {@code finally exit()}. The token is a shared singleton (its {@code close()} - * just decrements the counter), so no allocation happens per callback. + *

{@link #enter()} returns an {@link ISentryLifecycleToken} so callers can use + * try-with-resources instead of a manual {@code finally exit()}. The token is a shared singleton + * (its {@code close()} just decrements the counter), so no allocation happens per callback. */ @ApiStatus.Internal public final class SentryCallbackReentrancyGuard { diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 240f3d0d1a3..7af4f6ccdca 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -377,12 +377,11 @@ class ScopeTest { lateinit var scope: Scope val options = SentryOptions().apply { - beforeBreadcrumb = - SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> - invocations++ - scope.addBreadcrumb(Breadcrumb()) - breadcrumb - } + beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + invocations++ + scope.addBreadcrumb(Breadcrumb()) + breadcrumb + } } scope = Scope(options) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 777fbb90fb9..c8988425541 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3295,11 +3295,10 @@ class SentryClientTest { var invocations = 0 lateinit var sut: SentryClient val options = { options: SentryOptions -> - options.beforeEnvelopeCallback = - SentryOptions.BeforeEnvelopeCallback { _, _ -> - invocations++ - sut.captureEvent(SentryEvent()) - } + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEvent(SentryEvent()) + } } sut = fixture.getSut(options) From 0511a65ed3321a648212d6fe885c0faf05b2a118 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 12:20:04 +0200 Subject: [PATCH 07/12] docs(changelog): Move re-entrancy entry to Unreleased The rebase folded the previous Unreleased section into the released 8.48.0, stranding the callback re-entrancy entry there. Move it back under Unreleased. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bfce62a03..787c5dec6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, or `beforeSendLog` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, or log) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. ## 8.49.0 @@ -94,8 +96,6 @@ - Fix `NoSuchMethodError` from using `Math.floorDiv`/`Math.floorMod` overloads that are unavailable on Java 8 ([#5743](https://github.com/getsentry/sentry-java/pull/5743)) - Fix main thread identification parsing for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) - Do not send threads without stacktraces for ApplicationExitInfo ANRs ([#5733](https://github.com/getsentry/sentry-java/pull/5733)) -- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, or `beforeSendLog` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) - - Captures made from within a user callback (event, transaction, breadcrumb, or log) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. - Record byte-level client reports when event processors discard logs or trace metrics ([#5718](https://github.com/getsentry/sentry-java/pull/5718)) - Name the device-info caching thread `SentryDeviceInfoCache` so all threads spawned by the SDK are identifiable ([#5684](https://github.com/getsentry/sentry-java/pull/5684)) - Apply byte-category rate limits to log and trace metric envelope items ([#5716](https://github.com/getsentry/sentry-java/pull/5716)) From 6a631f4e9c4832a659df0e94d3737d16811f5284 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 12:32:20 +0200 Subject: [PATCH 08/12] fix(core): Guard sendEnvelope against callback re-entrancy captureEnvelope and captureCheckIn route into sendEnvelope without the isActive() entry check every other capture path has. A beforeEnvelope callback that itself captured an envelope or check-in re-entered sendEnvelope, re-ran the callback, and recursed to a StackOverflowError - the same failure class the rest of this PR fixes. Check the guard at the top of sendEnvelope, the single choke point every send flows through, rather than adding a check to each public entry point. In normal flow the guard is already inactive there (the before* callback has exited), so an active guard can only mean a callback triggered the send, which is dropped. This also closes the hole for any future sender routed through sendEnvelope. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- .../src/main/java/io/sentry/SentryClient.java | 13 +++++++ .../test/java/io/sentry/SentryClientTest.kt | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 787c5dec6ad..6d37da44d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) - `SentryTagModifierNode.isImportantForBounds` now matches the default behavior and returns `true` ([#5789](https://github.com/getsentry/sentry-java/pull/5789)) -- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, or `beforeSendLog` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) - - Captures made from within a user callback (event, transaction, breadcrumb, or log) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. +- Prevent a `StackOverflowError` when a `beforeSend`, `beforeBreadcrumb`, `beforeSendLog`, or `beforeEnvelope` callback triggers another capture (directly or through a logging integration such as Timber) ([#5737](https://github.com/getsentry/sentry-java/pull/5737)) + - Captures made from within a user callback (event, transaction, breadcrumb, log, envelope, or check-in) are now dropped while that callback runs, instead of recursing. Captures made by event processors are unaffected. ## 8.49.0 diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index d205f0472b0..3340333c96b 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -955,6 +955,19 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint private @NotNull SentryId sendEnvelope( @NotNull final SentryEnvelope envelope, @Nullable final Hint hint) throws IOException { + // captureEnvelope and captureCheckIn have no entry-level guard, so a callback that captures + // one of those would recurse back into beforeEnvelopeCallback. In normal flow the guard is + // already inactive by the time we get here (the before* callback has exited), so an active + // guard means a callback triggered this send. + if (SentryCallbackReentrancyGuard.isActive()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Envelope captured from within a callback was dropped to prevent recursion."); + return SentryId.EMPTY_ID; + } + final @Nullable SentryOptions.BeforeEnvelopeCallback beforeEnvelopeCallback = options.getBeforeEnvelopeCallback(); if (beforeEnvelopeCallback != null) { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index c8988425541..7f38851bbf1 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3308,6 +3308,44 @@ class SentryClientTest { verify(fixture.transport, times(1)).send(any(), anyOrNull()) } + @Test + fun `when beforeEnvelopeCallback captures an envelope, the nested envelope is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureEnvelope(SentryEnvelope(SentryId(UUID.randomUUID()), null, setOf())) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + // Callback runs only for the outer envelope; the nested captureEnvelope is dropped in + // sendEnvelope before its callback would run. + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + + @Test + fun `when beforeEnvelopeCallback captures a check-in, the nested check-in is dropped and does not recurse`() { + var invocations = 0 + lateinit var sut: SentryClient + val options = { options: SentryOptions -> + options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> + invocations++ + sut.captureCheckIn(CheckIn("some_slug", CheckInStatus.OK), null, null) + } + } + sut = fixture.getSut(options) + + sut.captureEvent(SentryEvent(), Hint()) + + assertEquals(1, invocations) + verify(fixture.transport, times(1)).send(any(), anyOrNull()) + } + @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { val sut = fixture.getSut { options -> From b3a492ffb97dc2025343a19f6227cf65e705f5c6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 12:37:51 +0200 Subject: [PATCH 09/12] test(core): Drop redundant check-in re-entrancy test captureCheckIn and captureEnvelope both funnel into sendEnvelope and hit the same isActive() guard, so the check-in test exercised no code path the envelope test did not already cover. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/java/io/sentry/SentryClientTest.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 7f38851bbf1..fa37cb0b70b 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -3328,24 +3328,6 @@ class SentryClientTest { verify(fixture.transport, times(1)).send(any(), anyOrNull()) } - @Test - fun `when beforeEnvelopeCallback captures a check-in, the nested check-in is dropped and does not recurse`() { - var invocations = 0 - lateinit var sut: SentryClient - val options = { options: SentryOptions -> - options.beforeEnvelopeCallback = SentryOptions.BeforeEnvelopeCallback { _, _ -> - invocations++ - sut.captureCheckIn(CheckIn("some_slug", CheckInStatus.OK), null, null) - } - } - sut = fixture.getSut(options) - - sut.captureEvent(SentryEvent(), Hint()) - - assertEquals(1, invocations) - verify(fixture.transport, times(1)).send(any(), anyOrNull()) - } - @Test fun `beforeEnvelopeCallback may fail, but the transport is still sends the envelope `() { val sut = fixture.getSut { options -> From 8104eb15b1ea5285092e544092411bb79c00ddb7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 12:46:32 +0200 Subject: [PATCH 10/12] fix(core): Drop callback-recursion captures silently The re-entrancy guard dropped nested captures but logged a DEBUG line at seven of the eight drop sites. That log is itself routed back into Sentry by the same logging integrations this guard protects against (the Gradle plugin's logcat instrumentation feeds captureLog, whose own drop logs again), so logging on the drop path re-opens the very recursion the guard breaks. It only bites with SDK debug logging enabled, since the internal logger is otherwise a no-op, but dropping silently removes the failure mode outright. Drop without logging everywhere and document why in the guard's Javadoc and at each drop site. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/src/main/java/io/sentry/Scope.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 44 ++++--------------- .../util/SentryCallbackReentrancyGuard.java | 14 +++++- 3 files changed, 22 insertions(+), 38 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 60430a9b037..195e5b5b05a 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -494,7 +494,7 @@ public void addBreadcrumb(@NotNull Breadcrumb breadcrumb, @Nullable Hint hint) { if (breadcrumb == null || breadcrumbs instanceof DisabledQueue) { return; } - // Drop breadcrumbs added from within a user callback to prevent recursion. + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { return; } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 3340333c96b..a25aa9c79e1 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -107,12 +107,8 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @NotNull SentryEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SentryEvent is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Event captured from within a callback (beforeSend/beforeBreadcrumb/beforeSendLog) was dropped to prevent recursion."); return SentryId.EMPTY_ID; } @@ -321,12 +317,8 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Replay event captured from within a callback was dropped to prevent recursion."); return SentryId.EMPTY_ID; } @@ -958,13 +950,9 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint // captureEnvelope and captureCheckIn have no entry-level guard, so a callback that captures // one of those would recurse back into beforeEnvelopeCallback. In normal flow the guard is // already inactive by the time we get here (the before* callback has exited), so an active - // guard means a callback triggered this send. + // guard means a callback triggered this send. Drop silently: a log here can re-enter through a + // logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Envelope captured from within a callback was dropped to prevent recursion."); return SentryId.EMPTY_ID; } @@ -1000,12 +988,8 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint final @Nullable ProfilingTraceData profilingTraceData) { Objects.requireNonNull(transaction, "Transaction is required."); + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Transaction captured from within a callback was dropped to prevent recursion."); return SentryId.EMPTY_ID; } @@ -1227,12 +1211,8 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @Override public @NotNull SentryId captureFeedback( final @NotNull Feedback feedback, @Nullable Hint hint, final @NotNull IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Feedback captured from within a callback was dropped to prevent recursion."); return SentryId.EMPTY_ID; } @@ -1346,12 +1326,8 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint @ApiStatus.Experimental @Override public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Log captured from within a callback was dropped to prevent recursion."); return; } @@ -1409,12 +1385,8 @@ public void captureMetric( @Nullable SentryMetricsEvent metricsEvent, final @Nullable IScope scope, @Nullable Hint hint) { + // Drop silently to prevent recursion; a log here can re-enter through a logging integration. if (SentryCallbackReentrancyGuard.isActive()) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Metric captured from within a callback was dropped to prevent recursion."); return; } diff --git a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java index e4b885707e9..461e7daf18f 100644 --- a/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java +++ b/sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java @@ -16,6 +16,14 @@ * StackOverflowError}. Capture entry points consult {@link #isActive()} and drop the nested capture * while a callback is running. * + *

The nested capture MUST be dropped silently — callers must not log while the guard is active. + * The same logging integration that routes logs back into Sentry also routes the SDK's own + * diagnostic logs, so a "dropped to prevent recursion" log line would be turned into another + * capture, whose drop would log again, and so on. The guard suppresses the capture but not the log, + * so logging on the drop path re-opens exactly the recursion the guard exists to break. (This only + * bites when SDK debug logging is enabled, since {@code options.getLogger()} is otherwise a no-op, + * but dropping silently removes the failure mode entirely.) + * *

The guard is set ONLY around each callback's {@code execute(...)} invocation, never around the * whole capture pipeline, so captures made by event processors (which run outside the callback) are * not affected. @@ -38,7 +46,11 @@ public final class SentryCallbackReentrancyGuard { private SentryCallbackReentrancyGuard() {} - /** Whether a user callback is currently executing on this thread. */ + /** + * Whether a user callback is currently executing on this thread. When {@code true}, capture entry + * points must drop the capture and return without logging — see the class Javadoc for why logging + * on the drop path re-opens the recursion. + */ public static boolean isActive() { final @Nullable Integer current = depth.get(); return current != null && current > 0; From adde205e835d604f47f9eda709ac1a3a005caf1c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 12:48:48 +0200 Subject: [PATCH 11/12] docs(core): Document that captures inside before* callbacks are dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer implementing beforeSend/beforeBreadcrumb/beforeSendLog/etc. has no way to know that capturing from within the callback — directly or through a logging integration — is silently dropped to prevent recursion. State the contract on each customer-facing callback interface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/sentry/SentryOptions.java | 24 +++++++++++++++++++ .../java/io/sentry/SentryReplayOptions.java | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index cde0c37ba90..f10f2aede05 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3327,6 +3327,10 @@ public interface BeforeSendCallback { /** * Mutates or drop an event before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @param hint the hints * @return the original event or the mutated event or null if event was dropped @@ -3341,6 +3345,10 @@ public interface BeforeSendTransactionCallback { /** * Mutates or drop a transaction before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param transaction the transaction * @param hint the hints * @return the original transaction or the mutated transaction or null if transaction was @@ -3358,6 +3366,10 @@ public interface BeforeSendReplayCallback { * for a single replay (i.e. segments), you can check {@link SentryReplayEvent#getReplayId()} to * identify that the segments belong to the same replay. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @param hint the hint, contains {@link ReplayRecording}, can be accessed via {@link * Hint#getReplayRecording()} @@ -3373,6 +3385,10 @@ public interface BeforeBreadcrumbCallback { /** * Mutates or drop a callback before being added * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param breadcrumb the breadcrumb * @param hint the hints, usually the source of the breadcrumb * @return the original breadcrumb or the mutated breadcrumb of null if breadcrumb was dropped @@ -3961,6 +3977,10 @@ public interface BeforeSendLogCallback { /** * Mutates or drop a log event before being sent * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the event * @return the original log event or the mutated event or null if event was dropped */ @@ -4035,6 +4055,10 @@ public interface BeforeSendMetricCallback { /** * A callback which gets called right before a metric is about to be sent. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param metric the metric * @return the original metric, mutated metric or null if metric was dropped */ diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d1da6510cdb..a068e12f2a8 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -28,6 +28,10 @@ public interface BeforeErrorSamplingCallback { /** * Determines whether replay capture should proceed for the given error event. * + *

Do not capture from within this callback — directly, or indirectly through a logging + * integration that routes logs back into Sentry. Such nested captures are silently dropped to + * prevent infinite recursion. + * * @param event the error event that triggered the replay capture * @param hint the hint associated with the event * @return {@code true} if the error sample rate should be checked, {@code false} to skip replay From b328ffa3d8c08302226ec3bdfe082bbd29d55454 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 13:48:16 +0200 Subject: [PATCH 12/12] build(core): Update API dump for re-entrancy guard token The token refactor changed SentryCallbackReentrancyGuard.enter() to return an ISentryLifecycleToken and made exit() private, but the API dump was not regenerated at the time. The guard is @ApiStatus.Internal. Co-Authored-By: Claude Opus 4.8 (1M context) --- sentry/api/sentry.api | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 582d1a7a10e..c623e71d08f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7925,8 +7925,7 @@ public final class io/sentry/util/ScopesUtil { } public final class io/sentry/util/SentryCallbackReentrancyGuard { - public static fun enter ()V - public static fun exit ()V + public static fun enter ()Lio/sentry/ISentryLifecycleToken; public static fun isActive ()Z }