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..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
@@ -18,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;
@@ -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..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
@@ -310,4 +311,76 @@ 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())
+ }
}