Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
20bfd1c
fixed debug symbol upload project name
bitsandfoxes Jun 24, 2026
360e3cf
Add app-hang heartbeat coroutine to SentryMonoBehaviour
bitsandfoxes Jun 8, 2026
c9146d5
Forward app-hang options to sentry-native
bitsandfoxes Jun 8, 2026
c94b7c6
Start app-hang heartbeat coroutine on native init
bitsandfoxes Jun 8, 2026
74d6a36
Mention macOS in app-hang tracking tooltip
bitsandfoxes Jun 8, 2026
eab0f31
Disable C# ANR watchdog on macOS when native app-hang tracking is ena…
bitsandfoxes Jun 8, 2026
24b23bd
Bump sentry-native to include the app hang feature
bitsandfoxes Jun 9, 2026
1956fe4
tweak
bitsandfoxes Jun 9, 2026
ecdb6be
bumped sentry-native, targeting desktop
bitsandfoxes Jun 10, 2026
3a715d3
tooltips and logs
bitsandfoxes Jun 11, 2026
8350c0b
app hangs in integration test
bitsandfoxes Jun 19, 2026
c1ca597
updated changelog
bitsandfoxes Jun 22, 2026
38d98a7
run app hang test on macos with sentry-native
bitsandfoxes Jun 22, 2026
7a8d8c7
comments
bitsandfoxes Jun 22, 2026
9a29a04
reverted changelog
bitsandfoxes Jun 22, 2026
c9450bd
updated changelog
bitsandfoxes Jun 22, 2026
f607f51
made native app hang tracking experimental
bitsandfoxes Jun 22, 2026
dd8b25c
fixed bridge method names
bitsandfoxes Jun 23, 2026
bc0eeaf
Apply suggestion from @bitsandfoxes
bitsandfoxes Jun 22, 2026
7355756
bumped native
bitsandfoxes Jun 23, 2026
61558ce
updated changelog
bitsandfoxes Jun 24, 2026
cfe79c1
.
bitsandfoxes Jun 24, 2026
f0b1505
Update CHANGELOG.md
bitsandfoxes Jun 24, 2026
5b384a7
make the heartbeat reentrant
bitsandfoxes Jun 24, 2026
37b571c
Merge branch 'feat/native-app-hang' of https://github.com/getsentry/s…
bitsandfoxes Jun 24, 2026
b6665e1
Apply suggestion from @JoshuaMoelans
bitsandfoxes Jun 24, 2026
c5dfc59
added switch stubs
bitsandfoxes Jun 24, 2026
bb51773
Merge branch 'main' into feat/native-app-hang
bitsandfoxes Jun 24, 2026
01be258
updated changelog
bitsandfoxes Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-run-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ jobs:
- name: Run Integration Tests (macOS)
if: inputs.platform == 'macos'
timeout-minutes: 20
env:
SENTRY_TEST_BACKEND: ${{ inputs.backend }}
run: |
$env:SENTRY_TEST_PLATFORM = "Desktop"
$env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.app/Contents/MacOS/IntegrationTest"
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features

