From 30828c98956dfa3a2124ca3f08e2596ce833a1d1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:35:40 +0000 Subject: [PATCH 1/2] feat(anr): add anrStackTraceProvider option to SentryAndroidOptions Adds a nullable Supplier anrStackTraceProvider field to SentryAndroidOptions. When non-null, AnrProfilingIntegration uses it to obtain the stack trace instead of calling mainThread.getStackTrace() directly. This lets hybrid SDKs (Flutter, React Native, etc.) inject a custom provider that returns combined or natively-enriched frames alongside JVM frames, fixing ANR profiling for architectures where Thread.getStackTrace() only sees the JVM side of the call stack. Default is null, preserving existing behaviour. Co-authored-by: Markus Hintersteiner --- .../android/core/SentryAndroidOptions.java | 32 +++++++++ .../core/anr/AnrProfilingIntegration.java | 8 ++- .../android/core/SentryAndroidOptionsTest.kt | 24 +++++++ .../core/anr/AnrProfilingIntegrationTest.kt | 72 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bb9ec17aabd..5a2bd7f895e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; +import java.util.function.Supplier; import io.sentry.Hint; import io.sentry.IScope; import io.sentry.ISpan; @@ -261,6 +262,15 @@ public interface BeforeCaptureCallback { private @Nullable Double anrProfilingSampleRate; + /** + * Optional provider for the stack trace used during ANR profiling. When set, the integration + * calls this supplier instead of {@link Thread#getStackTrace()} on the main thread. This lets + * hybrid SDKs (e.g. Flutter, React Native) supply a combined or native-enriched stack trace. + * + *

Defaults to {@code null}, which falls back to {@code mainThread.getStackTrace()}. + */ + private @Nullable Supplier anrStackTraceProvider; + private boolean enableAnrFingerprinting = true; public SentryAndroidOptions() { @@ -732,6 +742,28 @@ public boolean isAnrProfilingEnabled() { return anrProfilingSampleRate != null && anrProfilingSampleRate > 0; } + /** + * Returns the custom stack trace provider used during ANR profiling, or {@code null} if the + * default {@link Thread#getStackTrace()} behaviour should be used. + */ + public @Nullable Supplier getAnrStackTraceProvider() { + return anrStackTraceProvider; + } + + /** + * Sets a custom stack trace provider used during ANR profiling. When non-null the integration + * calls this supplier instead of {@link Thread#getStackTrace()} on the main thread. Hybrid SDKs + * can use this to expose Dart / JS / native frames alongside JVM frames. + * + *

Pass {@code null} (the default) to restore the built-in behaviour. + * + * @param anrStackTraceProvider supplier that returns the current stack trace, or {@code null} + */ + public void setAnrStackTraceProvider( + final @Nullable Supplier anrStackTraceProvider) { + this.anrStackTraceProvider = anrStackTraceProvider; + } + /** * Returns whether ANR fingerprinting is enabled. When enabled, the SDK assigns static * fingerprints to ANR events that would otherwise produce noisy grouping. Currently, this applies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 97ec0434249..fbe97ef7efb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -235,8 +236,13 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept || mainThreadState == MainThreadState.ANR_DETECTED)) { if (numCollectedStacks.get() < MAX_NUM_STACKS) { final long start = SystemClock.uptimeMillis(); + final @Nullable SentryAndroidOptions opts = options; + final @Nullable Supplier provider = + opts != null ? opts.getAnrStackTraceProvider() : null; + final @NotNull StackTraceElement[] stackTrace = + provider != null ? provider.get() : mainThread.getStackTrace(); final @NotNull AnrStackTrace trace = - new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); + new AnrStackTrace(System.currentTimeMillis(), stackTrace); final long duration = SystemClock.uptimeMillis() - start; if (logger.isEnabled(SentryLevel.DEBUG)) { logger.log( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..67679cccec2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -3,11 +3,13 @@ package io.sentry.android.core import io.sentry.ITransactionProfiler import io.sentry.NoOpTransactionProfiler import io.sentry.protocol.DebugImage +import java.util.function.Supplier import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import org.mockito.kotlin.mock @@ -233,6 +235,28 @@ class SentryAndroidOptionsTest { sentryOptions.anrProfilingSampleRate = 2.0 } + @Test + fun `anrStackTraceProvider is null by default`() { + val sentryOptions = SentryAndroidOptions() + assertNull(sentryOptions.anrStackTraceProvider) + } + + @Test + fun `anrStackTraceProvider can be set and retrieved`() { + val sentryOptions = SentryAndroidOptions() + val provider = Supplier> { emptyArray() } + sentryOptions.anrStackTraceProvider = provider + assertSame(provider, sentryOptions.anrStackTraceProvider) + } + + @Test + fun `anrStackTraceProvider can be cleared to null`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrStackTraceProvider = Supplier> { emptyArray() } + sentryOptions.anrStackTraceProvider = null + assertNull(sentryOptions.anrStackTraceProvider) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 2ae48fb3253..d7319dc9cda 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -21,6 +21,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock +import java.util.function.Supplier @RunWith(AndroidJUnit4::class) class AnrProfilingIntegrationTest { @@ -310,4 +311,75 @@ class AnrProfilingIntegrationTest { integration.close() } + + @Test + fun `custom anrStackTraceProvider is used when set`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val customFrames = + arrayOf( + StackTraceElement("com.example.Dart", "dartMain", "main.dart", 42), + StackTraceElement("com.example.Flutter", "runApp", "app.dart", 10), + ) + val providerCallCount = java.util.concurrent.atomic.AtomicInteger(0) + val customProvider = Supplier> { + providerCallCount.incrementAndGet() + customFrames + } + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + anrStackTraceProvider = customProvider + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + // Advance time into the suspicious window and trigger a stack capture + SystemClock.setCurrentTimeMillis(3_000) + integration.checkMainThread(mainThread) + + // One stack should have been collected using the custom provider + assertEquals(1, integration.numCollectedStacks.get()) + assertTrue(providerCallCount.get() > 0, "Custom provider should have been called") + + val stacks = integration.profileManager.load().stacks + assertEquals(1, stacks.size) + val capturedFrames = stacks[0].stack + assertEquals("com.example.Dart", capturedFrames[0].className) + assertEquals("dartMain", capturedFrames[0].methodName) + } + + @Test + fun `null anrStackTraceProvider falls back to mainThread getStackTrace`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + // anrStackTraceProvider left as null (default) + } + + assertEquals(null, androidOptions.anrStackTraceProvider) + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + SystemClock.setCurrentTimeMillis(3_000) + integration.checkMainThread(mainThread) + + // Should still have collected one stack via the default path + assertEquals(1, integration.numCollectedStacks.get()) + val stacks = integration.profileManager.load().stacks + assertEquals(1, stacks.size) + // Frames should be real JVM frames (non-empty) + assertTrue(stacks[0].stack.isNotEmpty()) + } } From 31b617999ee9ebd83ffa5dd031138d380406568d Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 2 Jun 2026 11:39:00 +0000 Subject: [PATCH 2/2] Format code --- .../io/sentry/android/core/SentryAndroidOptions.java | 2 +- .../android/core/anr/AnrProfilingIntegrationTest.kt | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 5a2bd7f895e..f23f8901cf5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -3,7 +3,6 @@ import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; -import java.util.function.Supplier; import io.sentry.Hint; import io.sentry.IScope; import io.sentry.ISpan; @@ -19,6 +18,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; import io.sentry.util.SampleRateUtils; +import java.util.function.Supplier; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index d7319dc9cda..9d5b9ab9a10 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -9,6 +9,7 @@ import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty +import java.util.function.Supplier import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -21,7 +22,6 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock -import java.util.function.Supplier @RunWith(AndroidJUnit4::class) class AnrProfilingIntegrationTest { @@ -323,10 +323,11 @@ class AnrProfilingIntegrationTest { StackTraceElement("com.example.Flutter", "runApp", "app.dart", 10), ) val providerCallCount = java.util.concurrent.atomic.AtomicInteger(0) - val customProvider = Supplier> { - providerCallCount.incrementAndGet() - customFrames - } + val customProvider = + Supplier> { + providerCallCount.incrementAndGet() + customFrames + } val androidOptions = SentryAndroidOptions().apply {