From c7428ae482c034143e4456ab490e756b72bcd834 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 30 Oct 2026 10:40:53 +0100 Subject: [PATCH] Detect app hangs when the game attaches to the app's sentry-android --- docs/agent-guides/platform-android.md | 2 +- src/Sentry.Unity.Android/SentryJava.cs | 21 ++++++++---- .../SentryNativeAndroid.cs | 7 ++-- .../SentryMonoBehaviour.AppHang.cs | 32 ++++++++++++------- .../SentryNativeAndroidTests.cs | 30 +++++++++++++++++ .../TestSentryJava.cs | 3 ++ .../SentryMonoBehaviourAppHangTests.cs | 19 +++++++++++ 7 files changed, 94 insertions(+), 20 deletions(-) diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index cf85d0fe1..02c4dda75 100644 --- a/docs/agent-guides/platform-android.md +++ b/docs/agent-guides/platform-android.md @@ -12,7 +12,7 @@ - Runtime initialization writes `io.sentry.auto-init=false`; C# initializes the Java SDK later. - Build-time initialization writes options to the Android manifest and lets Java auto-initialize. - Runtime option changes cannot alter build-time initialization behavior. -- External initialization (Unity as a Library, experimental): the export ships no SDK, writes no manifest entries (they would merge into the app's), and keeps the ProGuard consumer rules. At runtime the game attaches if sentry-android is enabled in its process (no user, environment, or trace sync, no close, no NDK app-hang heartbeat). In a secondary process (e.g. `:Unity`), where the app didn't initialize it, the game initializes it from the Unity options with its own cache directory (`cache/sentry-`), so it doesn't take over the main process's session and persisted scope, and with ANR and tombstone reporting off, since the main process's SDK reports the exits of all the app's processes. There it continues the main process's trace, read from that SDK's persisted scope (`cache/sentry//.scope-cache/trace.json`) at start and on resume, and turns off its own trace generation. The symbol upload task goes into `unityLibrary/build.gradle` and runs after `mergeJniLibFolders`, since the app never builds `launcher`. In the main process without an SDK, native support is skipped. The process name comes from `Application.getProcessName` (API 28+) or `/proc/self/cmdline` below. C# sessions are off and the app's release and environment are used unless set. +- External initialization (Unity as a Library, experimental): the export ships no SDK, writes no manifest entries (they would merge into the app's), and keeps the ProGuard consumer rules. At runtime the game attaches if sentry-android is enabled in its process (no user, environment, or trace sync, no close, the NDK app-hang heartbeat only if the app enabled `enableNdkAppHangTracking`). In a secondary process (e.g. `:Unity`), where the app didn't initialize it, the game initializes it from the Unity options with its own cache directory (`cache/sentry-`), so it doesn't take over the main process's session and persisted scope, and with ANR and tombstone reporting off, since the main process's SDK reports the exits of all the app's processes. There it continues the main process's trace, read from that SDK's persisted scope (`cache/sentry//.scope-cache/trace.json`) at start and on resume, and turns off its own trace generation. The symbol upload task goes into `unityLibrary/build.gradle` and runs after `mergeJniLibFolders`, since the app never builds `launcher`. In the main process without an SDK, native support is skipped. The process name comes from `Application.getProcessName` (API 28+) or `/proc/self/cmdline` below. C# sessions are off and the app's release and environment are used unless set. ## Export Processing diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 2afdfb24d..bebbeea64 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -14,6 +14,7 @@ internal interface ISentryJava public bool? IsMainProcess(); public void Init(SentryUnityOptions options); public string? GetOption(string getter); + public bool? IsNdkAppHangTrackingEnabled(); public string? GetTraceHeader(); public string? GetBaggageHeader(); public string? GetMainProcessTraceHeader(); @@ -276,12 +277,20 @@ public void Init(SentryUnityOptions options) } // A string option of the native SDK, e.g. `getRelease` or `getEnvironment`. - public string? GetOption(string getter) + public string? GetOption(string getter) => + ReadOptions(nameof(GetOption), options => options.Call(getter)); + + // Whether the app's native SDK runs app hang detection. It only watches a thread that sends heartbeats. + public bool? IsNdkAppHangTrackingEnabled() => + ReadOptions(nameof(IsNdkAppHangTrackingEnabled), options => + (bool?)(options.Call("isEnableNdk") && options.Call("isEnableNdkAppHangTracking"))); + + private T? ReadOptions(string caller, Func read) { if (!MainThreadData.IsMainThread()) { - _logger?.LogError("Calling GetOption() on Android SDK requires running on MainThread"); - return null; + _logger?.LogError("Calling {0}() on Android SDK requires running on MainThread", caller); + return default; } try @@ -289,14 +298,14 @@ public void Init(SentryUnityOptions options) using var sentry = GetSentryJava(); using var hub = sentry.CallStatic("getCurrentHub"); using var options = hub?.Call("getOptions"); - return options?.Call(getter); + return options is null ? default : read(options); } catch (Exception e) { - _logger?.LogError(e, "Calling 'SentryJava.GetOption' failed."); + _logger?.LogError(e, "Calling 'SentryJava.{0}' failed.", caller); } - return null; + return default; } // The `sentry-trace` header of the app's SDK in the main process: its active span, otherwise its propagation context. diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 70e736fc2..2f653f671 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -119,8 +119,11 @@ public static void Configure(SentryUnityOptions options) } } - // When attached, the app's native SDK decides on app hang tracking. - if (!isAttached && options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) + // When attached, the app's native SDK decides on app hang tracking. It doesn't send heartbeats, so the game does. + var appHangTracking = isAttached + ? SentryJava.IsNdkAppHangTrackingEnabled() is true + : options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled; + if (appHangTracking) { Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); SentryMonoBehaviour.Instance.StartAppHangHeartbeat( diff --git a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs index a7f0c542b..bb45b1046 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs @@ -26,7 +26,10 @@ public partial class SentryMonoBehaviour public Coroutine StartAppHangHeartbeat(Action heartbeat) => StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval); - internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause) + internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause) => + StartAppHangHeartbeat(heartbeat, pause, AppHangHeartbeatInterval); + + internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause, TimeSpan interval) { if (_appHangPause is not null) { @@ -46,10 +49,14 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause) if (_appHangArmed) { heartbeat(); + // Unloading and reloading the player (Unity as a Library) resets the frame count and time, and a + // coroutine suspended across it stalls for as long as the game ran before. A new one starts on the new clock. + StopCoroutine(_appHangHeartbeat); + _appHangHeartbeat = StartCoroutine(AppHangHeartbeatCoroutine(heartbeat, interval, arm: false)); } }; ApplicationResuming += _appHangResume; - return StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval); + return StartAppHangHeartbeat(heartbeat, interval); } // Internal overload so tests can use a short interval. @@ -65,16 +72,19 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, TimeSpan interval) return _appHangHeartbeat; } - private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interval) + private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interval, bool arm = true) { - // Skipping the first frame. The first heartbeat both latches the main thread as the - // monitored target and arms detection. The monitor no-op without having received a - // heartbeat. During startup, splash screen plus the first scene load routinely block the - // main thread longer than the hang timeout and would cause false positives. - // This also works in batchmode/headless (e.g. LinuxServer), unlike WaitForEndOfFrame. - yield return null; - heartbeat(); - _appHangArmed = true; + if (arm) + { + // Skipping the first frame. The first heartbeat both latches the main thread as the + // monitored target and arms detection. The monitor no-op without having received a + // heartbeat. During startup, splash screen plus the first scene load routinely block the + // main thread longer than the hang timeout and would cause false positives. + // This also works in batchmode/headless (e.g. LinuxServer), unlike WaitForEndOfFrame. + yield return null; + heartbeat(); + _appHangArmed = true; + } var wait = new WaitForSecondsRealtime((float)interval.TotalSeconds); while (true) diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index 7cdbc94a9..d36b4f2cf 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -245,6 +245,36 @@ public void Configure_ExternalAppInitialized_AttachesWithoutOwningIdentity() Assert.False(options.AutoSessionTracking); } + [Test] + [TestCase(true, true)] + [TestCase(false, false)] + [TestCase(null, false)] + public void Configure_ExternalAppInitialized_StartsHeartbeatWhenAppTracksAppHangs(bool? appTracksAppHangs, bool expected) + { + var logger = new TestLogger(); + var options = new SentryUnityOptions + { + AndroidNativeInitializationType = NativeInitializationType.External, + Debug = true, + DiagnosticLevel = SentryLevel.Debug, + DiagnosticLogger = logger + }; + _testSentryJava.Enabled = true; + _testSentryJava.NdkAppHangTracking = appTracksAppHangs; + + try + { + SentryNativeAndroid.Configure(options); + + Assert.AreEqual(expected, logger.Logs.Any(log => log.message.Contains("Starting the app-hang heartbeat"))); + } + finally + { + // The heartbeat's pause handler would call into the native SDK when other tests pause the shared instance. + UnityEngine.Object.DestroyImmediate(SentryMonoBehaviour.Instance.gameObject); + } + } + [Test] public void Configure_ExternalNotInitializedInMainProcess_SkipsNativeSupport() { diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index 32c2d6ba4..b14fc1caf 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -12,6 +12,7 @@ internal class TestSentryJava : ISentryJava public string? InstallationId { get; set; } public bool? IsCrashedLastRun { get; set; } public bool? MainProcess { get; set; } = true; + public bool? NdkAppHangTracking { get; set; } public bool InitCalled { get; private set; } public bool CloseCalled { get; private set; } public Dictionary Options { get; } = new(); @@ -35,6 +36,8 @@ public void Init(SentryUnityOptions options) public string? GetOption(string getter) => Options.TryGetValue(getter, out var value) ? value : null; + public bool? IsNdkAppHangTrackingEnabled() => NdkAppHangTracking; + public string? GetTraceHeader() => TraceHeader; public string? GetBaggageHeader() => BaggageHeader; diff --git a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs index 4b0edf7f3..6e59bfc86 100644 --- a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs +++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs @@ -94,6 +94,25 @@ public IEnumerator StartAppHangHeartbeat_BeatsRightAwayOnResume() Assert.AreEqual(countWhilePaused + 1, count); } + [UnityTest] + public IEnumerator StartAppHangHeartbeat_RestartsHeartbeatsOnResume() + { + var sut = GetSut(); + var count = 0; + + var heartbeat = sut.StartAppHangHeartbeat(() => count++, () => { }, TimeSpan.FromSeconds(0.05)); + yield return null; + + // Like a reload of the player, which leaves the suspended coroutine stalled. + sut.UpdatePauseStatus(true); + sut.StopCoroutine(heartbeat); + sut.UpdatePauseStatus(false); + var countAfterResume = count; + yield return new WaitForSecondsRealtime(0.12f); + + Assert.Greater(count, countAfterResume); + } + [Test] public void StartAppHangHeartbeat_DoesNotArmOnResumeBeforeFirstHeartbeat() {