- Updated the dependency resolution within the SDK. This enables .NET Standard 2.1 both for building the SDK from source and for setting the Api Compatibility Level in the Player Settings in your project. The assembly aliasing was originally intended to prevent dependency conflicts the SDK might introduce by deeply renaming its bundled dependencies. However, this prevented Unity from resolving these dependencies at build time, creating ambiguity for certain types. To resolve this, the SDK now excludes `System.Buffers`, `System.Memory`, `System.Numerics.Vectors`, and `System.Threading.Tasks.Extensions` from being aliased, letting the Unity build pipeline handle them. ([#2726](https://github.com/getsentry/sentry-unity/pull/2726))
- Added the experimental `options.Experimental.EnableNativeAppHangTracking` (default `false`) to enable app hang detection via `sentry-native` on macOS, Windows, and Linux. On macOS, this requires the macOS backend to be switched to `Native` instead of `Cocoa` in the Advanced -> Experimental settings. `sentry-native` monitors the main thread and produces an event including a stack trace for the hang, reusing the top-level `AppHangTimeout` (default `5s`). When effective, the Unity SDK's C# watchdog is skipped to avoid duplicate reports. iOS app hang detection remains controlled by the top-level `EnableAppHangTracking`. ([#2709](https://github.com/getsentry/sentry-unity/pull/2709))

### Dependencies

Expand Down
17 changes: 17 additions & 0 deletions package-dev/Plugins/Switch/sentry_native_stubs.c
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ void sentry_options_set_shutdown_timeout(void* options, uint64_t shutdown_timeou
(void)shutdown_timeout;
}

void sentry_options_set_enable_app_hang_tracking(void* options, int enabled)
{
(void)options;
(void)enabled;
}

void sentry_options_set_app_hang_timeout(void* options, uint64_t timeout)
{
(void)options;
(void)timeout;
}

void sentry_options_set_logger(void* options, void* logger, void* userdata)
{
(void)options;
Expand Down Expand Up @@ -315,6 +327,11 @@ void sentry_reinstall_backend(void)
/* No-op */
}

void sentry_app_hang_heartbeat(void)
{
/* No-op */
}

sentry_value_t sentry_get_modules_list(void)
{
/* Return null - no modules to report */
Expand Down
15 changes: 12 additions & 3 deletions src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,15 @@ internal static void Display(ScriptableSentryUnityOptions options, SentryCliOpti

options.EnableAppHangTracking = EditorGUILayout.Toggle(
new GUIContent("Enable",
"Enables app hang detection via the native SDK. Currently effective on iOS only; " +
"no-op on other platforms until each platform's native hang detection lands."),
"Enables app hang detection on iOS via sentry-cocoa. App hang detection on macOS, " +
"Windows, and Linux is experimental and controlled separately in the Experimental section."),
options.EnableAppHangTracking);

options.AppHangTimeout = EditorGUILayout.IntField(
new GUIContent("App Hang Timeout [ms]",
"The duration in [ms] for how long the main thread has to be blocked " +
"before an app hang is reported.\nDefault: 5000ms"),
"before an app hang is reported. Shared with the experimental native app hang " +
"detection.\nDefault: 5000ms"),
options.AppHangTimeout);
options.AppHangTimeout = Math.Max(0, options.AppHangTimeout);
}
Expand Down Expand Up @@ -270,6 +271,14 @@ internal static void Display(ScriptableSentryUnityOptions options, SentryCliOpti
"Uploads crashes immediately."),
options.Experimental.LinuxBackend);
}

options.Experimental.EnableNativeAppHangTracking = EditorGUILayout.Toggle(
new GUIContent(
"Native App Hang Tracking",
"Enables app hang detection via sentry-native on macOS, Windows, and Linux. Requires the " +
"corresponding platform backend above to be set to 'Native'. Shares the App Hang Timeout " +
"configured in the App Hang Tracking section. iOS is unaffected by this option."),
options.Experimental.EnableNativeAppHangTracking);
}
EditorGUI.indentLevel--;
EditorGUILayout.EndFoldoutHeaderGroup();
Expand Down
25 changes: 24 additions & 1 deletion src/Sentry.Unity.Native/SentryNative.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static class SentryNative

private static bool ShouldReinstallBackend;
private static IDiagnosticLogger? Logger;
private static Action? OnQuitting;

/// <summary>
/// Configures the native SDK.
Expand Down Expand Up @@ -56,11 +57,17 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf
return;
}

ApplicationAdapter.Instance.Quitting += () =>
if (OnQuitting is not null)
{
ApplicationAdapter.Instance.Quitting -= OnQuitting;
}

OnQuitting = () =>
{
Logger?.LogDebug("Closing the sentry-native SDK");
SentryNativeBridge.Close();
};
ApplicationAdapter.Instance.Quitting += OnQuitting;
options.ScopeObserver = new NativeScopeObserver(options);
options.EnableScopeSync = true;
options.NativeContextWriter = new NativeContextWriter();
Expand All @@ -87,6 +94,22 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf
}
options.CrashedLastRun = () => crashedLastRun;

