Skip to content

fix(core): Prevent recursion when a callback triggers another capture#5737

Open
runningcode wants to merge 4 commits into
mainfrom
no/java-callback-reentrancy-guard
Open

fix(core): Prevent recursion when a callback triggers another capture#5737
runningcode wants to merge 4 commits into
mainfrom
no/java-callback-reentrancy-guard

Conversation

@runningcode

@runningcode runningcode commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

📜 Description

Adds a core-level re-entrancy guard so a user callback (beforeSend, beforeBreadcrumb, beforeSendLog, and the other beforeSend* variants) that triggers another SDK capture no longer recurses into a StackOverflowError.

💡 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 (SentryTimberTree fires captureEvent + 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
SentryCallbackReentrancyGuard is a shared thread-local flag set only around each callback's execute(...). 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 real Log.* output in integrations is untouched.

Dropping the nested capture (rather than sending it without running beforeSend) is deliberate — bypassing beforeSend would 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: beforeSendcaptureEvent, beforeSendLog→log, and cross-type (beforeSend emitting a log) — each verified to StackOverflowError/fail without the guard and pass with it, with the nested capture dropped.
  • ScopeTest: beforeBreadcrumbaddBreadcrumb.
  • SentryTimberIntegrationTest: end-to-end with a real Sentry.init, a planted SentryTimberTree, and a beforeSend that calls Timber.e(...) — verified to recurse without the guard and to run the callback exactly once with it.

Fixes SDK-CRASHES-JAVA-3T3H

JAVA-638

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>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

JAVA-638

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread sentry/src/main/java/io/sentry/util/SentryCallbackReentrancyGuard.java Outdated
@sentry

sentry Bot commented Jul 7, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.47.0 (1) release

⚙️ sentry-android Build Distribution Settings

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>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 312.40 ms 370.43 ms 58.03 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

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

Previous results on branch: no/java-callback-reentrancy-guard

Startup times

Revision Plain With Sentry Diff
61d85ae 310.71 ms 362.96 ms 52.25 ms

App size

Revision Plain With Sentry Diff
61d85ae 0 B 0 B 0 B

…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>
Comment on lines 1761 to 1768

// drop event in case of an error in beforeSendLog due to PII concerns
event = null;
} finally {
SentryCallbackReentrancyGuard.exit();
}
}
return event;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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~1720
  • sentry/src/main/java/io/sentry/SentryClient.java:1725~1731
  • sentry/src/main/java/io/sentry/SentryClient.java:1774~1780
  • sentry/src/main/java/io/sentry/SentryClient.java:1786~1793

Did we get this right? 👍 / 👎 to inform future reviews.

@adinauer adinauer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants