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()) + } }