if (options.Experimental.EnableNativeAppHangTracking)
{
Logger?.LogDebug("Starting the app-hang heartbeat coroutine.");
SentryMonoBehaviour.Instance.StartAppHangHeartbeat(SentryNativeBridge.AppHangHeartbeat);
Comment thread
bitsandfoxes marked this conversation as resolved.

// sentry-native handles app-hang detection on the desktop platforms. Where it is effective, skip the
// C# ANR watchdog so a hang isn't reported twice (mirrors the iOS/sentry-cocoa behavior).
if (platform is RuntimePlatform.OSXPlayer or RuntimePlatform.OSXServer
or RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsServer
or RuntimePlatform.LinuxPlayer or RuntimePlatform.LinuxServer)
{
Logger?.LogDebug("Disabling the C# ANR watchdog - sentry-native handles app hang detection.");
options.DisableAnrIntegration();
}
}

ShouldReinstallBackend = true;
}

Expand Down
18 changes: 18 additions & 0 deletions src/Sentry.Unity.Native/SentryNativeBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ is RuntimePlatform.WindowsPlayer or RuntimePlatform.WindowsServer
Logger?.LogInfo("Passing the native logs back to the C# layer is not supported on Mono - skipping native logger.");
}

Logger?.LogDebug("Setting EnableNativeAppHangTracking: {0}", options.Experimental.EnableNativeAppHangTracking);
sentry_options_set_enable_app_hang_tracking(cOptions, options.Experimental.EnableNativeAppHangTracking ? 1 : 0);

var appHangTimeoutMs = (ulong)Math.Max(0, options.AppHangTimeout.TotalMilliseconds);
Logger?.LogDebug("Setting AppHangTimeout: {0}ms", appHangTimeoutMs);
sentry_options_set_app_hang_timeout(cOptions, appHangTimeoutMs);
Comment thread
bitsandfoxes marked this conversation as resolved.

