Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion docs/agent-guides/platform-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<process>`), 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/<dsn hash>/.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 `merge<Variant>JniLibFolders`, 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-<process>`), 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/<dsn hash>/.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 `merge<Variant>JniLibFolders`, 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

Expand Down
21 changes: 15 additions & 6 deletions src/Sentry.Unity.Android/SentryJava.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -276,27 +277,35 @@ 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<string>(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<bool>("isEnableNdk") && options.Call<bool>("isEnableNdkAppHangTracking")));

private T? ReadOptions<T>(string caller, Func<AndroidJavaObject, T?> 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
{
using var sentry = GetSentryJava();
using var hub = sentry.CallStatic<AndroidJavaObject>("getCurrentHub");
using var options = hub?.Call<AndroidJavaObject>("getOptions");
return options?.Call<string>(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.
Expand Down
7 changes: 5 additions & 2 deletions src/Sentry.Unity.Android/SentryNativeAndroid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 21 additions & 11 deletions src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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.
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
3 changes: 3 additions & 0 deletions test/Sentry.Unity.Android.Tests/TestSentryJava.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?> Options { get; } = new();
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading