fix(core): Prevent recursion when a callback triggers another capture#5737
fix(core): Prevent recursion when a callback triggers another capture#5737runningcode wants to merge 4 commits into
Conversation
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1c810b5. Configure here.
📲 Install BuildsAndroid
|
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 <noreply@anthropic.com>
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| d364ace | 382.77 ms | 443.21 ms | 60.44 ms |
| 4c04bb8 | 333.16 ms | 408.16 ms | 75.00 ms |
| bb0ff41 | 344.70 ms | 413.82 ms | 69.12 ms |
| 91bb874 | 314.47 ms | 440.00 ms | 125.53 ms |
| bdbe1f4 | 380.66 ms | 464.44 ms | 83.78 ms |
| 8687935 | 294.00 ms | 304.02 ms | 10.02 ms |
| eb95ded | 317.51 ms | 369.08 ms | 51.57 ms |
| b936425 | 302.69 ms | 372.86 ms | 70.17 ms |
| d15471f | 342.08 ms | 415.44 ms | 73.35 ms |
| 5b66efd | 308.67 ms | 363.85 ms | 55.18 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| d364ace | 1.58 MiB | 2.11 MiB | 539.75 KiB |
| 4c04bb8 | 0 B | 0 B | 0 B |
| bb0ff41 | 0 B | 0 B | 0 B |
| 91bb874 | 1.58 MiB | 2.13 MiB | 559.07 KiB |
| bdbe1f4 | 1.58 MiB | 2.11 MiB | 538.88 KiB |
| 8687935 | 1.58 MiB | 2.19 MiB | 619.17 KiB |
| eb95ded | 0 B | 0 B | 0 B |
| b936425 | 0 B | 0 B | 0 B |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| 5b66efd | 1.58 MiB | 2.13 MiB | 559.07 KiB |
…g 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 <noreply@anthropic.com>
|
|
||
| // drop event in case of an error in beforeSendLog due to PII concerns | ||
| event = null; | ||
| } finally { | ||
| SentryCallbackReentrancyGuard.exit(); | ||
| } | ||
| } | ||
| return event; |
There was a problem hiding this comment.
Bug: Public methods like captureCheckIn and captureProfileChunk lack the isActive() guard, allowing nested captures from callbacks when they should be dropped.
Severity: HIGH
Suggested Fix
Add the if (!isActive()) { return; } guard at the beginning of the public methods captureCheckIn, captureProfileChunk, and captureEnvelope to ensure that nested calls from within callbacks are correctly dropped.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: sentry/src/main/java/io/sentry/SentryClient.java#L1761-L1768
Potential issue: Several public API methods, including `captureCheckIn`,
`captureProfileChunk`, and `captureEnvelope`, are missing an `isActive()` check at their
entry points. This violates the intended invariant that any new captures initiated from
within a user callback (e.g., `beforeSend`) should be dropped. If a user calls one of
these unguarded methods from a callback, the nested capture will be processed and sent
instead of being ignored. While the internal depth counter prevents recursive callback
execution, it does not prevent the initial capture from proceeding through the pipeline,
leading to unexpected behavior and data being sent when it should be suppressed.
Also affects:
sentry/src/main/java/io/sentry/SentryClient.java:1713~1720sentry/src/main/java/io/sentry/SentryClient.java:1725~1731sentry/src/main/java/io/sentry/SentryClient.java:1774~1780sentry/src/main/java/io/sentry/SentryClient.java:1786~1793
Did we get this right? 👍 / 👎 to inform future reviews.
adinauer
left a comment
There was a problem hiding this comment.
LGTM, we could consider also handling EventProcessor, onOversizedEvent, captureEnvelope and scope callbacks on things like Scopes.captureException.
| } | ||
|
|
||
| /** Marks that a user callback is starting to execute on this thread. */ | ||
| public static void enter() { |
There was a problem hiding this comment.
m we could return a ISentryLifecycleToken that implements AutoCloseable here so we can use it in try-with-resources statements and not forget to call exit in a finally block correctly.

📜 Description
Adds a core-level re-entrancy guard so a user callback (
beforeSend,beforeBreadcrumb,beforeSendLog, and the otherbeforeSend*variants) that triggers another SDK capture no longer recurses into aStackOverflowError.💡 Motivation and Context
These callbacks run synchronously on the caller thread with only a
try/catch— nothing prevents re-entrant capture. So a callback that captures again — directly, or transitively through a logging integration that routes back into Sentry — loops forever:captureX()→executeBeforeX()→ callback logs/captures → (integration) →captureX()→ …This is the same class of bug as the logcat
StackOverflowError(SDK-CRASHES-JAVA-3T3H, fixed per-integration in #5734). It also affects the Timber integration (SentryTimberTreefirescaptureEvent+addBreadcrumb+logger().log()on every Timber call) and any app code that captures from within a callback. Rather than patch each integration, this fixes the whole class once in the core.Solution
SentryCallbackReentrancyGuardis a shared thread-local flag set only around each callback'sexecute(...). The capture entry points (SentryClient.captureEvent/captureTransaction/captureLog,Scope.addBreadcrumb) drop a nested capture while the flag is active, breaking the loop across all capture types on the same thread. Captures made by event processors run outside the flag and are unaffected. The realLog.*output in integrations is untouched.Dropping the nested capture (rather than sending it without running
beforeSend) is deliberate — bypassingbeforeSendwould risk sending unscrubbed PII, which the SDK already guards against.💥 Behavioral change
A capture a callback makes intentionally (e.g. a diagnostic event, or re-capturing an enriched event) is now dropped while that callback runs, and logged at
DEBUG. This is the safe trade-off — such a nested capture is indistinguishable from recursion. Event processors are not affected. The per-integration guard added in #5734 becomes redundant (left in place; harmless).💚 How did you test it?
SentryClientTest:beforeSend→captureEvent,beforeSendLog→log, and cross-type (beforeSendemitting a log) — each verified toStackOverflowError/fail without the guard and pass with it, with the nested capture dropped.ScopeTest:beforeBreadcrumb→addBreadcrumb.SentryTimberIntegrationTest: end-to-end with a realSentry.init, a plantedSentryTimberTree, and abeforeSendthat callsTimber.e(...)— verified to recurse without the guard and to run the callback exactly once with it.Fixes SDK-CRASHES-JAVA-3T3H
JAVA-638