Logger?.LogDebug("Initializing sentry native");
return 0 == sentry_init(cOptions);
}
Expand Down Expand Up @@ -151,6 +158,8 @@ internal static string GetDatabasePath(SentryUnityOptions options, IApplication?

internal static void ReinstallBackend() => sentry_reinstall_backend();

internal static void AppHangHeartbeat() => sentry_app_hang_heartbeat();

// libsentry.so
[DllImport(SentryLib)]
private static extern IntPtr sentry_options_new();
Expand Down Expand Up @@ -193,6 +202,12 @@ internal static string GetDatabasePath(SentryUnityOptions options, IApplication?
[DllImport(SentryLib)]
private static extern void sentry_options_set_enable_metrics(IntPtr options, int enable_metrics);

[DllImport(SentryLib)]
private static extern void sentry_options_set_enable_app_hang_tracking(IntPtr options, int enabled);

[DllImport(SentryLib)]
private static extern void sentry_options_set_app_hang_timeout(IntPtr options, ulong timeout);

[UnmanagedFunctionPointer(CallingConvention.Cdecl, SetLastError = true)]
private delegate void sentry_logger_function_t(int level, IntPtr message, IntPtr argsAddress, IntPtr userData);

Expand Down Expand Up @@ -354,4 +369,7 @@ private static void WithMarshalledStruct<T>(T structure, Action<IntPtr> action)

[DllImport(SentryLib)]
private static extern void sentry_reinstall_backend();

[DllImport(SentryLib)]
private static extern void sentry_app_hang_heartbeat();
}
9 changes: 9 additions & 0 deletions src/Sentry.Unity/ExperimentalSentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,13 @@ public class ExperimentalSentryUnityOptions
/// a minimum of 10 seconds so the out-of-process handler has time to flush before the player exits.
/// </summary>
[field: SerializeField] public LinuxBackend LinuxBackend { get; set; } = LinuxBackend.Breakpad;

/// <summary>
/// Enables app hang detection via <c>sentry-native</c> on macOS, Windows, and Linux. Defaults to
/// <c>false</c>. Requires the backend to be switched to <see cref="Sentry.Unity.MacosBackend.Native"/>
/// on macOS. <c>sentry-native</c> monitors the main thread and
/// produces an app hang event including a stack trace. When enabled, the C# watchdog is skipped to avoid
/// duplicate reports. The timeout is taken from <c>AppHangTimeout</c>.
/// </summary>
[field: SerializeField] public bool EnableNativeAppHangTracking { get; set; } = false;
}
1 change: 1 addition & 0 deletions src/Sentry.Unity/ScriptableSentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ internal SentryUnityOptions ToSentryUnityOptions(
options.Experimental.MacosBackend = Experimental.MacosBackend;
options.Experimental.WindowsBackend = Experimental.WindowsBackend;
options.Experimental.LinuxBackend = Experimental.LinuxBackend;
options.Experimental.EnableNativeAppHangTracking = Experimental.EnableNativeAppHangTracking;

// By default, the cacheDirectoryPath gets set on known platforms. We're overwriting this behaviour here.
if (!EnableOfflineCaching)
Expand Down
55 changes: 55 additions & 0 deletions src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using System.Collections;
using UnityEngine;

namespace Sentry.Unity;

/// <summary>
/// Drives the periodic heartbeat used by sentry-native's app-hang detection.
/// The coroutine runs on the Unity main thread, which is the thread the native
/// daemon latches onto as the monitored target.
/// </summary>
public partial class SentryMonoBehaviour
{
private static readonly TimeSpan AppHangHeartbeatInterval = TimeSpan.FromSeconds(1);

private Coroutine? _appHangHeartbeat;

/// <summary>
/// Starts the app-hang heartbeat on the main thread at a fixed 1-second interval. Arming is
/// deferred until the player loop is running (see <see cref="AppHangHeartbeatCoroutine"/>) so
/// the synchronous startup stall isn't reported as a hang.
/// </summary>
public Coroutine StartAppHangHeartbeat(Action heartbeat) =>
StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval);

// Internal overload so tests can use a short interval.
internal Coroutine StartAppHangHeartbeat(Action heartbeat, TimeSpan interval)
{
if (_appHangHeartbeat is not null)
{
StopCoroutine(_appHangHeartbeat);
}

_appHangHeartbeat = StartCoroutine(AppHangHeartbeatCoroutine(heartbeat, interval));
return _appHangHeartbeat;
}

private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interval)
{
// 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();

var wait = new WaitForSecondsRealtime((float)interval.TotalSeconds);
while (true)
{
yield return wait;
heartbeat();
}
}
}
6 changes: 3 additions & 3 deletions src/Sentry.Unity/SentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,9 @@ public sealed class SentryUnityOptions : SentryOptions
public bool IosWatchdogTerminationIntegrationEnabled { get; set; } = false;

/// <summary>
/// Enables app hang detection on platforms whose native SDK can deliver Unity-thread hang
/// coverage. Currently effective on iOS only; on other platforms this is a no-op until each
/// platform's native hang detection lands.
/// Enables app hang detection on iOS through <c>sentry-cocoa</c>, which monitors the main thread.
/// App hang detection on macOS, Windows, and Linux is experimental and controlled separately via
/// <c>Experimental.EnableNativeAppHangTracking</c>.
/// </summary>
public bool EnableAppHangTracking { get; set; } = true;

Expand Down
52 changes: 52 additions & 0 deletions test/IntegrationTest/Integration.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,55 @@ if ($env:SENTRY_TEST_PLATFORM -ne "WebGL") {
}
}
}

