From ca08d445c54417fbaf3b976abccccd8f513bebfc Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:05:10 +0000 Subject: [PATCH 1/5] fix(logcat): Add re-entrancy guard to SentryLogcatAdapter --- .../android/core/SentryLogcatAdapter.java | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java index 1e649c1783f..eaaba803940 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java @@ -19,6 +19,21 @@ @ApiStatus.Internal public final class SentryLogcatAdapter { + /** + * Re-entrancy guard to prevent infinite recursion. When the Sentry Android Gradle Plugin + * replaces Log.* calls with SentryLogcatAdapter.* calls, it also transforms calls inside the + * Sentry SDK itself (e.g. AndroidLogger). This can cause a cycle: + * SentryLogcatAdapter.w() -> addBreadcrumb() -> executeBeforeBreadcrumb() (on exception) -> + * DiagnosticLogger.log() -> AndroidLogger.log() -> Log.w() -> SentryLogcatAdapter.w() -> ... + */ + private static final ThreadLocal isAddingBreadcrumb = + new ThreadLocal() { + @Override + protected Boolean initialValue() { + return Boolean.FALSE; + } + }; + private static void addAsBreadcrumb( @Nullable String tag, @NotNull SentryLevel level, @Nullable String msg) { addAsBreadcrumb(tag, level, msg, null); @@ -34,17 +49,25 @@ private static void addAsBreadcrumb( @NotNull final SentryLevel level, @Nullable final String msg, @Nullable final Throwable tr) { - Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setCategory("Logcat"); - breadcrumb.setMessage(msg); - breadcrumb.setLevel(level); - if (tag != null) { - breadcrumb.setData("tag", tag); + if (Boolean.TRUE.equals(isAddingBreadcrumb.get())) { + return; } - if (tr != null && tr.getMessage() != null) { - breadcrumb.setData("throwable", tr.getMessage()); + isAddingBreadcrumb.set(Boolean.TRUE); + try { + Breadcrumb breadcrumb = new Breadcrumb(); + breadcrumb.setCategory("Logcat"); + breadcrumb.setMessage(msg); + breadcrumb.setLevel(level); + if (tag != null) { + breadcrumb.setData("tag", tag); + } + if (tr != null && tr.getMessage() != null) { + breadcrumb.setData("throwable", tr.getMessage()); + } + Sentry.addBreadcrumb(breadcrumb); + } finally { + isAddingBreadcrumb.set(Boolean.FALSE); } - Sentry.addBreadcrumb(breadcrumb); } private static void addAsLog( From 92227f9199c18204e67d7421d66366513ec223d2 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 7 Jul 2026 12:08:59 +0000 Subject: [PATCH 2/5] Format code --- .../io/sentry/android/core/SentryLogcatAdapter.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java index eaaba803940..2ff4190f51d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java @@ -20,11 +20,11 @@ public final class SentryLogcatAdapter { /** - * Re-entrancy guard to prevent infinite recursion. When the Sentry Android Gradle Plugin - * replaces Log.* calls with SentryLogcatAdapter.* calls, it also transforms calls inside the - * Sentry SDK itself (e.g. AndroidLogger). This can cause a cycle: - * SentryLogcatAdapter.w() -> addBreadcrumb() -> executeBeforeBreadcrumb() (on exception) -> - * DiagnosticLogger.log() -> AndroidLogger.log() -> Log.w() -> SentryLogcatAdapter.w() -> ... + * Re-entrancy guard to prevent infinite recursion. When the Sentry Android Gradle Plugin replaces + * Log.* calls with SentryLogcatAdapter.* calls, it also transforms calls inside the Sentry SDK + * itself (e.g. AndroidLogger). This can cause a cycle: SentryLogcatAdapter.w() -> addBreadcrumb() + * -> executeBeforeBreadcrumb() (on exception) -> DiagnosticLogger.log() -> AndroidLogger.log() -> + * Log.w() -> SentryLogcatAdapter.w() -> ... */ private static final ThreadLocal isAddingBreadcrumb = new ThreadLocal() { From d581262a2444b398228a7f5bb594dba41b12f896 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 14:23:39 +0200 Subject: [PATCH 3/5] test(logcat): Add re-entrancy regression test and clarify guard docs Add a regression test reproducing SDK-CRASHES-JAVA-3T3H: a beforeBreadcrumb callback that logs re-enters SentryLogcatAdapter while a Logcat breadcrumb is being added, which recurses to a StackOverflowError without the guard. Also correct the guard's Javadoc. The recursion is not caused by the plugin transforming AndroidLogger inside the SDK (io.sentry.* classes are excluded from logcat instrumentation); it is caused by app code reachable during breadcrumb processing calling a rewritten Log.* method. Co-Authored-By: Claude Opus 4.8 --- .../android/core/SentryLogcatAdapter.java | 12 +++++----- .../android/core/SentryLogcatAdapterTest.kt | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java index 2ff4190f51d..c1724f88698 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java @@ -20,11 +20,13 @@ public final class SentryLogcatAdapter { /** - * Re-entrancy guard to prevent infinite recursion. When the Sentry Android Gradle Plugin replaces - * Log.* calls with SentryLogcatAdapter.* calls, it also transforms calls inside the Sentry SDK - * itself (e.g. AndroidLogger). This can cause a cycle: SentryLogcatAdapter.w() -> addBreadcrumb() - * -> executeBeforeBreadcrumb() (on exception) -> DiagnosticLogger.log() -> AndroidLogger.log() -> - * Log.w() -> SentryLogcatAdapter.w() -> ... + * Re-entrancy guard to prevent infinite recursion. The Sentry Android Gradle Plugin rewrites + * {@code android.util.Log.*} calls in the app's own code to {@code SentryLogcatAdapter.*} (SDK + * classes under {@code io.sentry.*} are excluded). Recursion happens when code that runs while a + * Logcat breadcrumb is being added itself calls a rewritten {@code Log.*} method. The common path + * is an app {@code beforeBreadcrumb} callback that logs: SentryLogcatAdapter.w() -> + * addAsBreadcrumb() -> Sentry.addBreadcrumb() -> Scope.executeBeforeBreadcrumb() -> app callback + * -> Log.w() (rewritten) -> SentryLogcatAdapter.w() -> ... */ private static final ThreadLocal isAddingBreadcrumb = new ThreadLocal() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1a84a1282da..93a2c94fc27 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -202,6 +202,28 @@ class SentryLogcatAdapterTest { ) } + @Test + fun `re-entrant logcat call while adding a breadcrumb does not recurse`() { + // Reproduces SDK-CRASHES-JAVA-3T3H: when the Sentry Android Gradle Plugin rewrites the app's + // Log.* calls to SentryLogcatAdapter.*, a beforeBreadcrumb callback that logs re-enters the + // adapter while a Logcat breadcrumb is being added, which without a guard recurses until a + // StackOverflowError. + fixture.initSut { + it.beforeBreadcrumb = + SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + fixture.breadcrumbs.add(breadcrumb) + SentryLogcatAdapter.w(tag, "$commonMsg re-entrant") + breadcrumb + } + } + + SentryLogcatAdapter.w(tag, "$commonMsg warning") + + // The re-entrant call is skipped by the guard, so only the original breadcrumb is recorded. + assertEquals(1, fixture.breadcrumbs.size) + assertEquals("$commonMsg warning", fixture.breadcrumbs.first().message) + } + private fun Breadcrumb.assert( expectedTag: String, expectedMessage: String, From d709a6131e52bd1117686eb0681ab141931dfa1c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 14:31:41 +0200 Subject: [PATCH 4/5] fix(logcat): Extend re-entrancy guard to the logs path The guard only covered addAsBreadcrumb(). The public methods also call addAsLog() sequentially, which could recurse the same way: a beforeSendLog callback that logs re-enters SentryLogcatAdapter while a Logcat log is being sent, leading to a StackOverflowError. Share a single thread-local guard across both addAsBreadcrumb() and addAsLog() so any re-entrant capture on the same thread is a no-op, while the real Log.* call is left untouched. Add a regression test for the logs path and use Google Truth for the new assertions. Co-Authored-By: Claude Opus 4.8 --- sentry-android-core/build.gradle.kts | 1 + .../android/core/SentryLogcatAdapter.java | 41 +++++++++++-------- .../android/core/SentryLogcatAdapterTest.kt | 25 ++++++++++- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de486..f4813df42a7 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -104,6 +104,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) testImplementation(libs.roboelectric) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.androidx.core.ktx) testImplementation(libs.androidx.test.core) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java index c1724f88698..3de8b941efe 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryLogcatAdapter.java @@ -23,12 +23,13 @@ public final class SentryLogcatAdapter { * Re-entrancy guard to prevent infinite recursion. The Sentry Android Gradle Plugin rewrites * {@code android.util.Log.*} calls in the app's own code to {@code SentryLogcatAdapter.*} (SDK * classes under {@code io.sentry.*} are excluded). Recursion happens when code that runs while a - * Logcat breadcrumb is being added itself calls a rewritten {@code Log.*} method. The common path - * is an app {@code beforeBreadcrumb} callback that logs: SentryLogcatAdapter.w() -> - * addAsBreadcrumb() -> Sentry.addBreadcrumb() -> Scope.executeBeforeBreadcrumb() -> app callback - * -> Log.w() (rewritten) -> SentryLogcatAdapter.w() -> ... + * Logcat breadcrumb or log is being captured itself calls a rewritten {@code Log.*} method. The + * common paths are an app {@code beforeBreadcrumb} or {@code beforeSendLog} callback that logs: + * SentryLogcatAdapter.w() -> addAsBreadcrumb()/addAsLog() -> app callback -> Log.w() (rewritten) + * -> SentryLogcatAdapter.w() -> ... The guard makes any re-entrant capture on the same thread a + * no-op, while the real {@code Log.*} call is left untouched so logcat output is preserved. */ - private static final ThreadLocal isAddingBreadcrumb = + private static final ThreadLocal isCapturing = new ThreadLocal() { @Override protected Boolean initialValue() { @@ -51,10 +52,10 @@ private static void addAsBreadcrumb( @NotNull final SentryLevel level, @Nullable final String msg, @Nullable final Throwable tr) { - if (Boolean.TRUE.equals(isAddingBreadcrumb.get())) { + if (Boolean.TRUE.equals(isCapturing.get())) { return; } - isAddingBreadcrumb.set(Boolean.TRUE); + isCapturing.set(Boolean.TRUE); try { Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setCategory("Logcat"); @@ -68,7 +69,7 @@ private static void addAsBreadcrumb( } Sentry.addBreadcrumb(breadcrumb); } finally { - isAddingBreadcrumb.set(Boolean.FALSE); + isCapturing.set(Boolean.FALSE); } } @@ -76,19 +77,27 @@ private static void addAsLog( @NotNull final SentryLogLevel level, @Nullable final String msg, @Nullable final Throwable tr) { + if (Boolean.TRUE.equals(isCapturing.get())) { + return; + } final @NotNull ScopesAdapter scopes = ScopesAdapter.getInstance(); // Check if logs are enabled before doing expensive operations if (!scopes.getOptions().getLogs().isEnabled()) { return; } - final @Nullable String trMessage = tr != null ? tr.getMessage() : null; - final @NotNull SentryLogParameters params = new SentryLogParameters(); - params.setOrigin("auto.log.logcat"); - - if (tr == null || trMessage == null) { - scopes.logger().log(level, params, msg); - } else { - scopes.logger().log(level, params, msg != null ? (msg + "\n" + trMessage) : trMessage); + isCapturing.set(Boolean.TRUE); + try { + final @Nullable String trMessage = tr != null ? tr.getMessage() : null; + final @NotNull SentryLogParameters params = new SentryLogParameters(); + params.setOrigin("auto.log.logcat"); + + if (tr == null || trMessage == null) { + scopes.logger().log(level, params, msg); + } else { + scopes.logger().log(level, params, msg != null ? (msg + "\n" + trMessage) : trMessage); + } + } finally { + isCapturing.set(Boolean.FALSE); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 93a2c94fc27..ebe8022d156 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -2,6 +2,7 @@ package io.sentry.android.core import android.os.Bundle import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Sentry import io.sentry.SentryLevel @@ -220,8 +221,28 @@ class SentryLogcatAdapterTest { SentryLogcatAdapter.w(tag, "$commonMsg warning") // The re-entrant call is skipped by the guard, so only the original breadcrumb is recorded. - assertEquals(1, fixture.breadcrumbs.size) - assertEquals("$commonMsg warning", fixture.breadcrumbs.first().message) + assertThat(fixture.breadcrumbs).hasSize(1) + assertThat(fixture.breadcrumbs.first().message).isEqualTo("$commonMsg warning") + } + + @Test + fun `re-entrant logcat call while sending a log does not recurse`() { + // Same recursion as the breadcrumb path, but via a beforeSendLog callback that logs while a + // Logcat log is being sent. + fixture.initSut { + it.logs.beforeSend = + SentryOptions.Logs.BeforeSendLogCallback { logEvent -> + fixture.logs.add(logEvent) + SentryLogcatAdapter.w(tag, "$commonMsg re-entrant") + logEvent + } + } + + SentryLogcatAdapter.w(tag, "$commonMsg warning") + + // The re-entrant call is skipped by the guard, so only the original log is recorded. + assertThat(fixture.logs).hasSize(1) + assertThat(fixture.logs.first().body).isEqualTo("$commonMsg warning") } private fun Breadcrumb.assert( From 9455c94768adcd92bd5babb027be2cdae22d6df7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Jul 2026 14:33:09 +0200 Subject: [PATCH 5/5] docs(changelog): Add entry for logcat re-entrancy fix Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 107b916808a..9d0da03b0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes +- Fix `StackOverflowError` from infinite recursion in `SentryLogcatAdapter` when a `beforeBreadcrumb` or `beforeSendLog` callback logs while logcat instrumentation is enabled ([#5734](https://github.com/getsentry/sentry-java/pull/5734)) - 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))