diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index abcca4f883..918acc385f 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -86,6 +86,9 @@ dependencies { compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) compileOnly(projects.sentryAndroidDistribution) + // Used at runtime by AnrIntegration's heartbeat watchdog to capture native stacks via the NDK + // companion. Optional at runtime - sentry-android-ndk provides it transitively when present. + compileOnly(libs.sentry.native.ndk) // lifecycle processor, session tracking implementation(libs.androidx.lifecycle.common.java8) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 5704cf7d7d..a1cbbdc69d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -401,6 +401,12 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); + // Heartbeat-mode app-hang detection for non-Looper main threads (e.g. Unity / Unreal). + // Self-gates on SentryAndroidOptions.anrThreadId == 0 at register() time, so it's harmless + // to install unconditionally here. We can't gate on the option value yet because user + // configuration runs after this method. + options.addIntegration(new AnrHeartbeatIntegration(context)); + options.addIntegration(new AnrProfilingIntegration()); // registerActivityLifecycleCallbacks is only available if Context is an AppContext diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java new file mode 100644 index 0000000000..61aac4689f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java @@ -0,0 +1,334 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Debug; +import io.sentry.AnrHeartbeatRegistry; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.SentryStackTrace; +import io.sentry.protocol.SentryThread; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Heartbeat-based app-hang detection for runtimes whose main thread is not an Android Looper thread + * (e.g. Unity, Unreal). The host runtime calls {@link io.sentry.Sentry#notifyAnrThreadAlive()} + * regularly from the monitored thread; if no heartbeat arrives within {@link + * SentryAndroidOptions#getAnrTimeoutIntervalMillis()}, an ANR event is reported with the captured + * native stack of the monitored thread (when the NDK companion is available). + * + *
Self-gates on {@code SentryAndroidOptions.anrThreadId == 0} at register time, so installing + * this integration unconditionally is safe. + * + *
Orthogonal to {@link AnrIntegration} (Looper probe) and {@link AnrV2Integration} ({@code
+ * ApplicationExitInfo}): all three can coexist because they monitor different signals.
+ */
+public final class AnrHeartbeatIntegration implements Integration, Closeable {
+
+ /** Polling cadence of the watchdog thread, in ms. Independent of the heartbeat cadence. */
+ static final long POLLING_INTERVAL_MS = 500L;
+
+ private final @NotNull Context context;
+
+ @SuppressLint("StaticFieldLeak")
+ @Nullable
+ private static volatile HeartbeatWatchDog watchdog;
+
+ private static final @NotNull AutoClosableReentrantLock watchdogLock =
+ new AutoClosableReentrantLock();
+
+ @Nullable private SentryOptions options;
+
+ public AnrHeartbeatIntegration(final @NotNull Context context) {
+ this.context = ContextUtils.getApplicationContext(context);
+ }
+
+ @Override
+ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) {
+ this.options = Objects.requireNonNull(options, "SentryOptions is required");
+ final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options;
+ final ILogger logger = androidOptions.getLogger();
+
+ final long anrThreadId = androidOptions.getAnrThreadId();
+ if (anrThreadId == 0) {
+ logger.log(
+ SentryLevel.DEBUG,
+ "AnrHeartbeatIntegration disabled: SentryAndroidOptions.anrThreadId is not set.");
+ return;
+ }
+
+ if (!androidOptions.isAnrEnabled()) {
+ logger.log(SentryLevel.DEBUG, "AnrHeartbeatIntegration disabled: ANR detection is off.");
+ return;
+ }
+
+ try (final @NotNull ISentryLifecycleToken ignored = watchdogLock.acquire()) {
+ if (watchdog != null) {
+ logger.log(SentryLevel.DEBUG, "AnrHeartbeatIntegration already installed; skipping.");
+ return;
+ }
+
+ // Resolve the monitored thread name once at register time. Falls back to a generic name
+ // if /proc is unreadable, which is rare on real devices.
+ final @Nullable String threadName = readThreadName(anrThreadId);
+
+ final HeartbeatWatchDog wd =
+ new HeartbeatWatchDog(
+ androidOptions.getAnrTimeoutIntervalMillis(),
+ POLLING_INTERVAL_MS,
+ androidOptions.isAnrReportInDebug(),
+ error -> reportAnr(scopes, androidOptions, anrThreadId, threadName, error),
+ logger);
+ wd.start();
+ watchdog = wd;
+ AnrHeartbeatRegistry.setListener(wd::notifyAlive);
+ addIntegrationToSdkVersion("AnrHeartbeat");
+
+ logger.log(
+ SentryLevel.DEBUG,
+ "AnrHeartbeatIntegration installed (tid=%d, timeout=%d ms, thread=%s).",
+ anrThreadId,
+ androidOptions.getAnrTimeoutIntervalMillis(),
+ threadName);
+ }
+ }
+
+ @Override
+ public void close() {
+ try (final @NotNull ISentryLifecycleToken ignored = watchdogLock.acquire()) {
+ AnrHeartbeatRegistry.setListener(null);
+ if (watchdog != null) {
+ watchdog.interrupt();
+ watchdog = null;
+ if (options != null) {
+ options.getLogger().log(SentryLevel.DEBUG, "AnrHeartbeatIntegration removed.");
+ }
+ }
+ }
+ }
+
+ @TestOnly
+ @Nullable
+ HeartbeatWatchDog getWatchdog() {
+ return watchdog;
+ }
+
+ private void reportAnr(
+ final @NotNull IScopes scopes,
+ final @NotNull SentryAndroidOptions options,
+ final long tid,
+ final @Nullable String threadName,
+ final @NotNull ApplicationNotResponding error) {
+ options.getLogger().log(SentryLevel.INFO, "ANR triggered with message: %s", error.getMessage());
+
+ final boolean isAppInBackground = Boolean.TRUE.equals(AppState.getInstance().isInBackground());
+
+ String message = "ANR for at least " + options.getAnrTimeoutIntervalMillis() + " ms.";
+ if (isAppInBackground) {
+ message = "Background " + message;
+ }
+ final ApplicationNotResponding wrapped = new ApplicationNotResponding(message);
+ final Mechanism mechanism = new Mechanism();
+ mechanism.setType("ANR");
+ // The watchdog thread is not the culprit — let event processors prefer the monitored thread.
+ final Throwable throwable = new ExceptionMechanismException(mechanism, wrapped, null, true);
+
+ final SentryEvent event = new SentryEvent(throwable);
+ event.setLevel(SentryLevel.ERROR);
+
+ attachNativeStack(event, tid, threadName, options);
+
+ final AnrIntegration.AnrHint anrHint = new AnrIntegration.AnrHint(isAppInBackground);
+ final Hint hint = HintUtils.createWithTypeCheckHint(anrHint);
+ scopes.captureEvent(event, hint);
+ }
+
+ private void attachNativeStack(
+ final @NotNull SentryEvent event,
+ final long tid,
+ final @Nullable String threadName,
+ final @NotNull SentryAndroidOptions options) {
+ try {
+ final long[] addresses = io.sentry.ndk.SentryNdk.captureThreadStack(tid);
+ if (addresses.length == 0) {
+ options
+ .getLogger()
+ .log(
+ SentryLevel.WARNING,
+ "Captured 0 native frames for thread %d; skipping native stack attachment.",
+ tid);
+ return;
+ }
+
+ final List Intended for runtimes whose main thread is not an Android Looper thread (e.g. Unity,
+ * Unreal). Set to 0 (default) to use the standard Looper-probe watchdog.
+ */
+ private long anrThreadId = 0;
+
/**
* Enable or disable automatic breadcrumbs for Activity lifecycle. Using
* Application.ActivityLifecycleCallbacks
@@ -330,6 +342,28 @@ public void setAnrReportInDebug(boolean anrReportInDebug) {
this.anrReportInDebug = anrReportInDebug;
}
+ /**
+ * Returns the Linux kernel thread ID (TID) monitored by the ANR watchdog in heartbeat mode.
+ * Default is 0 (Looper-probe mode).
+ *
+ * @return the TID or 0 if heartbeat mode is disabled
+ */
+ public long getAnrThreadId() {
+ return anrThreadId;
+ }
+
+ /**
+ * Sets the Linux kernel thread ID (TID) monitored by the ANR watchdog. When set to a non-zero
+ * value, the watchdog switches to heartbeat mode and expects {@link
+ * io.sentry.Sentry#notifyAnrThreadAlive()} to be called regularly from that thread. Default is 0
+ * (Looper-probe mode).
+ *
+ * @param tid the TID or 0 to disable heartbeat mode
+ */
+ public void setAnrThreadId(final long tid) {
+ this.anrThreadId = tid;
+ }
+
/**
* Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled.
*
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt
new file mode 100644
index 0000000000..4f6249e010
--- /dev/null
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt
@@ -0,0 +1,168 @@
+package io.sentry.android.core
+
+import android.content.Context
+import io.sentry.AnrHeartbeatRegistry
+import io.sentry.IScopes
+import io.sentry.SentryLevel
+import io.sentry.exception.ExceptionMechanismException
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.timeout
+import org.mockito.kotlin.verify
+
+class AnrHeartbeatIntegrationTest {
+ private val context = mock This class is internal SDK plumbing and not part of the public API. End-user app code should
+ * not call it directly.
+ */
+@ApiStatus.Internal
+public final class AnrHeartbeatRegistry {
+
+ private AnrHeartbeatRegistry() {}
+
+ private static volatile @Nullable Runnable listener;
+
+ /**
+ * Registers a heartbeat listener. Pass {@code null} to clear (e.g. on integration close).
+ *
+ * @param r the listener, or {@code null} to clear
+ */
+ public static void setListener(final @Nullable Runnable r) {
+ listener = r;
+ }
+
+ /** Notifies the registered listener, if any. A no-op if no listener has been registered. */
+ public static void notifyAlive() {
+ final Runnable r = listener;
+ if (r != null) {
+ r.run();
+ }
+ }
+}
diff --git a/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt b/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt
new file mode 100644
index 0000000000..2434df6a0f
--- /dev/null
+++ b/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt
@@ -0,0 +1,62 @@
+package io.sentry
+
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AnrHeartbeatRegistryTest {
+
+ @AfterTest
+ fun tearDown() {
+ // Reset the static registry to avoid cross-test bleed.
+ AnrHeartbeatRegistry.setListener(null)
+ }
+
+ @Test
+ fun `notifyAlive without a listener is a no-op`() {
+ // No setListener call - this must not throw.
+ AnrHeartbeatRegistry.notifyAlive()
+ }
+
+ @Test
+ fun `notifyAlive invokes the registered listener`() {
+ val counter = AtomicInteger(0)
+ AnrHeartbeatRegistry.setListener({ counter.incrementAndGet() })
+
+ AnrHeartbeatRegistry.notifyAlive()
+ AnrHeartbeatRegistry.notifyAlive()
+ AnrHeartbeatRegistry.notifyAlive()
+
+ assertEquals(3, counter.get())
+ }
+
+ @Test
+ fun `clearing the listener stops notifications`() {
+ val counter = AtomicInteger(0)
+ AnrHeartbeatRegistry.setListener({ counter.incrementAndGet() })
+ AnrHeartbeatRegistry.notifyAlive()
+ assertEquals(1, counter.get())
+
+ AnrHeartbeatRegistry.setListener(null)
+ AnrHeartbeatRegistry.notifyAlive()
+ AnrHeartbeatRegistry.notifyAlive()
+ assertEquals(1, counter.get())
+ }
+
+ @Test
+ fun `setListener replaces the previous listener`() {
+ val firstCount = AtomicInteger(0)
+ val secondCount = AtomicInteger(0)
+
+ AnrHeartbeatRegistry.setListener({ firstCount.incrementAndGet() })
+ AnrHeartbeatRegistry.notifyAlive()
+
+ AnrHeartbeatRegistry.setListener({ secondCount.incrementAndGet() })
+ AnrHeartbeatRegistry.notifyAlive()
+ AnrHeartbeatRegistry.notifyAlive()
+
+ assertEquals(1, firstCount.get())
+ assertEquals(2, secondCount.get())
+ }
+}