# In-proc app-hang detection is driven by the C# heartbeat coroutine that pings sentry-native, so it
# is active for every desktop configuration that uses the sentry-native code path. The exception
# is macOS with the Cocoa backend: there the SDK uses sentry-cocoa, which has its own app-hang path
# and never starts the in-proc heartbeat, so skip that one configuration.
$isCocoaBackend = $env:SENTRY_TEST_BACKEND -eq "cocoa"
if ($env:SENTRY_TEST_PLATFORM -eq "Desktop" -and -not $isCocoaBackend) {
Describe "Unity $($env:SENTRY_TEST_PLATFORM) App Hang Tests" {

Context "App Hang Capture" {
BeforeAll {
$script:runEvent = $null
$script:runResult = Invoke-TestAction -Action "app-hang-capture"

# The native app-hang event is captured in-proc (same run, no relaunch). Its event ID
# is generated natively, so look it up by the unique scope tag the app sets instead.
$hangId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1
if ($hangId) {
Write-Host "::group::Getting event content"
$script:runEvent = Get-SentryTestEvent -TagName "test.app_hang_id" -TagValue "$hangId" -TimeoutSeconds 300
Write-Host "::endgroup::"
}
}

It "<Name>" -ForEach $CommonTestCases {
& $testBlock -SentryEvent $runEvent -TestType "app-hang-capture" -RunResult $runResult -TestSetup $script:TestSetup
}

It "Has error level" {
($runEvent.tags | Where-Object { $_.key -eq "level" }).value | Should -Be "error"
}

It "Has AppHang exception with stacktrace" {
$runEvent.exception | Should -Not -BeNullOrEmpty
$runEvent.exception.values | Should -Not -BeNullOrEmpty
$exception = $runEvent.exception.values[0]
$exception | Should -Not -BeNullOrEmpty
$exception.type | Should -Be "AppHang"
$exception.value | Should -Match "App hung for at least"
$exception.stacktrace | Should -Not -BeNullOrEmpty
}

It "Has AppHang mechanism" {
$mechanism = $runEvent.exception.values[0].mechanism
$mechanism | Should -Not -BeNullOrEmpty
$mechanism.type | Should -Be "AppHang"
$mechanism.handled | Should -Be $true
$mechanism.synthetic | Should -Be $true
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public override void Configure(SentryUnityOptions options)
// Disable ANR to avoid test interference
options.DisableAnrIntegration();

// App Hang tracking
options.Experimental.EnableNativeAppHangTracking = true;
Comment thread
bitsandfoxes marked this conversation as resolved.
options.AppHangTimeout = TimeSpan.FromSeconds(2);

// Runtime initialization for integration tests
options.AndroidNativeInitializationType = NativeInitializationType.Runtime;
options.IosNativeInitializationType = NativeInitializationType.Runtime;
Expand Down
37 changes: 37 additions & 0 deletions test/Scripts.Integration.Test/Scripts/IntegrationTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public void Start()
case "crash-capture":
StartCoroutine(CrashCapture());
break;
case "app-hang-capture":
StartCoroutine(AppHangCapture());
break;
case "crash-send":
CrashSend();
break;
Expand Down Expand Up @@ -211,6 +214,40 @@ private IEnumerator CrashCapture()
Application.Quit(1);
}

private IEnumerator AppHangCapture()
{
var hangId = Guid.NewGuid().ToString();

AddIntegrationTestContext("app-hang-capture");

// The native app-hang event is captured in-proc by sentry-native and its event ID is not
// visible to C#. Tag the scope with a unique ID so the test harness can look the event up,
// the same way crash-capture does (scope tags sync to the native layer).
SentrySdk.ConfigureScope(scope =>
{
scope.SetTag("test.app_hang_id", hangId);
});

// Wait for the scope sync to complete and for the app-hang heartbeat coroutine to arm
// (arming is deliberately deferred by a frame so startup isn't reported as a hang).
yield return new WaitForSeconds(0.5f);

Logger.Log($"EVENT_CAPTURED: {hangId}");
Logger.Log("APP HANG TEST: Blocking the main thread to trigger native app-hang detection");

// Block the main thread well past AppHangTimeout (2s in IntegrationOptionsConfiguration),
// clearing the watchdog's 500ms poll and 1s heartbeat interval so detection reliably fires.
System.Threading.Thread.Sleep(5000);

// The main thread is responsive again; let the heartbeat resume and flush the captured event.
yield return null;

SentrySdk.FlushAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();

Logger.Log("APP HANG TEST: Flush complete, quitting.");
Application.Quit(0);
}

private void CrashSend()
{
Logger.Log("CrashSend: Initializing Sentry to flush cached crash report...");
Expand Down
Loading
Loading