From f81ace4f9ea4f4cba3c62f9208a0333382dfff7f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 16 Oct 2026 16:56:29 +0200 Subject: [PATCH 01/44] wip --- docs/agent-guides/platform-android.md | 1 + docs/agent-guides/platform-apple.md | 1 + .../SentryNativeAndroid.cs | 23 ++++++++- .../BuildPostProcess.cs | 4 +- .../Android/AndroidManifestConfiguration.cs | 20 +++++++- src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 24 +++++++-- src/Sentry.Unity/SentryUnityOptions.cs | 10 ++++ .../SentryNativeAndroidTests.cs | 40 +++++++++++++++ .../TestSentryJava.cs | 6 ++- .../AndroidManifestConfigurationTests.cs | 51 +++++++++++++++++++ .../BuildPostProcessorTests.cs | 20 ++++++++ 11 files changed, 189 insertions(+), 11 deletions(-) diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 0f3b4f4f7..040b95860 100644 --- a/docs/agent-guides/platform-android.md +++ b/docs/agent-guides/platform-android.md @@ -12,6 +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 leaves `auto-init` unset (it would merge into the host app's manifest) and does not copy the Android SDK or add its Gradle dependencies; the host app depends on `io.sentry:sentry-android`. ProGuard keep rules still ship. C# never initializes or closes the Java SDK, attaches only if the host already initialized it, and keeps the C# ANR watchdog. ## Export Processing diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index cf68019e4..0df7848ba 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,6 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and keeps the C# ANR watchdog. ## iOS Export diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 005aed753..0b8de6dee 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -39,6 +39,13 @@ public static void Configure(SentryUnityOptions options) SentryJava ??= new SentryJava(Logger); if (!SentryJava.IsSentryJavaPresent()) { + if (options.AndroidNativeInitializationType is NativeInitializationType.External) + { + Logger?.LogWarning("The Android SDK is expected to be provided by the app but it is missing. " + + "Add 'io.sentry:sentry-android' to the app. Skipping native support."); + return; + } + Logger?.LogError("Android Native Support has been enabled but the " + "Android SDK is missing. This could have been caused by a mismatching" + "build time / runtime configuration. Please make sure you have " + @@ -48,10 +55,17 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Checking whether the Android SDK has already been initialized"); + var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; if (SentryJava.IsEnabled() is true) { Logger?.LogDebug("The Android SDK is already initialized"); } + else if (isExternal) + { + Logger?.LogWarning("The Android SDK is expected to be initialized by the app but it is not. " + + "Make sure the app initializes Sentry before the game starts. Skipping native support."); + return; + } else { Logger?.LogInfo("Initializing the Android SDK"); @@ -74,7 +88,8 @@ public static void Configure(SentryUnityOptions options) options.EnableScopeSync = true; options.NativeDebugImageProvider = new Native.NativeDebugImageProvider(); - if (options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) + // The app's options decide whether sentry-native tracks app hangs, so the C# ANR watchdog stays in charge. + if (!isExternal && options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) { Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); SentryMonoBehaviour.Instance.StartAppHangHeartbeat( @@ -119,7 +134,11 @@ public static void Configure(SentryUnityOptions options) e, "Failed to reinstall backend. Captured native crashes will miss scope data and tag."); } - options.NativeSupportCloseCallback = () => Close(options); + // The app owns the SDK's lifetime. + if (!isExternal) + { + options.NativeSupportCloseCallback = () => Close(options); + } Logger?.LogDebug("Fetching installation ID"); diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index 1285ed51d..0691b9e22 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -76,8 +76,8 @@ internal static void AddSentryToXcodeProject(SentryUnityOptions? options, // We want to avoid users getting stuck on a cached built output. // This can happen if the user appends builds // - and toggles the `IosNativeSupportEnabled` from `true` to `false` - // - and toggles the `IosNativeInitializationType` from `Standalone` to `Runtime` - if (options.IosNativeInitializationType is NativeInitializationType.Runtime) + // - and toggles the `IosNativeInitializationType` from `BuildTime` to `Runtime` or `External` + if (options.IosNativeInitializationType is not NativeInitializationType.BuildTime) { var mainPath = Path.Combine(pathToProject, SentryXcodeProject.MainPath); if (File.Exists(mainPath)) diff --git a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs index f2519ba9c..9b0601c8b 100644 --- a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs +++ b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs @@ -120,6 +120,13 @@ public void OnPostGenerateGradleAndroidProject(string basePath) /// private bool AndroidSdkEnabled => _androidSdkEnabled ??= EvaluateAndroidSdkEnabled(); + /// + /// Whether the Android SDK gets copied into the Gradle project. With + /// the app that embeds the game provides it. + /// + private bool BundlesAndroidSdk => + AndroidSdkEnabled && _options?.AndroidNativeInitializationType is not NativeInitializationType.External; + private bool EvaluateAndroidSdkEnabled() { if (_options is null) @@ -167,6 +174,15 @@ internal void ModifyManifest(string basePath) return; } + if (_options?.AndroidNativeInitializationType is NativeInitializationType.External) + { + // 'auto-init' would merge into the app's manifest and turn off the app's SDK. + _logger.LogDebug("Leaving 'auto-init' unset. The app initializes the Android SDK."); + _ = androidManifest.Save(); + + return; + } + if (_options?.AndroidNativeInitializationType is NativeInitializationType.Runtime) { _logger.LogDebug("Setting 'auto-init' to 'false'. The Android SDK will be initialized at runtime."); @@ -258,7 +274,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad var androidSdkPath = Path.Combine(unityProjectPath, "Packages", SentryPackageInfo.GetName(), "Plugins", "Android", "Sentry~"); var targetPath = Path.Combine(gradlePath, "unityLibrary", "libs"); - if (AndroidSdkEnabled) + if (BundlesAndroidSdk) { if (!Directory.Exists(androidSdkPath)) { @@ -301,7 +317,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad internal void AddAndroidSdkDependencies(string gradleProjectPath) { var tool = new GradleSetup(_logger, gradleProjectPath); - var nativeSupportEnabled = AndroidSdkEnabled; + var nativeSupportEnabled = BundlesAndroidSdk; try { diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index 3d6293238..e2d67d04d 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -30,12 +30,21 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } + var isExternal = platform == RuntimePlatform.IPhonePlayer && + options.IosNativeInitializationType is NativeInitializationType.External; + if (platform == RuntimePlatform.IPhonePlayer) { if (SentryCocoaBridgeProxy.IsEnabled()) { Logger?.LogDebug("The native SDK is already initialized"); } + else if (isExternal) + { + Logger?.LogWarning("The native SDK is expected to be initialized by the app but it is not. " + + "Make sure the app initializes Sentry before the game starts. Skipping native support."); + return; + } else if (!SentryCocoaBridgeProxy.Init(options)) { Logger?.LogWarning("Failed to initialize the native SDK"); @@ -55,7 +64,8 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf options.ScopeObserver = new NativeScopeObserver("macOS", options); } - if (options.EnableAppHangTracking) + // The app's options decide whether sentry-cocoa tracks app hangs, so the C# ANR watchdog stays in charge. + if (!isExternal && options.EnableAppHangTracking) { Logger?.LogDebug("Disabling the C# ANR watchdog - sentry-cocoa handles app hang detection."); #pragma warning disable CS0618 // Type or member is obsolete @@ -63,7 +73,10 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf #pragma warning restore CS0618 // Type or member is obsolete } - SentryCocoaBridgeProxy.SetSdkName(); // Since we're not building the SDK we have to overwrite the name here + if (!isExternal) + { + SentryCocoaBridgeProxy.SetSdkName(); // Since we're not building the SDK we have to overwrite the name here + } options.NativeContextWriter = new NativeContextWriter(); options.EnableScopeSync = true; @@ -77,7 +90,12 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return crashedLastRun; }; - options.NativeSupportCloseCallback += () => Close(options); + // The app owns the SDK's lifetime. + if (!isExternal) + { + options.NativeSupportCloseCallback += () => Close(options); + } + if (options.UnityInfo.IL2CPP) { var installationId = SentryCocoaBridgeProxy.GetInstallationId(); diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 95249276a..5d4f7af7e 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -267,6 +267,8 @@ internal bool NativeAppHangTrackingEnabled /// When set to , the options are written and hardcoded into the /// Xcode project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. + /// When set to , the app that embeds the game initializes the native + /// SDK, e.g. when using Unity as a Library. /// public NativeInitializationType IosNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -284,6 +286,8 @@ internal bool NativeAppHangTrackingEnabled /// When set to , the options are written and hardcoded into the /// Gradle project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. + /// When set to , the app that embeds the game initializes the native + /// SDK, e.g. when using Unity as a Library. /// public NativeInitializationType AndroidNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -748,4 +752,10 @@ public enum NativeInitializationType /// game. Options that you modify programmatically will not apply to the native SDK. /// BuildTime, + /// + /// The app that embeds the game owns the native SDK, e.g. when using Unity as a Library. The SDK does not initialize + /// or close the native SDK. It attaches to it if the app has initialized it before the game starts. Options that + /// you set in Unity do not apply to the native SDK. + /// + External, } diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index db5b37d7c..d0e560093 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -231,4 +231,44 @@ public void Configure_NativeInitFails_LogsErrorAndReturns() Assert.Null(options.ScopeObserver); } + + [Test] + public void Configure_ExternalNotInitialized_LogsWarningAndDoesNotInitialize() + { + var options = new SentryUnityOptions + { + Debug = true, + DiagnosticLevel = SentryLevel.Debug, + DiagnosticLogger = _logger, + AndroidNativeInitializationType = NativeInitializationType.External + }; + _testSentryJava.Enabled = false; + + SentryNativeAndroid.Configure(options); + + Assert.IsTrue(_logger.Logs.Any(log => + log.logLevel == SentryLevel.Warning && + log.message.Contains("expected to be initialized by the app"))); + Assert.False(_testSentryJava.InitCalled); + Assert.Null(options.ScopeObserver); + Assert.False(options.EnableScopeSync); + } + + [Test] + public void Configure_ExternalInitialized_AttachesWithoutTakingOwnership() + { + var options = new SentryUnityOptions + { + AndroidNativeInitializationType = NativeInitializationType.External + }; + _testSentryJava.Enabled = true; + + SentryNativeAndroid.Configure(options); + + Assert.False(_testSentryJava.InitCalled); + Assert.IsAssignableFrom(options.ScopeObserver); + Assert.True(options.EnableScopeSync); + Assert.True(_reinstallCalled); + Assert.Null(options.NativeSupportCloseCallback); + } } diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index 84c7f3735..08deedc51 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -10,16 +10,18 @@ internal class TestSentryJava : ISentryJava public bool SentryPresent { get; set; } = true; public string? InstallationId { get; set; } public bool? IsCrashedLastRun { get; set; } + public bool InitCalled { get; private set; } + public bool CloseCalled { get; private set; } public bool? IsEnabled() => Enabled; - public void Init(SentryUnityOptions options) { } + public void Init(SentryUnityOptions options) => InitCalled = true; public string? GetInstallationId() => InstallationId; public bool? CrashedLastRun() => IsCrashedLastRun; - public void Close() { } + public void Close() => CloseCalled = true; public void WriteScope( string? AppStartTime, diff --git a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs index 6fb65b643..ab36d9676 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs @@ -170,6 +170,19 @@ public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeRunt StringAssert.Contains("", manifest); } + [Test] + public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeExternal_DoesNotSetAutoInitOrOptions() + { + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + var manifest = WithAndroidManifest(basePath => sut.ModifyManifest(basePath)); + + _fixture.UnityTestLogger.AssertLogContains(SentryLevel.Debug, "Leaving 'auto-init' unset. The app initializes the Android SDK."); // Sanity Check + + StringAssert.DoesNotContain("io.sentry.auto-init", manifest); + StringAssert.DoesNotContain("io.sentry.dsn", manifest); + } + [Test] public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeBuildTime_AddsSentryAndAutoInits() { @@ -538,6 +551,44 @@ public void CopyAndroidSdkToGradleProject_AndroidNativeSupportEnabled_CopiesAndr Directory.Delete(fakeProjectPath, true); } + [Test] + public void CopyAndroidSdkToGradleProject_InitTypeExternalAndSdkAlreadyCopied_RemovesAndroidSdkFromGradleProject() + { + var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var unityProjectPath = Path.Combine(fakeProjectPath, "UnityProject"); + var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); + DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); + var androidSdk = Path.Combine(gradleProjectPath, "unityLibrary", "libs", "androidSdk.jar"); + File.Create(androidSdk).Close(); + + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + + sut.CopyAndroidSdkToGradleProject(unityProjectPath, gradleProjectPath); + + Assert.IsFalse(File.Exists(androidSdk)); + + Directory.Delete(fakeProjectPath, true); + } + + [Test] + public void AddAndroidSdkDependencies_InitTypeExternal_DoesNotAddDependencies() + { + var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); + DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); + + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + + sut.AddAndroidSdkDependencies(gradleProjectPath); + + var unityLibraryGradle = File.ReadAllText(Path.Combine(gradleProjectPath, "unityLibrary", "build.gradle")); + StringAssert.DoesNotContain(GradleSetup.SdkDependencies, unityLibraryGradle); + + Directory.Delete(fakeProjectPath, true); + } + [Test] public void CopyAndroidSdkToGradleProject_AndroidNativeSupportDisabledButSdkAlreadyCopied_RemovesAndroidSdkFromGradleProject() { diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 445f682bd..9be06888e 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -100,6 +100,26 @@ public void AddSentryToXcodeProject_NativeSupportEnabledAndInitializationTypeRun Assert.IsFalse(NativeMain.ContainsSentry(mainFile, null)); // The main.mm must not contain Sentry } + [Test] + public void AddSentryToXcodeProject_NativeSupportEnabledAndInitializationTypeExternal_SetsUpSdkButDoesNotInitialize() + { + var options = new SentryUnityOptions + { + Dsn = "https://k@h/p", + IosNativeSupportEnabled = true, + IosNativeInitializationType = NativeInitializationType.External + }; + var testLogger = new TestLogger(); + + BuildPostProcess.AddSentryToXcodeProject(options, null, testLogger, _outputProjectPath); + + var mainFile = File.ReadAllText(Path.Combine(_outputProjectPath, SentryXcodeProject.MainPath)); + var bridgePath = Path.Combine(_outputProjectPath, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName); + + Assert.IsTrue(File.Exists(bridgePath)); + Assert.IsFalse(NativeMain.ContainsSentry(mainFile, null)); // The main.mm must not contain Sentry + } + [Test] public void AddSentryToXcodeProject_NativeSupportEnabledAndInitializationTypeBuildTime_SetsUpSdkAndAddsAutoInitialize() { From 70514de696a7b8cb546fd3e4f2928236376b5d3d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 18:24:01 +0200 Subject: [PATCH 02/44] Support Unity as a Library on iOS with external native initialization --- CHANGELOG.md | 6 + docs/agent-guides/platform-android.md | 1 - docs/agent-guides/platform-apple.md | 4 +- package-dev/Plugins/iOS/SentryNativeBridge.m | 333 ++++++++++++++---- .../Plugins/iOS/SentryNativeBridgeNoOp.m | 6 + .../Plugins/macOS/SentryNativeBridge.m | 11 +- .../SentryNativeAndroid.cs | 23 +- .../BuildPostProcess.cs | 33 +- .../SentryXcodeProject.cs | 54 ++- .../Android/AndroidManifestConfiguration.cs | 20 +- src/Sentry.Unity.iOS/NativeScopeObserver.cs | 59 +++- .../SentryCocoaBridgeProxy.cs | 9 + src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 54 ++- src/Sentry.Unity/NativePropagationContext.cs | 64 ++++ src/Sentry.Unity/SentryUnityOptions.cs | 12 +- .../SentryNativeAndroidTests.cs | 40 --- .../TestSentryJava.cs | 6 +- .../AndroidManifestConfigurationTests.cs | 51 --- .../BuildPostProcessorTests.cs | 18 + .../SentryXcodeProjectTests.cs | 24 ++ .../NativePropagationContextTests.cs | 80 +++++ 21 files changed, 670 insertions(+), 238 deletions(-) create mode 100644 src/Sentry.Unity/NativePropagationContext.cs create mode 100644 test/Sentry.Unity.Tests/NativePropagationContextTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ee836469d..4bbfbe933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, and debug symbols upload from the `UnityFramework` target. + ## 4.11.0 ### Fixes diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 040b95860..0f3b4f4f7 100644 --- a/docs/agent-guides/platform-android.md +++ b/docs/agent-guides/platform-android.md @@ -12,7 +12,6 @@ - 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 leaves `auto-init` unset (it would merge into the host app's manifest) and does not copy the Android SDK or add its Gradle dependencies; the host app depends on `io.sentry:sentry-android`. ProGuard keep rules still ship. C# never initializes or closes the Java SDK, attaches only if the host already initialized it, and keeps the C# ANR watchdog. ## Export Processing diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index 0df7848ba..d86f8f1e9 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,13 +19,15 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and keeps the C# ANR watchdog. +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and keeps the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use), and removes the `unity` context while the game is unloaded. ## iOS Export - iOS editor code is in `src/Sentry.Unity.Editor.iOS/`. - It copies `Sentry.xcframework~` and bridge source, mutates the PBX project through reflection, embeds/links the framework, adds `-ObjC`, and can write build-time options into `main.mm`. - Reflection avoids requiring iOS editor modules on Windows or Linux. +- The iOS bridge looks up sentry-cocoa classes at runtime: the `SentryObjC*` classes when the export bundles the framework, otherwise the core classes of the app's own SDK (`SentrySDKInternal`, `SentryScope`, `SentryBreadcrumb`, `SentryUser`). It is compiled with `-fobjc-exceptions`. +- External exports copy no framework and add the symbol upload build phase to `UnityFramework`, which an embedding app builds. - Disabled/unconfigured iOS still installs a no-op bridge so P/Invoke symbols resolve. - Changing an append build from build-time to runtime/disabled requires a Replace export; postprocessing fails rather than preserve stale `main.mm` initialization. diff --git a/package-dev/Plugins/iOS/SentryNativeBridge.m b/package-dev/Plugins/iOS/SentryNativeBridge.m index 6631804da..cb05f48a1 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -1,14 +1,13 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN +#import +#include +#include static NSDateFormatter *_Nullable sentry_cachedISO8601Formatter(void) { static NSDateFormatter *formatter = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ formatter = [[NSDateFormatter alloc] init]; - formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'"; + [formatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"]; formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"]; formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; }); @@ -36,11 +35,71 @@ return nil; } -// macOS only -// On iOS, the SDK is linked statically so we don't need to dlopen() it. -int SentryNativeBridgeLoadLibrary() { return 1; } +static int loadStatus = -1; // unitialized + +// The SDK the game bundles, through the SentryObjC hybrid API. Missing when the app brings its own SDK. +static Class SentryObjCSDK; +static Class SentryObjCId; +static Class SentryObjCSpanId; +static Class SentryObjCHttpStatusCodeRange; + +// Scope sync. Either the SentryObjC classes, or the core classes of the app's own SDK (e.g. Unity as a +// Library). Both take the same selectors and keys. +static Class SentrySDKClass; +static Class SentryBreadcrumbClass; +static Class SentryUserClass; + +typedef NS_ENUM(NSInteger, SentryObjCLastRunStatus) { + SentryObjCLastRunStatusUnknown = 0, + SentryObjCLastRunStatusDidNotCrash, + SentryObjCLastRunStatusDidCrash, +}; + +// [SentryObjCSDK internal] is the hybrid-SDK entry point used for options parsing, SDK metadata, +// and trace propagation. +static id SentryInternalApi(void) { return [SentryObjCSDK performSelector:@selector(internal)]; } + +// Returns (bool): 0 on failure, 1 on success +// WARNING: you may only call other Sentry* functions AFTER calling this AND only if it returned "1" +int SentryNativeBridgeLoadLibrary() +{ + if (loadStatus == -1) { + loadStatus = 0; // init to "error" + do { + // The classes are looked up at runtime: the SDK is either linked into UnityFramework, or part of the + // app that embeds the game. + SentryObjCSDK = objc_getClass("SentryObjCSDK"); + if (SentryObjCSDK) { + SentryObjCId = objc_getClass("SentryObjCId"); + SentryObjCSpanId = objc_getClass("SentryObjCSpanId"); + SentryObjCHttpStatusCodeRange = objc_getClass("SentryObjCHttpStatusCodeRange"); + SentrySDKClass = SentryObjCSDK; + SentryBreadcrumbClass = objc_getClass("SentryObjCBreadcrumb"); + SentryUserClass = objc_getClass("SentryObjCUser"); + } else { + SentrySDKClass = objc_getClass("SentrySDKInternal"); + SentryBreadcrumbClass = objc_getClass("SentryBreadcrumb"); + SentryUserClass = objc_getClass("SentryUser"); + } + + if (!SentrySDKClass || !SentryBreadcrumbClass || !SentryUserClass) { + NSLog(@"Sentry (bridge): Couldn't find the Sentry SDK"); + break; + } -int SentryNativeBridgeIsEnabled() { return [SentryObjCSDK isEnabled] ? 1 : 0; } + loadStatus = 1; + } while (false); + } + return loadStatus; +} + +int SentryNativeBridgeIsEnabled() +{ + if (SentryNativeBridgeLoadLibrary() != 1) { + return 0; + } + return ((BOOL (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(isEnabled)) ? 1 : 0; +} const void *SentryNativeBridgeOptionsNew() { @@ -77,7 +136,13 @@ void SentryNativeBridgeOptionsAddFailedRequestStatusCodeRange(const void *option ranges = [[NSMutableArray alloc] init]; dictOptions[@"failedRequestStatusCodes"] = ranges; } - [ranges addObject:[[SentryObjCHttpStatusCodeRange alloc] initWithMin:min max:max]]; + id instance = [SentryObjCHttpStatusCodeRange alloc]; + // initWithMin:max: takes NSInteger args - use objc_msgSend directly + id range = ((id (*)(id, SEL, NSInteger, NSInteger))objc_msgSend)( + instance, @selector(initWithMin:max:), (NSInteger)min, (NSInteger)max); + if (range) { + [ranges addObject:range]; + } } int SentryNativeBridgeStartWithOptions(const void *options) @@ -85,27 +150,70 @@ int SentryNativeBridgeStartWithOptions(const void *options) NSMutableDictionary *dictOptions = (__bridge_transfer NSMutableDictionary *)options; NSError *error = nil; - SentryObjCOptions *sentryOptions = [[SentryObjCSDK internal] optionsFromDictionary:dictOptions error:&error]; + if (!SentryObjCSDK) { + return 0; + } + + id (*optionsFromDictionary)(id, SEL, NSDictionary *, NSError * __autoreleasing *) = + (void *)objc_msgSend; + id sentryOptions = optionsFromDictionary(SentryInternalApi(), @selector(optionsFromDictionary:error:), + dictOptions, &error); + if (error != nil) { return 0; } - [SentryObjCSDK startWithOptions:sentryOptions]; + [SentryObjCSDK performSelector:@selector(startWithOptions:) withObject:sentryOptions]; return 1; } +void SentryConfigureScope(void (^callback)(id)) +{ + // setValue:forKey: may throw if the property is not found; same for performSelector. + // Even though this shouldn't happen, better not take the chance of letting an unhandled + // exception while setting error info - it would just crash the app immediately. + @try { + [SentrySDKClass performSelector:@selector(configureScope:) withObject:callback]; + } @catch (NSException *exception) { + NSLog(@"Sentry (bridge): failed to configure scope: %@", exception.reason); + } +} + +/*******************************************************************************/ +/* The classes are only known at runtime. Mainly: */ +/* - call: [class performSelector:@selector(arg1:arg2:) */ +/* withObject:arg1Value withObject:arg2Value]; */ +/* - use `id` as variable types */ +/* - use [obj setValue:value forKey:@"prop"] instead of `obj.prop = value` */ +/*******************************************************************************/ + void SentryNativeBridgeSetSdkName() { - [SentryObjCSDK internal].sdk.name = @"sentry.cocoa.unity"; + id sdkApi = [SentryInternalApi() performSelector:@selector(sdk)]; + [sdkApi performSelector:@selector(setName:) withObject:@"sentry.cocoa.unity"]; } int SentryNativeBridgeCrashedLastRun() { - return SentryObjCSDK.lastRunStatus == SentryObjCLastRunStatusDidCrash ? 1 : 0; + @try { + NSInteger lastRunStatus = + ((NSInteger (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(lastRunStatus)); + return lastRunStatus == SentryObjCLastRunStatusDidCrash ? 1 : 0; + } @catch (NSException *exception) { + NSLog(@"Sentry (bridge): failed to get lastRunStatus: %@", exception.reason); + } + return -1; } -void SentryNativeBridgeClose() { [SentryObjCSDK close]; } +void SentryNativeBridgeClose() +{ + @try { + [SentryObjCSDK performSelector:@selector(close)]; + } @catch (NSException *exception) { + NSLog(@"Sentry (bridge): failed to close: %@", exception.reason); + } +} void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, const char *type, const char *category, int level, const char **dataKeys, const char **dataValues, int dataCount) @@ -119,26 +227,30 @@ void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, NSString *typeString = _NSStringOrNil(type); NSString *categoryString = _NSStringOrNil(category) ?: @"default"; // Category cannot be nil - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - SentryObjCBreadcrumb *breadcrumb = [[SentryObjCBreadcrumb alloc] - initWithLevel:level - category:categoryString]; + SentryConfigureScope(^(id scope) { + id breadcrumb = [[SentryBreadcrumbClass alloc] init]; if (timestampString != nil && timestampString.length > 0) { NSDate *date = [sentry_cachedISO8601Formatter() dateFromString:timestampString]; if (date != nil) { - breadcrumb.timestamp = date; + [breadcrumb setValue:date forKey:@"timestamp"]; } } if (messageString != nil) { - breadcrumb.message = messageString; + [breadcrumb setValue:messageString forKey:@"message"]; } if (typeString != nil) { - breadcrumb.type = typeString; + [breadcrumb setValue:typeString forKey:@"type"]; + } + + if (categoryString != nil) { + [breadcrumb setValue:categoryString forKey:@"category"]; } + [breadcrumb setValue:[NSNumber numberWithInt:level] forKey:@"level"]; + if (dataCount > 0 && dataKeys != NULL && dataValues != NULL) { NSMutableDictionary *data = [NSMutableDictionary dictionaryWithCapacity:dataCount]; for (int i = 0; i < dataCount; i++) { @@ -148,11 +260,11 @@ void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, data[key] = value; } } - breadcrumb.data = data; + [breadcrumb setValue:data forKey:@"data"]; } - [scope addBreadcrumb:breadcrumb]; - }]; + [scope performSelector:@selector(addBreadcrumb:) withObject:breadcrumb]; + }); } void SentryNativeBridgeSetExtra(const char *key, const char *value) @@ -164,9 +276,11 @@ void SentryNativeBridgeSetExtra(const char *key, const char *value) NSString *keyString = [NSString stringWithUTF8String:key]; NSString *valueString = _NSStringOrNil(value); - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - [scope setExtraValue:valueString forKey:keyString]; - }]; + SentryConfigureScope(^(id scope) { + [scope performSelector:@selector(setExtraValue:forKey:) + withObject:valueString + withObject:keyString]; + }); } void SentryNativeBridgeSetTag(const char *key, const char *value) @@ -178,18 +292,20 @@ void SentryNativeBridgeSetTag(const char *key, const char *value) NSString *keyString = [NSString stringWithUTF8String:key]; NSString *valueString = _NSStringOrNil(value); - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - [scope setTagValue:valueString forKey:keyString]; - }]; + SentryConfigureScope(^(id scope) { + [scope performSelector:@selector(setTagValue:forKey:) + withObject:valueString + withObject:keyString]; + }); } void SentryNativeBridgeSetEnvironment(const char *environment) { NSString *environmentString = _NSStringOrNil(environment); - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - [scope setEnvironment:environmentString]; - }]; + SentryConfigureScope(^(id scope) { + [scope performSelector:@selector(setEnvironment:) withObject:environmentString]; + }); } void SentryNativeBridgeUnsetTag(const char *key) @@ -200,9 +316,9 @@ void SentryNativeBridgeUnsetTag(const char *key) NSString *keyString = [NSString stringWithUTF8String:key]; - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - [scope removeTagForKey:keyString]; - }]; + SentryConfigureScope(^(id scope) { + [scope performSelector:@selector(removeTagForKey:) withObject:keyString]; + }); } void SentryNativeBridgeSetUser( @@ -212,50 +328,139 @@ void SentryNativeBridgeSetUser( NSString *userIdString = _NSStringOrNil(userId); NSString *ipAddressString = _NSStringOrNil(ipAddress); NSString *usernameString = _NSStringOrNil(username); - - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { - SentryObjCUser *user = [[SentryObjCUser alloc] init]; - user.email = emailString; - user.userId = userIdString; - user.ipAddress = ipAddressString; - user.username = usernameString; + SentryConfigureScope(^(id scope) { + id user = [[SentryUserClass alloc] init]; + + [user setValue:emailString forKey:@"email"]; + [user setValue:userIdString forKey:@"userId"]; + [user setValue:ipAddressString forKey:@"ipAddress"]; + [user setValue:usernameString forKey:@"username"]; - [scope setUser:user]; - }]; + [scope performSelector:@selector(setUser:) withObject:user]; + }); } void SentryNativeBridgeUnsetUser() { - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { [scope setUser:nil]; }]; + SentryConfigureScope( + ^(id scope) { [scope performSelector:@selector(setUser:) withObject:nil]; }); } -char *SentryNativeBridgeGetInstallationId() +// Create a null terminated C string on the heap as expected by marshalling. +// See Tips for iOS in https://docs.unity3d.com/Manual/PluginsForIOS.html +static char *SentryCopyString(NSString *value) { - // Create a null terminated C string on the heap as expected by marshalling. - // See Tips for iOS in https://docs.unity3d.com/Manual/PluginsForIOS.html - const char *nsStringUtf8 = [[SentryObjCSDK internal].sdk.installationID UTF8String]; + if (value == nil) { + return NULL; + } + + const char *nsStringUtf8 = [value UTF8String]; size_t len = strlen(nsStringUtf8) + 1; char *cString = (char *)malloc(len); memcpy(cString, nsStringUtf8, len); return cString; } +// The options of the app's own SDK. Through `SentrySDKInternal`, which every distribution has. +static id SentryAppOptions(void) +{ + id hub = [objc_getClass("SentrySDKInternal") performSelector:@selector(currentHub)]; + id client = [hub performSelector:@selector(getClient)]; + return [client valueForKey:@"options"]; +} + +static NSString *SentryInstallationId(void) +{ + if (SentryObjCSDK) { + id sdkApi = [SentryInternalApi() performSelector:@selector(sdk)]; + return [sdkApi performSelector:@selector(installationID)]; + } + + // The app's own SDK: read the ID the way it does, from its cache directory. + @try { + NSString *cacheDirectoryPath = [SentryAppOptions() valueForKey:@"cacheDirectoryPath"]; + Class installation = NSClassFromString(@"Sentry.SentryInstallation"); + if (cacheDirectoryPath == nil + || ![installation respondsToSelector:@selector(idWithCacheDirectoryPath:)]) { + return nil; + } + return [installation performSelector:@selector(idWithCacheDirectoryPath:) + withObject:cacheDirectoryPath]; + } @catch (NSException *exception) { + NSLog(@"Sentry (bridge): failed to get the installation ID: %@", exception.reason); + return nil; + } +} + +char *SentryNativeBridgeGetInstallationId() { return SentryCopyString(SentryInstallationId()); } + +// A string option of the app's own SDK, e.g. `releaseName`, `environment`, or `dist`. +char *SentryNativeBridgeGetOption(const char *name) +{ + if (name == NULL) { + return NULL; + } + + @try { + id value = [SentryAppOptions() valueForKey:[NSString stringWithUTF8String:name]]; + return [value isKindOfClass:[NSString class]] ? SentryCopyString(value) : NULL; + } @catch (NSException *exception) { + NSLog(@"Sentry (bridge): failed to get option '%s': %@", name, exception.reason); + return NULL; + } +} + +// The `sentry-trace` header of the app's own SDK: its active span, otherwise its propagation context. +char *SentryNativeBridgeGetTraceHeader() +{ + __block NSString *traceHeader = nil; + SentryConfigureScope(^(id scope) { + id header = nil; + id span = [scope respondsToSelector:@selector(span)] ? [scope performSelector:@selector(span)] : nil; + if (span != nil && [span respondsToSelector:@selector(toTraceHeader)]) { + header = [span performSelector:@selector(toTraceHeader)]; + } else if ([scope respondsToSelector:@selector(propagationContextTraceHeader)]) { + header = [scope performSelector:@selector(propagationContextTraceHeader)]; + } + if ([header respondsToSelector:@selector(value)]) { + traceHeader = [header performSelector:@selector(value)]; + } + }); + return SentryCopyString(traceHeader); +} + +void SentryNativeBridgeRemoveContext(const char *key) +{ + if (key == NULL) { + return; + } + + NSString *keyString = [NSString stringWithUTF8String:key]; + + SentryConfigureScope(^(id scope) { + [scope performSelector:@selector(removeContextForKey:) withObject:keyString]; + }); +} + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { return; } - NSString *traceIdStr = [NSString stringWithUTF8String:traceId]; - NSString *spanIdStr = [NSString stringWithUTF8String:spanId]; + id sentryTraceId = ((id (*)(id, SEL, NSString *))objc_msgSend)( + [SentryObjCId alloc], @selector(initWithUUIDString:), + [NSString stringWithUTF8String:traceId]); - SentryObjCId *sentryTraceId = [[SentryObjCId alloc] initWithUUIDString:traceIdStr]; - SentryObjCSpanId *sentrySpanId = [[SentryObjCSpanId alloc] initWithValue:spanIdStr]; + id sentrySpanId = ((id (*)(id, SEL, NSString *))objc_msgSend)( + [SentryObjCSpanId alloc], @selector(initWithValue:), + [NSString stringWithUTF8String:spanId]); - if (sentryTraceId && sentrySpanId) { - [[SentryObjCSDK internal] setTrace:sentryTraceId spanId:sentrySpanId]; - } + [SentryInternalApi() + performSelector:@selector(setTrace:spanId:) + withObject:sentryTraceId + withObject:sentrySpanId]; } void SentryNativeBridgeWriteScope( // clang-format off @@ -295,7 +500,7 @@ void SentryNativeBridgeWriteScope( // clang-format off ) // clang-format on { // Note: we're using a NSMutableDictionary because it will skip fields with nil values. - [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + SentryConfigureScope(^(id scope) { NSMutableDictionary *gpu = [[NSMutableDictionary alloc] init]; gpu[@"id"] = _NSNumberOrNil(GpuId); gpu[@"name"] = _NSStringOrNil(GpuName); @@ -312,7 +517,7 @@ void SentryNativeBridgeWriteScope( // clang-format off gpu[@"vendor_id"] = _NSStringOrNil(GpuVendorId); gpu[@"multi_threaded_rendering"] = _NSBoolOrNil(GpuMultiThreadedRendering); gpu[@"graphics_shader_level"] = _NSStringOrNil(GpuGraphicsShaderLevel); - [scope setContextValue:gpu forKey:@"gpu"]; + [scope performSelector:@selector(setContextValue:forKey:) withObject:gpu withObject:@"gpu"]; NSMutableDictionary *unity = [[NSMutableDictionary alloc] init]; unity[@"editor_version"] = _NSStringOrNil(EditorVersion); @@ -320,8 +525,8 @@ void SentryNativeBridgeWriteScope( // clang-format off unity[@"target_frame_rate"] = _NSStringOrNil(UnityTargetFrameRate); unity[@"copy_texture_support"] = _NSStringOrNil(UnityCopyTextureSupport); unity[@"rendering_threading_mode"] = _NSStringOrNil(UnityRenderingThreadingMode); - [scope setContextValue:unity forKey:@"unity"]; - }]; + [scope performSelector:@selector(setContextValue:forKey:) + withObject:unity + withObject:@"unity"]; + }); } - -NS_ASSUME_NONNULL_END diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m index dc52c16e0..498401d22 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -32,6 +32,12 @@ void SentryNativeBridgeUnsetUser() { } char *SentryNativeBridgeGetInstallationId() { return NULL; } +char *_Nullable SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } + +void SentryNativeBridgeRemoveContext(const char *key) { } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } void SentryNativeBridgeSetEnvironment(const char *environment) { } diff --git a/package-dev/Plugins/macOS/SentryNativeBridge.m b/package-dev/Plugins/macOS/SentryNativeBridge.m index 0f034de92..2e3daa9b8 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -176,8 +176,8 @@ void SentryConfigureScope(void (^callback)(id)) } /*******************************************************************************/ -/* The remaining code is a copy of iOS/SentryNativeBridge.m with changes to */ -/* make it work with dynamically loaded classes. Mainly: */ +/* The remaining code mirrors iOS/SentryNativeBridge.m. Both work with classes */ +/* that are only known at runtime. Mainly: */ /* - call: [class performSelector:@selector(arg1:arg2:) */ /* withObject:arg1Value withObject:arg2Value]; */ /* or xCode warns of class/instance method not found */ @@ -357,6 +357,13 @@ void SentryNativeBridgeUnsetUser() return cString; } +// External initialization is iOS only. +char *SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *SentryNativeBridgeGetTraceHeader() { return NULL; } + +void SentryNativeBridgeRemoveContext(const char *key) { } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 0b8de6dee..005aed753 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -39,13 +39,6 @@ public static void Configure(SentryUnityOptions options) SentryJava ??= new SentryJava(Logger); if (!SentryJava.IsSentryJavaPresent()) { - if (options.AndroidNativeInitializationType is NativeInitializationType.External) - { - Logger?.LogWarning("The Android SDK is expected to be provided by the app but it is missing. " + - "Add 'io.sentry:sentry-android' to the app. Skipping native support."); - return; - } - Logger?.LogError("Android Native Support has been enabled but the " + "Android SDK is missing. This could have been caused by a mismatching" + "build time / runtime configuration. Please make sure you have " + @@ -55,17 +48,10 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Checking whether the Android SDK has already been initialized"); - var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; if (SentryJava.IsEnabled() is true) { Logger?.LogDebug("The Android SDK is already initialized"); } - else if (isExternal) - { - Logger?.LogWarning("The Android SDK is expected to be initialized by the app but it is not. " + - "Make sure the app initializes Sentry before the game starts. Skipping native support."); - return; - } else { Logger?.LogInfo("Initializing the Android SDK"); @@ -88,8 +74,7 @@ public static void Configure(SentryUnityOptions options) options.EnableScopeSync = true; options.NativeDebugImageProvider = new Native.NativeDebugImageProvider(); - // The app's options decide whether sentry-native tracks app hangs, so the C# ANR watchdog stays in charge. - if (!isExternal && options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) + if (options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) { Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); SentryMonoBehaviour.Instance.StartAppHangHeartbeat( @@ -134,11 +119,7 @@ public static void Configure(SentryUnityOptions options) e, "Failed to reinstall backend. Captured native crashes will miss scope data and tag."); } - // The app owns the SDK's lifetime. - if (!isExternal) - { - options.NativeSupportCloseCallback = () => Close(options); - } + options.NativeSupportCloseCallback = () => Close(options); Logger?.LogDebug("Fetching installation ID"); diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index 0691b9e22..44e95a008 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -115,21 +115,34 @@ internal static void SetupSentry(SentryUnityOptions options, IDiagnosticLogger l { logger.LogInfo("Attempting to add Sentry to the Xcode project."); + // The app that embeds the game links its own SDK, which the bridge finds at runtime. + var bundlesSdk = options.IosNativeInitializationType is not NativeInitializationType.External; + try { - // The SentryObjC.xcframework ends in '~' to hide it from Unity. This prevents Unity from exporting it with the Xcode build. - // Ideally, we would let Unity copy this over but: - // - Detection of `.xcframework` as datatype and non-folder happened in Unity 2021 - // - Without a `.meta` file we cannot opt-in embedding the framework - // - Even if Unity copies it, the framework still requires to be 'linked with binary' for it to work - var frameworkPath = Path.GetFullPath(Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "iOS", SentryXcodeProject.FrameworkName + "~")); - CopyFramework(frameworkPath, Path.Combine(pathToProject, "Frameworks", SentryXcodeProject.FrameworkName), logger); + if (bundlesSdk) + { + // The SentryObjC.xcframework ends in '~' to hide it from Unity. This prevents Unity from exporting it with the Xcode build. + // Ideally, we would let Unity copy this over but: + // - Detection of `.xcframework` as datatype and non-folder happened in Unity 2021 + // - Without a `.meta` file we cannot opt-in embedding the framework + // - Even if Unity copies it, the framework still requires to be 'linked with binary' for it to work + var frameworkPath = Path.GetFullPath(Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "iOS", SentryXcodeProject.FrameworkName + "~")); + CopyFramework(frameworkPath, Path.Combine(pathToProject, "Frameworks", SentryXcodeProject.FrameworkName), logger); + } var nativeBridgePath = Path.GetFullPath(Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "iOS", SentryXcodeProject.BridgeName)); CopyFile(nativeBridgePath, Path.Combine(pathToProject, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName), logger); using var sentryXcodeProject = SentryXcodeProject.Open(pathToProject, logger); - sentryXcodeProject.AddSentryFramework(); + if (bundlesSdk) + { + sentryXcodeProject.AddSentryFramework(); + } + else + { + sentryXcodeProject.SetDebugInformationFormat(); + } sentryXcodeProject.AddSentryNativeBridge(); if (options.IosNativeInitializationType is NativeInitializationType.BuildTime) @@ -171,7 +184,9 @@ internal static void SetupSentryCli(SentryUnityOptions options, try { using var sentryXcodeProject = SentryXcodeProject.Open(pathToProject, logger); - sentryXcodeProject.AddBuildPhaseSymbolUpload(cliOptions); + // The app that embeds the game builds `UnityFramework`, but not the Unity app target. + sentryXcodeProject.AddBuildPhaseSymbolUpload(cliOptions, + uploadFromUnityFramework: options.IosNativeInitializationType is NativeInitializationType.External); } catch (Exception e) { diff --git a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs index 9ab59ab55..7fcfd563b 100644 --- a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs +++ b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -150,14 +151,19 @@ private void AddBuildProperties() SetSearchPathBuildProperty("$(inherited)"); SetSearchPathBuildProperty("$(PROJECT_DIR)/Frameworks/"); - var setBuildPropertyMethod = _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }); - setBuildPropertyMethod.Invoke(_project, new object[] { _mainTargetGuid, "DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym" }); - setBuildPropertyMethod.Invoke(_project, new object[] { _unityFrameworkTargetGuid, "DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym" }); + SetDebugInformationFormat(); _pbxProjectType.GetMethod("AddBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }) .Invoke(_project, new object[] { _mainTargetGuid, "OTHER_LDFLAGS", "-ObjC" }); } + public void SetDebugInformationFormat() + { + var setBuildPropertyMethod = _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }); + setBuildPropertyMethod.Invoke(_project, new object[] { _mainTargetGuid, "DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym" }); + setBuildPropertyMethod.Invoke(_project, new object[] { _unityFrameworkTargetGuid, "DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym" }); + } + public void AddSentryNativeBridge() { _logger?.LogInfo("Adding the Sentry Native Bridge."); @@ -166,8 +172,9 @@ public void AddSentryNativeBridge() var bridgeGuid = (string)_pbxProjectType.GetMethod("AddFile", BindingFlags.Public | BindingFlags.Instance) .Invoke(_project, new object[] { relativeBridgePath, relativeBridgePath, 1 }); // 1 is PBXSourceTree.Source - _pbxProjectType.GetMethod("AddFileToBuild", BindingFlags.Public | BindingFlags.Instance) - .Invoke(_project, new[] { _unityFrameworkTargetGuid, bridgeGuid }); + // The bridge guards its runtime lookups with @try, but Unity turns Objective-C exceptions off. + _pbxProjectType.GetMethod("AddFileToBuildWithFlags", new[] { typeof(string), typeof(string), typeof(string) }) + .Invoke(_project, new object[] { _unityFrameworkTargetGuid, bridgeGuid, "-fobjc-exceptions" }); _logger?.LogDebug("Successfully added the Sentry Native Bridge."); } @@ -179,24 +186,26 @@ internal void SetSearchPathBuildProperty(string path) .Invoke(_project, new object[] { new[] { _mainTargetGuid, _unityFrameworkTargetGuid }, "FRAMEWORK_SEARCH_PATHS", path }); } - public void AddBuildPhaseSymbolUpload(SentryCliOptions sentryCliOptions) + public void AddBuildPhaseSymbolUpload(SentryCliOptions sentryCliOptions, bool uploadFromUnityFramework = false) { _logger?.LogInfo("Setting up Sentry CLI build properties and upload script."); + var targetGuid = uploadFromUnityFramework ? _unityFrameworkTargetGuid : _mainTargetGuid; + _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _mainTargetGuid, SymbolUploadPropertyName, sentryCliOptions.UploadSymbols ? "YES" : "NO" }); + .Invoke(_project, new object[] { targetGuid, SymbolUploadPropertyName, sentryCliOptions.UploadSymbols ? "YES" : "NO" }); // Set additional upload properties _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _mainTargetGuid, IncludeSourcesPropertyName, sentryCliOptions.UploadSources ? "YES" : "NO" }); + .Invoke(_project, new object[] { targetGuid, IncludeSourcesPropertyName, sentryCliOptions.UploadSources ? "YES" : "NO" }); _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _mainTargetGuid, AllowFailurePropertyName, sentryCliOptions.IgnoreCliErrors ? "YES" : "NO" }); + .Invoke(_project, new object[] { targetGuid, AllowFailurePropertyName, sentryCliOptions.IgnoreCliErrors ? "YES" : "NO" }); _pbxProjectType.GetMethod("SetBuildProperty", new[] { typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _mainTargetGuid, PrintLogsPropertyName, sentryCliOptions.IgnoreCliErrors ? "NO" : "YES" }); + .Invoke(_project, new object[] { targetGuid, PrintLogsPropertyName, sentryCliOptions.IgnoreCliErrors ? "NO" : "YES" }); - if (MainTargetContainsSymbolUploadBuildPhase()) + if (ContainsSymbolUploadBuildPhase(targetGuid)) { _logger?.LogInfo("Success. Build phase '{0}' was already added.", SymbolUploadPhaseName); return; @@ -204,8 +213,19 @@ public void AddBuildPhaseSymbolUpload(SentryCliOptions sentryCliOptions) _logger?.LogDebug("Adding the upload script to {0}.", SymbolUploadPhaseName); - _pbxProjectType.GetMethod("AddShellScriptBuildPhase", new[] { typeof(string), typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _mainTargetGuid, SymbolUploadPhaseName, "/bin/sh", _uploadScript }); + // Declaring the dSYM as input makes Xcode generate it before the upload runs. + var addWithInputPaths = _pbxProjectType.GetMethod("AddShellScriptBuildPhase", + new[] { typeof(string), typeof(string), typeof(string), typeof(string), typeof(List) }); + if (uploadFromUnityFramework && addWithInputPaths is not null) + { + addWithInputPaths.Invoke(_project, new object[] { targetGuid, SymbolUploadPhaseName, "/bin/sh", _uploadScript, + new List { "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}" } }); + } + else + { + _pbxProjectType.GetMethod("AddShellScriptBuildPhase", new[] { typeof(string), typeof(string), typeof(string), typeof(string) }) + .Invoke(_project, new object[] { targetGuid, SymbolUploadPhaseName, "/bin/sh", _uploadScript }); + } _logger?.LogInfo("Success. Added automated debug symbol upload script to build phase."); } @@ -224,10 +244,14 @@ public void AddNativeOptions(SentryUnityOptions options, Action NativeMain.AddSentry(Path.Combine(_projectRoot, MainPath), options.DiagnosticLogger); - internal bool MainTargetContainsSymbolUploadBuildPhase() + internal bool MainTargetContainsSymbolUploadBuildPhase() => ContainsSymbolUploadBuildPhase(_mainTargetGuid); + + internal bool UnityFrameworkTargetContainsSymbolUploadBuildPhase() => ContainsSymbolUploadBuildPhase(_unityFrameworkTargetGuid); + + private bool ContainsSymbolUploadBuildPhase(string targetGuid) { var allBuildPhases = (string[])_pbxProjectType.GetMethod("GetAllBuildPhasesForTarget", BindingFlags.Public | BindingFlags.Instance) - .Invoke(_project, new object[] { _mainTargetGuid }); + .Invoke(_project, new object[] { targetGuid }); var getBuildPhaseNameMethod = _pbxProjectType.GetMethod("GetBuildPhaseName", BindingFlags.Public | BindingFlags.Instance); return allBuildPhases.Any(buildPhase => diff --git a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs index 9b0601c8b..f2519ba9c 100644 --- a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs +++ b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs @@ -120,13 +120,6 @@ public void OnPostGenerateGradleAndroidProject(string basePath) /// private bool AndroidSdkEnabled => _androidSdkEnabled ??= EvaluateAndroidSdkEnabled(); - /// - /// Whether the Android SDK gets copied into the Gradle project. With - /// the app that embeds the game provides it. - /// - private bool BundlesAndroidSdk => - AndroidSdkEnabled && _options?.AndroidNativeInitializationType is not NativeInitializationType.External; - private bool EvaluateAndroidSdkEnabled() { if (_options is null) @@ -174,15 +167,6 @@ internal void ModifyManifest(string basePath) return; } - if (_options?.AndroidNativeInitializationType is NativeInitializationType.External) - { - // 'auto-init' would merge into the app's manifest and turn off the app's SDK. - _logger.LogDebug("Leaving 'auto-init' unset. The app initializes the Android SDK."); - _ = androidManifest.Save(); - - return; - } - if (_options?.AndroidNativeInitializationType is NativeInitializationType.Runtime) { _logger.LogDebug("Setting 'auto-init' to 'false'. The Android SDK will be initialized at runtime."); @@ -274,7 +258,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad var androidSdkPath = Path.Combine(unityProjectPath, "Packages", SentryPackageInfo.GetName(), "Plugins", "Android", "Sentry~"); var targetPath = Path.Combine(gradlePath, "unityLibrary", "libs"); - if (BundlesAndroidSdk) + if (AndroidSdkEnabled) { if (!Directory.Exists(androidSdkPath)) { @@ -317,7 +301,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad internal void AddAndroidSdkDependencies(string gradleProjectPath) { var tool = new GradleSetup(_logger, gradleProjectPath); - var nativeSupportEnabled = BundlesAndroidSdk; + var nativeSupportEnabled = AndroidSdkEnabled; try { diff --git a/src/Sentry.Unity.iOS/NativeScopeObserver.cs b/src/Sentry.Unity.iOS/NativeScopeObserver.cs index 393ee19ef..706d00d56 100644 --- a/src/Sentry.Unity.iOS/NativeScopeObserver.cs +++ b/src/Sentry.Unity.iOS/NativeScopeObserver.cs @@ -1,10 +1,32 @@ using System; +using Sentry.Extensibility; namespace Sentry.Unity.iOS; public class NativeScopeObserver : ScopeObserver { - public NativeScopeObserver(string name, SentryOptions options) : base(name, options) { } + private readonly SentryOptions _options; + + // The app that embeds the game owns the native SDK's user, environment, and trace. + private readonly bool _isExternal; + + public NativeScopeObserver(string name, SentryOptions options) : this(name, options, false) { } + + internal NativeScopeObserver(string name, SentryOptions options, bool isExternal) : base(name, options) + { + _options = options; + _isExternal = isExternal; + } + + private bool IsOwnedByApp(string property) + { + if (_isExternal) + { + _options.DiagnosticLogger?.LogDebug("Not syncing the {0}. The app owns it.", property); + } + + return _isExternal; + } public override void AddBreadcrumbImpl(Breadcrumb breadcrumb) { @@ -47,16 +69,37 @@ public override void SetExtraImpl(string key, string? value) => public override void UnsetTagImpl(string key) => SentryCocoaBridgeProxy.UnsetTag(key); - public override void SetUserImpl(SentryUser user) => - SentryCocoaBridgeProxy.SetUser(user.Email, user.Id, user.IpAddress, user.Username); + public override void SetUserImpl(SentryUser user) + { + if (!IsOwnedByApp("user")) + { + SentryCocoaBridgeProxy.SetUser(user.Email, user.Id, user.IpAddress, user.Username); + } + } - public override void UnsetUserImpl() => SentryCocoaBridgeProxy.UnsetUser(); + public override void UnsetUserImpl() + { + if (!IsOwnedByApp("user")) + { + SentryCocoaBridgeProxy.UnsetUser(); + } + } - public override void SetTraceImpl(SentryId traceId, SpanId spanId) => - SentryCocoaBridgeProxy.SetTrace(traceId.ToString(), spanId.ToString()); + public override void SetTraceImpl(SentryId traceId, SpanId spanId) + { + if (!IsOwnedByApp("trace")) + { + SentryCocoaBridgeProxy.SetTrace(traceId.ToString(), spanId.ToString()); + } + } - public override void SetEnvironmentImpl(string? environment) => - SentryCocoaBridgeProxy.SetEnvironment(environment); + public override void SetEnvironmentImpl(string? environment) + { + if (!IsOwnedByApp("environment")) + { + SentryCocoaBridgeProxy.SetEnvironment(environment); + } + } public override void AddFileAttachmentImpl(string filePath, string fileName, string? contentType) { diff --git a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs index 2f5596a5e..22296a19e 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -146,6 +146,15 @@ public static extern void AddBreadcrumb(string timestamp, string? message, strin [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetInstallationId")] public static extern string GetInstallationId(); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetOption")] + public static extern string? GetOption(string name); + + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetTraceHeader")] + public static extern string? GetTraceHeader(); + + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeRemoveContext")] + public static extern void RemoveContext(string key); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeSetTrace")] public static extern void SetTrace(string traceId, string spanId); diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index e2d67d04d..6742c7288 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -1,6 +1,7 @@ using Sentry.Extensibility; using Sentry.Unity.Integrations; using UnityEngine; +using UnityEngine.SceneManagement; namespace Sentry.Unity.iOS; @@ -51,8 +52,20 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } - options.ScopeObserver = new NativeScopeObserver("iOS", options); + options.ScopeObserver = new NativeScopeObserver("iOS", options, isExternal); + if (isExternal) + { + UseAppReleaseAndEnvironment(options); + NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader); + RemoveUnityContextWhileUnloaded(options); + + if (options.AutoSessionTracking) + { + Logger?.LogDebug("Disabling session tracking - the app tracks sessions."); + options.AutoSessionTracking = false; + } + } } else { @@ -112,6 +125,45 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf Logger?.LogInfo("Successfully configured the native SDK"); } + // Events from the game belong to the app's release and environment, unless they were set in the options. + private static void UseAppReleaseAndEnvironment(SentryUnityOptions options) + { + if (options.Release == options.DefaultRelease && SentryCocoaBridgeProxy.GetOption("releaseName") is { } release) + { + Logger?.LogDebug("Using the app's release: {0}", release); + options.Release = release; + options.Distribution ??= SentryCocoaBridgeProxy.GetOption("dist"); + } + + if (options.Environment == options.DefaultEnvironment && SentryCocoaBridgeProxy.GetOption("environment") is { } environment) + { + Logger?.LogDebug("Using the app's environment: {0}", environment); + options.Environment = environment; + } + } + + // The app keeps running while the game is unloaded, and can load it again. The C# SDK stays initialized. + private static void RemoveUnityContextWhileUnloaded(SentryUnityOptions options) + { + var unloaded = false; + Application.unloading += () => + { + Logger?.LogDebug("Removing the Unity context from the app's scope."); + SentryCocoaBridgeProxy.RemoveContext("unity"); + unloaded = true; + }; + SceneManager.sceneLoaded += (_, _) => + { + if (!unloaded || options.NativeContextWriter is not { } contextWriter) + { + return; + } + + unloaded = false; + SentrySdk.ConfigureScope(contextWriter.Write); + }; + } + /// /// Closes the native Cocoa support. /// diff --git a/src/Sentry.Unity/NativePropagationContext.cs b/src/Sentry.Unity/NativePropagationContext.cs new file mode 100644 index 000000000..ebd61f7f5 --- /dev/null +++ b/src/Sentry.Unity/NativePropagationContext.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using Sentry.Extensibility; +using Sentry.Internal; + +namespace Sentry.Unity; + +/// +/// The trace of a native SDK that the app embedding the game owns, e.g. when using Unity as a Library. +/// Events, logs, and outgoing requests from the game continue it. Read on every use, so the game follows when the app +/// starts a new trace. +/// +internal sealed class NativePropagationContext : IExternalPropagationContext +{ + private readonly SentryOptions _options; + private readonly Func _getTraceHeader; + private readonly SentryTraceHeader? _traceHeader; + + private NativePropagationContext(SentryOptions options, Func getTraceHeader, SentryTraceHeader? traceHeader) + { + _options = options; + _getTraceHeader = getTraceHeader; + _traceHeader = traceHeader; + } + + internal static void Use(SentryOptions options, Func getTraceHeader) => + options.ExternalPropagationContext = new NativePropagationContext(options, getTraceHeader, null); + + public IExternalPropagationContext Snapshot() => + new NativePropagationContext(_options, _getTraceHeader, Parse(_getTraceHeader())); + + public SentryId? TraceId => _traceHeader?.TraceId; + public SpanId? SpanId => _traceHeader?.SpanId; + public SpanId? ParentSpanId => null; + public bool IsSampled => _traceHeader?.IsSampled ?? false; + public double? SampleRate => null; + public double? SampleRand => null; + public DynamicSamplingContext? DynamicSamplingContext => null; + + public BaggageHeader GetBaggageHeader() => + GetDynamicSamplingContext(_options, null)?.ToBaggageHeader() + ?? BaggageHeader.Create(new List>()); + + public DynamicSamplingContext? GetDynamicSamplingContext(SentryOptions options, IReplaySession? replaySession) => + this.CreateDynamicSamplingContext(options, replaySession); + + private SentryTraceHeader? Parse(string? traceHeader) + { + if (string.IsNullOrEmpty(traceHeader)) + { + return null; + } + + try + { + return SentryTraceHeader.Parse(traceHeader!); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogWarning("Failed to parse the native trace header: {0}", e, traceHeader); + return null; + } + } +} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 5d4f7af7e..be51d2c35 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -286,8 +286,6 @@ internal bool NativeAppHangTrackingEnabled /// When set to , the options are written and hardcoded into the /// Gradle project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. - /// When set to , the app that embeds the game initializes the native - /// SDK, e.g. when using Unity as a Library. /// public NativeInitializationType AndroidNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -493,6 +491,10 @@ internal string? DefaultUserId /// internal ContextWriter? NativeContextWriter { get; set; } = null; + // Lets the native SDK of an app that embeds the game provide release and environment, unless they were set. + internal string? DefaultRelease { get; } + internal string DefaultEnvironment { get; } + /// /// Provides debug images from the native SDK for IL2CPP line number support. /// @@ -628,6 +630,9 @@ or RuntimePlatform.PS5 ? "editor" : "production"; + DefaultRelease = Release; + DefaultEnvironment = Environment; + CaptureStructuredLogsForLogType = new Dictionary { { LogType.Log, false }, @@ -755,7 +760,8 @@ public enum NativeInitializationType /// /// The app that embeds the game owns the native SDK, e.g. when using Unity as a Library. The SDK does not initialize /// or close the native SDK. It attaches to it if the app has initialized it before the game starts. Options that - /// you set in Unity do not apply to the native SDK. + /// you set in Unity do not apply to the native SDK. Events from the game use the app's release, environment, and + /// trace, unless the release and environment are set in the options. Currently supported on iOS. /// External, } diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index d0e560093..db5b37d7c 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -231,44 +231,4 @@ public void Configure_NativeInitFails_LogsErrorAndReturns() Assert.Null(options.ScopeObserver); } - - [Test] - public void Configure_ExternalNotInitialized_LogsWarningAndDoesNotInitialize() - { - var options = new SentryUnityOptions - { - Debug = true, - DiagnosticLevel = SentryLevel.Debug, - DiagnosticLogger = _logger, - AndroidNativeInitializationType = NativeInitializationType.External - }; - _testSentryJava.Enabled = false; - - SentryNativeAndroid.Configure(options); - - Assert.IsTrue(_logger.Logs.Any(log => - log.logLevel == SentryLevel.Warning && - log.message.Contains("expected to be initialized by the app"))); - Assert.False(_testSentryJava.InitCalled); - Assert.Null(options.ScopeObserver); - Assert.False(options.EnableScopeSync); - } - - [Test] - public void Configure_ExternalInitialized_AttachesWithoutTakingOwnership() - { - var options = new SentryUnityOptions - { - AndroidNativeInitializationType = NativeInitializationType.External - }; - _testSentryJava.Enabled = true; - - SentryNativeAndroid.Configure(options); - - Assert.False(_testSentryJava.InitCalled); - Assert.IsAssignableFrom(options.ScopeObserver); - Assert.True(options.EnableScopeSync); - Assert.True(_reinstallCalled); - Assert.Null(options.NativeSupportCloseCallback); - } } diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index 08deedc51..84c7f3735 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -10,18 +10,16 @@ internal class TestSentryJava : ISentryJava public bool SentryPresent { get; set; } = true; public string? InstallationId { get; set; } public bool? IsCrashedLastRun { get; set; } - public bool InitCalled { get; private set; } - public bool CloseCalled { get; private set; } public bool? IsEnabled() => Enabled; - public void Init(SentryUnityOptions options) => InitCalled = true; + public void Init(SentryUnityOptions options) { } public string? GetInstallationId() => InstallationId; public bool? CrashedLastRun() => IsCrashedLastRun; - public void Close() => CloseCalled = true; + public void Close() { } public void WriteScope( string? AppStartTime, diff --git a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs index ab36d9676..6fb65b643 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs @@ -170,19 +170,6 @@ public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeRunt StringAssert.Contains("", manifest); } - [Test] - public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeExternal_DoesNotSetAutoInitOrOptions() - { - _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; - var sut = _fixture.GetSut(); - var manifest = WithAndroidManifest(basePath => sut.ModifyManifest(basePath)); - - _fixture.UnityTestLogger.AssertLogContains(SentryLevel.Debug, "Leaving 'auto-init' unset. The app initializes the Android SDK."); // Sanity Check - - StringAssert.DoesNotContain("io.sentry.auto-init", manifest); - StringAssert.DoesNotContain("io.sentry.dsn", manifest); - } - [Test] public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeBuildTime_AddsSentryAndAutoInits() { @@ -551,44 +538,6 @@ public void CopyAndroidSdkToGradleProject_AndroidNativeSupportEnabled_CopiesAndr Directory.Delete(fakeProjectPath, true); } - [Test] - public void CopyAndroidSdkToGradleProject_InitTypeExternalAndSdkAlreadyCopied_RemovesAndroidSdkFromGradleProject() - { - var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var unityProjectPath = Path.Combine(fakeProjectPath, "UnityProject"); - var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); - DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); - var androidSdk = Path.Combine(gradleProjectPath, "unityLibrary", "libs", "androidSdk.jar"); - File.Create(androidSdk).Close(); - - _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; - var sut = _fixture.GetSut(); - - sut.CopyAndroidSdkToGradleProject(unityProjectPath, gradleProjectPath); - - Assert.IsFalse(File.Exists(androidSdk)); - - Directory.Delete(fakeProjectPath, true); - } - - [Test] - public void AddAndroidSdkDependencies_InitTypeExternal_DoesNotAddDependencies() - { - var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); - DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); - - _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; - var sut = _fixture.GetSut(); - - sut.AddAndroidSdkDependencies(gradleProjectPath); - - var unityLibraryGradle = File.ReadAllText(Path.Combine(gradleProjectPath, "unityLibrary", "build.gradle")); - StringAssert.DoesNotContain(GradleSetup.SdkDependencies, unityLibraryGradle); - - Directory.Delete(fakeProjectPath, true); - } - [Test] public void CopyAndroidSdkToGradleProject_AndroidNativeSupportDisabledButSdkAlreadyCopied_RemovesAndroidSdkFromGradleProject() { diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 9be06888e..b5478760d 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -206,6 +206,24 @@ public void SetupSentry_CopiesFrameworkAndBridge() StringAssert.Contains("[SentryObjCSDK", File.ReadAllText(bridgePath)); // Sanity check } + [Test] + public void SetupSentry_InitializationTypeExternal_CopiesBridgeButNotFramework() + { + var options = new SentryUnityOptions { IosNativeInitializationType = NativeInitializationType.External }; + var testLogger = new TestLogger(); + + BuildPostProcess.SetupSentry(options, testLogger, _outputProjectPath); + + var bridgePath = Path.Combine(_outputProjectPath, "Libraries", SentryPackageInfo.GetName(), + SentryXcodeProject.BridgeName); + var frameworkPath = Path.Combine(_outputProjectPath, "Frameworks", SentryXcodeProject.FrameworkName); + + Assert.IsTrue(File.Exists(bridgePath)); + Assert.IsFalse(Directory.Exists(frameworkPath)); + StringAssert.DoesNotContain(SentryXcodeProject.FrameworkName, + File.ReadAllText(Path.Combine(_outputProjectPath, "Unity-iPhone.xcodeproj", "project.pbxproj"))); + } + [Test] public void AddSentryToXcodeProject_AppendBuildsAndToggleNativeSupport_ThrowsBuildFailedException() { diff --git a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs index 4cd486e4c..ccb68c8fb 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs @@ -147,6 +147,30 @@ public void AddBuildPhaseSymbolUpload_CleanXcodeProject_BuildPhaseSymbolUploadAd Assert.IsTrue(doesContainUploadPhase); } + [Test] + public void AddBuildPhaseSymbolUpload_UploadFromUnityFramework_BuildPhaseAddedToUnityFramework() + { + var xcodeProject = _fixture.GetSut(); + xcodeProject.ReadFromProjectFile(); + + xcodeProject.AddBuildPhaseSymbolUpload(new SentryCliOptions(), uploadFromUnityFramework: true); + + Assert.IsTrue(xcodeProject.UnityFrameworkTargetContainsSymbolUploadBuildPhase()); + Assert.IsFalse(xcodeProject.MainTargetContainsSymbolUploadBuildPhase()); + StringAssert.Contains("${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", xcodeProject.ProjectToString()); + } + + [Test] + public void AddSentryNativeBridge_CompilesBridgeWithObjectiveCExceptions() + { + var xcodeProject = _fixture.GetSut(); + xcodeProject.ReadFromProjectFile(); + + xcodeProject.AddSentryNativeBridge(); + + StringAssert.Contains("-fobjc-exceptions", xcodeProject.ProjectToString()); + } + [Test] public void AddBuildPhaseSymbolUpload_PhaseAlreadyAdded_LogsAndDoesNotAddAgain() { diff --git a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs new file mode 100644 index 000000000..00798f172 --- /dev/null +++ b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using NUnit.Framework; +using Sentry.Unity.Tests.SharedClasses; + +namespace Sentry.Unity.Tests; + +public class NativePropagationContextTests +{ + private const string TraceId = "75302ac48a024bde9a3b3734a82e36c8"; + private const string SpanId = "1000000000000000"; + + private readonly TestLogger _logger = new(); + private string? _traceHeader; + + private SentryUnityOptions GetOptions() + { + var options = new SentryUnityOptions { Dsn = "https://key@sentry.io/1", DiagnosticLogger = _logger, Debug = true }; + NativePropagationContext.Use(options, () => _traceHeader); + return options; + } + + [Test] + public void Snapshot_NativeTraceHeader_ContinuesNativeTrace() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.AreEqual(SentryId.Parse(TraceId), context.TraceId); + Assert.AreEqual(Sentry.SpanId.Parse(SpanId), context.SpanId); + Assert.IsTrue(context.IsSampled); + } + + [Test] + public void Snapshot_NoNativeTraceHeader_HasNoTrace() + { + _traceHeader = null; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.IsNull(context.TraceId); + Assert.IsNull(context.SpanId); + } + + [Test] + public void Snapshot_NativeTraceChanges_FollowsNativeTrace() + { + var options = GetOptions(); + _traceHeader = $"{SentryId.Create()}-{SpanId}"; + var first = options.ExternalPropagationContext!.Snapshot(); + + _traceHeader = $"{TraceId}-{SpanId}"; + var second = options.ExternalPropagationContext!.Snapshot(); + + Assert.AreNotEqual(first.TraceId, second.TraceId); + Assert.AreEqual(SentryId.Parse(TraceId), second.TraceId); + } + + [Test] + public void Snapshot_InvalidNativeTraceHeader_LogsAndHasNoTrace() + { + _traceHeader = "invalid"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.IsNull(context.TraceId); + Assert.IsTrue(_logger.Logs.Any(log => + log.logLevel == SentryLevel.Warning && log.message.Contains("Failed to parse the native trace header"))); + } + + [Test] + public void GetBaggageHeader_NativeTraceHeader_ContainsNativeTraceId() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + + var baggage = GetOptions().ExternalPropagationContext!.Snapshot().GetBaggageHeader(); + + StringAssert.Contains($"sentry-trace_id={TraceId}", baggage.ToString()); + } +} From 8575da3e2903609871cedd9f224b3936080a4411 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 18:30:37 +0200 Subject: [PATCH 03/44] c# watchdog no --- docs/agent-guides/platform-apple.md | 2 +- src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index d86f8f1e9..408d7caa6 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,7 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and keeps the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use), and removes the `unity` context while the game is unloaded. +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use), and removes the `unity` context while the game is unloaded. ## iOS Export diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index 6742c7288..eaf3328ea 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -77,8 +77,8 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf options.ScopeObserver = new NativeScopeObserver("macOS", options); } - // The app's options decide whether sentry-cocoa tracks app hangs, so the C# ANR watchdog stays in charge. - if (!isExternal && options.EnableAppHangTracking) + // With external initialization, the app's sentry-cocoa handles app hangs. + if (isExternal || options.EnableAppHangTracking) { Logger?.LogDebug("Disabling the C# ANR watchdog - sentry-cocoa handles app hang detection."); #pragma warning disable CS0618 // Type or member is obsolete From f01bbb94f5f62bda864bc71ce04f01c0f225ec1f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 18:52:06 +0200 Subject: [PATCH 04/44] external native scope observer, keep context --- docs/agent-guides/platform-apple.md | 2 +- package-dev/Plugins/iOS/SentryNativeBridge.m | 13 ---- .../Plugins/iOS/SentryNativeBridgeNoOp.m | 2 - .../Plugins/macOS/SentryNativeBridge.m | 2 - .../ExternalNativeScopeObserver.cs | 25 ++++++++ src/Sentry.Unity.iOS/NativeScopeObserver.cs | 59 +++---------------- .../SentryCocoaBridgeProxy.cs | 3 - src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 28 +-------- 8 files changed, 37 insertions(+), 97 deletions(-) create mode 100644 src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index 408d7caa6..5f08eaa7e 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,7 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use), and removes the `unity` context while the game is unloaded. +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use). ## iOS Export diff --git a/package-dev/Plugins/iOS/SentryNativeBridge.m b/package-dev/Plugins/iOS/SentryNativeBridge.m index cb05f48a1..d00ceb5ac 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -430,19 +430,6 @@ static id SentryAppOptions(void) return SentryCopyString(traceHeader); } -void SentryNativeBridgeRemoveContext(const char *key) -{ - if (key == NULL) { - return; - } - - NSString *keyString = [NSString stringWithUTF8String:key]; - - SentryConfigureScope(^(id scope) { - [scope performSelector:@selector(removeContextForKey:) withObject:keyString]; - }); -} - void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m index 498401d22..96d5c2873 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -36,8 +36,6 @@ void SentryNativeBridgeUnsetUser() { } char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } -void SentryNativeBridgeRemoveContext(const char *key) { } - void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } void SentryNativeBridgeSetEnvironment(const char *environment) { } diff --git a/package-dev/Plugins/macOS/SentryNativeBridge.m b/package-dev/Plugins/macOS/SentryNativeBridge.m index 2e3daa9b8..7329e3130 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -362,8 +362,6 @@ void SentryNativeBridgeUnsetUser() char *SentryNativeBridgeGetTraceHeader() { return NULL; } -void SentryNativeBridgeRemoveContext(const char *key) { } - void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs new file mode 100644 index 000000000..19cdd900f --- /dev/null +++ b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs @@ -0,0 +1,25 @@ +using Sentry.Extensibility; + +namespace Sentry.Unity.iOS; + +/// +/// Scope sync for : the app that embeds the game owns the native SDK's +/// user, environment, and trace, so the game doesn't sync them. +/// +internal sealed class ExternalNativeScopeObserver : NativeScopeObserver +{ + private readonly SentryOptions _options; + + public ExternalNativeScopeObserver(string name, SentryOptions options) : base(name, options) => _options = options; + + public override void SetUserImpl(SentryUser user) => LogNotSynced("user"); + + public override void UnsetUserImpl() => LogNotSynced("user"); + + public override void SetTraceImpl(SentryId traceId, SpanId spanId) => LogNotSynced("trace"); + + public override void SetEnvironmentImpl(string? environment) => LogNotSynced("environment"); + + private void LogNotSynced(string property) => + _options.DiagnosticLogger?.LogDebug("Not syncing the {0}. The app owns it.", property); +} diff --git a/src/Sentry.Unity.iOS/NativeScopeObserver.cs b/src/Sentry.Unity.iOS/NativeScopeObserver.cs index 706d00d56..393ee19ef 100644 --- a/src/Sentry.Unity.iOS/NativeScopeObserver.cs +++ b/src/Sentry.Unity.iOS/NativeScopeObserver.cs @@ -1,32 +1,10 @@ using System; -using Sentry.Extensibility; namespace Sentry.Unity.iOS; public class NativeScopeObserver : ScopeObserver { - private readonly SentryOptions _options; - - // The app that embeds the game owns the native SDK's user, environment, and trace. - private readonly bool _isExternal; - - public NativeScopeObserver(string name, SentryOptions options) : this(name, options, false) { } - - internal NativeScopeObserver(string name, SentryOptions options, bool isExternal) : base(name, options) - { - _options = options; - _isExternal = isExternal; - } - - private bool IsOwnedByApp(string property) - { - if (_isExternal) - { - _options.DiagnosticLogger?.LogDebug("Not syncing the {0}. The app owns it.", property); - } - - return _isExternal; - } + public NativeScopeObserver(string name, SentryOptions options) : base(name, options) { } public override void AddBreadcrumbImpl(Breadcrumb breadcrumb) { @@ -69,37 +47,16 @@ public override void SetExtraImpl(string key, string? value) => public override void UnsetTagImpl(string key) => SentryCocoaBridgeProxy.UnsetTag(key); - public override void SetUserImpl(SentryUser user) - { - if (!IsOwnedByApp("user")) - { - SentryCocoaBridgeProxy.SetUser(user.Email, user.Id, user.IpAddress, user.Username); - } - } + public override void SetUserImpl(SentryUser user) => + SentryCocoaBridgeProxy.SetUser(user.Email, user.Id, user.IpAddress, user.Username); - public override void UnsetUserImpl() - { - if (!IsOwnedByApp("user")) - { - SentryCocoaBridgeProxy.UnsetUser(); - } - } + public override void UnsetUserImpl() => SentryCocoaBridgeProxy.UnsetUser(); - public override void SetTraceImpl(SentryId traceId, SpanId spanId) - { - if (!IsOwnedByApp("trace")) - { - SentryCocoaBridgeProxy.SetTrace(traceId.ToString(), spanId.ToString()); - } - } + public override void SetTraceImpl(SentryId traceId, SpanId spanId) => + SentryCocoaBridgeProxy.SetTrace(traceId.ToString(), spanId.ToString()); - public override void SetEnvironmentImpl(string? environment) - { - if (!IsOwnedByApp("environment")) - { - SentryCocoaBridgeProxy.SetEnvironment(environment); - } - } + public override void SetEnvironmentImpl(string? environment) => + SentryCocoaBridgeProxy.SetEnvironment(environment); public override void AddFileAttachmentImpl(string filePath, string fileName, string? contentType) { diff --git a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs index 22296a19e..a3e5d35c7 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -152,9 +152,6 @@ public static extern void AddBreadcrumb(string timestamp, string? message, strin [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetTraceHeader")] public static extern string? GetTraceHeader(); - [DllImport("__Internal", EntryPoint = "SentryNativeBridgeRemoveContext")] - public static extern void RemoveContext(string key); - [DllImport("__Internal", EntryPoint = "SentryNativeBridgeSetTrace")] public static extern void SetTrace(string traceId, string spanId); diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index eaf3328ea..18885093f 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -1,7 +1,6 @@ using Sentry.Extensibility; using Sentry.Unity.Integrations; using UnityEngine; -using UnityEngine.SceneManagement; namespace Sentry.Unity.iOS; @@ -52,13 +51,14 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } - options.ScopeObserver = new NativeScopeObserver("iOS", options, isExternal); + options.ScopeObserver = isExternal + ? new ExternalNativeScopeObserver("iOS", options) + : new NativeScopeObserver("iOS", options); if (isExternal) { UseAppReleaseAndEnvironment(options); NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader); - RemoveUnityContextWhileUnloaded(options); if (options.AutoSessionTracking) { @@ -142,28 +142,6 @@ private static void UseAppReleaseAndEnvironment(SentryUnityOptions options) } } - // The app keeps running while the game is unloaded, and can load it again. The C# SDK stays initialized. - private static void RemoveUnityContextWhileUnloaded(SentryUnityOptions options) - { - var unloaded = false; - Application.unloading += () => - { - Logger?.LogDebug("Removing the Unity context from the app's scope."); - SentryCocoaBridgeProxy.RemoveContext("unity"); - unloaded = true; - }; - SceneManager.sceneLoaded += (_, _) => - { - if (!unloaded || options.NativeContextWriter is not { } contextWriter) - { - return; - } - - unloaded = false; - SentrySdk.ConfigureScope(contextWriter.Write); - }; - } - /// /// Closes the native Cocoa support. /// From 167d6844f88ce9669681b5388695c9293617fb38 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 19:13:43 +0200 Subject: [PATCH 05/44] native scope observer tweaks --- .../ExternalNativeScopeObserver.cs | 17 +++++------------ test/Scripts.Tests/package-release.zip.snapshot | 8 ++++++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs index 19cdd900f..1c874f34a 100644 --- a/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs +++ b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs @@ -1,5 +1,3 @@ -using Sentry.Extensibility; - namespace Sentry.Unity.iOS; /// @@ -8,18 +6,13 @@ namespace Sentry.Unity.iOS; /// internal sealed class ExternalNativeScopeObserver : NativeScopeObserver { - private readonly SentryOptions _options; - - public ExternalNativeScopeObserver(string name, SentryOptions options) : base(name, options) => _options = options; - - public override void SetUserImpl(SentryUser user) => LogNotSynced("user"); + public ExternalNativeScopeObserver(string name, SentryOptions options) : base(name, options) { } - public override void UnsetUserImpl() => LogNotSynced("user"); + public override void SetUserImpl(SentryUser user) { } - public override void SetTraceImpl(SentryId traceId, SpanId spanId) => LogNotSynced("trace"); + public override void UnsetUserImpl() { } - public override void SetEnvironmentImpl(string? environment) => LogNotSynced("environment"); + public override void SetTraceImpl(SentryId traceId, SpanId spanId) { } - private void LogNotSynced(string property) => - _options.DiagnosticLogger?.LogDebug("Not syncing the {0}. The app owns it.", property); + public override void SetEnvironmentImpl(string? environment) { } } diff --git a/test/Scripts.Tests/package-release.zip.snapshot b/test/Scripts.Tests/package-release.zip.snapshot index 5d884886c..e31026829 100644 --- a/test/Scripts.Tests/package-release.zip.snapshot +++ b/test/Scripts.Tests/package-release.zip.snapshot @@ -285,15 +285,15 @@ Plugins/Windows/Sentry~/ Plugins/Windows/SentryNative~/ Plugins/Windows/SentryNative~/x64/sentry-crash.exe Plugins/Windows/SentryNative~/x64/sentry-crash.pdb +Plugins/Windows/SentryNative~/x64/sentry-native.dll Plugins/Windows/SentryNative~/x64/sentry-wer.dll Plugins/Windows/SentryNative~/x64/sentry-wer.pdb -Plugins/Windows/SentryNative~/x64/sentry-native.dll Plugins/Windows/SentryNative~/x64/sentry.pdb Plugins/Windows/SentryNative~/x86/sentry-crash.exe Plugins/Windows/SentryNative~/x86/sentry-crash.pdb +Plugins/Windows/SentryNative~/x86/sentry-native.dll Plugins/Windows/SentryNative~/x86/sentry-wer.dll Plugins/Windows/SentryNative~/x86/sentry-wer.pdb -Plugins/Windows/SentryNative~/x86/sentry-native.dll Plugins/Windows/SentryNative~/x86/sentry.pdb Plugins/Windows/Sentry~/x64/crashpad_handler.exe Plugins/Windows/Sentry~/x64/crashpad_wer.dll @@ -401,6 +401,10 @@ Samples~/unity-of-bugs/Scripts/ThirdPartySampleButtons.cs Samples~/unity-of-bugs/Scripts/ThirdPartySampleButtons.cs.meta Samples~/unity-of-bugs/Scripts/ThreadingSamples.cs Samples~/unity-of-bugs/Scripts/ThreadingSamples.cs.meta +Samples~/unity-of-bugs/Scripts/UaaLCliConfiguration.cs +Samples~/unity-of-bugs/Scripts/UaaLCliConfiguration.cs.meta +Samples~/unity-of-bugs/Scripts/UaaLOptionConfiguration.cs +Samples~/unity-of-bugs/Scripts/UaaLOptionConfiguration.cs.meta Samples~/unity-of-bugs/Scripts/NativeSupport/AndroidButtons.cs Samples~/unity-of-bugs/Scripts/NativeSupport/AndroidButtons.cs.meta Samples~/unity-of-bugs/Scripts/NativeSupport/CPlugin.c From 537ee12b0acef90d399295deffa00ee1be99b38d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 19:29:34 +0200 Subject: [PATCH 06/44] pin to ubuntu 24 --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/create-unity-matrix.yml | 2 +- .github/workflows/danger.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test-build-ios.yml | 2 +- .github/workflows/test-build-linux.yml | 2 +- .github/workflows/test-create.yml | 2 +- .github/workflows/test-run-android.yml | 2 +- .github/workflows/test-run-desktop.yml | 2 +- .github/workflows/test-run-webgl.yml | 2 +- .github/workflows/update-deps.yml | 4 ++-- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6151395e8..ad148a4aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ defaults: jobs: cancel-previous-workflow: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 @@ -38,11 +38,11 @@ jobs: matrix: include: - target: Android - host: ubuntu-latest + host: ubuntu-24.04 - target: Linux - host: ubuntu-latest + host: ubuntu-24.04 - target: LinuxNative - host: ubuntu-latest + host: ubuntu-24.04 - target: Windows host: windows-latest - target: WindowsNative @@ -75,7 +75,7 @@ jobs: package-validation: name: UPM Package validation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -135,7 +135,7 @@ jobs: name: Build ${{ matrix.platform }} ${{ matrix.unity-version }} Test if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} needs: [test-create, create-unity-matrix] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -565,7 +565,7 @@ jobs: build-size-summary: name: Build Size - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} needs: [test-build-webgl, test-build-android, test-compile-ios, test-build-linux, test-build-windows, test-build-macos] steps: diff --git a/.github/workflows/create-unity-matrix.yml b/.github/workflows/create-unity-matrix.yml index 3fffae108..966e71914 100644 --- a/.github/workflows/create-unity-matrix.yml +++ b/.github/workflows/create-unity-matrix.yml @@ -25,7 +25,7 @@ env: jobs: create-unity-matrix: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: unity-matrix: ${{ steps.set-matrix.outputs.matrix }} steps: diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 0ec9dcccf..aeae42afc 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -5,6 +5,6 @@ on: jobs: danger: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: getsentry/github-workflows/danger@26f565c05d0dd49f703d238706b775883037d76b # v3 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 6be001e9b..720bddf4e 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -5,7 +5,7 @@ on: jobs: format-code: name: Format Code - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f37c48d98..01f60ec1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ permissions: jobs: job_release: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: 'Release a new version: ${{ github.event.inputs.version }}' steps: - name: Get auth token diff --git a/.github/workflows/test-build-ios.yml b/.github/workflows/test-build-ios.yml index 9dab592ec..118bb4c06 100644 --- a/.github/workflows/test-build-ios.yml +++ b/.github/workflows/test-build-ios.yml @@ -20,7 +20,7 @@ defaults: jobs: build: name: ${{ inputs.unity-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: include: diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 5535dc050..1f2b2309d 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -20,7 +20,7 @@ defaults: jobs: build: name: Linux ${{ inputs.unity-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor diff --git a/.github/workflows/test-create.yml b/.github/workflows/test-create.yml index 067df280d..33ce665b8 100644 --- a/.github/workflows/test-create.yml +++ b/.github/workflows/test-create.yml @@ -16,7 +16,7 @@ defaults: jobs: create: name: ${{ inputs.unity-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor diff --git a/.github/workflows/test-run-android.yml b/.github/workflows/test-run-android.yml index 67b6471f6..dcb471dab 100644 --- a/.github/workflows/test-run-android.yml +++ b/.github/workflows/test-run-android.yml @@ -26,7 +26,7 @@ jobs: name: ${{ inputs.unity-version }} ${{ inputs.api-level }} ${{ inputs.init-type }} # Unity 6000.x builds ARM64 so we run Redroid (containerized arm64 Android) on an # arm64 Linux runner. - runs-on: ${{ fromJSON(inputs.unity-version) >= 6000 && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} + runs-on: ${{ fromJSON(inputs.unity-version) >= 6000 && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} env: ARTIFACTS_PATH: samples/IntegrationTest/test-artifacts/ HOMEBREW_NO_INSTALL_CLEANUP: 1 diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index 4cf4f7e24..e5b0ffa13 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -37,7 +37,7 @@ defaults: jobs: run: name: ${{ inputs.platform }}${{ inputs.architecture && format(' {0}', inputs.architecture) || '' }}${{ inputs.scripting && format(' {0}', inputs.scripting) || '' }}${{ inputs.backend && format(' ({0})', inputs.backend) || '' }} ${{ inputs.unity-version }} - runs-on: ${{ inputs.platform == 'linux' && 'ubuntu-latest' || inputs.platform == 'macos' && 'macos-latest' || 'windows-latest' }} + runs-on: ${{ inputs.platform == 'linux' && 'ubuntu-24.04' || inputs.platform == 'macos' && 'macos-latest' || 'windows-latest' }} env: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/test-run-webgl.yml b/.github/workflows/test-run-webgl.yml index cff15cb81..8d243ca5e 100644 --- a/.github/workflows/test-run-webgl.yml +++ b/.github/workflows/test-run-webgl.yml @@ -18,7 +18,7 @@ defaults: jobs: run: name: WebGL ${{ inputs.unity-version }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 90be275f6..4c8ee93ad 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -27,7 +27,7 @@ jobs: post-update-script: scripts/sync-dotnet-global-json.ps1 - name: CLI path: modules/sentry-cli.properties - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: getsentry/github-workflows/updater@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 with: @@ -40,7 +40,7 @@ jobs: name: Unity ${{ matrix.unity-prefix }} PR # Note: we're using the negative check here so this runs also for workflow_dispatch. if: github.event.schedule != '0 3 * * *' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 defaults: run: shell: pwsh From 1c3598d926cab6c65e4300e6500e30712e47c53d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 19:39:22 +0200 Subject: [PATCH 07/44] updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbfbe933..1100c3fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, and debug symbols upload from the `UnityFramework` target. +- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ## 4.11.0 From 0cc691b9e0dadd24d87e1a6188cde50a5ffe39eb Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 22 Oct 2026 12:23:37 +0200 Subject: [PATCH 08/44] Nest game transactions in the native trace and inherit its sampling decision --- CHANGELOG.md | 2 +- docs/agent-guides/platform-apple.md | 2 +- package-dev/Plugins/iOS/SentryNativeBridge.m | 40 +++++-- .../Plugins/iOS/SentryNativeBridgeNoOp.m | 2 + .../Plugins/macOS/SentryNativeBridge.m | 2 + .../SentryCocoaBridgeProxy.cs | 3 + src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 3 +- src/Sentry.Unity/NativePropagationContext.cs | 62 +++++++++-- src/Sentry.Unity/SentrySdk.Dotnet.cs | 8 +- .../package-release.zip.snapshot | 4 - .../NativePropagationContextTests.cs | 100 +++++++++++++++++- test/Sentry.Unity.Tests/Stubs/TestHub.cs | 9 +- 12 files changed, 211 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1100c3fb0..b3cd7db05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ## 4.11.0 diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index 5f08eaa7e..c556bdd51 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,7 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use). +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use) unless a sampled C# span is active. Transactions started through `Sentry.Unity.SentrySdk.StartTransaction(name, operation)` join the host's trace: as a child of the host's active span, inheriting its sampling decision and DSC, otherwise as a root with sampling deferred to the game. ## iOS Export diff --git a/package-dev/Plugins/iOS/SentryNativeBridge.m b/package-dev/Plugins/iOS/SentryNativeBridge.m index d00ceb5ac..15ca2d7d8 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -411,25 +411,49 @@ static id SentryAppOptions(void) } } -// The `sentry-trace` header of the app's own SDK: its active span, otherwise its propagation context. +static id SentryActiveSpan(id scope) +{ + return [scope respondsToSelector:@selector(span)] ? [scope performSelector:@selector(span)] : nil; +} + +// The `sentry-trace` header of the app's own SDK: its active span, otherwise its propagation context. The +// propagation context has no sampling decision, so its sampled flag is left out (deferred sampling). char *SentryNativeBridgeGetTraceHeader() { __block NSString *traceHeader = nil; SentryConfigureScope(^(id scope) { - id header = nil; - id span = [scope respondsToSelector:@selector(span)] ? [scope performSelector:@selector(span)] : nil; + id span = SentryActiveSpan(scope); if (span != nil && [span respondsToSelector:@selector(toTraceHeader)]) { - header = [span performSelector:@selector(toTraceHeader)]; + id header = [span performSelector:@selector(toTraceHeader)]; + if ([header respondsToSelector:@selector(value)]) { + traceHeader = [header performSelector:@selector(value)]; + } } else if ([scope respondsToSelector:@selector(propagationContextTraceHeader)]) { - header = [scope performSelector:@selector(propagationContextTraceHeader)]; - } - if ([header respondsToSelector:@selector(value)]) { - traceHeader = [header performSelector:@selector(value)]; + id header = [scope performSelector:@selector(propagationContextTraceHeader)]; + if ([header respondsToSelector:@selector(value)]) { + NSArray *parts = [[header performSelector:@selector(value)] componentsSeparatedByString:@"-"]; + if (parts.count >= 2) { + traceHeader = [NSString stringWithFormat:@"%@-%@", parts[0], parts[1]]; + } + } } }); return SentryCopyString(traceHeader); } +// The `baggage` header of the app's own SDK's active span, if any. +char *SentryNativeBridgeGetBaggageHeader() +{ + __block NSString *baggageHeader = nil; + SentryConfigureScope(^(id scope) { + id span = SentryActiveSpan(scope); + if (span != nil && [span respondsToSelector:@selector(baggageHttpHeader)]) { + baggageHeader = [span performSelector:@selector(baggageHttpHeader)]; + } + }); + return SentryCopyString(baggageHeader); +} + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m index 96d5c2873..573ff0157 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -36,6 +36,8 @@ void SentryNativeBridgeUnsetUser() { } char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } +char *_Nullable SentryNativeBridgeGetBaggageHeader() { return NULL; } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } void SentryNativeBridgeSetEnvironment(const char *environment) { } diff --git a/package-dev/Plugins/macOS/SentryNativeBridge.m b/package-dev/Plugins/macOS/SentryNativeBridge.m index 7329e3130..718c1bc66 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -362,6 +362,8 @@ void SentryNativeBridgeUnsetUser() char *SentryNativeBridgeGetTraceHeader() { return NULL; } +char *SentryNativeBridgeGetBaggageHeader() { return NULL; } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs index a3e5d35c7..5df08f666 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -152,6 +152,9 @@ public static extern void AddBreadcrumb(string timestamp, string? message, strin [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetTraceHeader")] public static extern string? GetTraceHeader(); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetBaggageHeader")] + public static extern string? GetBaggageHeader(); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeSetTrace")] public static extern void SetTrace(string traceId, string spanId); diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index 18885093f..07ebd192c 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -58,7 +58,8 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf if (isExternal) { UseAppReleaseAndEnvironment(options); - NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader); + NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader, + SentryCocoaBridgeProxy.GetBaggageHeader); if (options.AutoSessionTracking) { diff --git a/src/Sentry.Unity/NativePropagationContext.cs b/src/Sentry.Unity/NativePropagationContext.cs index ebd61f7f5..284bd1706 100644 --- a/src/Sentry.Unity/NativePropagationContext.cs +++ b/src/Sentry.Unity/NativePropagationContext.cs @@ -7,27 +7,61 @@ namespace Sentry.Unity; /// /// The trace of a native SDK that the app embedding the game owns, e.g. when using Unity as a Library. -/// Events, logs, and outgoing requests from the game continue it. Read on every use, so the game follows when the app -/// starts a new trace. +/// The game's transactions join it, and events, logs, and outgoing requests outside of them continue it. Read on +/// every use, so the game follows when the app starts a new trace. /// internal sealed class NativePropagationContext : IExternalPropagationContext { private readonly SentryOptions _options; private readonly Func _getTraceHeader; + private readonly Func _getBaggageHeader; + private readonly Func _getSpan; private readonly SentryTraceHeader? _traceHeader; - private NativePropagationContext(SentryOptions options, Func getTraceHeader, SentryTraceHeader? traceHeader) + private NativePropagationContext(SentryOptions options, Func getTraceHeader, + Func getBaggageHeader, Func getSpan, SentryTraceHeader? traceHeader) { _options = options; _getTraceHeader = getTraceHeader; + _getBaggageHeader = getBaggageHeader; + _getSpan = getSpan; _traceHeader = traceHeader; } - internal static void Use(SentryOptions options, Func getTraceHeader) => - options.ExternalPropagationContext = new NativePropagationContext(options, getTraceHeader, null); + internal static void Use(SentryOptions options, Func getTraceHeader, Func getBaggageHeader, + Func? getSpan = null) => + options.ExternalPropagationContext = new NativePropagationContext(options, getTraceHeader, getBaggageHeader, + getSpan ?? Sentry.SentrySdk.GetSpan, null); + internal static ITransactionTracer StartTransaction(IHub hub, string name, string operation) => + hub.GetSentryOptions()?.ExternalPropagationContext is NativePropagationContext context + ? context.StartTransactionInNativeTrace(hub, name, operation) + : hub.StartTransaction(name, operation); + + // The game's own sampled transaction takes precedence. The native trace is the fallback. public IExternalPropagationContext Snapshot() => - new NativePropagationContext(_options, _getTraceHeader, Parse(_getTraceHeader())); + new NativePropagationContext(_options, _getTraceHeader, _getBaggageHeader, _getSpan, + _getSpan() is { IsSampled: true } ? null : Parse(_getTraceHeader())); + + // Child of the native SDK's active span, inheriting its sampling decision and DSC. Without one, the native trace + // has no sampling decision, so the transaction is a root in it and the game decides. + internal ITransactionTracer StartTransactionInNativeTrace(IHub hub, string name, string operation) + { + if (Parse(_getTraceHeader()) is not { } traceHeader) + { + return hub.StartTransaction(name, operation); + } + + if (traceHeader.IsSampled is not { } isSampled) + { + return hub.StartTransaction(new TransactionContext(name, operation, traceId: traceHeader.TraceId)); + } + + var context = new TransactionContext(name, operation, parentSpanId: traceHeader.SpanId, + traceId: traceHeader.TraceId, isSampled: isSampled, isParentSampled: isSampled); + return hub.StartTransaction(context, new Dictionary(), + GetNativeDynamicSamplingContext(traceHeader.TraceId)); + } public SentryId? TraceId => _traceHeader?.TraceId; public SpanId? SpanId => _traceHeader?.SpanId; @@ -44,6 +78,22 @@ public BaggageHeader GetBaggageHeader() => public DynamicSamplingContext? GetDynamicSamplingContext(SentryOptions options, IReplaySession? replaySession) => this.CreateDynamicSamplingContext(options, replaySession); + // Header and baggage are read separately, so the baggage is dropped if the native SDK moved to another trace. + internal DynamicSamplingContext? GetNativeDynamicSamplingContext(SentryId traceId) + { + var baggageHeader = _getBaggageHeader(); + if (string.IsNullOrEmpty(baggageHeader) + || BaggageHeader.TryParse(baggageHeader!, onlySentry: true)?.CreateDynamicSamplingContext() is not { } dsc) + { + return null; + } + + return dsc.Items.TryGetValue("trace_id", out var dscTraceId) + && string.Equals(dscTraceId, traceId.ToString(), StringComparison.OrdinalIgnoreCase) + ? dsc + : null; + } + private SentryTraceHeader? Parse(string? traceHeader) { if (string.IsNullOrEmpty(traceHeader)) diff --git a/src/Sentry.Unity/SentrySdk.Dotnet.cs b/src/Sentry.Unity/SentrySdk.Dotnet.cs index 5799cce8c..33966a487 100644 --- a/src/Sentry.Unity/SentrySdk.Dotnet.cs +++ b/src/Sentry.Unity/SentrySdk.Dotnet.cs @@ -487,14 +487,18 @@ public static ITransactionTracer StartTransaction(ITransactionContext context) /// [DebuggerStepThrough] public static ITransactionTracer StartTransaction(string name, string operation) - => Sentry.SentrySdk.StartTransaction(name, operation); + => NativePropagationContext.StartTransaction(Sentry.SentrySdk.CurrentHub, name, operation); /// /// Starts a transaction. /// [DebuggerStepThrough] public static ITransactionTracer StartTransaction(string name, string operation, string? description) - => Sentry.SentrySdk.StartTransaction(name, operation, description); + { + var transaction = StartTransaction(name, operation); + transaction.Description = description; + return transaction; + } /// /// Starts a transaction. diff --git a/test/Scripts.Tests/package-release.zip.snapshot b/test/Scripts.Tests/package-release.zip.snapshot index e31026829..cb91d89d6 100644 --- a/test/Scripts.Tests/package-release.zip.snapshot +++ b/test/Scripts.Tests/package-release.zip.snapshot @@ -401,10 +401,6 @@ Samples~/unity-of-bugs/Scripts/ThirdPartySampleButtons.cs Samples~/unity-of-bugs/Scripts/ThirdPartySampleButtons.cs.meta Samples~/unity-of-bugs/Scripts/ThreadingSamples.cs Samples~/unity-of-bugs/Scripts/ThreadingSamples.cs.meta -Samples~/unity-of-bugs/Scripts/UaaLCliConfiguration.cs -Samples~/unity-of-bugs/Scripts/UaaLCliConfiguration.cs.meta -Samples~/unity-of-bugs/Scripts/UaaLOptionConfiguration.cs -Samples~/unity-of-bugs/Scripts/UaaLOptionConfiguration.cs.meta Samples~/unity-of-bugs/Scripts/NativeSupport/AndroidButtons.cs Samples~/unity-of-bugs/Scripts/NativeSupport/AndroidButtons.cs.meta Samples~/unity-of-bugs/Scripts/NativeSupport/CPlugin.c diff --git a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs index 00798f172..cec3c175e 100644 --- a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs +++ b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs @@ -1,6 +1,8 @@ using System.Linq; using NUnit.Framework; +using Sentry.Internal; using Sentry.Unity.Tests.SharedClasses; +using Sentry.Unity.Tests.Stubs; namespace Sentry.Unity.Tests; @@ -11,14 +13,24 @@ public class NativePropagationContextTests private readonly TestLogger _logger = new(); private string? _traceHeader; + private string? _baggageHeader; + private ISpan? _span; private SentryUnityOptions GetOptions() { var options = new SentryUnityOptions { Dsn = "https://key@sentry.io/1", DiagnosticLogger = _logger, Debug = true }; - NativePropagationContext.Use(options, () => _traceHeader); + NativePropagationContext.Use(options, () => _traceHeader, () => _baggageHeader, () => _span); return options; } + private NativePropagationContext GetSut() => (NativePropagationContext)GetOptions().ExternalPropagationContext!; + + private static ISpan GetSpan(bool isSampled) + { + var context = new TransactionContext("name", "operation"); + return isSampled ? new TransactionTracer(new TestHub(), context) : new UnsampledTransaction(new TestHub(), context); + } + [Test] public void Snapshot_NativeTraceHeader_ContinuesNativeTrace() { @@ -68,6 +80,28 @@ public void Snapshot_InvalidNativeTraceHeader_LogsAndHasNoTrace() log.logLevel == SentryLevel.Warning && log.message.Contains("Failed to parse the native trace header"))); } + [Test] + public void Snapshot_SampledSpanActive_DefersToSpan() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + _span = GetSpan(isSampled: true); + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.IsNull(context.TraceId); + } + + [Test] + public void Snapshot_UnsampledSpanActive_ContinuesNativeTrace() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + _span = GetSpan(isSampled: false); + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.AreEqual(SentryId.Parse(TraceId), context.TraceId); + } + [Test] public void GetBaggageHeader_NativeTraceHeader_ContainsNativeTraceId() { @@ -77,4 +111,68 @@ public void GetBaggageHeader_NativeTraceHeader_ContainsNativeTraceId() StringAssert.Contains($"sentry-trace_id={TraceId}", baggage.ToString()); } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void StartTransactionInNativeTrace_NativeSpan_ChildInheritsSamplingDecision(bool isSampled) + { + _traceHeader = $"{TraceId}-{SpanId}-{(isSampled ? 1 : 0)}"; + var hub = new TestHub(); + + GetSut().StartTransactionInNativeTrace(hub, "name", "operation"); + + var context = (TransactionContext)hub.StartedTransactions.Single(); + Assert.AreEqual(SentryId.Parse(TraceId), context.TraceId); + Assert.AreEqual(Sentry.SpanId.Parse(SpanId), context.ParentSpanId); + Assert.AreEqual(isSampled, context.IsSampled); + Assert.AreEqual(isSampled, context.IsParentSampled); + } + + [Test] + public void StartTransactionInNativeTrace_NoNativeSpan_RootWithDeferredSampling() + { + _traceHeader = $"{TraceId}-{SpanId}"; + var hub = new TestHub(); + + GetSut().StartTransactionInNativeTrace(hub, "name", "operation"); + + var context = (TransactionContext)hub.StartedTransactions.Single(); + Assert.AreEqual(SentryId.Parse(TraceId), context.TraceId); + Assert.IsNull(context.ParentSpanId); + Assert.IsNull(context.IsSampled); + Assert.IsNull(context.IsParentSampled); + } + + [Test] + public void StartTransactionInNativeTrace_NoNativeTrace_StartsNewTrace() + { + _traceHeader = null; + + var transaction = GetSut().StartTransactionInNativeTrace(new TestHub(), "name", "operation"); + + Assert.AreNotEqual(SentryId.Parse(TraceId), transaction.TraceId); + Assert.IsNull(transaction.ParentSpanId); + } + + [Test] + public void GetNativeDynamicSamplingContext_BaggageOfSameTrace_ReturnsNativeDsc() + { + _baggageHeader = $"sentry-trace_id={TraceId},sentry-public_key=key,sentry-sample_rate=0.5,sentry-sample_rand=0.25,sentry-sampled=true"; + + var dsc = GetSut().GetNativeDynamicSamplingContext(SentryId.Parse(TraceId)); + + Assert.IsNotNull(dsc); + Assert.AreEqual("0.25", dsc!.Items["sample_rand"]); + } + + [Test] + public void GetNativeDynamicSamplingContext_BaggageOfOtherTrace_ReturnsNull() + { + _baggageHeader = $"sentry-trace_id={SentryId.Create()},sentry-public_key=key,sentry-sample_rate=0.5,sentry-sampled=true"; + + var dsc = GetSut().GetNativeDynamicSamplingContext(SentryId.Parse(TraceId)); + + Assert.IsNull(dsc); + } } diff --git a/test/Sentry.Unity.Tests/Stubs/TestHub.cs b/test/Sentry.Unity.Tests/Stubs/TestHub.cs index 9c8af21dc..3d07cf039 100644 --- a/test/Sentry.Unity.Tests/Stubs/TestHub.cs +++ b/test/Sentry.Unity.Tests/Stubs/TestHub.cs @@ -12,10 +12,12 @@ internal sealed class TestHub : IHub private readonly List _capturedEvents = new(); private readonly List _capturedTransactions = new(); private readonly List> _configureScopeCalls = new(); + private readonly List _startedTransactions = new(); public IReadOnlyList CapturedEvents => _capturedEvents; public IReadOnlyList CapturedTransactions => _capturedTransactions; public IReadOnlyList> ConfigureScopeCalls => _configureScopeCalls; + public IReadOnlyList StartedTransactions => _startedTransactions; public TestHub(bool isEnabled = true, SentryStructuredLogger? logger = null) { @@ -127,8 +129,11 @@ public void WithScope(Action scopeCallback) public SentryMetricEmitter Metrics => DisabledSentryMetricEmitter.Instance; - public ITransactionTracer StartTransaction(ITransactionContext context, IReadOnlyDictionary customSamplingContext) => - new TransactionTracer(this, context); + public ITransactionTracer StartTransaction(ITransactionContext context, IReadOnlyDictionary customSamplingContext) + { + _startedTransactions.Add(context); + return new TransactionTracer(this, context); + } public void BindException(Exception exception, ISpan span) { From a4e6b39694e8f71258292ed8f85f665b16f262a1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 22 Oct 2026 16:59:07 +0200 Subject: [PATCH 09/44] Add Unity as a Library support on Android --- CHANGELOG.md | 1 + docs/agent-guides/platform-android.md | 1 + .../ExternalAndroidJavaScopeObserver.cs | 39 ++++ src/Sentry.Unity.Android/SentryJava.cs | 209 +++++++++++++++++- .../SentryNativeAndroid.cs | 98 +++++++- .../Android/AndroidManifestConfiguration.cs | 29 ++- .../Android/DebugSymbolUpload.cs | 61 +++-- src/Sentry.Unity/SentryUnityOptions.cs | 6 +- .../SentryNativeAndroidTests.cs | 98 ++++++++ .../TestSentryJava.cs | 29 ++- .../AndroidManifestConfigurationTests.cs | 31 +++ .../Android/DebugSymbolUploadTests.cs | 38 +++- 12 files changed, 603 insertions(+), 37 deletions(-) create mode 100644 src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cd7db05..916933f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace. C# events use the app's release and environment. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ## 4.11.0 diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 0f3b4f4f7..24561000a 100644 --- a/docs/agent-guides/platform-android.md +++ b/docs/agent-guides/platform-android.md @@ -12,6 +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): 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. 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. Process detection needs API 28. C# sessions are off and the app's release and environment are used unless set. ## Export Processing diff --git a/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs new file mode 100644 index 000000000..844171bac --- /dev/null +++ b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs @@ -0,0 +1,39 @@ +namespace Sentry.Unity.Android; + +/// +/// Scope sync for . The game follows the app's trace. When attached to +/// the app's SDK, the app also owns the user and environment. In its own process, the game's SDK needs them. +/// +internal sealed class ExternalAndroidJavaScopeObserver : AndroidJavaScopeObserver +{ + private readonly bool _isAttached; + + public ExternalAndroidJavaScopeObserver(SentryOptions options, ISentryJava sentryJava, bool isAttached) + : base(options, sentryJava) => _isAttached = isAttached; + + public override void SetUserImpl(SentryUser user) + { + if (!_isAttached) + { + base.SetUserImpl(user); + } + } + + public override void UnsetUserImpl() + { + if (!_isAttached) + { + base.UnsetUserImpl(); + } + } + + public override void SetTraceImpl(SentryId traceId, SpanId spanId) { } + + public override void SetEnvironmentImpl(string? environment) + { + if (!_isAttached) + { + base.SetEnvironmentImpl(environment); + } + } +} diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index a8b0b58cf..e9dcc559a 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -10,7 +10,12 @@ namespace Sentry.Unity.Android; internal interface ISentryJava { public bool? IsEnabled(); + public bool? IsMainProcess(); public void Init(SentryUnityOptions options); + public string? GetOption(string getter); + public string? GetTraceHeader(); + public string? GetBaggageHeader(); + public string? GetMainProcessTraceHeader(); public string? GetInstallationId(); public bool? CrashedLastRun(); public void Close(); @@ -65,6 +70,7 @@ internal class SentryJava : ISentryJava private readonly AutoResetEvent _scopeSyncEvent; private readonly Thread _scopeSyncThread; private readonly ConcurrentQueue<(Action action, string actionName)> _scopeSyncItems = new(); + private static readonly TimeSpan JniResultTimeout = TimeSpan.FromMilliseconds(500); private volatile bool _closed; private static AndroidJavaObject GetInternalSentryJava() => new AndroidJavaClass("io.sentry.android.core.InternalSentrySdk"); @@ -102,6 +108,47 @@ public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) return null; } + // Whether the game runs in the app's main process. With Unity as a Library, the game can run in its own process. + public bool? IsMainProcess() + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling IsMainProcess() on Android SDK requires running on MainThread"); + return null; + } + + try + { + if (GetProcessName() is not { } processName) + { + return null; + } + + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + return processName == activity.Call("getPackageName"); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.IsMainProcess' failed."); + } + + return null; + } + + // `Application.getProcessName` requires API 28. + private static string? GetProcessName() + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + if (version.GetStatic("SDK_INT") < 28) + { + return null; + } + + using var application = new AndroidJavaClass("android.app.Application"); + return application.CallStatic("getProcessName"); + } + public void Init(SentryUnityOptions options) { if (!MainThreadData.IsMainThread()) @@ -120,9 +167,29 @@ public void Init(SentryUnityOptions options) { androidOptions.Call("setDsn", options.Dsn); androidOptions.Call("setDebug", options.Debug); - androidOptions.Call("setRelease", options.Release); + + // With external initialization, the native SDK keeps the app's release and environment unless they were set. + var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; + if (!isExternal || options.Release != options.DefaultRelease) + { + androidOptions.Call("setRelease", options.Release); + } androidOptions.Call("setDist", options.Distribution); - androidOptions.Call("setEnvironment", options.Environment); + if (!isExternal || options.Environment != options.DefaultEnvironment) + { + androidOptions.Call("setEnvironment", options.Environment); + } + + // With external initialization, the game runs in its own process, next to the app's SDK in the main + // process. Its own cache directory keeps it from taking over the app's session and persisted scope. + if (isExternal && GetProcessName() is { } processName) + { + using var cacheDir = context.Call("getCacheDir"); + var processSuffix = processName.Substring(processName.IndexOf(':') + 1); + androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); + // The game continues the app's trace instead of starting new ones on its own screens and taps. + androidOptions.Call("setEnableAutoTraceIdGeneration", false); + } using var sentryLevelClass = new AndroidJavaClass("io.sentry.SentryLevel"); var levelString = GetLevelString(options.DiagnosticLevel); @@ -176,6 +243,98 @@ public void Init(SentryUnityOptions options) } } + // A string option of the native SDK, e.g. `getRelease` or `getEnvironment`. + public string? GetOption(string getter) + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling GetOption() on Android SDK requires running on MainThread"); + return null; + } + + try + { + using var sentry = GetSentryJava(); + using var hub = sentry.CallStatic("getCurrentHub"); + using var options = hub?.Call("getOptions"); + return options?.Call(getter); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.GetOption' failed."); + } + + return null; + } + + // The `sentry-trace` header of the native SDK: its active span, otherwise its propagation context. + // The app's SDK in the main process persists its trace to its cache directory, next to ours: same DSN hash, + // default 'sentry' directory. + public string? GetMainProcessTraceHeader() + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling GetMainProcessTraceHeader() on Android SDK requires running on MainThread"); + return null; + } + + try + { + using var sentry = GetSentryJava(); + using var hub = sentry.CallStatic("getCurrentHub"); + using var options = hub.Call("getOptions"); + using var cacheDir = new AndroidJavaObject("java.io.File", options.Call("getCacheDirPath")); + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var appCacheDir = activity.Call("getCacheDir"); + var tracePath = $"{appCacheDir.Call("getAbsolutePath")}/sentry/{cacheDir.Call("getName")}/.scope-cache/trace.json"; + using var traceFile = new AndroidJavaObject("java.io.File", tracePath); + if (!traceFile.Call("exists")) + { + _logger?.LogDebug("No trace persisted by the main process at '{0}'.", tracePath); + return null; + } + + using var fileReader = new AndroidJavaObject("java.io.FileReader", traceFile); + using var reader = new AndroidJavaObject("java.io.BufferedReader", fileReader); + using var serializer = options.Call("getSerializer"); + using var javaClass = new AndroidJavaClass("java.lang.Class"); + using var spanContextClass = javaClass.CallStatic("forName", "io.sentry.SpanContext"); + using var spanContext = serializer.Call("deserialize", reader, spanContextClass); + reader.Call("close"); + if (spanContext is null) + { + return null; + } + + using var traceId = spanContext.Call("getTraceId"); + using var spanId = spanContext.Call("getSpanId"); + using var sampled = spanContext.Call("getSampled"); + var header = $"{traceId.Call("toString")}-{spanId.Call("toString")}"; + return sampled is null ? header : $"{header}-{(sampled.Call("booleanValue") ? 1 : 0)}"; + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.GetMainProcessTraceHeader' failed."); + } + + return null; + } + + public string? GetTraceHeader() => RunJniSafeAndWait(() => + { + using var sentry = GetSentryJava(); + using var header = sentry.CallStatic("getTraceparent"); + return header?.Call("getValue"); + }); + + public string? GetBaggageHeader() => RunJniSafeAndWait(() => + { + using var sentry = GetSentryJava(); + using var header = sentry.CallStatic("getBaggage"); + return header?.Call("getValue"); + }); + public string? GetInstallationId() { if (!MainThreadData.IsMainThread()) @@ -475,6 +634,52 @@ internal void RunJniSafe(Action action, [CallerMemberName] string actionName = " } } + // Like `RunJniSafe`, but waits for the result. Off the main thread, the call runs on the scope sync thread, which + // is attached to the JVM. + internal T? RunJniSafeAndWait(Func func, [CallerMemberName] string actionName = "", bool? isMainThread = null) + { + if (_closed) + { + return default; + } + + isMainThread ??= MainThreadData.IsMainThread(); + if (isMainThread is true || Thread.CurrentThread == _scopeSyncThread) + { + try + { + return func(); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling '{0}' failed.", actionName); + return default; + } + } + + T? result = default; + var done = new ManualResetEventSlim(false); + _scopeSyncItems.Enqueue((() => + { + try + { + result = func(); + } + finally + { + done.Set(); + } + }, actionName)); + _scopeSyncEvent.Set(); + + if (!done.Wait(JniResultTimeout)) + { + _logger?.LogWarning("Timed out waiting for '{0}'.", actionName); + } + + return result; + } + private void SyncScope() { _androidJNI.AttachCurrentThread(); diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 005aed753..f6f63d5d5 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -35,25 +35,45 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Checking whether the Android SDK is present."); + var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; + // If it's not been set (in a test) SentryJava ??= new SentryJava(Logger); if (!SentryJava.IsSentryJavaPresent()) { - Logger?.LogError("Android Native Support has been enabled but the " + - "Android SDK is missing. This could have been caused by a mismatching" + - "build time / runtime configuration. Please make sure you have " + - "Android Native Support enabled during build time."); + if (isExternal) + { + Logger?.LogError("The Android SDK is missing. With external initialization, the app has to depend on " + + "'io.sentry:sentry-android'."); + } + else + { + Logger?.LogError("Android Native Support has been enabled but the " + + "Android SDK is missing. This could have been caused by a mismatching" + + "build time / runtime configuration. Please make sure you have " + + "Android Native Support enabled during build time."); + } return; } Logger?.LogDebug("Checking whether the Android SDK has already been initialized"); + var isAttached = false; + if (SentryJava.IsEnabled() is true) { Logger?.LogDebug("The Android SDK is already initialized"); + isAttached = isExternal; + } + else if (isExternal && SentryJava.IsMainProcess() is not false) + { + Logger?.LogWarning("The native SDK is expected to be initialized by the app but it is not. " + + "Make sure the app initializes Sentry before the game starts. Skipping native support."); + return; } else { + // With external initialization, the game runs in its own process, where the app has not initialized the SDK. Logger?.LogInfo("Initializing the Android SDK"); SentryJava.Init(options); @@ -70,11 +90,34 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Configuring scope sync"); options.NativeContextWriter = new NativeContextWriter(SentryJava); - options.ScopeObserver = new AndroidJavaScopeObserver(options, SentryJava); + options.ScopeObserver = isExternal + ? new ExternalAndroidJavaScopeObserver(options, SentryJava, isAttached) + : new AndroidJavaScopeObserver(options, SentryJava); options.EnableScopeSync = true; options.NativeDebugImageProvider = new Native.NativeDebugImageProvider(); - if (options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) + if (isExternal) + { + UseAppReleaseAndEnvironment(options, SentryJava); + + if (isAttached) + { + NativePropagationContext.Use(options, SentryJava.GetTraceHeader, SentryJava.GetBaggageHeader); + } + else + { + UseMainProcessTrace(options, SentryJava); + } + + if (options.AutoSessionTracking) + { + Logger?.LogDebug("Disabling session tracking - the app tracks sessions."); + options.AutoSessionTracking = false; + } + } + + // When attached, the app's native SDK decides on app hang tracking. + if (!isAttached && options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) { Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); SentryMonoBehaviour.Instance.StartAppHangHeartbeat( @@ -119,7 +162,11 @@ public static void Configure(SentryUnityOptions options) e, "Failed to reinstall backend. Captured native crashes will miss scope data and tag."); } - options.NativeSupportCloseCallback = () => Close(options); + // When attached, the app owns the SDK's lifetime. + if (!isAttached) + { + options.NativeSupportCloseCallback = () => Close(options); + } Logger?.LogDebug("Fetching installation ID"); @@ -136,6 +183,43 @@ public static void Configure(SentryUnityOptions options) Logger?.LogInfo("Successfully configured the Android SDK"); } + // The game runs in its own process and continues the trace the app was in when it showed the game. + private static void UseMainProcessTrace(SentryUnityOptions options, ISentryJava sentryJava) + { + string? traceHeader = null; + + void Refresh() + { + traceHeader = sentryJava.GetMainProcessTraceHeader(); + if (traceHeader is not null && SentryTraceHeader.Parse(traceHeader) is { } header) + { + Logger?.LogDebug("Continuing the app's trace: {0}", traceHeader); + sentryJava.SetTrace(header.TraceId, header.SpanId); + } + } + + Refresh(); + SentryMonoBehaviour.Instance.ApplicationResuming += Refresh; + NativePropagationContext.Use(options, () => traceHeader, () => null); + } + + // Events from the game belong to the app's release and environment, unless they were set in the options. + private static void UseAppReleaseAndEnvironment(SentryUnityOptions options, ISentryJava sentryJava) + { + if (options.Release == options.DefaultRelease && sentryJava.GetOption("getRelease") is { } release) + { + Logger?.LogDebug("Using the app's release: {0}", release); + options.Release = release; + options.Distribution ??= sentryJava.GetOption("getDist"); + } + + if (options.Environment == options.DefaultEnvironment && sentryJava.GetOption("getEnvironment") is { } environment) + { + Logger?.LogDebug("Using the app's environment: {0}", environment); + options.Environment = environment; + } + } + /// /// Closes the native Android support. /// diff --git a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs index f2519ba9c..54b85b79c 100644 --- a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs +++ b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs @@ -120,6 +120,13 @@ public void OnPostGenerateGradleAndroidProject(string basePath) /// private bool AndroidSdkEnabled => _androidSdkEnabled ??= EvaluateAndroidSdkEnabled(); + /// + /// With external initialization, the app that embeds the game (Unity as a Library) brings the Android SDK. + /// + private bool IsExternal => _options?.AndroidNativeInitializationType is NativeInitializationType.External; + + private bool ShipsAndroidSdk => AndroidSdkEnabled && !IsExternal; + private bool EvaluateAndroidSdkEnabled() { if (_options is null) @@ -155,6 +162,16 @@ internal void ModifyManifest(string basePath) var androidManifest = new AndroidManifest(manifestPath, _logger); androidManifest.RemovePreviousConfigurations(); + + if (IsExternal) + { + // The manifest merges into the app's. Its Sentry configuration is the app's. + _logger.LogDebug("Leaving the Android SDK's configuration to the app that embeds the game."); + _ = androidManifest.Save(); + + return; + } + androidManifest.AddDisclaimerComment(); if (!AndroidSdkEnabled) @@ -258,7 +275,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad var androidSdkPath = Path.Combine(unityProjectPath, "Packages", SentryPackageInfo.GetName(), "Plugins", "Android", "Sentry~"); var targetPath = Path.Combine(gradlePath, "unityLibrary", "libs"); - if (AndroidSdkEnabled) + if (ShipsAndroidSdk) { if (!Directory.Exists(androidSdkPath)) { @@ -301,7 +318,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad internal void AddAndroidSdkDependencies(string gradleProjectPath) { var tool = new GradleSetup(_logger, gradleProjectPath); - var nativeSupportEnabled = AndroidSdkEnabled; + var nativeSupportEnabled = ShipsAndroidSdk; try { @@ -326,7 +343,7 @@ internal void SetupSymbolsUpload(string unityProjectPath, string gradleProjectPa var isExporting = EditorUserBuildSettings.exportAsGoogleAndroidProject; _logger.LogInfo("The project is exporting: '{0}'", isExporting); - var symbolsUpload = new DebugSymbolUpload(_logger, _sentryCliOptions, unityProjectPath, gradleProjectPath, isExporting, AndroidUtils.ShouldUploadMapping()); + var symbolsUpload = new DebugSymbolUpload(_logger, _sentryCliOptions, unityProjectPath, gradleProjectPath, isExporting, AndroidUtils.ShouldUploadMapping(), isExternal: IsExternal); if (_options is not { Enabled: true, AndroidNativeSupportEnabled: true }) { @@ -364,10 +381,10 @@ internal void SetupSymbolsUpload(string unityProjectPath, string gradleProjectPa // In practice, users should be able to build the project on any platform, regardless of where Unity // ran. In that case, we would either need to include all CLI binaries and switch in Gradle, or let // gradle download CLI on demand (relevant code could be taken from sentry-java repo?). - var launcherDirectory = Path.Combine(gradleProjectPath, "launcher"); - var sentryCliPath = SentryCli.SetupSentryCli(isExporting ? launcherDirectory : null); + var moduleDirectory = Path.Combine(gradleProjectPath, symbolsUpload.ModuleName); + var sentryCliPath = SentryCli.SetupSentryCli(isExporting ? moduleDirectory : null); - SentryCli.CreateSentryProperties(launcherDirectory, _sentryCliOptions!, _options!); + SentryCli.CreateSentryProperties(moduleDirectory, _sentryCliOptions!, _options!); symbolsUpload.TryCopySymbolsToGradleProject(); // We need to remove the old upload task first as the project persists across consecutive builds and the diff --git a/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs b/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs index ab6c41d67..16e50df0c 100644 --- a/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs +++ b/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs @@ -26,6 +26,7 @@ internal class DebugSymbolUpload private readonly string _gradleScriptPath; private readonly bool _isExporting; private readonly bool _isMinifyEnabled; + private readonly bool _isExternal; private readonly SentryCliOptions? _cliOptions; private readonly List _symbolUploadPaths; @@ -77,10 +78,18 @@ private string SymbolUploadTaskFormat } stringBuilder.AppendLine(" }"); - stringBuilder.AppendLine(" tasks.named('assembleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('assembleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('bundleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('bundleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + if (_isExternal) + { + // The app builds the library but never assembles it. IL2CPP builds before the native libraries merge. + stringBuilder.AppendLine(" tasks.matching { it.name ==~ /merge\\w+JniLibFolders/ }.configureEach { finalizedBy 'sentryUploadSymbols' }"); + } + else + { + stringBuilder.AppendLine(" tasks.named('assembleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('assembleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('bundleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('bundleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + } stringBuilder.AppendLine("}"); return stringBuilder.ToString(); @@ -93,21 +102,27 @@ public DebugSymbolUpload(IDiagnosticLogger logger, string gradleProjectPath, bool isExporting = false, bool minifyEnabled = false, - IApplication? application = null) + IApplication? application = null, + bool isExternal = false) { _logger = logger; _unityProjectPath = unityProjectPath; _gradleProjectPath = gradleProjectPath; - _gradleScriptPath = Path.Combine(_gradleProjectPath, "launcher/build.gradle"); + _isExternal = isExternal; + _gradleScriptPath = Path.Combine(_gradleProjectPath, ModuleName, "build.gradle"); _isExporting = isExporting; - _isMinifyEnabled = minifyEnabled; + // The app minifies, so it uploads the mapping. + _isMinifyEnabled = minifyEnabled && !isExternal; _cliOptions = cliOptions; _symbolUploadPaths = GetSymbolUploadPaths(application); _mappingFilePath = GetMappingFilePath(application); } + // With external initialization, the app embeds 'unityLibrary' and never builds 'launcher'. + internal string ModuleName => _isExternal ? "unityLibrary" : "launcher"; + public void AppendUploadToGradleFile(string sentryCliPath) { _logger.LogInfo("Appending debug symbols upload task to gradle file."); @@ -131,9 +146,10 @@ public void AppendUploadToGradleFile(string sentryCliPath) if (_isExporting) { - _logger.LogInfo("Project is exporting. Setting upload-dif to 'project.rootDir'"); + _logger.LogInfo("Project is exporting. Setting upload-dif to the exported project."); - uploadDifArguments += ", project.rootDir"; + // The exported project is the root unless an app includes 'unityLibrary' in its own build. + uploadDifArguments += _isExternal ? ", project.projectDir.parentFile" : ", project.rootDir"; sentryCliPath = $"./{Path.GetFileName(sentryCliPath)}"; } else if (_symbolUploadPaths.Count > 0) @@ -168,7 +184,7 @@ public void AppendUploadToGradleFile(string sentryCliPath) symbolUploadText = symbolUploadText.Replace(UploadArgsMarker, uploadDifArguments); symbolUploadText = symbolUploadText.Replace(ProguardArgsMarker, uploadProguardArguments); - var gradleBuildFile = LoadGradleScript(); + var gradleBuildFile = LoadGradleScript(_gradleScriptPath); if (gradleBuildFile.Contains(SymbolUploadTaskStartComment) || gradleBuildFile.Contains("sentryUploadSymbols")) { throw new InvalidOperationException($"Failed to create Debug Symbol Upload Task. Task already exists in gradle file. A clean build should resolve this.{RaiseIssuePrompt}"); @@ -181,20 +197,33 @@ public void AppendUploadToGradleFile(string sentryCliPath) File.WriteAllText(_gradleScriptPath, stringBuilder.ToString()); } - private string LoadGradleScript() + private static string LoadGradleScript(string gradleScriptPath) { - if (!File.Exists(_gradleScriptPath)) + if (!File.Exists(gradleScriptPath)) { - throw new FileNotFoundException($"Failed to find the gradle config.{RaiseIssuePrompt}", _gradleScriptPath); + throw new FileNotFoundException($"Failed to find the gradle config.{RaiseIssuePrompt}", gradleScriptPath); } - return File.ReadAllText(_gradleScriptPath); + return File.ReadAllText(gradleScriptPath); } public void RemoveUploadFromGradleFile() { _logger.LogDebug("Attempting to remove the previous upload task from the gradle project."); - var gradleBuildFile = LoadGradleScript(); + // The task may have been added to the other module by a build with a different initialization type. + foreach (var moduleName in new[] { "launcher", "unityLibrary" }) + { + var gradleScriptPath = Path.Combine(_gradleProjectPath, moduleName, "build.gradle"); + if (gradleScriptPath == _gradleScriptPath || File.Exists(gradleScriptPath)) + { + RemoveUploadFromGradleFile(gradleScriptPath); + } + } + } + + private void RemoveUploadFromGradleFile(string gradleScriptPath) + { + var gradleBuildFile = LoadGradleScript(gradleScriptPath); if (!gradleBuildFile.Contains(SymbolUploadTaskStartComment)) { _logger.LogDebug("No previous upload task found."); @@ -212,7 +241,7 @@ public void RemoveUploadFromGradleFile() throw new InvalidOperationException($"Failed to remove Debug Symbol Upload Task from gradle file. A clean build should resolve this.{RaiseIssuePrompt}"); } - using var streamWriter = File.CreateText(_gradleScriptPath); + using var streamWriter = File.CreateText(gradleScriptPath); streamWriter.Write(gradleBuildFile); } diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index be51d2c35..d6865200b 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -286,6 +286,8 @@ internal bool NativeAppHangTrackingEnabled /// When set to , the options are written and hardcoded into the /// Gradle project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. + /// When set to , the app that embeds the game provides the native + /// SDK, e.g. when using Unity as a Library. /// public NativeInitializationType AndroidNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -761,7 +763,9 @@ public enum NativeInitializationType /// The app that embeds the game owns the native SDK, e.g. when using Unity as a Library. The SDK does not initialize /// or close the native SDK. It attaches to it if the app has initialized it before the game starts. Options that /// you set in Unity do not apply to the native SDK. Events from the game use the app's release, environment, and - /// trace, unless the release and environment are set in the options. Currently supported on iOS. + /// trace, unless the release and environment are set in the options. Currently supported on iOS and Android. + /// On Android, when the game runs in its own process, where the app did not initialize the native SDK, the SDK + /// initializes it there with the options set in Unity and owns it in that process. /// External, } diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index db5b37d7c..bcda2264c 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -231,4 +231,102 @@ public void Configure_NativeInitFails_LogsErrorAndReturns() Assert.Null(options.ScopeObserver); } + [Test] + public void Configure_ExternalAppInitialized_AttachesWithoutOwningIdentity() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = true; + + SentryNativeAndroid.Configure(options); + + Assert.False(_testSentryJava.InitCalled); + Assert.IsInstanceOf(options.ScopeObserver); + Assert.IsNull(options.NativeSupportCloseCallback); + Assert.False(options.AutoSessionTracking); + } + + [Test] + public void Configure_ExternalNotInitializedInMainProcess_SkipsNativeSupport() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = true; + + SentryNativeAndroid.Configure(options); + + Assert.False(_testSentryJava.InitCalled); + Assert.Null(options.ScopeObserver); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_InitializesAndOwnsSdk() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + + SentryNativeAndroid.Configure(options); + + Assert.True(_testSentryJava.InitCalled); + Assert.IsInstanceOf(options.ScopeObserver); + Assert.NotNull(options.NativeSupportCloseCallback); + Assert.False(options.AutoSessionTracking); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_ContinuesMainProcessTrace() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessTraceHeader = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("75302ac48a024bde9a3b3734a82e36c8", _testSentryJava.Trace?.TraceId.ToString()); + Assert.AreEqual("1000000000000000", _testSentryJava.Trace?.SpanId.ToString()); + } + + [Test] + public void Configure_External_UsesAppReleaseAndEnvironment() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Options["getRelease"] = "io.sentry.host@1.0+1"; + _testSentryJava.Options["getEnvironment"] = "staging"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("io.sentry.host@1.0+1", options.Release); + Assert.AreEqual("staging", options.Environment); + } + + [Test] + public void Configure_ExternalReleaseAndEnvironmentSet_KeepsThem() + { + var options = new SentryUnityOptions + { + AndroidNativeInitializationType = NativeInitializationType.External, + Release = "game@2.0", + Environment = "game-env" + }; + _testSentryJava.Options["getRelease"] = "io.sentry.host@1.0+1"; + _testSentryJava.Options["getEnvironment"] = "staging"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("game@2.0", options.Release); + Assert.AreEqual("game-env", options.Environment); + } + + [Test] + public void Configure_RuntimeAlreadyInitialized_KeepsOwnership() + { + var options = new SentryUnityOptions(); + _testSentryJava.Enabled = true; + + SentryNativeAndroid.Configure(options); + + Assert.IsNotInstanceOf(options.ScopeObserver); + Assert.NotNull(options.NativeSupportCloseCallback); + } } diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index 84c7f3735..d5661d939 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Sentry.Extensibility; namespace Sentry.Unity.Android.Tests; @@ -10,16 +11,38 @@ internal class TestSentryJava : ISentryJava public bool SentryPresent { get; set; } = true; public string? InstallationId { get; set; } public bool? IsCrashedLastRun { get; set; } + public bool? MainProcess { get; set; } = true; + public bool InitCalled { get; private set; } + public bool CloseCalled { get; private set; } + public Dictionary Options { get; } = new(); + public string? TraceHeader { get; set; } + public string? BaggageHeader { get; set; } + public string? MainProcessTraceHeader { get; set; } + public (SentryId TraceId, SpanId SpanId)? Trace { get; private set; } public bool? IsEnabled() => Enabled; - public void Init(SentryUnityOptions options) { } + public bool? IsMainProcess() => MainProcess; + + public void Init(SentryUnityOptions options) + { + InitCalled = true; + Enabled = InitSuccessful; + } + + public string? GetOption(string getter) => Options.TryGetValue(getter, out var value) ? value : null; + + public string? GetTraceHeader() => TraceHeader; + + public string? GetBaggageHeader() => BaggageHeader; + + public string? GetMainProcessTraceHeader() => MainProcessTraceHeader; public string? GetInstallationId() => InstallationId; public bool? CrashedLastRun() => IsCrashedLastRun; - public void Close() { } + public void Close() => CloseCalled = true; public void WriteScope( string? AppStartTime, @@ -53,7 +76,7 @@ public void SetUser(SentryUser user) { } public void UnsetUser() { } - public void SetTrace(SentryId traceId, SpanId spanId) { } + public void SetTrace(SentryId traceId, SpanId spanId) => Trace = (traceId, spanId); public void SetEnvironment(string? environment) { } diff --git a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs index 6fb65b643..cb7192c9b 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs @@ -170,6 +170,37 @@ public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeRunt StringAssert.Contains("", manifest); } + [Test] + public void ModifyManifest_InitTypeExternal_DoesNotAddSentry() + { + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + var manifest = WithAndroidManifest(basePath => sut.ModifyManifest(basePath)); + + StringAssert.DoesNotContain("io.sentry.auto-init", manifest); + StringAssert.DoesNotContain("io.sentry.dsn", manifest); + } + + [Test] + public void CopyAndroidSdkToGradleProject_InitTypeExternal_RemovesAndroidSdkFromGradleProject() + { + var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var unityProjectPath = Path.Combine(fakeProjectPath, "UnityProject"); + var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); + DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); + var androidSdk = Path.Combine(gradleProjectPath, "unityLibrary", "libs", "androidSdk.jar"); + File.Create(androidSdk).Close(); + + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + + sut.CopyAndroidSdkToGradleProject(unityProjectPath, gradleProjectPath); + + Assert.IsFalse(File.Exists(androidSdk)); + + Directory.Delete(fakeProjectPath, true); + } + [Test] public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeBuildTime_AddsSentryAndAutoInits() { diff --git a/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs b/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs index f863af8c7..2341cbac8 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs @@ -23,6 +23,7 @@ public class Fixture public bool IsExporting { get; set; } public bool IsMinifyEnabled { get; set; } public bool IgnoreCliErrors { get; set; } + public bool IsExternal { get; set; } public TestApplication Application { get; set; } public Fixture() @@ -45,7 +46,8 @@ public Fixture() GradleProjectPath, IsExporting, IsMinifyEnabled, - Application + Application, + IsExternal ); } @@ -195,6 +197,38 @@ public void AppendUploadToGradleFile_AllRequirementsMet_AppendsUploadTask(bool i } } + [Test] + public void AppendUploadToGradleFile_External_AppendsUploadTaskToUnityLibrary() + { + _fixture.IsExporting = true; + _fixture.IsMinifyEnabled = true; + _fixture.IsExternal = true; + var sut = _fixture.GetSut(); + + sut.AppendUploadToGradleFile(_fixture.SentryCliPath); + + var actualFileContent = File.ReadAllText(GetGradleFilePath("unityLibrary")); + StringAssert.Contains("project.projectDir.parentFile", actualFileContent); + StringAssert.Contains("tasks.matching { it.name ==~ /merge\\w+JniLibFolders/ }.configureEach { finalizedBy 'sentryUploadSymbols' }", actualFileContent); + StringAssert.DoesNotContain("assembleRelease", actualFileContent); + StringAssert.DoesNotContain("upload-proguard", actualFileContent); + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath())); + } + + [Test] + public void RemoveUploadTaskFromGradleFile_InitializationTypeChanged_RemovesUploadTaskFromBothModules() + { + _fixture.GetSut().AppendUploadToGradleFile(_fixture.SentryCliPath); + _fixture.IsExternal = true; + var sut = _fixture.GetSut(); + sut.AppendUploadToGradleFile(_fixture.SentryCliPath); + + sut.RemoveUploadFromGradleFile(); + + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath())); + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath("unityLibrary"))); + } + [Test] public void RemoveUploadTaskFromGradleFile_GradleFileDoesNotExist_ThrowsFileNotFoundException() { @@ -293,5 +327,5 @@ public static void SetupFakeProject(string fakeProjectPath) } } - private string GetGradleFilePath() => Path.Combine(_fixture.GradleProjectPath, "launcher/build.gradle"); + private string GetGradleFilePath(string moduleName = "launcher") => Path.Combine(_fixture.GradleProjectPath, moduleName, "build.gradle"); } From e2e908ac900d6dc4d332c027649cde4f357b5db1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:13:27 +0200 Subject: [PATCH 10/44] Use the native trace's baggage for game events and outgoing requests in the host's trace --- src/Sentry.Unity/NativePropagationContext.cs | 6 ++-- .../NativePropagationContextTests.cs | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity/NativePropagationContext.cs b/src/Sentry.Unity/NativePropagationContext.cs index 284bd1706..745f3144a 100644 --- a/src/Sentry.Unity/NativePropagationContext.cs +++ b/src/Sentry.Unity/NativePropagationContext.cs @@ -17,6 +17,7 @@ internal sealed class NativePropagationContext : IExternalPropagationContext private readonly Func _getBaggageHeader; private readonly Func _getSpan; private readonly SentryTraceHeader? _traceHeader; + private readonly DynamicSamplingContext? _dynamicSamplingContext; private NativePropagationContext(SentryOptions options, Func getTraceHeader, Func getBaggageHeader, Func getSpan, SentryTraceHeader? traceHeader) @@ -26,6 +27,7 @@ private NativePropagationContext(SentryOptions options, Func getTraceHe _getBaggageHeader = getBaggageHeader; _getSpan = getSpan; _traceHeader = traceHeader; + _dynamicSamplingContext = traceHeader is null ? null : GetNativeDynamicSamplingContext(traceHeader.TraceId); } internal static void Use(SentryOptions options, Func getTraceHeader, Func getBaggageHeader, @@ -69,14 +71,14 @@ internal ITransactionTracer StartTransactionInNativeTrace(IHub hub, string name, public bool IsSampled => _traceHeader?.IsSampled ?? false; public double? SampleRate => null; public double? SampleRand => null; - public DynamicSamplingContext? DynamicSamplingContext => null; + public DynamicSamplingContext? DynamicSamplingContext => _dynamicSamplingContext; public BaggageHeader GetBaggageHeader() => GetDynamicSamplingContext(_options, null)?.ToBaggageHeader() ?? BaggageHeader.Create(new List>()); public DynamicSamplingContext? GetDynamicSamplingContext(SentryOptions options, IReplaySession? replaySession) => - this.CreateDynamicSamplingContext(options, replaySession); + _dynamicSamplingContext ?? this.CreateDynamicSamplingContext(options, replaySession); // Header and baggage are read separately, so the baggage is dropped if the native SDK moved to another trace. internal DynamicSamplingContext? GetNativeDynamicSamplingContext(SentryId traceId) diff --git a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs index cec3c175e..95867da6a 100644 --- a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs +++ b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs @@ -112,6 +112,39 @@ public void GetBaggageHeader_NativeTraceHeader_ContainsNativeTraceId() StringAssert.Contains($"sentry-trace_id={TraceId}", baggage.ToString()); } + [Test] + public void Snapshot_NativeBaggageOfSameTrace_UsesNativeDsc() + { + _traceHeader = $"{TraceId}-{SpanId}"; + _baggageHeader = $"sentry-trace_id={TraceId},sentry-public_key=native,sentry-sample_rate=0.5,sentry-sample_rand=0.25"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + var dsc = context.GetDynamicSamplingContext(new SentryOptions(), null!); + Assert.AreSame(context.DynamicSamplingContext, dsc); + Assert.AreEqual("native", dsc!.Items["public_key"]); + Assert.AreEqual("0.25", dsc.Items["sample_rand"]); + var baggage = context.GetBaggageHeader().ToString(); + StringAssert.Contains("sentry-public_key=native", baggage); + StringAssert.Contains("sentry-sample_rand=0.25", baggage); + StringAssert.DoesNotContain("sentry-sampled", baggage); + } + + [Test] + public void Snapshot_NativeBaggageOfOtherTrace_FallsBackToOwnDsc() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + _baggageHeader = $"sentry-trace_id={SentryId.Create()},sentry-public_key=native,sentry-sample_rand=0.25"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + + Assert.IsNull(context.DynamicSamplingContext); + var baggage = context.GetBaggageHeader().ToString(); + StringAssert.Contains($"sentry-trace_id={TraceId}", baggage); + StringAssert.Contains("sentry-public_key=key", baggage); + StringAssert.DoesNotContain("sentry-sample_rand=0.25", baggage); + } + [Test] [TestCase(true)] [TestCase(false)] From d6bdf09f5f18811e39ac40b80ba422912920598b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:13:27 +0200 Subject: [PATCH 11/44] Fail External append exports that still link SentryObjC into UnityFramework --- .../BuildPostProcess.cs | 12 ++++++++++ .../SentryXcodeProject.cs | 22 ++++++++++--------- .../BuildPostProcessorTests.cs | 18 +++++++++++++++ .../SentryXcodeProjectTests.cs | 12 ++++++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index 44e95a008..a1ea0a2b6 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -91,6 +91,18 @@ internal static void AddSentryToXcodeProject(SentryUnityOptions? options, } } } + + // The app that embeds the game doesn't embed the framework a previous build linked into `UnityFramework`. + if (options.IosNativeInitializationType is NativeInitializationType.External) + { + using var sentryXcodeProject = SentryXcodeProject.Open(pathToProject, logger); + if (sentryXcodeProject.ContainsSentryFramework()) + { + throw new BuildFailedException( + "The iOS native initialization type is 'External' but a previous build added the Sentry framework " + + "to the exported project. Select 'Replace' when exporting the project to create a clean project."); + } + } } internal static void SetupNoOpBridge(IDiagnosticLogger logger, string pathToProject) diff --git a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs index 7fcfd563b..38af4bf82 100644 --- a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs +++ b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs @@ -111,19 +111,14 @@ public void AddSentryFramework() _logger?.LogInfo("Adding the build properties and flags."); AddBuildProperties(); - var relativeFrameworkPath = Path.Combine("Frameworks", FrameworkName); - - var hasFileMethod = _pbxProjectType.GetMethod("ContainsFileByProjectPath", new[] { typeof(string) }); - if (hasFileMethod != null) + if (ContainsSentryFramework()) { - var hasAddedFramework = (bool)hasFileMethod.Invoke(_project, new object[] { relativeFrameworkPath }); - if (hasAddedFramework) - { - _logger?.LogDebug("Skipping. The Sentry framework has already been added to the project."); - return; - } + _logger?.LogDebug("Skipping. The Sentry framework has already been added to the project."); + return; } + var relativeFrameworkPath = Path.Combine("Frameworks", FrameworkName); + _logger?.LogInfo("Adding the Sentry framework."); var frameworkGuid = (string)_pbxProjectType.GetMethod("AddFile", BindingFlags.Public | BindingFlags.Instance) @@ -146,6 +141,13 @@ public void AddSentryFramework() _logger?.LogDebug("Successfully added the Sentry framework."); } + internal bool ContainsSentryFramework() + { + var hasFileMethod = _pbxProjectType.GetMethod("ContainsFileByProjectPath", new[] { typeof(string) }); + return hasFileMethod != null + && (bool)hasFileMethod.Invoke(_project, new object[] { Path.Combine("Frameworks", FrameworkName) }); + } + private void AddBuildProperties() { SetSearchPathBuildProperty("$(inherited)"); diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index b5478760d..5a645d69f 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -243,6 +243,24 @@ public void AddSentryToXcodeProject_AppendBuildsAndToggleNativeSupport_ThrowsBui Directory.Delete(_outputProjectPath, true); } + [Test] + public void AddSentryToXcodeProject_AppendBuildsAndSwitchToExternal_ThrowsBuildFailedException() + { + using (var xcodeProject = SentryXcodeProject.Open(_outputProjectPath)) + { + xcodeProject.AddSentryFramework(); + } + var options = new SentryUnityOptions + { + Dsn = "https://k@h/p", + IosNativeSupportEnabled = true, + IosNativeInitializationType = NativeInitializationType.External + }; + + Assert.Throws(() => + BuildPostProcess.AddSentryToXcodeProject(options, null, new TestLogger(), _outputProjectPath)); + } + [Test] public void CopyFrameworkToXcodeProject_CopyFramework_DirectoryExists() { diff --git a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs index ccb68c8fb..05d61a0fc 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs @@ -76,6 +76,18 @@ public void AddSentryFramework_CleanXcodeProject_SentryWasAdded() StringAssert.Contains(SentryXcodeProject.FrameworkName, xcodeProject.ProjectToString()); } + [Test] + public void ContainsSentryFramework_FrameworkAdded_ReturnsTrue() + { + var xcodeProject = _fixture.GetSut(); + xcodeProject.ReadFromProjectFile(); + Assert.IsFalse(xcodeProject.ContainsSentryFramework()); + + xcodeProject.AddSentryFramework(); + + Assert.IsTrue(xcodeProject.ContainsSentryFramework()); + } + [Test] public void AddSentryFramework_EmbedsFramework() { From bdf5384b561cf8520dfe5c2c328c50b93f377e6d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:13:27 +0200 Subject: [PATCH 12/44] Move UseAppReleaseAndEnvironment to SentryUnityOptions to share it with Android --- src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 20 +-------- src/Sentry.Unity/SentryUnityOptions.cs | 18 ++++++++ .../SentryUnityOptionsTests.cs | 41 +++++++++++++++++++ 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index 07ebd192c..18abda0bd 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -57,7 +57,8 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf if (isExternal) { - UseAppReleaseAndEnvironment(options); + options.UseAppReleaseAndEnvironment(() => SentryCocoaBridgeProxy.GetOption("releaseName"), + () => SentryCocoaBridgeProxy.GetOption("dist"), () => SentryCocoaBridgeProxy.GetOption("environment")); NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader, SentryCocoaBridgeProxy.GetBaggageHeader); @@ -126,23 +127,6 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf Logger?.LogInfo("Successfully configured the native SDK"); } - // Events from the game belong to the app's release and environment, unless they were set in the options. - private static void UseAppReleaseAndEnvironment(SentryUnityOptions options) - { - if (options.Release == options.DefaultRelease && SentryCocoaBridgeProxy.GetOption("releaseName") is { } release) - { - Logger?.LogDebug("Using the app's release: {0}", release); - options.Release = release; - options.Distribution ??= SentryCocoaBridgeProxy.GetOption("dist"); - } - - if (options.Environment == options.DefaultEnvironment && SentryCocoaBridgeProxy.GetOption("environment") is { } environment) - { - Logger?.LogDebug("Using the app's environment: {0}", environment); - options.Environment = environment; - } - } - /// /// Closes the native Cocoa support. /// diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index be51d2c35..24ab697fe 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -495,6 +495,24 @@ internal string? DefaultUserId internal string? DefaultRelease { get; } internal string DefaultEnvironment { get; } + // Events from the game belong to the app's release and environment, unless they were set in the options. + internal void UseAppReleaseAndEnvironment(Func getRelease, Func getDistribution, + Func getEnvironment) + { + if (Release == DefaultRelease && getRelease() is { } release) + { + DiagnosticLogger?.LogDebug("Using the app's release: {0}", release); + Release = release; + Distribution ??= getDistribution(); + } + + if (Environment == DefaultEnvironment && getEnvironment() is { } environment) + { + DiagnosticLogger?.LogDebug("Using the app's environment: {0}", environment); + Environment = environment; + } + } + /// /// Provides debug images from the native SDK for IL2CPP line number support. /// diff --git a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs index 0959fa01c..b51d828b6 100644 --- a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs +++ b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs @@ -112,6 +112,47 @@ public void Ctor_Release_IsProductNameAtVersion() => $"{_fixture.Application.ProductName}@{_fixture.Application.Version}", _fixture.GetSut().Release); + [Test] + public void UseAppReleaseAndEnvironment_DefaultsKept_UsesAppValues() + { + var options = _fixture.GetSut(); + + options.UseAppReleaseAndEnvironment(() => "app@1.0", () => "42", () => "staging"); + + Assert.AreEqual("app@1.0", options.Release); + Assert.AreEqual("42", options.Distribution); + Assert.AreEqual("staging", options.Environment); + } + + [Test] + public void UseAppReleaseAndEnvironment_SetInOptions_KeepsOptions() + { + var options = _fixture.GetSut(); + options.Release = "game@2.0"; + options.Distribution = "7"; + options.Environment = "game"; + + options.UseAppReleaseAndEnvironment(() => "app@1.0", () => "42", () => "staging"); + + Assert.AreEqual("game@2.0", options.Release); + Assert.AreEqual("7", options.Distribution); + Assert.AreEqual("game", options.Environment); + } + + [Test] + public void UseAppReleaseAndEnvironment_AppHasNoValues_KeepsDefaults() + { + var options = _fixture.GetSut(); + var release = options.Release; + var environment = options.Environment; + + options.UseAppReleaseAndEnvironment(() => null, () => "42", () => null); + + Assert.AreEqual(release, options.Release); + Assert.IsNull(options.Distribution); + Assert.AreEqual(environment, options.Environment); + } + [Test] [TestCase("\n")] [TestCase("\t")] From bddaccefd6f9a107e85c2492752b2afe6d1ebb66 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:15:16 +0200 Subject: [PATCH 13/44] Turn off ANR and tombstone reporting in the game's own process so the app's exits aren't reported twice --- docs/agent-guides/platform-android.md | 2 +- src/Sentry.Unity.Android/SentryJava.cs | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 24561000a..344b6fce4 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): 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. 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. Process detection needs API 28. C# sessions are off and the app's release and environment are used unless set. +- External initialization (Unity as a Library): 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. Process detection needs API 28. 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 e9dcc559a..314eacbe9 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -180,17 +180,6 @@ public void Init(SentryUnityOptions options) androidOptions.Call("setEnvironment", options.Environment); } - // With external initialization, the game runs in its own process, next to the app's SDK in the main - // process. Its own cache directory keeps it from taking over the app's session and persisted scope. - if (isExternal && GetProcessName() is { } processName) - { - using var cacheDir = context.Call("getCacheDir"); - var processSuffix = processName.Substring(processName.IndexOf(':') + 1); - androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); - // The game continues the app's trace instead of starting new ones on its own screens and taps. - androidOptions.Call("setEnableAutoTraceIdGeneration", false); - } - using var sentryLevelClass = new AndroidJavaClass("io.sentry.SentryLevel"); var levelString = GetLevelString(options.DiagnosticLevel); using var sentryLevel = sentryLevelClass.GetStatic(levelString); @@ -219,6 +208,20 @@ public void Init(SentryUnityOptions options) androidOptions.Call("setTombstoneEnabled", options.AndroidTombstoneEnabled); androidOptions.Call("setReportHistoricalTombstones", options.AndroidReportHistoricalTombstones); + // With external initialization, the game runs in its own process, next to the app's SDK in the main + // process. Its own cache directory keeps it from taking over the app's session and persisted scope. + if (isExternal && GetProcessName() is { } processName) + { + using var cacheDir = context.Call("getCacheDir"); + var processSuffix = processName.Substring(processName.IndexOf(':') + 1); + androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); + // The game continues the app's trace instead of starting new ones on its own screens and taps. + androidOptions.Call("setEnableAutoTraceIdGeneration", false); + // The app's SDK reports the exits of all of the app's processes, including this one. + androidOptions.Call("setAnrEnabled", false); + androidOptions.Call("setTombstoneEnabled", false); + } + using (var logsOptions = androidOptions.Call("getLogs")) { logsOptions.Call("setEnabled", options.EnableLogs); From c2195c8e417b1eda01656a4fe50f4b19c69a351b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:17:23 +0200 Subject: [PATCH 14/44] Use the release, dist and environment the app's SDK persisted in the game's own process instead of sentry-java's defaults --- src/Sentry.Unity.Android/SentryJava.cs | 113 ++++++++++-------- .../SentryNativeAndroid.cs | 1 + 2 files changed, 66 insertions(+), 48 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 314eacbe9..9e3bd7856 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -76,6 +76,12 @@ internal class SentryJava : ISentryJava private static AndroidJavaObject GetInternalSentryJava() => new AndroidJavaClass("io.sentry.android.core.InternalSentrySdk"); private static AndroidJavaObject GetSentryJava() => new AndroidJavaClass("io.sentry.Sentry"); + private static AndroidJavaObject GetCurrentActivity() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + return unityPlayer.GetStatic("currentActivity"); + } + public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) { _logger = logger; @@ -124,8 +130,7 @@ public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) return null; } - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); + using var activity = GetCurrentActivity(); return processName == activity.Call("getPackageName"); } catch (Exception e) @@ -160,8 +165,7 @@ public void Init(SentryUnityOptions options) try { using var sentry = new AndroidJavaClass("io.sentry.android.core.SentryAndroid"); - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var context = unityPlayer.GetStatic("currentActivity"); + using var context = GetCurrentActivity(); sentry.CallStatic("init", context, new AndroidOptionsConfiguration(androidOptions => { @@ -212,6 +216,20 @@ public void Init(SentryUnityOptions options) // process. Its own cache directory keeps it from taking over the app's session and persisted scope. if (isExternal && GetProcessName() is { } processName) { + // Its events belong to the app's release and environment, persisted by the app's SDK. + if (options.Release == options.DefaultRelease && ReadMainProcessOption(androidOptions, "release.json") is { } release) + { + androidOptions.Call("setRelease", release); + if (options.Distribution is null) + { + androidOptions.Call("setDist", ReadMainProcessOption(androidOptions, "dist.json")); + } + } + if (options.Environment == options.DefaultEnvironment && ReadMainProcessOption(androidOptions, "environment.json") is { } environment) + { + androidOptions.Call("setEnvironment", environment); + } + using var cacheDir = context.Call("getCacheDir"); var processSuffix = processName.Substring(processName.IndexOf(':') + 1); androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); @@ -270,9 +288,7 @@ public void Init(SentryUnityOptions options) return null; } - // The `sentry-trace` header of the native SDK: its active span, otherwise its propagation context. - // The app's SDK in the main process persists its trace to its cache directory, next to ours: same DSN hash, - // default 'sentry' directory. + // The `sentry-trace` header of the app's SDK in the main process: its active span, otherwise its propagation context. public string? GetMainProcessTraceHeader() { if (!MainThreadData.IsMainThread()) @@ -286,25 +302,7 @@ public void Init(SentryUnityOptions options) using var sentry = GetSentryJava(); using var hub = sentry.CallStatic("getCurrentHub"); using var options = hub.Call("getOptions"); - using var cacheDir = new AndroidJavaObject("java.io.File", options.Call("getCacheDirPath")); - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - using var appCacheDir = activity.Call("getCacheDir"); - var tracePath = $"{appCacheDir.Call("getAbsolutePath")}/sentry/{cacheDir.Call("getName")}/.scope-cache/trace.json"; - using var traceFile = new AndroidJavaObject("java.io.File", tracePath); - if (!traceFile.Call("exists")) - { - _logger?.LogDebug("No trace persisted by the main process at '{0}'.", tracePath); - return null; - } - - using var fileReader = new AndroidJavaObject("java.io.FileReader", traceFile); - using var reader = new AndroidJavaObject("java.io.BufferedReader", fileReader); - using var serializer = options.Call("getSerializer"); - using var javaClass = new AndroidJavaClass("java.lang.Class"); - using var spanContextClass = javaClass.CallStatic("forName", "io.sentry.SpanContext"); - using var spanContext = serializer.Call("deserialize", reader, spanContextClass); - reader.Call("close"); + using var spanContext = ReadMainProcessFile(options, ".scope-cache/trace.json", "io.sentry.SpanContext"); if (spanContext is null) { return null; @@ -324,6 +322,46 @@ public void Init(SentryUnityOptions options) return null; } + private string? ReadMainProcessOption(AndroidJavaObject options, string fileName) + { + using var value = ReadMainProcessFile(options, $".options-cache/{fileName}", "java.lang.String"); + return value?.Call("toString"); + } + + // A file that the app's SDK in the main process persisted to its cache directory, next to ours: same DSN hash, + // default 'sentry' directory. + private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className) + { + try + { + using var cacheDir = new AndroidJavaObject("java.io.File", options.Call("getCacheDirPath")); + using var activity = GetCurrentActivity(); + using var appCacheDir = activity.Call("getCacheDir"); + var filePath = $"{appCacheDir.Call("getAbsolutePath")}/sentry/{cacheDir.Call("getName")}/{path}"; + using var file = new AndroidJavaObject("java.io.File", filePath); + if (!file.Call("exists")) + { + _logger?.LogDebug("Nothing persisted by the main process at '{0}'.", filePath); + return null; + } + + using var fileReader = new AndroidJavaObject("java.io.FileReader", file); + using var reader = new AndroidJavaObject("java.io.BufferedReader", fileReader); + using var serializer = options.Call("getSerializer"); + using var javaClass = new AndroidJavaClass("java.lang.Class"); + using var type = javaClass.CallStatic("forName", className); + var result = serializer.Call("deserialize", reader, type); + reader.Call("close"); + return result; + } + catch (Exception e) + { + _logger?.LogError(e, "Reading '{0}' persisted by the main process failed.", path); + } + + return null; + } + public string? GetTraceHeader() => RunJniSafeAndWait(() => { using var sentry = GetSentryJava(); @@ -338,28 +376,7 @@ public void Init(SentryUnityOptions options) return header?.Call("getValue"); }); - public string? GetInstallationId() - { - if (!MainThreadData.IsMainThread()) - { - _logger?.LogError("Calling GetInstallationId() on Android SDK requires running on MainThread"); - return null; - } - - try - { - using var sentry = GetSentryJava(); - using var hub = sentry.CallStatic("getCurrentHub"); - using var options = hub?.Call("getOptions"); - return options?.Call("getDistinctId"); - } - catch (Exception e) - { - _logger?.LogError(e, "Calling 'SentryJava.GetInstallationId' failed."); - } - - return null; - } + public string? GetInstallationId() => GetOption("getDistinctId"); /// /// Returns whether the last run resulted in a crash. diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index f6f63d5d5..781ee5bdd 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -204,6 +204,7 @@ void Refresh() } // Events from the game belong to the app's release and environment, unless they were set in the options. + // In its own process, the game's SDK took them from the app's SDK when it initialized. private static void UseAppReleaseAndEnvironment(SentryUnityOptions options, ISentryJava sentryJava) { if (options.Release == options.DefaultRelease && sentryJava.GetOption("getRelease") is { } release) From 361cb334154f07edbf00341e00eb28362a3cf145 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:17:48 +0200 Subject: [PATCH 15/44] Keep the sentry-java classes the External mode looks up by name from R8 renaming --- package-dev/Plugins/Android/proguard-sentry-unity.pro | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package-dev/Plugins/Android/proguard-sentry-unity.pro b/package-dev/Plugins/Android/proguard-sentry-unity.pro index 2d60bf651..9567b3b69 100644 --- a/package-dev/Plugins/Android/proguard-sentry-unity.pro +++ b/package-dev/Plugins/Android/proguard-sentry-unity.pro @@ -6,6 +6,14 @@ -keep class io.sentry.SentryLevel { *; } -keep class io.sentry.SentryOptions { *; } -keep class io.sentry.Hub { *; } +-keep class io.sentry.IHub { *; } +-keep class io.sentry.IScopes { *; } +-keep class io.sentry.HubScopesWrapper { *; } +-keep class io.sentry.SentryTraceHeader { *; } +-keep class io.sentry.BaggageHeader { *; } +-keep class io.sentry.SpanContext { *; } +-keep class io.sentry.ISerializer { *; } +-keep class io.sentry.JsonSerializer { *; } -keep class io.sentry.Breadcrumb { *; } -keep class io.sentry.Scope { *; } -keep class io.sentry.ScopeCallback { *; } From 26776502c52eb7725969df71758d6f56b1b3fa0f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:18:11 +0200 Subject: [PATCH 16/44] Read the process name from /proc/self/cmdline below API 28 so the game's own process is detected there --- docs/agent-guides/platform-android.md | 2 +- src/Sentry.Unity.Android/SentryJava.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 344b6fce4..7877e3fd4 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): 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. Process detection needs API 28. C# sessions are off and the app's release and environment are used unless set. +- External initialization (Unity as a Library): 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. ## Export Processing diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 9e3bd7856..1cfb36167 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.IO; using System.Runtime.CompilerServices; using System.Threading; using Sentry.Extensibility; @@ -141,13 +142,17 @@ public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) return null; } - // `Application.getProcessName` requires API 28. + // `Application.getProcessName` requires API 28. Below, the process name is the NUL-terminated `/proc/self/cmdline`. private static string? GetProcessName() { using var version = new AndroidJavaClass("android.os.Build$VERSION"); if (version.GetStatic("SDK_INT") < 28) { - return null; +#pragma warning disable SN0001 + var cmdline = File.ReadAllText("/proc/self/cmdline"); +#pragma warning restore SN0001 + var end = cmdline.IndexOf('\0'); + return end < 0 ? cmdline : cmdline.Substring(0, end); } using var application = new AndroidJavaClass("android.app.Application"); From 1be5dc201abab9123670c0e241ccae5416b5619e Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:19:14 +0200 Subject: [PATCH 17/44] Read the native baggage on first use so snapshots for trace and span IDs skip the bridge call --- src/Sentry.Unity/NativePropagationContext.cs | 21 ++++++++--- .../NativePropagationContextTests.cs | 36 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/Sentry.Unity/NativePropagationContext.cs b/src/Sentry.Unity/NativePropagationContext.cs index 745f3144a..f1983cf9c 100644 --- a/src/Sentry.Unity/NativePropagationContext.cs +++ b/src/Sentry.Unity/NativePropagationContext.cs @@ -17,7 +17,8 @@ internal sealed class NativePropagationContext : IExternalPropagationContext private readonly Func _getBaggageHeader; private readonly Func _getSpan; private readonly SentryTraceHeader? _traceHeader; - private readonly DynamicSamplingContext? _dynamicSamplingContext; + private DynamicSamplingContext? _dynamicSamplingContext; + private bool _dynamicSamplingContextRead; private NativePropagationContext(SentryOptions options, Func getTraceHeader, Func getBaggageHeader, Func getSpan, SentryTraceHeader? traceHeader) @@ -27,7 +28,6 @@ private NativePropagationContext(SentryOptions options, Func getTraceHe _getBaggageHeader = getBaggageHeader; _getSpan = getSpan; _traceHeader = traceHeader; - _dynamicSamplingContext = traceHeader is null ? null : GetNativeDynamicSamplingContext(traceHeader.TraceId); } internal static void Use(SentryOptions options, Func getTraceHeader, Func getBaggageHeader, @@ -71,14 +71,27 @@ internal ITransactionTracer StartTransactionInNativeTrace(IHub hub, string name, public bool IsSampled => _traceHeader?.IsSampled ?? false; public double? SampleRate => null; public double? SampleRand => null; - public DynamicSamplingContext? DynamicSamplingContext => _dynamicSamplingContext; + // Read on first use, as most snapshots only need the trace and span IDs. + public DynamicSamplingContext? DynamicSamplingContext + { + get + { + if (!_dynamicSamplingContextRead) + { + _dynamicSamplingContext = _traceHeader is null ? null : GetNativeDynamicSamplingContext(_traceHeader.TraceId); + _dynamicSamplingContextRead = true; + } + + return _dynamicSamplingContext; + } + } public BaggageHeader GetBaggageHeader() => GetDynamicSamplingContext(_options, null)?.ToBaggageHeader() ?? BaggageHeader.Create(new List>()); public DynamicSamplingContext? GetDynamicSamplingContext(SentryOptions options, IReplaySession? replaySession) => - _dynamicSamplingContext ?? this.CreateDynamicSamplingContext(options, replaySession); + DynamicSamplingContext ?? this.CreateDynamicSamplingContext(options, replaySession); // Header and baggage are read separately, so the baggage is dropped if the native SDK moved to another trace. internal DynamicSamplingContext? GetNativeDynamicSamplingContext(SentryId traceId) diff --git a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs index 95867da6a..5db426c66 100644 --- a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs +++ b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs @@ -14,12 +14,20 @@ public class NativePropagationContextTests private readonly TestLogger _logger = new(); private string? _traceHeader; private string? _baggageHeader; + private int _baggageHeaderReads; private ISpan? _span; + [SetUp] + public void SetUp() => _baggageHeaderReads = 0; + private SentryUnityOptions GetOptions() { var options = new SentryUnityOptions { Dsn = "https://key@sentry.io/1", DiagnosticLogger = _logger, Debug = true }; - NativePropagationContext.Use(options, () => _traceHeader, () => _baggageHeader, () => _span); + NativePropagationContext.Use(options, () => _traceHeader, () => + { + _baggageHeaderReads++; + return _baggageHeader; + }, () => _span); return options; } @@ -130,6 +138,32 @@ public void Snapshot_NativeBaggageOfSameTrace_UsesNativeDsc() StringAssert.DoesNotContain("sentry-sampled", baggage); } + [Test] + public void Snapshot_OnlyTraceAndSpanIdRead_DoesNotReadNativeBaggage() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + _baggageHeader = $"sentry-trace_id={TraceId},sentry-public_key=native"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + _ = context.TraceId; + _ = context.SpanId; + + Assert.AreEqual(0, _baggageHeaderReads); + } + + [Test] + public void Snapshot_NativeDscReadTwice_ReadsNativeBaggageOnce() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + _baggageHeader = $"sentry-trace_id={TraceId},sentry-public_key=native"; + + var context = GetOptions().ExternalPropagationContext!.Snapshot(); + _ = context.DynamicSamplingContext; + _ = context.GetBaggageHeader(); + + Assert.AreEqual(1, _baggageHeaderReads); + } + [Test] public void Snapshot_NativeBaggageOfOtherTrace_FallsBackToOwnDsc() { From bcb52d8c5109755ba3dadc073e419692f512ce5b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:19:37 +0200 Subject: [PATCH 18/44] Stop refreshing the app's trace on resume once native support closes --- src/Sentry.Unity.Android/SentryNativeAndroid.cs | 6 ++++-- .../SentryNativeAndroidTests.cs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 781ee5bdd..04689a665 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -165,7 +165,7 @@ public static void Configure(SentryUnityOptions options) // When attached, the app owns the SDK's lifetime. if (!isAttached) { - options.NativeSupportCloseCallback = () => Close(options); + options.NativeSupportCloseCallback += () => Close(options); } Logger?.LogDebug("Fetching installation ID"); @@ -199,7 +199,9 @@ void Refresh() } Refresh(); - SentryMonoBehaviour.Instance.ApplicationResuming += Refresh; + var monoBehaviour = SentryMonoBehaviour.Instance; + monoBehaviour.ApplicationResuming += Refresh; + options.NativeSupportCloseCallback += () => monoBehaviour.ApplicationResuming -= Refresh; NativePropagationContext.Use(options, () => traceHeader, () => null); } diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index bcda2264c..c56349865 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -287,6 +287,23 @@ public void Configure_ExternalNotInitializedInGameProcess_ContinuesMainProcessTr Assert.AreEqual("1000000000000000", _testSentryJava.Trace?.SpanId.ToString()); } + [Test] + public void Configure_ExternalNotInitializedInGameProcess_StopsRefreshingTraceOnClose() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessTraceHeader = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000"; + + SentryNativeAndroid.Configure(options); + options.NativeSupportCloseCallback?.Invoke(); + _testSentryJava.MainProcessTraceHeader = "b6ee9f3a1c0d4e2f8a7b6c5d4e3f2a1b-2000000000000000"; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreEqual("75302ac48a024bde9a3b3734a82e36c8", _testSentryJava.Trace?.TraceId.ToString()); + } + [Test] public void Configure_External_UsesAppReleaseAndEnvironment() { From ed5155053e133107880c408bb910f4c77a1a8895 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:20:32 +0200 Subject: [PATCH 19/44] Close the main process file reader on failure and keep the app's last trace when a read comes up empty --- src/Sentry.Unity.Android/SentryJava.cs | 17 +++++++++++------ src/Sentry.Unity.Android/SentryNativeAndroid.cs | 7 ++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 1cfb36167..70db3854a 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -352,12 +352,17 @@ public void Init(SentryUnityOptions options) using var fileReader = new AndroidJavaObject("java.io.FileReader", file); using var reader = new AndroidJavaObject("java.io.BufferedReader", fileReader); - using var serializer = options.Call("getSerializer"); - using var javaClass = new AndroidJavaClass("java.lang.Class"); - using var type = javaClass.CallStatic("forName", className); - var result = serializer.Call("deserialize", reader, type); - reader.Call("close"); - return result; + try + { + using var serializer = options.Call("getSerializer"); + using var javaClass = new AndroidJavaClass("java.lang.Class"); + using var type = javaClass.CallStatic("forName", className); + return serializer.Call("deserialize", reader, type); + } + finally + { + reader.Call("close"); + } } catch (Exception e) { diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 04689a665..47bc045f4 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -190,10 +190,11 @@ private static void UseMainProcessTrace(SentryUnityOptions options, ISentryJava void Refresh() { - traceHeader = sentryJava.GetMainProcessTraceHeader(); - if (traceHeader is not null && SentryTraceHeader.Parse(traceHeader) is { } header) + // The app's SDK rewrites the file, so a read can come up empty. The previous trace still applies. + if (sentryJava.GetMainProcessTraceHeader() is { } refreshed && SentryTraceHeader.Parse(refreshed) is { } header) { - Logger?.LogDebug("Continuing the app's trace: {0}", traceHeader); + Logger?.LogDebug("Continuing the app's trace: {0}", refreshed); + traceHeader = refreshed; sentryJava.SetTrace(header.TraceId, header.SpanId); } } From 4f2c77dbf658d66e82f8d373361b30ac26194e06 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:20:59 +0200 Subject: [PATCH 20/44] Drop the External scope observer's environment override since sentry-java can't sync the environment --- .../ExternalAndroidJavaScopeObserver.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs index 844171bac..6b5cb9396 100644 --- a/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs +++ b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs @@ -2,7 +2,7 @@ namespace Sentry.Unity.Android; /// /// Scope sync for . The game follows the app's trace. When attached to -/// the app's SDK, the app also owns the user and environment. In its own process, the game's SDK needs them. +/// the app's SDK, the app also owns the user. In its own process, the game's SDK needs it. /// internal sealed class ExternalAndroidJavaScopeObserver : AndroidJavaScopeObserver { @@ -28,12 +28,4 @@ public override void UnsetUserImpl() } public override void SetTraceImpl(SentryId traceId, SpanId spanId) { } - - public override void SetEnvironmentImpl(string? environment) - { - if (!_isAttached) - { - base.SetEnvironmentImpl(environment); - } - } } From 844a3e52eef6ff6a4f84a849ba26d745933f8912 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:21:06 +0200 Subject: [PATCH 21/44] Link the Android Unity as a Library changelog entry to its PR and describe the release, environment and ANR handling --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916933f52..ca74e3d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features - Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) -- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace. C# events use the app's release and environment. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace, leaving ANRs and tombstones to the app's SDK. Events from the game, native and C#, use the app's release and environment unless they are set in the options. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) ## 4.11.0 From 2b7045145063b53914cd0984769dd2e945b81545 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:26:45 +0200 Subject: [PATCH 22/44] Keep ANR reporting in the game's own process below API 30, where the app's SDK can't see other processes' exits --- src/Sentry.Unity.Android/SentryJava.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 70db3854a..baf126069 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -240,9 +240,13 @@ public void Init(SentryUnityOptions options) androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); // The game continues the app's trace instead of starting new ones on its own screens and taps. androidOptions.Call("setEnableAutoTraceIdGeneration", false); - // The app's SDK reports the exits of all of the app's processes, including this one. - androidOptions.Call("setAnrEnabled", false); - androidOptions.Call("setTombstoneEnabled", false); + // From API 30, the app's SDK reports the exits of all of the app's processes, including this one. + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + if (version.GetStatic("SDK_INT") >= 30) + { + androidOptions.Call("setAnrEnabled", false); + androidOptions.Call("setTombstoneEnabled", false); + } } using (var logsOptions = androidOptions.Call("getLogs")) From 3b194f9da92d01c647cb17f749d184e3a2e67b95 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:26:46 +0200 Subject: [PATCH 23/44] Make closing the Android SDK idempotent now that the close callback is chained --- src/Sentry.Unity.Android/SentryJava.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index baf126069..62b3af1e5 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -753,6 +753,11 @@ private void SyncScope() public void Close() { + if (_closed) + { + return; + } + _closed = true; _scopeSyncShutdownSource.Cancel(); From 8b5af40151e067f7f75d554c19755c4cfe458d88 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:27:05 +0200 Subject: [PATCH 24/44] Use the shared UseAppReleaseAndEnvironment helper on Android --- .../SentryNativeAndroid.cs | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 47bc045f4..8ec73a9d7 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -98,7 +98,9 @@ public static void Configure(SentryUnityOptions options) if (isExternal) { - UseAppReleaseAndEnvironment(options, SentryJava); + // In its own process, the game's SDK took them from the app's SDK when it initialized. + options.UseAppReleaseAndEnvironment(() => SentryJava.GetOption("getRelease"), + () => SentryJava.GetOption("getDist"), () => SentryJava.GetOption("getEnvironment")); if (isAttached) { @@ -206,24 +208,6 @@ void Refresh() NativePropagationContext.Use(options, () => traceHeader, () => null); } - // Events from the game belong to the app's release and environment, unless they were set in the options. - // In its own process, the game's SDK took them from the app's SDK when it initialized. - private static void UseAppReleaseAndEnvironment(SentryUnityOptions options, ISentryJava sentryJava) - { - if (options.Release == options.DefaultRelease && sentryJava.GetOption("getRelease") is { } release) - { - Logger?.LogDebug("Using the app's release: {0}", release); - options.Release = release; - options.Distribution ??= sentryJava.GetOption("getDist"); - } - - if (options.Environment == options.DefaultEnvironment && sentryJava.GetOption("getEnvironment") is { } environment) - { - Logger?.LogDebug("Using the app's environment: {0}", environment); - options.Environment = environment; - } - } - /// /// Closes the native Android support. /// From 9c0c3e93324e8d5f9cffbbe43c97269472694956 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:42:11 +0200 Subject: [PATCH 25/44] Use the app's user on C# events and logs with external initialization unless the game sets its own --- CHANGELOG.md | 2 +- docs/agent-guides/platform-apple.md | 2 +- package-dev/Plugins/iOS/SentryNativeBridge.m | 21 +++ .../Plugins/iOS/SentryNativeBridgeNoOp.m | 2 + .../Plugins/macOS/SentryNativeBridge.m | 2 + .../SentryCocoaBridgeProxy.cs | 3 + src/Sentry.Unity.iOS/SentryNativeCocoa.cs | 1 + src/Sentry.Unity/NativeAppUser.cs | 101 ++++++++++++++ test/Sentry.Unity.Tests/NativeAppUserTests.cs | 130 ++++++++++++++++++ 9 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 src/Sentry.Unity/NativeAppUser.cs create mode 100644 test/Sentry.Unity.Tests/NativeAppUserTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cd7db05..fe00d8fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ## 4.11.0 diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index c556bdd51..56682390b 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,7 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, continues the host's trace (`NativePropagationContext`, read through the bridge on every use) unless a sampled C# span is active. Transactions started through `Sentry.Unity.SentrySdk.StartTransaction(name, operation)` join the host's trace: as a child of the host's active span, inheriting its sampling decision and DSC, otherwise as a root with sampling deferred to the game. +- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, puts the host's user on C# events (per event) and logs (at start and on resume) unless the game sets its own (`NativeAppUser`), continues the host's trace (`NativePropagationContext`, read through the bridge on every use) unless a sampled C# span is active. Transactions started through `Sentry.Unity.SentrySdk.StartTransaction(name, operation)` join the host's trace: as a child of the host's active span, inheriting its sampling decision and DSC, otherwise as a root with sampling deferred to the game. ## iOS Export diff --git a/package-dev/Plugins/iOS/SentryNativeBridge.m b/package-dev/Plugins/iOS/SentryNativeBridge.m index 15ca2d7d8..59af44ee9 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -454,6 +454,27 @@ static id SentryActiveSpan(id scope) return SentryCopyString(baggageHeader); } +// The user on the app's own SDK's scope, serialized to JSON. +char *SentryNativeBridgeGetUser() +{ + __block NSString *userJson = nil; + SentryConfigureScope(^(id scope) { + id user = [scope respondsToSelector:@selector(userObject)] + ? [scope performSelector:@selector(userObject)] + : nil; + if (user == nil || ![user respondsToSelector:@selector(serialize)]) { + return; + } + NSDictionary *serialized = [user performSelector:@selector(serialize)]; + if (![NSJSONSerialization isValidJSONObject:serialized]) { + return; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:serialized options:0 error:nil]; + userJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + }); + return SentryCopyString(userJson); +} + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m index 573ff0157..21dda35de 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -38,6 +38,8 @@ void SentryNativeBridgeUnsetUser() { } char *_Nullable SentryNativeBridgeGetBaggageHeader() { return NULL; } +char *_Nullable SentryNativeBridgeGetUser() { return NULL; } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } void SentryNativeBridgeSetEnvironment(const char *environment) { } diff --git a/package-dev/Plugins/macOS/SentryNativeBridge.m b/package-dev/Plugins/macOS/SentryNativeBridge.m index 718c1bc66..6c0e9ed94 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -364,6 +364,8 @@ void SentryNativeBridgeUnsetUser() char *SentryNativeBridgeGetBaggageHeader() { return NULL; } +char *SentryNativeBridgeGetUser() { return NULL; } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs index 5df08f666..1a1b9b902 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -155,6 +155,9 @@ public static extern void AddBreadcrumb(string timestamp, string? message, strin [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetBaggageHeader")] public static extern string? GetBaggageHeader(); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeGetUser")] + public static extern string? GetUser(); + [DllImport("__Internal", EntryPoint = "SentryNativeBridgeSetTrace")] public static extern void SetTrace(string traceId, string spanId); diff --git a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs index 18abda0bd..fc4f33a5c 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -61,6 +61,7 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf () => SentryCocoaBridgeProxy.GetOption("dist"), () => SentryCocoaBridgeProxy.GetOption("environment")); NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader, SentryCocoaBridgeProxy.GetBaggageHeader); + NativeAppUser.Use(options, () => NativeAppUser.FromJson(SentryCocoaBridgeProxy.GetUser())); if (options.AutoSessionTracking) { diff --git a/src/Sentry.Unity/NativeAppUser.cs b/src/Sentry.Unity/NativeAppUser.cs new file mode 100644 index 000000000..5cf687cb5 --- /dev/null +++ b/src/Sentry.Unity/NativeAppUser.cs @@ -0,0 +1,101 @@ +using System; +using System.Text.Json; +using Sentry.Extensibility; +using Sentry.Integrations; + +namespace Sentry.Unity; + +/// +/// The user of a native SDK that the app embedding the game owns, e.g. when using Unity as a Library. The game's events +/// carry it, read on every event, and so do its logs, read at start and whenever the game resumes. A user the game +/// sets itself takes precedence. +/// +internal sealed class NativeAppUser : ISentryEventProcessor, ISdkIntegration +{ + private readonly SentryUnityOptions _options; + private readonly Func _getAppUser; + // The app's user as last put on the scope, to tell it apart from a user the game set. + private volatile SentryUser? _applied; + + internal NativeAppUser(SentryUnityOptions options, Func getAppUser) + { + _options = options; + _getAppUser = getAppUser; + } + + internal static void Use(SentryUnityOptions options, Func getAppUser) + { + var appUser = new NativeAppUser(options, getAppUser); + options.AddEventProcessor(appUser); + options.AddIntegration(appUser); + } + + public void Register(IHub hub, SentryOptions options) + { + hub.ConfigureScope(Apply); + SentryMonoBehaviour.Instance.ApplicationResuming += () => + { + if (hub.IsEnabled) + { + hub.ConfigureScope(Apply); + } + }; + } + + public SentryEvent Process(SentryEvent @event) + { + if (IsOwnedByApp(@event.User)) + { + @event.User = GetUser().Clone(); + } + + return @event; + } + + internal void Apply(Scope scope) + { + if (IsOwnedByApp(scope.User)) + { + var user = GetUser(); + _applied = user; + scope.User = user.Clone(); + } + } + + // The app's user, or the installation ID while the app has none. + private SentryUser GetUser() + { + SentryUser? appUser = null; + try + { + appUser = _getAppUser(); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogWarning("Failed to get the app's user.", e); + } + + return appUser is { } && HasData(appUser) ? appUser : new SentryUser { Id = _options.DefaultUserId }; + } + + // Not set by the game: empty, the installation ID, or the app's user set here before. + private bool IsOwnedByApp(SentryUser user) => + !HasData(user) + || (user.Id == _options.DefaultUserId && user.Username is null && user.Email is null && user.IpAddress is null) + || (_applied is { } applied && user.Id == applied.Id && user.Username == applied.Username + && user.Email == applied.Email && user.IpAddress == applied.IpAddress); + + private static bool HasData(SentryUser user) => + user.Id is not null || user.Username is not null || user.Email is not null || user.IpAddress is not null; + + internal static SentryUser? FromJson(string? json) + { + if (string.IsNullOrEmpty(json)) + { + return null; + } + + using var document = JsonDocument.Parse(json!); + return SentryUser.FromJson(document.RootElement); + } +} diff --git a/test/Sentry.Unity.Tests/NativeAppUserTests.cs b/test/Sentry.Unity.Tests/NativeAppUserTests.cs new file mode 100644 index 000000000..4adb1162b --- /dev/null +++ b/test/Sentry.Unity.Tests/NativeAppUserTests.cs @@ -0,0 +1,130 @@ +using NUnit.Framework; + +namespace Sentry.Unity.Tests; + +public class NativeAppUserTests +{ + private const string InstallationId = "installation-id"; + + private SentryUser? _appUser; + + private NativeAppUser GetSut() => + new(new SentryUnityOptions { DefaultUserId = InstallationId }, () => _appUser); + + private static SentryEvent GetEvent(SentryUser user) => new() { User = user }; + + [Test] + public void Process_InstallationIdUser_UsesAppUser() + { + _appUser = new SentryUser { Id = "app-user", Email = "app@example.com" }; + + var @event = GetSut().Process(GetEvent(new SentryUser { Id = InstallationId })); + + Assert.AreEqual("app-user", @event.User.Id); + Assert.AreEqual("app@example.com", @event.User.Email); + } + + [Test] + public void Process_EmptyUser_UsesAppUser() + { + _appUser = new SentryUser { Id = "app-user" }; + + var @event = GetSut().Process(GetEvent(new SentryUser())); + + Assert.AreEqual("app-user", @event.User.Id); + } + + [Test] + public void Process_NoAppUser_UsesInstallationId() + { + _appUser = null; + + var @event = GetSut().Process(GetEvent(new SentryUser())); + + Assert.AreEqual(InstallationId, @event.User.Id); + } + + [Test] + public void Process_UserSetByGame_KeepsIt() + { + _appUser = new SentryUser { Id = "app-user" }; + + var @event = GetSut().Process(GetEvent(new SentryUser { Id = "game-user" })); + + Assert.AreEqual("game-user", @event.User.Id); + } + + [Test] + public void Apply_AppUserChanges_FollowsApp() + { + var sut = GetSut(); + var scope = new Scope(new SentryOptions()); + _appUser = new SentryUser { Id = "app-user" }; + sut.Apply(scope); + + _appUser = new SentryUser { Id = "other-app-user" }; + sut.Apply(scope); + + Assert.AreEqual("other-app-user", scope.User.Id); + } + + [Test] + public void Process_AppUserChangedAfterScopeWasSet_FollowsApp() + { + var sut = GetSut(); + var scope = new Scope(new SentryOptions()); + _appUser = new SentryUser { Id = "app-user" }; + sut.Apply(scope); + + _appUser = new SentryUser { Id = "other-app-user" }; + var first = sut.Process(GetEvent(scope.User.Clone())); + var second = sut.Process(GetEvent(scope.User.Clone())); + + Assert.AreEqual("other-app-user", first.User.Id); + Assert.AreEqual("other-app-user", second.User.Id); + } + + [Test] + public void Apply_AppUserRemoved_FallsBackToInstallationId() + { + var sut = GetSut(); + var scope = new Scope(new SentryOptions()); + _appUser = new SentryUser { Id = "app-user" }; + sut.Apply(scope); + + _appUser = null; + sut.Apply(scope); + + Assert.AreEqual(InstallationId, scope.User.Id); + } + + [Test] + public void Apply_UserSetByGameAfterApp_KeepsIt() + { + var sut = GetSut(); + var scope = new Scope(new SentryOptions()); + _appUser = new SentryUser { Id = "app-user" }; + sut.Apply(scope); + + scope.User = new SentryUser { Id = "game-user" }; + sut.Apply(scope); + + Assert.AreEqual("game-user", scope.User.Id); + } + + [Test] + public void FromJson_SerializedCocoaUser_ParsesFields() + { + var user = NativeAppUser.FromJson( + "{\"id\":\"app-user\",\"email\":\"app@example.com\",\"username\":\"app\",\"ip_address\":\"{{auto}}\"}"); + + Assert.NotNull(user); + Assert.AreEqual("app-user", user!.Id); + Assert.AreEqual("app@example.com", user.Email); + Assert.AreEqual("app", user.Username); + Assert.AreEqual("{{auto}}", user.IpAddress); + } + + [Test] + public void FromJson_Null_ReturnsNull() => Assert.IsNull(NativeAppUser.FromJson(null)); +} From 849cbcac8eaa4191817cc998a7da5d7660f5c00a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:44:32 +0200 Subject: [PATCH 26/44] Use the app's user on C# events on Android, from its SDK when attached and from what it persisted in the game's own process --- src/Sentry.Unity.Android/SentryJava.cs | 46 +++++++++++++++++++ .../SentryNativeAndroid.cs | 11 +++-- .../TestSentryJava.cs | 6 +++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index 62b3af1e5..d87151edb 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -17,6 +17,8 @@ internal interface ISentryJava public string? GetTraceHeader(); public string? GetBaggageHeader(); public string? GetMainProcessTraceHeader(); + public SentryUser? GetUser(); + public SentryUser? GetMainProcessUser(); public string? GetInstallationId(); public bool? CrashedLastRun(); public void Close(); @@ -331,6 +333,41 @@ public void Init(SentryUnityOptions options) return null; } + // The user the app's SDK in the main process persisted. + public SentryUser? GetMainProcessUser() + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling GetMainProcessUser() on Android SDK requires running on MainThread"); + return null; + } + + try + { + using var sentry = GetSentryJava(); + using var hub = sentry.CallStatic("getCurrentHub"); + using var options = hub.Call("getOptions"); + using var user = ReadMainProcessFile(options, ".scope-cache/user.json", "io.sentry.protocol.User"); + return ToSentryUser(user); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.GetMainProcessUser' failed."); + } + + return null; + } + + private static SentryUser? ToSentryUser(AndroidJavaObject? user) => user is null + ? null + : new SentryUser + { + Id = user.Call("getId"), + Username = user.Call("getUsername"), + Email = user.Call("getEmail"), + IpAddress = user.Call("getIpAddress") + }; + private string? ReadMainProcessOption(AndroidJavaObject options, string fileName) { using var value = ReadMainProcessFile(options, $".options-cache/{fileName}", "java.lang.String"); @@ -383,6 +420,15 @@ public void Init(SentryUnityOptions options) return header?.Call("getValue"); }); + // The user on the native SDK's scope. + public SentryUser? GetUser() => RunJniSafeAndWait(() => + { + using var sentry = GetInternalSentryJava(); + using var scope = sentry.CallStatic("getCurrentScope"); + using var user = scope?.Call("getUser"); + return ToSentryUser(user); + }); + public string? GetBaggageHeader() => RunJniSafeAndWait(() => { using var sentry = GetSentryJava(); diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 8ec73a9d7..e0766fa87 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -105,10 +105,11 @@ public static void Configure(SentryUnityOptions options) if (isAttached) { NativePropagationContext.Use(options, SentryJava.GetTraceHeader, SentryJava.GetBaggageHeader); + NativeAppUser.Use(options, SentryJava.GetUser); } else { - UseMainProcessTrace(options, SentryJava); + UseMainProcessScope(options, SentryJava); } if (options.AutoSessionTracking) @@ -185,10 +186,11 @@ public static void Configure(SentryUnityOptions options) Logger?.LogInfo("Successfully configured the Android SDK"); } - // The game runs in its own process and continues the trace the app was in when it showed the game. - private static void UseMainProcessTrace(SentryUnityOptions options, ISentryJava sentryJava) + // The game runs in its own process and continues the trace the app was in when it showed the game, with its user. + private static void UseMainProcessScope(SentryUnityOptions options, ISentryJava sentryJava) { string? traceHeader = null; + SentryUser? appUser = null; void Refresh() { @@ -199,6 +201,8 @@ void Refresh() traceHeader = refreshed; sentryJava.SetTrace(header.TraceId, header.SpanId); } + + appUser = sentryJava.GetMainProcessUser(); } Refresh(); @@ -206,6 +210,7 @@ void Refresh() monoBehaviour.ApplicationResuming += Refresh; options.NativeSupportCloseCallback += () => monoBehaviour.ApplicationResuming -= Refresh; NativePropagationContext.Use(options, () => traceHeader, () => null); + NativeAppUser.Use(options, () => appUser); } /// diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index d5661d939..bc33d338e 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -18,6 +18,8 @@ internal class TestSentryJava : ISentryJava public string? TraceHeader { get; set; } public string? BaggageHeader { get; set; } public string? MainProcessTraceHeader { get; set; } + public SentryUser? User { get; set; } + public SentryUser? MainProcessUser { get; set; } public (SentryId TraceId, SpanId SpanId)? Trace { get; private set; } public bool? IsEnabled() => Enabled; @@ -38,6 +40,10 @@ public void Init(SentryUnityOptions options) public string? GetMainProcessTraceHeader() => MainProcessTraceHeader; + public SentryUser? GetUser() => User; + + public SentryUser? GetMainProcessUser() => MainProcessUser; + public string? GetInstallationId() => InstallationId; public bool? CrashedLastRun() => IsCrashedLastRun; From 91fbb28aa010f6bbcb62110b2b4539ae29cc050a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:50:33 +0200 Subject: [PATCH 27/44] Recognize the SDK's default user by the scope's initial user so the app's user also applies with SendDefaultPii --- src/Sentry.Unity/NativeAppUser.cs | 33 ++++++++++----- test/Sentry.Unity.Tests/NativeAppUserTests.cs | 40 +++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/Sentry.Unity/NativeAppUser.cs b/src/Sentry.Unity/NativeAppUser.cs index 5cf687cb5..495f572a3 100644 --- a/src/Sentry.Unity/NativeAppUser.cs +++ b/src/Sentry.Unity/NativeAppUser.cs @@ -14,7 +14,9 @@ internal sealed class NativeAppUser : ISentryEventProcessor, ISdkIntegration { private readonly SentryUnityOptions _options; private readonly Func _getAppUser; - // The app's user as last put on the scope, to tell it apart from a user the game set. + // The SDK's default user on the scope, and the app's user as last put there, to tell them apart from a user the + // game set. + private volatile SentryUser? _default; private volatile SentryUser? _applied; internal NativeAppUser(SentryUnityOptions options, Func getAppUser) @@ -32,7 +34,7 @@ internal static void Use(SentryUnityOptions options, Func getAppUse public void Register(IHub hub, SentryOptions options) { - hub.ConfigureScope(Apply); + hub.ConfigureScope(Initialize); SentryMonoBehaviour.Instance.ApplicationResuming += () => { if (hub.IsEnabled) @@ -46,12 +48,20 @@ public SentryEvent Process(SentryEvent @event) { if (IsOwnedByApp(@event.User)) { - @event.User = GetUser().Clone(); + var user = GetUser().Clone(); + user.IpAddress ??= @event.User.IpAddress; + @event.User = user; } return @event; } + internal void Initialize(Scope scope) + { + _default = scope.User.Clone(); + Apply(scope); + } + internal void Apply(Scope scope) { if (IsOwnedByApp(scope.User)) @@ -62,7 +72,7 @@ internal void Apply(Scope scope) } } - // The app's user, or the installation ID while the app has none. + // The app's user, or the SDK's default user while the app has none. private SentryUser GetUser() { SentryUser? appUser = null; @@ -75,15 +85,18 @@ private SentryUser GetUser() _options.DiagnosticLogger?.LogWarning("Failed to get the app's user.", e); } - return appUser is { } && HasData(appUser) ? appUser : new SentryUser { Id = _options.DefaultUserId }; + return appUser is { } && HasData(appUser) + ? appUser + : _default ?? new SentryUser { Id = _options.DefaultUserId }; } - // Not set by the game: empty, the installation ID, or the app's user set here before. + // Not set by the game: no ID and email of its own. The SDK's defaults fill in username and IP address. private bool IsOwnedByApp(SentryUser user) => - !HasData(user) - || (user.Id == _options.DefaultUserId && user.Username is null && user.Email is null && user.IpAddress is null) - || (_applied is { } applied && user.Id == applied.Id && user.Username == applied.Username - && user.Email == applied.Email && user.IpAddress == applied.IpAddress); + IsOwnedByApp(user.Id, _options.DefaultUserId, _default?.Id, _applied?.Id) + && IsOwnedByApp(user.Email, null, _default?.Email, _applied?.Email); + + private static bool IsOwnedByApp(string? value, string? defaultUserId, string? defaultValue, string? appliedValue) => + value is null || value == defaultUserId || value == defaultValue || value == appliedValue; private static bool HasData(SentryUser user) => user.Id is not null || user.Username is not null || user.Email is not null || user.IpAddress is not null; diff --git a/test/Sentry.Unity.Tests/NativeAppUserTests.cs b/test/Sentry.Unity.Tests/NativeAppUserTests.cs index 4adb1162b..01624c87e 100644 --- a/test/Sentry.Unity.Tests/NativeAppUserTests.cs +++ b/test/Sentry.Unity.Tests/NativeAppUserTests.cs @@ -112,6 +112,46 @@ public void Apply_UserSetByGameAfterApp_KeepsIt() Assert.AreEqual("game-user", scope.User.Id); } + [Test] + public void Process_SendDefaultPiiDefaults_UsesAppUserAndKeepsIpAddress() + { + _appUser = new SentryUser { Id = "app-user" }; + + var @event = GetSut().Process(GetEvent( + new SentryUser { Id = InstallationId, Username = "device-user", IpAddress = "{{auto}}" })); + + Assert.AreEqual("app-user", @event.User.Id); + Assert.AreEqual("{{auto}}", @event.User.IpAddress); + } + + [Test] + public void Initialize_DefaultUserWithoutNativeInstallationId_UsesAppUser() + { + var sut = new NativeAppUser(new SentryUnityOptions(), () => _appUser); + var scope = new Scope(new SentryOptions()); + scope.User = new SentryUser { Id = "dotnet-installation-id", Username = "device-user" }; + _appUser = new SentryUser { Id = "app-user" }; + + sut.Initialize(scope); + + Assert.AreEqual("app-user", scope.User.Id); + } + + [Test] + public void Initialize_NoAppUser_KeepsDefaultUser() + { + var sut = new NativeAppUser(new SentryUnityOptions(), () => _appUser); + var scope = new Scope(new SentryOptions()); + scope.User = new SentryUser { Id = "dotnet-installation-id", Username = "device-user" }; + _appUser = null; + + sut.Initialize(scope); + var @event = sut.Process(GetEvent(scope.User.Clone())); + + Assert.AreEqual("dotnet-installation-id", @event.User.Id); + Assert.AreEqual("device-user", @event.User.Username); + } + [Test] public void FromJson_SerializedCocoaUser_ParsesFields() { From 03e0ce2dd68f3039ec74040f96dddbd3fec39700 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:53:06 +0200 Subject: [PATCH 28/44] Keep a username or IP address the game sets by comparing every user field against the SDK's defaults --- src/Sentry.Unity/NativeAppUser.cs | 13 ++++++--- test/Sentry.Unity.Tests/NativeAppUserTests.cs | 28 ++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Sentry.Unity/NativeAppUser.cs b/src/Sentry.Unity/NativeAppUser.cs index 495f572a3..610c30393 100644 --- a/src/Sentry.Unity/NativeAppUser.cs +++ b/src/Sentry.Unity/NativeAppUser.cs @@ -12,6 +12,9 @@ namespace Sentry.Unity; /// internal sealed class NativeAppUser : ISentryEventProcessor, ISdkIntegration { + // Set on events by the SDK with `SendDefaultPii`. + private const string AutoIpAddress = "{{auto}}"; + private readonly SentryUnityOptions _options; private readonly Func _getAppUser; // The SDK's default user on the scope, and the app's user as last put there, to tell them apart from a user the @@ -90,13 +93,15 @@ private SentryUser GetUser() : _default ?? new SentryUser { Id = _options.DefaultUserId }; } - // Not set by the game: no ID and email of its own. The SDK's defaults fill in username and IP address. + // Not set by the game: each field is unset, the SDK's default, or the app's user's. private bool IsOwnedByApp(SentryUser user) => IsOwnedByApp(user.Id, _options.DefaultUserId, _default?.Id, _applied?.Id) - && IsOwnedByApp(user.Email, null, _default?.Email, _applied?.Email); + && IsOwnedByApp(user.Username, null, _default?.Username, _applied?.Username) + && IsOwnedByApp(user.Email, null, _default?.Email, _applied?.Email) + && IsOwnedByApp(user.IpAddress, AutoIpAddress, _default?.IpAddress, _applied?.IpAddress); - private static bool IsOwnedByApp(string? value, string? defaultUserId, string? defaultValue, string? appliedValue) => - value is null || value == defaultUserId || value == defaultValue || value == appliedValue; + private static bool IsOwnedByApp(string? value, string? sdkValue, string? defaultValue, string? appliedValue) => + value is null || value == sdkValue || value == defaultValue || value == appliedValue; private static bool HasData(SentryUser user) => user.Id is not null || user.Username is not null || user.Email is not null || user.IpAddress is not null; diff --git a/test/Sentry.Unity.Tests/NativeAppUserTests.cs b/test/Sentry.Unity.Tests/NativeAppUserTests.cs index 01624c87e..e0fb83a6e 100644 --- a/test/Sentry.Unity.Tests/NativeAppUserTests.cs +++ b/test/Sentry.Unity.Tests/NativeAppUserTests.cs @@ -54,6 +54,28 @@ public void Process_UserSetByGame_KeepsIt() Assert.AreEqual("game-user", @event.User.Id); } + [Test] + public void Process_UsernameSetByGame_KeepsIt() + { + _appUser = new SentryUser { Id = "app-user" }; + + var @event = GetSut().Process(GetEvent(new SentryUser { Username = "player1" })); + + Assert.AreEqual("player1", @event.User.Username); + Assert.IsNull(@event.User.Id); + } + + [Test] + public void Process_UsernameAddedByGameToInstallationId_KeepsIt() + { + _appUser = new SentryUser { Id = "app-user" }; + + var @event = GetSut().Process(GetEvent(new SentryUser { Id = InstallationId, Username = "player1" })); + + Assert.AreEqual(InstallationId, @event.User.Id); + Assert.AreEqual("player1", @event.User.Username); + } + [Test] public void Apply_AppUserChanges_FollowsApp() { @@ -115,9 +137,13 @@ public void Apply_UserSetByGameAfterApp_KeepsIt() [Test] public void Process_SendDefaultPiiDefaults_UsesAppUserAndKeepsIpAddress() { + var sut = GetSut(); + var scope = new Scope(new SentryOptions()); + scope.User = new SentryUser { Id = InstallationId, Username = "device-user" }; + sut.Initialize(scope); _appUser = new SentryUser { Id = "app-user" }; - var @event = GetSut().Process(GetEvent( + var @event = sut.Process(GetEvent( new SentryUser { Id = InstallationId, Username = "device-user", IpAddress = "{{auto}}" })); Assert.AreEqual("app-user", @event.User.Id); From c3b5294ba12eff86441372d472f2e1949215e72f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Sat, 24 Oct 2026 00:05:48 +0200 Subject: [PATCH 29/44] Mention the app's user in the Android Unity as a Library changelog entry and drop the duplicated iOS entry --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb22d7dce..1b64934aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,8 @@ ### Features -- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) -- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace, leaving ANRs and tombstones to the app's SDK. Events from the game, native and C#, use the app's release and environment unless they are set in the options. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) - Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace, leaving ANRs and tombstones to the app's SDK. Events from the game, native and C#, use the app's release, environment, and user unless they are set in the options. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) ## 4.11.0 From ed325e16fedb9cf14c9ffc5c04b2d5dec1090b7b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 15:39:19 +0100 Subject: [PATCH 30/44] Mark Unity as a Library support with external initialization as experimental --- CHANGELOG.md | 4 +--- docs/agent-guides/platform-apple.md | 2 +- src/Sentry.Unity/SentryUnityOptions.cs | 5 +++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42cebf06d..7f4448e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,7 @@ ### Features -- Added support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) - -## Unreleased +- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ### Dependencies diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index 56682390b..8a1e5ec6b 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,7 +19,7 @@ - App-hang tracking disables the C# ANR watchdog on iOS and macOS when the Cocoa backend is active. - macOS Cocoa screenshots are disabled because UIKit is unavailable. - Cocoa attachment synchronization is not implemented. -- iOS external initialization: C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, puts the host's user on C# events (per event) and logs (at start and on resume) unless the game sets its own (`NativeAppUser`), continues the host's trace (`NativePropagationContext`, read through the bridge on every use) unless a sampled C# span is active. Transactions started through `Sentry.Unity.SentrySdk.StartTransaction(name, operation)` join the host's trace: as a child of the host's active span, inheriting its sampling decision and DSC, otherwise as a root with sampling deferred to the game. +- iOS external initialization (experimental): C# never initializes or closes Cocoa, attaches only if the host app already initialized it, keeps the host's SDK name, and disables the C# ANR watchdog. It doesn't sync user, environment, or trace to the host's scope, turns off C# session tracking, uses the host's release and environment unless set, puts the host's user on C# events (per event) and logs (at start and on resume) unless the game sets its own (`NativeAppUser`), continues the host's trace (`NativePropagationContext`, read through the bridge on every use) unless a sampled C# span is active. Transactions started through `Sentry.Unity.SentrySdk.StartTransaction(name, operation)` join the host's trace: as a child of the host's active span, inheriting its sampling decision and DSC, otherwise as a root with sampling deferred to the game. ## iOS Export diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 24ab697fe..ecf5f443b 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -268,7 +268,7 @@ internal bool NativeAppHangTrackingEnabled /// Xcode project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. /// When set to , the app that embeds the game initializes the native - /// SDK, e.g. when using Unity as a Library. + /// SDK, e.g. when using Unity as a Library. This is experimental. /// public NativeInitializationType IosNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -779,7 +779,8 @@ public enum NativeInitializationType /// The app that embeds the game owns the native SDK, e.g. when using Unity as a Library. The SDK does not initialize /// or close the native SDK. It attaches to it if the app has initialized it before the game starts. Options that /// you set in Unity do not apply to the native SDK. Events from the game use the app's release, environment, and - /// trace, unless the release and environment are set in the options. Currently supported on iOS. + /// trace, unless the release and environment are set in the options. Currently supported on iOS. Experimental: the + /// behavior may change between releases. /// External, } From 1a362c65d99d95e944836b6f747c37d7d3eb2cb4 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 15:40:25 +0100 Subject: [PATCH 31/44] Mark Unity as a Library support on Android as experimental --- CHANGELOG.md | 2 +- docs/agent-guides/platform-android.md | 2 +- src/Sentry.Unity/SentryUnityOptions.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db19425b..85b050ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features - Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) -- Added support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace, leaving ANRs and tombstones to the app's SDK. Events from the game, native and C#, use the app's release, environment, and user unless they are set in the options. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) +- Added experimental support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the export ships no sentry-android and leaves the app's manifest alone. The game attaches to the sentry-android SDK the app started in the same process, or, when the game runs in its own process, starts it there with the options set in Unity and continues the app's trace, leaving ANRs and tombstones to the app's SDK. Events from the game, native and C#, use the app's release, environment, and user unless they are set in the options. Debug symbols are uploaded when the app builds `unityLibrary`. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) ### Dependencies diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 7877e3fd4..cf85d0fe1 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): 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, 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. ## Export Processing diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 80b9dc47f..a14a7f54b 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -287,7 +287,7 @@ internal bool NativeAppHangTrackingEnabled /// Gradle project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. /// When set to , the app that embeds the game provides the native - /// SDK, e.g. when using Unity as a Library. + /// SDK, e.g. when using Unity as a Library. This is experimental. /// public NativeInitializationType AndroidNativeInitializationType { get; set; } = NativeInitializationType.Runtime; From 88314aa2210eb2f70f7f360067bb178243aa8f66 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 16:32:24 +0100 Subject: [PATCH 32/44] Drop the Ubuntu 24 runner pin and the snapshot reorder that aren't part of Unity as a Library --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/create-unity-matrix.yml | 2 +- .github/workflows/danger.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test-build-ios.yml | 2 +- .github/workflows/test-build-linux.yml | 2 +- .github/workflows/test-create.yml | 2 +- .github/workflows/test-run-android.yml | 2 +- .github/workflows/test-run-desktop.yml | 2 +- .github/workflows/test-run-webgl.yml | 2 +- .github/workflows/update-deps.yml | 4 ++-- test/Scripts.Tests/package-release.zip.snapshot | 4 ++-- 13 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad148a4aa..6151395e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ defaults: jobs: cancel-previous-workflow: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1 @@ -38,11 +38,11 @@ jobs: matrix: include: - target: Android - host: ubuntu-24.04 + host: ubuntu-latest - target: Linux - host: ubuntu-24.04 + host: ubuntu-latest - target: LinuxNative - host: ubuntu-24.04 + host: ubuntu-latest - target: Windows host: windows-latest - target: WindowsNative @@ -75,7 +75,7 @@ jobs: package-validation: name: UPM Package validation - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -135,7 +135,7 @@ jobs: name: Build ${{ matrix.platform }} ${{ matrix.unity-version }} Test if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} needs: [test-create, create-unity-matrix] - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -565,7 +565,7 @@ jobs: build-size-summary: name: Build Size - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} needs: [test-build-webgl, test-build-android, test-compile-ios, test-build-linux, test-build-windows, test-build-macos] steps: diff --git a/.github/workflows/create-unity-matrix.yml b/.github/workflows/create-unity-matrix.yml index bd2d30579..2ad5eb93d 100644 --- a/.github/workflows/create-unity-matrix.yml +++ b/.github/workflows/create-unity-matrix.yml @@ -25,7 +25,7 @@ env: jobs: create-unity-matrix: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: unity-matrix: ${{ steps.set-matrix.outputs.matrix }} steps: diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index aeae42afc..0ec9dcccf 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -5,6 +5,6 @@ on: jobs: danger: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - uses: getsentry/github-workflows/danger@26f565c05d0dd49f703d238706b775883037d76b # v3 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 720bddf4e..6be001e9b 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -5,7 +5,7 @@ on: jobs: format-code: name: Format Code - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01f60ec1f..f37c48d98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ permissions: jobs: job_release: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest name: 'Release a new version: ${{ github.event.inputs.version }}' steps: - name: Get auth token diff --git a/.github/workflows/test-build-ios.yml b/.github/workflows/test-build-ios.yml index 118bb4c06..9dab592ec 100644 --- a/.github/workflows/test-build-ios.yml +++ b/.github/workflows/test-build-ios.yml @@ -20,7 +20,7 @@ defaults: jobs: build: name: ${{ inputs.unity-version }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest strategy: matrix: include: diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 1f2b2309d..5535dc050 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -20,7 +20,7 @@ defaults: jobs: build: name: Linux ${{ inputs.unity-version }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest env: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor diff --git a/.github/workflows/test-create.yml b/.github/workflows/test-create.yml index 33ce665b8..067df280d 100644 --- a/.github/workflows/test-create.yml +++ b/.github/workflows/test-create.yml @@ -16,7 +16,7 @@ defaults: jobs: create: name: ${{ inputs.unity-version }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest env: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor diff --git a/.github/workflows/test-run-android.yml b/.github/workflows/test-run-android.yml index d29cf4e09..6400e761d 100644 --- a/.github/workflows/test-run-android.yml +++ b/.github/workflows/test-run-android.yml @@ -26,7 +26,7 @@ jobs: name: ${{ inputs.unity-version }} ${{ inputs.api-level }} ${{ inputs.init-type }} # Unity 6000.x builds ARM64 so we run Redroid (containerized arm64 Android) on an # arm64 Linux runner. - runs-on: ${{ fromJSON(inputs.unity-version) >= 6000 && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + runs-on: ${{ fromJSON(inputs.unity-version) >= 6000 && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} env: ARTIFACTS_PATH: samples/IntegrationTest/test-artifacts/ HOMEBREW_NO_INSTALL_CLEANUP: 1 diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index e5b0ffa13..4cf4f7e24 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -37,7 +37,7 @@ defaults: jobs: run: name: ${{ inputs.platform }}${{ inputs.architecture && format(' {0}', inputs.architecture) || '' }}${{ inputs.scripting && format(' {0}', inputs.scripting) || '' }}${{ inputs.backend && format(' ({0})', inputs.backend) || '' }} ${{ inputs.unity-version }} - runs-on: ${{ inputs.platform == 'linux' && 'ubuntu-24.04' || inputs.platform == 'macos' && 'macos-latest' || 'windows-latest' }} + runs-on: ${{ inputs.platform == 'linux' && 'ubuntu-latest' || inputs.platform == 'macos' && 'macos-latest' || 'windows-latest' }} env: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/test-run-webgl.yml b/.github/workflows/test-run-webgl.yml index 8d243ca5e..cff15cb81 100644 --- a/.github/workflows/test-run-webgl.yml +++ b/.github/workflows/test-run-webgl.yml @@ -18,7 +18,7 @@ defaults: jobs: run: name: WebGL ${{ inputs.unity-version }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest env: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 4c8ee93ad..90be275f6 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -27,7 +27,7 @@ jobs: post-update-script: scripts/sync-dotnet-global-json.ps1 - name: CLI path: modules/sentry-cli.properties - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - uses: getsentry/github-workflows/updater@959162c159b866489d3fabe93327f48f9b8cb7ee # 3.4.1 with: @@ -40,7 +40,7 @@ jobs: name: Unity ${{ matrix.unity-prefix }} PR # Note: we're using the negative check here so this runs also for workflow_dispatch. if: github.event.schedule != '0 3 * * *' - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest defaults: run: shell: pwsh diff --git a/test/Scripts.Tests/package-release.zip.snapshot b/test/Scripts.Tests/package-release.zip.snapshot index cb91d89d6..5d884886c 100644 --- a/test/Scripts.Tests/package-release.zip.snapshot +++ b/test/Scripts.Tests/package-release.zip.snapshot @@ -285,15 +285,15 @@ Plugins/Windows/Sentry~/ Plugins/Windows/SentryNative~/ Plugins/Windows/SentryNative~/x64/sentry-crash.exe Plugins/Windows/SentryNative~/x64/sentry-crash.pdb -Plugins/Windows/SentryNative~/x64/sentry-native.dll Plugins/Windows/SentryNative~/x64/sentry-wer.dll Plugins/Windows/SentryNative~/x64/sentry-wer.pdb +Plugins/Windows/SentryNative~/x64/sentry-native.dll Plugins/Windows/SentryNative~/x64/sentry.pdb Plugins/Windows/SentryNative~/x86/sentry-crash.exe Plugins/Windows/SentryNative~/x86/sentry-crash.pdb -Plugins/Windows/SentryNative~/x86/sentry-native.dll Plugins/Windows/SentryNative~/x86/sentry-wer.dll Plugins/Windows/SentryNative~/x86/sentry-wer.pdb +Plugins/Windows/SentryNative~/x86/sentry-native.dll Plugins/Windows/SentryNative~/x86/sentry.pdb Plugins/Windows/Sentry~/x64/crashpad_handler.exe Plugins/Windows/Sentry~/x64/crashpad_wer.dll From 9bc8b9c6d046c0000248fc18e6a53654537d6d23 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 18:14:04 +0100 Subject: [PATCH 33/44] Give external initialization its own bridge with respondsToSelector checks and keep the typed bridge for bundled exports --- docs/agent-guides/platform-apple.md | 2 +- package-dev/Plugins/iOS/SentryNativeBridge.m | 366 ++++------------- .../Plugins/iOS/SentryNativeBridgeExternal.m | 383 ++++++++++++++++++ .../iOS/SentryNativeBridgeExternal.m.meta | 78 ++++ .../Plugins/macOS/SentryNativeBridge.m | 4 +- .../BuildPostProcess.cs | 3 +- .../SentryXcodeProject.cs | 6 +- .../package-release.zip.snapshot | 2 + .../BuildPostProcessorTests.cs | 2 + .../SentryNativeBridgeTests.cs | 21 +- .../SentryXcodeProjectTests.cs | 11 - 11 files changed, 551 insertions(+), 327 deletions(-) create mode 100644 package-dev/Plugins/iOS/SentryNativeBridgeExternal.m create mode 100644 package-dev/Plugins/iOS/SentryNativeBridgeExternal.m.meta diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index 8a1e5ec6b..2c25f2a79 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -26,7 +26,7 @@ - iOS editor code is in `src/Sentry.Unity.Editor.iOS/`. - It copies `Sentry.xcframework~` and bridge source, mutates the PBX project through reflection, embeds/links the framework, adds `-ObjC`, and can write build-time options into `main.mm`. - Reflection avoids requiring iOS editor modules on Windows or Linux. -- The iOS bridge looks up sentry-cocoa classes at runtime: the `SentryObjC*` classes when the export bundles the framework, otherwise the core classes of the app's own SDK (`SentrySDKInternal`, `SentryScope`, `SentryBreadcrumb`, `SentryUser`). It is compiled with `-fobjc-exceptions`. +- External exports copy `SentryNativeBridgeExternal.m` as the bridge instead: it looks up the core classes of the app's own SDK (`SentrySDKInternal`, `SentryScope`, `SentryBreadcrumb`) at runtime and checks every selector with `respondsToSelector:`, as Unity builds without Objective-C exceptions. Keep its entry points in sync with the other bridges. - External exports copy no framework and add the symbol upload build phase to `UnityFramework`, which an embedding app builds. - Disabled/unconfigured iOS still installs a no-op bridge so P/Invoke symbols resolve. - Changing an append build from build-time to runtime/disabled requires a Replace export; postprocessing fails rather than preserve stale `main.mm` initialization. diff --git a/package-dev/Plugins/iOS/SentryNativeBridge.m b/package-dev/Plugins/iOS/SentryNativeBridge.m index 59af44ee9..fefb76eee 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -1,13 +1,14 @@ -#import -#include -#include +#import +#import + +NS_ASSUME_NONNULL_BEGIN static NSDateFormatter *_Nullable sentry_cachedISO8601Formatter(void) { static NSDateFormatter *formatter = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ formatter = [[NSDateFormatter alloc] init]; - [formatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"]; + formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'"; formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"]; formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; }); @@ -35,71 +36,11 @@ return nil; } -static int loadStatus = -1; // unitialized - -// The SDK the game bundles, through the SentryObjC hybrid API. Missing when the app brings its own SDK. -static Class SentryObjCSDK; -static Class SentryObjCId; -static Class SentryObjCSpanId; -static Class SentryObjCHttpStatusCodeRange; - -// Scope sync. Either the SentryObjC classes, or the core classes of the app's own SDK (e.g. Unity as a -// Library). Both take the same selectors and keys. -static Class SentrySDKClass; -static Class SentryBreadcrumbClass; -static Class SentryUserClass; - -typedef NS_ENUM(NSInteger, SentryObjCLastRunStatus) { - SentryObjCLastRunStatusUnknown = 0, - SentryObjCLastRunStatusDidNotCrash, - SentryObjCLastRunStatusDidCrash, -}; - -// [SentryObjCSDK internal] is the hybrid-SDK entry point used for options parsing, SDK metadata, -// and trace propagation. -static id SentryInternalApi(void) { return [SentryObjCSDK performSelector:@selector(internal)]; } - -// Returns (bool): 0 on failure, 1 on success -// WARNING: you may only call other Sentry* functions AFTER calling this AND only if it returned "1" -int SentryNativeBridgeLoadLibrary() -{ - if (loadStatus == -1) { - loadStatus = 0; // init to "error" - do { - // The classes are looked up at runtime: the SDK is either linked into UnityFramework, or part of the - // app that embeds the game. - SentryObjCSDK = objc_getClass("SentryObjCSDK"); - if (SentryObjCSDK) { - SentryObjCId = objc_getClass("SentryObjCId"); - SentryObjCSpanId = objc_getClass("SentryObjCSpanId"); - SentryObjCHttpStatusCodeRange = objc_getClass("SentryObjCHttpStatusCodeRange"); - SentrySDKClass = SentryObjCSDK; - SentryBreadcrumbClass = objc_getClass("SentryObjCBreadcrumb"); - SentryUserClass = objc_getClass("SentryObjCUser"); - } else { - SentrySDKClass = objc_getClass("SentrySDKInternal"); - SentryBreadcrumbClass = objc_getClass("SentryBreadcrumb"); - SentryUserClass = objc_getClass("SentryUser"); - } - - if (!SentrySDKClass || !SentryBreadcrumbClass || !SentryUserClass) { - NSLog(@"Sentry (bridge): Couldn't find the Sentry SDK"); - break; - } +// macOS only +// On iOS, the SDK is linked statically so we don't need to dlopen() it. +int SentryNativeBridgeLoadLibrary() { return 1; } - loadStatus = 1; - } while (false); - } - return loadStatus; -} - -int SentryNativeBridgeIsEnabled() -{ - if (SentryNativeBridgeLoadLibrary() != 1) { - return 0; - } - return ((BOOL (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(isEnabled)) ? 1 : 0; -} +int SentryNativeBridgeIsEnabled() { return [SentryObjCSDK isEnabled] ? 1 : 0; } const void *SentryNativeBridgeOptionsNew() { @@ -136,13 +77,7 @@ void SentryNativeBridgeOptionsAddFailedRequestStatusCodeRange(const void *option ranges = [[NSMutableArray alloc] init]; dictOptions[@"failedRequestStatusCodes"] = ranges; } - id instance = [SentryObjCHttpStatusCodeRange alloc]; - // initWithMin:max: takes NSInteger args - use objc_msgSend directly - id range = ((id (*)(id, SEL, NSInteger, NSInteger))objc_msgSend)( - instance, @selector(initWithMin:max:), (NSInteger)min, (NSInteger)max); - if (range) { - [ranges addObject:range]; - } + [ranges addObject:[[SentryObjCHttpStatusCodeRange alloc] initWithMin:min max:max]]; } int SentryNativeBridgeStartWithOptions(const void *options) @@ -150,70 +85,27 @@ int SentryNativeBridgeStartWithOptions(const void *options) NSMutableDictionary *dictOptions = (__bridge_transfer NSMutableDictionary *)options; NSError *error = nil; - if (!SentryObjCSDK) { - return 0; - } - - id (*optionsFromDictionary)(id, SEL, NSDictionary *, NSError * __autoreleasing *) = - (void *)objc_msgSend; - id sentryOptions = optionsFromDictionary(SentryInternalApi(), @selector(optionsFromDictionary:error:), - dictOptions, &error); - + SentryObjCOptions *sentryOptions = [[SentryObjCSDK internal] optionsFromDictionary:dictOptions error:&error]; if (error != nil) { return 0; } - [SentryObjCSDK performSelector:@selector(startWithOptions:) withObject:sentryOptions]; + [SentryObjCSDK startWithOptions:sentryOptions]; return 1; } -void SentryConfigureScope(void (^callback)(id)) -{ - // setValue:forKey: may throw if the property is not found; same for performSelector. - // Even though this shouldn't happen, better not take the chance of letting an unhandled - // exception while setting error info - it would just crash the app immediately. - @try { - [SentrySDKClass performSelector:@selector(configureScope:) withObject:callback]; - } @catch (NSException *exception) { - NSLog(@"Sentry (bridge): failed to configure scope: %@", exception.reason); - } -} - -/*******************************************************************************/ -/* The classes are only known at runtime. Mainly: */ -/* - call: [class performSelector:@selector(arg1:arg2:) */ -/* withObject:arg1Value withObject:arg2Value]; */ -/* - use `id` as variable types */ -/* - use [obj setValue:value forKey:@"prop"] instead of `obj.prop = value` */ -/*******************************************************************************/ - void SentryNativeBridgeSetSdkName() { - id sdkApi = [SentryInternalApi() performSelector:@selector(sdk)]; - [sdkApi performSelector:@selector(setName:) withObject:@"sentry.cocoa.unity"]; + [SentryObjCSDK internal].sdk.name = @"sentry.cocoa.unity"; } int SentryNativeBridgeCrashedLastRun() { - @try { - NSInteger lastRunStatus = - ((NSInteger (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(lastRunStatus)); - return lastRunStatus == SentryObjCLastRunStatusDidCrash ? 1 : 0; - } @catch (NSException *exception) { - NSLog(@"Sentry (bridge): failed to get lastRunStatus: %@", exception.reason); - } - return -1; + return SentryObjCSDK.lastRunStatus == SentryObjCLastRunStatusDidCrash ? 1 : 0; } -void SentryNativeBridgeClose() -{ - @try { - [SentryObjCSDK performSelector:@selector(close)]; - } @catch (NSException *exception) { - NSLog(@"Sentry (bridge): failed to close: %@", exception.reason); - } -} +void SentryNativeBridgeClose() { [SentryObjCSDK close]; } void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, const char *type, const char *category, int level, const char **dataKeys, const char **dataValues, int dataCount) @@ -227,30 +119,26 @@ void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, NSString *typeString = _NSStringOrNil(type); NSString *categoryString = _NSStringOrNil(category) ?: @"default"; // Category cannot be nil - SentryConfigureScope(^(id scope) { - id breadcrumb = [[SentryBreadcrumbClass alloc] init]; + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + SentryObjCBreadcrumb *breadcrumb = [[SentryObjCBreadcrumb alloc] + initWithLevel:level + category:categoryString]; if (timestampString != nil && timestampString.length > 0) { NSDate *date = [sentry_cachedISO8601Formatter() dateFromString:timestampString]; if (date != nil) { - [breadcrumb setValue:date forKey:@"timestamp"]; + breadcrumb.timestamp = date; } } if (messageString != nil) { - [breadcrumb setValue:messageString forKey:@"message"]; + breadcrumb.message = messageString; } if (typeString != nil) { - [breadcrumb setValue:typeString forKey:@"type"]; + breadcrumb.type = typeString; } - if (categoryString != nil) { - [breadcrumb setValue:categoryString forKey:@"category"]; - } - - [breadcrumb setValue:[NSNumber numberWithInt:level] forKey:@"level"]; - if (dataCount > 0 && dataKeys != NULL && dataValues != NULL) { NSMutableDictionary *data = [NSMutableDictionary dictionaryWithCapacity:dataCount]; for (int i = 0; i < dataCount; i++) { @@ -260,11 +148,11 @@ void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, data[key] = value; } } - [breadcrumb setValue:data forKey:@"data"]; + breadcrumb.data = data; } - [scope performSelector:@selector(addBreadcrumb:) withObject:breadcrumb]; - }); + [scope addBreadcrumb:breadcrumb]; + }]; } void SentryNativeBridgeSetExtra(const char *key, const char *value) @@ -276,11 +164,9 @@ void SentryNativeBridgeSetExtra(const char *key, const char *value) NSString *keyString = [NSString stringWithUTF8String:key]; NSString *valueString = _NSStringOrNil(value); - SentryConfigureScope(^(id scope) { - [scope performSelector:@selector(setExtraValue:forKey:) - withObject:valueString - withObject:keyString]; - }); + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + [scope setExtraValue:valueString forKey:keyString]; + }]; } void SentryNativeBridgeSetTag(const char *key, const char *value) @@ -292,20 +178,18 @@ void SentryNativeBridgeSetTag(const char *key, const char *value) NSString *keyString = [NSString stringWithUTF8String:key]; NSString *valueString = _NSStringOrNil(value); - SentryConfigureScope(^(id scope) { - [scope performSelector:@selector(setTagValue:forKey:) - withObject:valueString - withObject:keyString]; - }); + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + [scope setTagValue:valueString forKey:keyString]; + }]; } void SentryNativeBridgeSetEnvironment(const char *environment) { NSString *environmentString = _NSStringOrNil(environment); - SentryConfigureScope(^(id scope) { - [scope performSelector:@selector(setEnvironment:) withObject:environmentString]; - }); + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + [scope setEnvironment:environmentString]; + }]; } void SentryNativeBridgeUnsetTag(const char *key) @@ -316,9 +200,9 @@ void SentryNativeBridgeUnsetTag(const char *key) NSString *keyString = [NSString stringWithUTF8String:key]; - SentryConfigureScope(^(id scope) { - [scope performSelector:@selector(removeTagForKey:) withObject:keyString]; - }); + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + [scope removeTagForKey:keyString]; + }]; } void SentryNativeBridgeSetUser( @@ -328,152 +212,43 @@ void SentryNativeBridgeSetUser( NSString *userIdString = _NSStringOrNil(userId); NSString *ipAddressString = _NSStringOrNil(ipAddress); NSString *usernameString = _NSStringOrNil(username); + + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { + SentryObjCUser *user = [[SentryObjCUser alloc] init]; - SentryConfigureScope(^(id scope) { - id user = [[SentryUserClass alloc] init]; + user.email = emailString; + user.userId = userIdString; + user.ipAddress = ipAddressString; + user.username = usernameString; - [user setValue:emailString forKey:@"email"]; - [user setValue:userIdString forKey:@"userId"]; - [user setValue:ipAddressString forKey:@"ipAddress"]; - [user setValue:usernameString forKey:@"username"]; - - [scope performSelector:@selector(setUser:) withObject:user]; - }); + [scope setUser:user]; + }]; } void SentryNativeBridgeUnsetUser() { - SentryConfigureScope( - ^(id scope) { [scope performSelector:@selector(setUser:) withObject:nil]; }); + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { [scope setUser:nil]; }]; } -// Create a null terminated C string on the heap as expected by marshalling. -// See Tips for iOS in https://docs.unity3d.com/Manual/PluginsForIOS.html -static char *SentryCopyString(NSString *value) +char *SentryNativeBridgeGetInstallationId() { - if (value == nil) { - return NULL; - } - - const char *nsStringUtf8 = [value UTF8String]; + // Create a null terminated C string on the heap as expected by marshalling. + // See Tips for iOS in https://docs.unity3d.com/Manual/PluginsForIOS.html + const char *nsStringUtf8 = [[SentryObjCSDK internal].sdk.installationID UTF8String]; size_t len = strlen(nsStringUtf8) + 1; char *cString = (char *)malloc(len); memcpy(cString, nsStringUtf8, len); return cString; } -// The options of the app's own SDK. Through `SentrySDKInternal`, which every distribution has. -static id SentryAppOptions(void) -{ - id hub = [objc_getClass("SentrySDKInternal") performSelector:@selector(currentHub)]; - id client = [hub performSelector:@selector(getClient)]; - return [client valueForKey:@"options"]; -} - -static NSString *SentryInstallationId(void) -{ - if (SentryObjCSDK) { - id sdkApi = [SentryInternalApi() performSelector:@selector(sdk)]; - return [sdkApi performSelector:@selector(installationID)]; - } - - // The app's own SDK: read the ID the way it does, from its cache directory. - @try { - NSString *cacheDirectoryPath = [SentryAppOptions() valueForKey:@"cacheDirectoryPath"]; - Class installation = NSClassFromString(@"Sentry.SentryInstallation"); - if (cacheDirectoryPath == nil - || ![installation respondsToSelector:@selector(idWithCacheDirectoryPath:)]) { - return nil; - } - return [installation performSelector:@selector(idWithCacheDirectoryPath:) - withObject:cacheDirectoryPath]; - } @catch (NSException *exception) { - NSLog(@"Sentry (bridge): failed to get the installation ID: %@", exception.reason); - return nil; - } -} - -char *SentryNativeBridgeGetInstallationId() { return SentryCopyString(SentryInstallationId()); } - -// A string option of the app's own SDK, e.g. `releaseName`, `environment`, or `dist`. -char *SentryNativeBridgeGetOption(const char *name) -{ - if (name == NULL) { - return NULL; - } - - @try { - id value = [SentryAppOptions() valueForKey:[NSString stringWithUTF8String:name]]; - return [value isKindOfClass:[NSString class]] ? SentryCopyString(value) : NULL; - } @catch (NSException *exception) { - NSLog(@"Sentry (bridge): failed to get option '%s': %@", name, exception.reason); - return NULL; - } -} - -static id SentryActiveSpan(id scope) -{ - return [scope respondsToSelector:@selector(span)] ? [scope performSelector:@selector(span)] : nil; -} +// Only the app's own SDK provides these, see SentryNativeBridgeExternal.m. +char *_Nullable SentryNativeBridgeGetOption(const char *name) { return NULL; } -// The `sentry-trace` header of the app's own SDK: its active span, otherwise its propagation context. The -// propagation context has no sampling decision, so its sampled flag is left out (deferred sampling). -char *SentryNativeBridgeGetTraceHeader() -{ - __block NSString *traceHeader = nil; - SentryConfigureScope(^(id scope) { - id span = SentryActiveSpan(scope); - if (span != nil && [span respondsToSelector:@selector(toTraceHeader)]) { - id header = [span performSelector:@selector(toTraceHeader)]; - if ([header respondsToSelector:@selector(value)]) { - traceHeader = [header performSelector:@selector(value)]; - } - } else if ([scope respondsToSelector:@selector(propagationContextTraceHeader)]) { - id header = [scope performSelector:@selector(propagationContextTraceHeader)]; - if ([header respondsToSelector:@selector(value)]) { - NSArray *parts = [[header performSelector:@selector(value)] componentsSeparatedByString:@"-"]; - if (parts.count >= 2) { - traceHeader = [NSString stringWithFormat:@"%@-%@", parts[0], parts[1]]; - } - } - } - }); - return SentryCopyString(traceHeader); -} +char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } -// The `baggage` header of the app's own SDK's active span, if any. -char *SentryNativeBridgeGetBaggageHeader() -{ - __block NSString *baggageHeader = nil; - SentryConfigureScope(^(id scope) { - id span = SentryActiveSpan(scope); - if (span != nil && [span respondsToSelector:@selector(baggageHttpHeader)]) { - baggageHeader = [span performSelector:@selector(baggageHttpHeader)]; - } - }); - return SentryCopyString(baggageHeader); -} +char *_Nullable SentryNativeBridgeGetBaggageHeader() { return NULL; } -// The user on the app's own SDK's scope, serialized to JSON. -char *SentryNativeBridgeGetUser() -{ - __block NSString *userJson = nil; - SentryConfigureScope(^(id scope) { - id user = [scope respondsToSelector:@selector(userObject)] - ? [scope performSelector:@selector(userObject)] - : nil; - if (user == nil || ![user respondsToSelector:@selector(serialize)]) { - return; - } - NSDictionary *serialized = [user performSelector:@selector(serialize)]; - if (![NSJSONSerialization isValidJSONObject:serialized]) { - return; - } - NSData *data = [NSJSONSerialization dataWithJSONObject:serialized options:0 error:nil]; - userJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - }); - return SentryCopyString(userJson); -} +char *_Nullable SentryNativeBridgeGetUser() { return NULL; } void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { @@ -481,18 +256,15 @@ void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) return; } - id sentryTraceId = ((id (*)(id, SEL, NSString *))objc_msgSend)( - [SentryObjCId alloc], @selector(initWithUUIDString:), - [NSString stringWithUTF8String:traceId]); + NSString *traceIdStr = [NSString stringWithUTF8String:traceId]; + NSString *spanIdStr = [NSString stringWithUTF8String:spanId]; - id sentrySpanId = ((id (*)(id, SEL, NSString *))objc_msgSend)( - [SentryObjCSpanId alloc], @selector(initWithValue:), - [NSString stringWithUTF8String:spanId]); + SentryObjCId *sentryTraceId = [[SentryObjCId alloc] initWithUUIDString:traceIdStr]; + SentryObjCSpanId *sentrySpanId = [[SentryObjCSpanId alloc] initWithValue:spanIdStr]; - [SentryInternalApi() - performSelector:@selector(setTrace:spanId:) - withObject:sentryTraceId - withObject:sentrySpanId]; + if (sentryTraceId && sentrySpanId) { + [[SentryObjCSDK internal] setTrace:sentryTraceId spanId:sentrySpanId]; + } } void SentryNativeBridgeWriteScope( // clang-format off @@ -532,7 +304,7 @@ void SentryNativeBridgeWriteScope( // clang-format off ) // clang-format on { // Note: we're using a NSMutableDictionary because it will skip fields with nil values. - SentryConfigureScope(^(id scope) { + [SentryObjCSDK configureScope:^(SentryObjCScope *scope) { NSMutableDictionary *gpu = [[NSMutableDictionary alloc] init]; gpu[@"id"] = _NSNumberOrNil(GpuId); gpu[@"name"] = _NSStringOrNil(GpuName); @@ -549,7 +321,7 @@ void SentryNativeBridgeWriteScope( // clang-format off gpu[@"vendor_id"] = _NSStringOrNil(GpuVendorId); gpu[@"multi_threaded_rendering"] = _NSBoolOrNil(GpuMultiThreadedRendering); gpu[@"graphics_shader_level"] = _NSStringOrNil(GpuGraphicsShaderLevel); - [scope performSelector:@selector(setContextValue:forKey:) withObject:gpu withObject:@"gpu"]; + [scope setContextValue:gpu forKey:@"gpu"]; NSMutableDictionary *unity = [[NSMutableDictionary alloc] init]; unity[@"editor_version"] = _NSStringOrNil(EditorVersion); @@ -557,8 +329,8 @@ void SentryNativeBridgeWriteScope( // clang-format off unity[@"target_frame_rate"] = _NSStringOrNil(UnityTargetFrameRate); unity[@"copy_texture_support"] = _NSStringOrNil(UnityCopyTextureSupport); unity[@"rendering_threading_mode"] = _NSStringOrNil(UnityRenderingThreadingMode); - [scope performSelector:@selector(setContextValue:forKey:) - withObject:unity - withObject:@"unity"]; - }); + [scope setContextValue:unity forKey:@"unity"]; + }]; } + +NS_ASSUME_NONNULL_END diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m new file mode 100644 index 000000000..19cfd2477 --- /dev/null +++ b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m @@ -0,0 +1,383 @@ +#import +#include +#include + +/*******************************************************************************/ +/* The bridge for `NativeInitializationType.External`: the app that embeds the */ +/* game links its own sentry-cocoa, e.g. Unity as a Library. The export ships */ +/* no headers, so its core classes are only known at runtime. Every call is */ +/* checked with respondsToSelector: first, so a missing selector is skipped */ +/* instead of throwing. */ +/*******************************************************************************/ + +static NSDateFormatter *_Nullable sentry_cachedISO8601Formatter(void) { + static NSDateFormatter *formatter = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + formatter = [[NSDateFormatter alloc] init]; + [formatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"]; + formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"]; + formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + }); + return formatter; +} + +static inline NSString *_NSStringOrNil(const char *value) +{ + return value ? [NSString stringWithUTF8String:value] : nil; +} + +static inline NSNumber *_NSNumberOrNil(int32_t value) +{ + return value == 0 ? nil : @(value); +} + +static inline NSNumber *_NSBoolOrNil(int8_t value) +{ + if (value == 0) { + return @NO; + } + if (value == 1) { + return @YES; + } + return nil; +} + +static int loadStatus = -1; // unitialized + +static Class SentrySDKClass; +static Class SentryBreadcrumbClass; + +// SentryLastRunStatus.didCrash +static const NSInteger SentryLastRunStatusDidCrash = 2; + +// The selectors are getters and setters, which don't return owned objects. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" +static id SentryCall(id target, SEL selector) +{ + return [target respondsToSelector:selector] ? [target performSelector:selector] : nil; +} + +static id SentryCallWith(id target, SEL selector, id argument) +{ + return [target respondsToSelector:selector] ? [target performSelector:selector withObject:argument] : nil; +} + +static void SentryCallWith2(id target, SEL selector, id argument1, id argument2) +{ + if ([target respondsToSelector:selector]) { + [target performSelector:selector withObject:argument1 withObject:argument2]; + } +} + +#pragma clang diagnostic pop + +static id SentryGetValue(id object, NSString *key) +{ + return [object respondsToSelector:NSSelectorFromString(key)] ? [object valueForKey:key] : nil; +} + +static void SentrySetValue(id object, NSString *key, id value) +{ + NSString *setter = [NSString stringWithFormat:@"set%@%@:", [[key substringToIndex:1] uppercaseString], + [key substringFromIndex:1]]; + if ([object respondsToSelector:NSSelectorFromString(setter)]) { + [object setValue:value forKey:key]; + } +} + +// Returns (bool): 0 on failure, 1 on success +// WARNING: you may only call other Sentry* functions AFTER calling this AND only if it returned "1" +int SentryNativeBridgeLoadLibrary() +{ + if (loadStatus == -1) { + loadStatus = 0; // init to "error" + SentrySDKClass = objc_getClass("SentrySDKInternal"); + SentryBreadcrumbClass = objc_getClass("SentryBreadcrumb"); + if (SentrySDKClass && SentryBreadcrumbClass) { + loadStatus = 1; + } else { + NSLog(@"Sentry (bridge): Couldn't find the app's Sentry SDK"); + } + } + return loadStatus; +} + +int SentryNativeBridgeIsEnabled() +{ + if (SentryNativeBridgeLoadLibrary() != 1 || ![SentrySDKClass respondsToSelector:@selector(isEnabled)]) { + return 0; + } + return ((BOOL (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(isEnabled)) ? 1 : 0; +} + +// The app starts, names and closes its SDK and owns its user, environment, and trace. +const void *SentryNativeBridgeOptionsNew() { return NULL; } + +void SentryNativeBridgeOptionsSetString(const void *options, const char *name, const char *value) { } + +void SentryNativeBridgeOptionsSetInt(const void *options, const char *name, int32_t value) { } + +void SentryNativeBridgeOptionsSetDouble(const void *options, const char *name, double value) { } + +void SentryNativeBridgeOptionsAddFailedRequestStatusCodeRange(const void *options, int32_t min, int32_t max) { } + +int SentryNativeBridgeStartWithOptions(const void *options) { return 0; } + +void SentryNativeBridgeSetSdkName() { } + +void SentryNativeBridgeClose() { } + +void SentryNativeBridgeSetEnvironment(const char *environment) { } + +void SentryNativeBridgeSetUser( + const char *email, const char *userId, const char *ipAddress, const char *username) { } + +void SentryNativeBridgeUnsetUser() { } + +void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } + +static void SentryConfigureScope(void (^callback)(id)) +{ + SentryCallWith(SentrySDKClass, @selector(configureScope:), callback); +} + +int SentryNativeBridgeCrashedLastRun() +{ + if (![SentrySDKClass respondsToSelector:@selector(lastRunStatus)]) { + return -1; + } + NSInteger lastRunStatus = ((NSInteger (*)(id, SEL))objc_msgSend)(SentrySDKClass, @selector(lastRunStatus)); + return lastRunStatus == SentryLastRunStatusDidCrash ? 1 : 0; +} + +void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, const char *type, + const char *category, int level, const char **dataKeys, const char **dataValues, int dataCount) +{ + if (timestamp == NULL && message == NULL && type == NULL && category == NULL) { + return; + } + + NSString *timestampString = _NSStringOrNil(timestamp); + NSString *messageString = _NSStringOrNil(message); + NSString *typeString = _NSStringOrNil(type); + NSString *categoryString = _NSStringOrNil(category) ?: @"default"; // Category cannot be nil + + SentryConfigureScope(^(id scope) { + id breadcrumb = [[SentryBreadcrumbClass alloc] init]; + + if (timestampString != nil && timestampString.length > 0) { + NSDate *date = [sentry_cachedISO8601Formatter() dateFromString:timestampString]; + if (date != nil) { + SentrySetValue(breadcrumb, @"timestamp", date); + } + } + + if (messageString != nil) { + SentrySetValue(breadcrumb, @"message", messageString); + } + + if (typeString != nil) { + SentrySetValue(breadcrumb, @"type", typeString); + } + + SentrySetValue(breadcrumb, @"category", categoryString); + SentrySetValue(breadcrumb, @"level", [NSNumber numberWithInt:level]); + + if (dataCount > 0 && dataKeys != NULL && dataValues != NULL) { + NSMutableDictionary *data = [NSMutableDictionary dictionaryWithCapacity:dataCount]; + for (int i = 0; i < dataCount; i++) { + NSString *key = _NSStringOrNil(dataKeys[i]); + NSString *value = _NSStringOrNil(dataValues[i]); + if (key != nil && value != nil) { + data[key] = value; + } + } + SentrySetValue(breadcrumb, @"data", data); + } + + SentryCallWith(scope, @selector(addBreadcrumb:), breadcrumb); + }); +} + +void SentryNativeBridgeSetExtra(const char *key, const char *value) +{ + if (key == NULL) { + return; + } + + NSString *keyString = [NSString stringWithUTF8String:key]; + NSString *valueString = _NSStringOrNil(value); + + SentryConfigureScope(^(id scope) { + SentryCallWith2(scope, @selector(setExtraValue:forKey:), valueString, keyString); + }); +} + +void SentryNativeBridgeSetTag(const char *key, const char *value) +{ + if (key == NULL) { + return; + } + + NSString *keyString = [NSString stringWithUTF8String:key]; + NSString *valueString = _NSStringOrNil(value); + + SentryConfigureScope(^(id scope) { + SentryCallWith2(scope, @selector(setTagValue:forKey:), valueString, keyString); + }); +} + +void SentryNativeBridgeUnsetTag(const char *key) +{ + if (key == NULL) { + return; + } + + NSString *keyString = [NSString stringWithUTF8String:key]; + + SentryConfigureScope(^(id scope) { + SentryCallWith(scope, @selector(removeTagForKey:), keyString); + }); +} + +// Create a null terminated C string on the heap as expected by marshalling. +// See Tips for iOS in https://docs.unity3d.com/Manual/PluginsForIOS.html +static char *SentryCopyString(NSString *value) +{ + if (![value isKindOfClass:[NSString class]]) { + return NULL; + } + + const char *nsStringUtf8 = [value UTF8String]; + size_t len = strlen(nsStringUtf8) + 1; + char *cString = (char *)malloc(len); + memcpy(cString, nsStringUtf8, len); + return cString; +} + +static id SentryAppOptions(void) +{ + id hub = SentryCall(SentrySDKClass, @selector(currentHub)); + id client = SentryCall(hub, @selector(getClient)); + return SentryGetValue(client, @"options"); +} + +// Read the ID the way the app's SDK does, from its cache directory. +char *SentryNativeBridgeGetInstallationId() +{ + NSString *cacheDirectoryPath = SentryGetValue(SentryAppOptions(), @"cacheDirectoryPath"); + if (cacheDirectoryPath == nil) { + return NULL; + } + Class installation = NSClassFromString(@"Sentry.SentryInstallation"); + return SentryCopyString(SentryCallWith(installation, @selector(idWithCacheDirectoryPath:), cacheDirectoryPath)); +} + +// A string option of the app's SDK, e.g. `releaseName`, `environment`, or `dist`. +char *SentryNativeBridgeGetOption(const char *name) +{ + if (name == NULL) { + return NULL; + } + return SentryCopyString(SentryGetValue(SentryAppOptions(), [NSString stringWithUTF8String:name])); +} + +// The `sentry-trace` header of the app's SDK: its active span, otherwise its propagation context. The +// propagation context has no sampling decision, so its sampled flag is left out (deferred sampling). +char *SentryNativeBridgeGetTraceHeader() +{ + __block NSString *traceHeader = nil; + SentryConfigureScope(^(id scope) { + id span = SentryCall(scope, @selector(span)); + if (span != nil) { + traceHeader = SentryCall(SentryCall(span, @selector(toTraceHeader)), @selector(value)); + return; + } + NSString *value = SentryCall(SentryCall(scope, @selector(propagationContextTraceHeader)), @selector(value)); + NSArray *parts = [value componentsSeparatedByString:@"-"]; + if (parts.count >= 2) { + traceHeader = [NSString stringWithFormat:@"%@-%@", parts[0], parts[1]]; + } + }); + return SentryCopyString(traceHeader); +} + +// The `baggage` header of the app's SDK's active span, if any. +char *SentryNativeBridgeGetBaggageHeader() +{ + __block NSString *baggageHeader = nil; + SentryConfigureScope(^(id scope) { + baggageHeader = SentryCall(SentryCall(scope, @selector(span)), @selector(baggageHttpHeader)); + }); + return SentryCopyString(baggageHeader); +} + +// The user on the app's SDK's scope, serialized to JSON. +char *SentryNativeBridgeGetUser() +{ + __block NSString *userJson = nil; + SentryConfigureScope(^(id scope) { + NSDictionary *serialized = SentryCall(SentryCall(scope, @selector(userObject)), @selector(serialize)); + if (serialized == nil || ![NSJSONSerialization isValidJSONObject:serialized]) { + return; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:serialized options:0 error:nil]; + userJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + }); + return SentryCopyString(userJson); +} + +void SentryNativeBridgeWriteScope( // clang-format off + int32_t GpuId, + const char *GpuName, + const char *GpuVendorName, + int32_t GpuMemorySize, + const char *GpuNpotSupport, + const char *GpuVersion, + const char *GpuApiType, + int32_t GpuMaxTextureSize, + int8_t GpuSupportsDrawCallInstancing, + int8_t GpuSupportsRayTracing, + int8_t GpuSupportsComputeShaders, + int8_t GpuSupportsGeometryShaders, + const char *GpuVendorId, + int8_t GpuMultiThreadedRendering, + const char *GpuGraphicsShaderLevel, + const char *EditorVersion, + const char *UnityInstallMode, + const char *UnityTargetFrameRate, + const char *UnityCopyTextureSupport, + const char *UnityRenderingThreadingMode +) // clang-format on +{ + // Note: we're using a NSMutableDictionary because it will skip fields with nil values. + SentryConfigureScope(^(id scope) { + NSMutableDictionary *gpu = [[NSMutableDictionary alloc] init]; + gpu[@"id"] = _NSNumberOrNil(GpuId); + gpu[@"name"] = _NSStringOrNil(GpuName); + gpu[@"vendor_name"] = _NSStringOrNil(GpuVendorName); + gpu[@"memory_size"] = _NSNumberOrNil(GpuMemorySize); + gpu[@"npot_support"] = _NSStringOrNil(GpuNpotSupport); + gpu[@"version"] = _NSStringOrNil(GpuVersion); + gpu[@"api_type"] = _NSStringOrNil(GpuApiType); + gpu[@"max_texture_size"] = _NSNumberOrNil(GpuMaxTextureSize); + gpu[@"supports_draw_call_instancing"] = _NSBoolOrNil(GpuSupportsDrawCallInstancing); + gpu[@"supports_ray_tracing"] = _NSBoolOrNil(GpuSupportsRayTracing); + gpu[@"supports_compute_shaders"] = _NSBoolOrNil(GpuSupportsComputeShaders); + gpu[@"supports_geometry_shaders"] = _NSBoolOrNil(GpuSupportsGeometryShaders); + gpu[@"vendor_id"] = _NSStringOrNil(GpuVendorId); + gpu[@"multi_threaded_rendering"] = _NSBoolOrNil(GpuMultiThreadedRendering); + gpu[@"graphics_shader_level"] = _NSStringOrNil(GpuGraphicsShaderLevel); + SentryCallWith2(scope, @selector(setContextValue:forKey:), gpu, @"gpu"); + + NSMutableDictionary *unity = [[NSMutableDictionary alloc] init]; + unity[@"editor_version"] = _NSStringOrNil(EditorVersion); + unity[@"install_mode"] = _NSStringOrNil(UnityInstallMode); + unity[@"target_frame_rate"] = _NSStringOrNil(UnityTargetFrameRate); + unity[@"copy_texture_support"] = _NSStringOrNil(UnityCopyTextureSupport); + unity[@"rendering_threading_mode"] = _NSStringOrNil(UnityRenderingThreadingMode); + SentryCallWith2(scope, @selector(setContextValue:forKey:), unity, @"unity"); + }); +} diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m.meta b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m.meta new file mode 100644 index 000000000..00924a42e --- /dev/null +++ b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m.meta @@ -0,0 +1,78 @@ +fileFormatVersion: 2 +guid: 1fc520c7b653427d830ac5aa68336424 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 0 + settings: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/package-dev/Plugins/macOS/SentryNativeBridge.m b/package-dev/Plugins/macOS/SentryNativeBridge.m index 6c0e9ed94..3d3361e38 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -176,8 +176,8 @@ void SentryConfigureScope(void (^callback)(id)) } /*******************************************************************************/ -/* The remaining code mirrors iOS/SentryNativeBridge.m. Both work with classes */ -/* that are only known at runtime. Mainly: */ +/* The remaining code is a copy of iOS/SentryNativeBridge.m with changes to */ +/* make it work with dynamically loaded classes. Mainly: */ /* - call: [class performSelector:@selector(arg1:arg2:) */ /* withObject:arg1Value withObject:arg2Value]; */ /* or xCode warns of class/instance method not found */ diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index a1ea0a2b6..02f57dbe6 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -143,7 +143,8 @@ internal static void SetupSentry(SentryUnityOptions options, IDiagnosticLogger l CopyFramework(frameworkPath, Path.Combine(pathToProject, "Frameworks", SentryXcodeProject.FrameworkName), logger); } - var nativeBridgePath = Path.GetFullPath(Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "iOS", SentryXcodeProject.BridgeName)); + var bridgeName = bundlesSdk ? SentryXcodeProject.BridgeName : SentryXcodeProject.ExternalBridgeName; + var nativeBridgePath = Path.GetFullPath(Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "iOS", bridgeName)); CopyFile(nativeBridgePath, Path.Combine(pathToProject, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName), logger); using var sentryXcodeProject = SentryXcodeProject.Open(pathToProject, logger); diff --git a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs index 38af4bf82..f9b060aa3 100644 --- a/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs +++ b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs @@ -18,6 +18,7 @@ internal class SentryXcodeProject : IDisposable internal const string FrameworkName = "SentryObjC.xcframework"; internal const string BridgeName = "SentryNativeBridge.m"; internal const string NoOpBridgeName = "SentryNativeBridgeNoOp.m"; + internal const string ExternalBridgeName = "SentryNativeBridgeExternal.m"; internal const string OptionsName = "SentryOptions.m"; internal const string SymbolUploadPhaseName = "SymbolUpload"; @@ -174,9 +175,8 @@ public void AddSentryNativeBridge() var bridgeGuid = (string)_pbxProjectType.GetMethod("AddFile", BindingFlags.Public | BindingFlags.Instance) .Invoke(_project, new object[] { relativeBridgePath, relativeBridgePath, 1 }); // 1 is PBXSourceTree.Source - // The bridge guards its runtime lookups with @try, but Unity turns Objective-C exceptions off. - _pbxProjectType.GetMethod("AddFileToBuildWithFlags", new[] { typeof(string), typeof(string), typeof(string) }) - .Invoke(_project, new object[] { _unityFrameworkTargetGuid, bridgeGuid, "-fobjc-exceptions" }); + _pbxProjectType.GetMethod("AddFileToBuild", BindingFlags.Public | BindingFlags.Instance) + .Invoke(_project, new[] { _unityFrameworkTargetGuid, bridgeGuid }); _logger?.LogDebug("Successfully added the Sentry Native Bridge."); } diff --git a/test/Scripts.Tests/package-release.zip.snapshot b/test/Scripts.Tests/package-release.zip.snapshot index 5d884886c..54f1f4782 100644 --- a/test/Scripts.Tests/package-release.zip.snapshot +++ b/test/Scripts.Tests/package-release.zip.snapshot @@ -63,6 +63,8 @@ Plugins/iOS/SentryCxaThrowHook.cpp Plugins/iOS/SentryCxaThrowHook.cpp.meta Plugins/iOS/SentryNativeBridge.m Plugins/iOS/SentryNativeBridge.m.meta +Plugins/iOS/SentryNativeBridgeExternal.m +Plugins/iOS/SentryNativeBridgeExternal.m.meta Plugins/iOS/SentryNativeBridgeNoOp.m Plugins/iOS/SentryNativeBridgeNoOp.m.meta Plugins/iOS/SentryObjC.xcframework~/ios-arm64/ diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 5a645d69f..8b17ae746 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -117,6 +117,8 @@ public void AddSentryToXcodeProject_NativeSupportEnabledAndInitializationTypeExt var bridgePath = Path.Combine(_outputProjectPath, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName); Assert.IsTrue(File.Exists(bridgePath)); + StringAssert.Contains("SentrySDKInternal", File.ReadAllText(bridgePath)); // The bridge to the app's own SDK + StringAssert.DoesNotContain("SentryObjC", File.ReadAllText(bridgePath)); Assert.IsFalse(NativeMain.ContainsSentry(mainFile, null)); // The main.mm must not contain Sentry } diff --git a/test/Sentry.Unity.Editor.iOS.Tests/SentryNativeBridgeTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/SentryNativeBridgeTests.cs index 25a75dbdc..8b7b53a1d 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/SentryNativeBridgeTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/SentryNativeBridgeTests.cs @@ -23,20 +23,17 @@ public void EntryPoints_ExistInNativeBridges() var packageRoot = Path.GetFullPath(Path.Combine( Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "..", "..")); - var bridgePath = Path.Combine(packageRoot, "Plugins", "iOS", "SentryNativeBridge.m"); - var noOpBridgePath = Path.Combine(packageRoot, "Plugins", "iOS", "SentryNativeBridgeNoOp.m"); + var bridgePaths = new[] { "SentryNativeBridge.m", "SentryNativeBridgeNoOp.m", "SentryNativeBridgeExternal.m" } + .Select(name => Path.Combine(packageRoot, "Plugins", "iOS", name)); - var bridgeContent = File.ReadAllText(bridgePath); - var noOpBridgeContent = File.ReadAllText(noOpBridgePath); - - foreach (var entryPoint in entryPoints) + foreach (var bridgePath in bridgePaths) { - var pattern = $@"{entryPoint}\s*\("; - - Assert.That(Regex.IsMatch(bridgeContent, pattern), - $"Entry point '{entryPoint}' not found in {bridgePath}"); - Assert.That(Regex.IsMatch(noOpBridgeContent, pattern), - $"Entry point '{entryPoint}' not found in {noOpBridgePath}"); + var bridgeContent = File.ReadAllText(bridgePath); + foreach (var entryPoint in entryPoints) + { + Assert.That(Regex.IsMatch(bridgeContent, $@"{entryPoint}\s*\("), + $"Entry point '{entryPoint}' not found in {bridgePath}"); + } } } } diff --git a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs index 05d61a0fc..6903cf79a 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/SentryXcodeProjectTests.cs @@ -172,17 +172,6 @@ public void AddBuildPhaseSymbolUpload_UploadFromUnityFramework_BuildPhaseAddedTo StringAssert.Contains("${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", xcodeProject.ProjectToString()); } - [Test] - public void AddSentryNativeBridge_CompilesBridgeWithObjectiveCExceptions() - { - var xcodeProject = _fixture.GetSut(); - xcodeProject.ReadFromProjectFile(); - - xcodeProject.AddSentryNativeBridge(); - - StringAssert.Contains("-fobjc-exceptions", xcodeProject.ProjectToString()); - } - [Test] public void AddBuildPhaseSymbolUpload_PhaseAlreadyAdded_LogsAndDoesNotAddAgain() { From 664f49eef60b12d3890d92b01408e9ea0aa5d667 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 18:27:46 +0100 Subject: [PATCH 34/44] Keep a release or environment set through the Editor overrides even when it matches the default --- src/Sentry.Unity/ScriptableSentryUnityOptions.cs | 2 ++ src/Sentry.Unity/SentryUnityOptions.cs | 7 +++++-- .../ScriptableSentryUnityOptionsTests.cs | 16 ++++++++++++++++ .../SentryUnityOptionsTests.cs | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 6bfb920f7..936d22a33 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -290,11 +290,13 @@ internal SentryUnityOptions ToSentryUnityOptions( if (!string.IsNullOrWhiteSpace(ReleaseOverride)) { options.Release = ReleaseOverride; + options.ReleaseOverridden = true; } if (!string.IsNullOrWhiteSpace(EnvironmentOverride)) { options.Environment = EnvironmentOverride; + options.EnvironmentOverridden = true; } options.AddBreadcrumbsForLogType[LogType.Log] = BreadcrumbsForLogs; diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index ecf5f443b..d89fb7b7a 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -494,19 +494,22 @@ internal string? DefaultUserId // Lets the native SDK of an app that embeds the game provide release and environment, unless they were set. internal string? DefaultRelease { get; } internal string DefaultEnvironment { get; } + // Set through the Editor's overrides, which may match the defaults. + internal bool ReleaseOverridden { get; set; } + internal bool EnvironmentOverridden { get; set; } // Events from the game belong to the app's release and environment, unless they were set in the options. internal void UseAppReleaseAndEnvironment(Func getRelease, Func getDistribution, Func getEnvironment) { - if (Release == DefaultRelease && getRelease() is { } release) + if (!ReleaseOverridden && Release == DefaultRelease && getRelease() is { } release) { DiagnosticLogger?.LogDebug("Using the app's release: {0}", release); Release = release; Distribution ??= getDistribution(); } - if (Environment == DefaultEnvironment && getEnvironment() is { } environment) + if (!EnvironmentOverridden && Environment == DefaultEnvironment && getEnvironment() is { } environment) { DiagnosticLogger?.LogDebug("Using the app's environment: {0}", environment); Environment = environment; diff --git a/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs b/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs index 030108086..e9ec78357 100644 --- a/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs +++ b/test/Sentry.Unity.Tests/ScriptableSentryUnityOptionsTests.cs @@ -101,6 +101,22 @@ public void ToSentryUnityOptions_ValueMapping_AreEqual(bool isBuilding, bool ena AssertOptions(expectedOptions, optionsActual); } + [Test] + [TestCase("", "", false, false)] + [TestCase("game@1.0", "production", true, true)] + public void ToSentryUnityOptions_Overrides_MarkReleaseAndEnvironmentOverridden( + string releaseOverride, string environmentOverride, bool releaseOverridden, bool environmentOverridden) + { + var scriptableOptions = ScriptableObject.CreateInstance(); + scriptableOptions.ReleaseOverride = releaseOverride; + scriptableOptions.EnvironmentOverride = environmentOverride; + + var options = scriptableOptions.ToSentryUnityOptions(application: _fixture.Application); + + Assert.AreEqual(releaseOverridden, options.ReleaseOverridden); + Assert.AreEqual(environmentOverridden, options.EnvironmentOverridden); + } + [Test] public void ToSentryUnityOptions_MapsAutoMetricsOptions() { diff --git a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs index b51d828b6..aa5ad642e 100644 --- a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs +++ b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs @@ -139,6 +139,22 @@ public void UseAppReleaseAndEnvironment_SetInOptions_KeepsOptions() Assert.AreEqual("game", options.Environment); } + [Test] + public void UseAppReleaseAndEnvironment_DefaultsSetAsOverrides_KeepsOptions() + { + var options = _fixture.GetSut(); + options.ReleaseOverridden = true; + options.EnvironmentOverridden = true; + var release = options.Release; + var environment = options.Environment; + + options.UseAppReleaseAndEnvironment(() => "app@1.0", () => "42", () => "staging"); + + Assert.AreEqual(release, options.Release); + Assert.IsNull(options.Distribution); + Assert.AreEqual(environment, options.Environment); + } + [Test] public void UseAppReleaseAndEnvironment_AppHasNoValues_KeepsDefaults() { From 5cbaa0b3e974a6ce4a3abcb0e891cedb3b429a26 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 20:26:08 +0100 Subject: [PATCH 35/44] Keep a release or environment overridden in the Editor in the game's own Android process --- src/Sentry.Unity.Android/SentryJava.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index d87151edb..fbf670895 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -224,7 +224,7 @@ public void Init(SentryUnityOptions options) if (isExternal && GetProcessName() is { } processName) { // Its events belong to the app's release and environment, persisted by the app's SDK. - if (options.Release == options.DefaultRelease && ReadMainProcessOption(androidOptions, "release.json") is { } release) + if (!options.ReleaseOverridden && options.Release == options.DefaultRelease && ReadMainProcessOption(androidOptions, "release.json") is { } release) { androidOptions.Call("setRelease", release); if (options.Distribution is null) @@ -232,7 +232,7 @@ public void Init(SentryUnityOptions options) androidOptions.Call("setDist", ReadMainProcessOption(androidOptions, "dist.json")); } } - if (options.Environment == options.DefaultEnvironment && ReadMainProcessOption(androidOptions, "environment.json") is { } environment) + if (!options.EnvironmentOverridden && options.Environment == options.DefaultEnvironment && ReadMainProcessOption(androidOptions, "environment.json") is { } environment) { androidOptions.Call("setEnvironment", environment); } From f334634fd5571fc6d791ba940790e56bcc14adeb Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 20:26:32 +0100 Subject: [PATCH 36/44] Pin the External bridge's empty platform scope in the plugin platform check --- test/Scripts.Tests/test-plugin-platforms.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/Scripts.Tests/test-plugin-platforms.ps1 b/test/Scripts.Tests/test-plugin-platforms.ps1 index 602beaf8c..9604f9d57 100644 --- a/test/Scripts.Tests/test-plugin-platforms.ps1 +++ b/test/Scripts.Tests/test-plugin-platforms.ps1 @@ -31,9 +31,10 @@ $ExpectedPluginScopes = @{ # the same symbol from its own stubs or from sentry-switch, and Xbox goes through msvcrt. "Plugins/PS5/sentry_utils.c" = "PS5" "Plugins/iOS/SentryCxaThrowHook.cpp" = "iOS" - # The two bridge sources target nothing on purpose. BuildPostProcess copies whichever one applies + # The bridge sources target nothing on purpose. BuildPostProcess copies whichever one applies # into the generated Xcode project, so enabling a platform here would collide with that copy. "Plugins/iOS/SentryNativeBridge.m" = "" + "Plugins/iOS/SentryNativeBridgeExternal.m" = "" "Plugins/iOS/SentryNativeBridgeNoOp.m" = "" "Plugins/macOS/SentryNativeBridge.m" = "OSXUniversal" "Runtime/Sentry.dll" = "Any" From 1ac43b4169c3c5ad4005f5bd045a449b00062f48 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 21:12:06 +0100 Subject: [PATCH 37/44] Pass a release or environment overridden in the Editor to sentry-java in the game's own Android process --- src/Sentry.Unity.Android/SentryJava.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index fbf670895..ee617dc9c 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -181,12 +181,12 @@ public void Init(SentryUnityOptions options) // With external initialization, the native SDK keeps the app's release and environment unless they were set. var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; - if (!isExternal || options.Release != options.DefaultRelease) + if (!isExternal || options.ReleaseOverridden || options.Release != options.DefaultRelease) { androidOptions.Call("setRelease", options.Release); } androidOptions.Call("setDist", options.Distribution); - if (!isExternal || options.Environment != options.DefaultEnvironment) + if (!isExternal || options.EnvironmentOverridden || options.Environment != options.DefaultEnvironment) { androidOptions.Call("setEnvironment", options.Environment); } From b66006433f6fe5ce80154967139bdc51a0fc8b23 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 12:03:02 +0100 Subject: [PATCH 38/44] Move the Unity as a Library entry below the fixes in the CHANGELOG --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eaa24a53..d3cbc86c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Features - -- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) - ### Behavioral Changes - Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events captured while a screenshot is still being taken share it. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. `SetBeforeSendScreenshot` now runs once per screenshot, receiving the first of the events sharing it that didn't get dropped. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) @@ -14,6 +10,10 @@ - Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and encoded on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +### Features + +- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) + ### Dependencies - Bump CLI from v3.8.0 to v3.8.2 ([#2893](https://github.com/getsentry/sentry-unity/pull/2893)) From 401711226c74a145ef31465128cc062dba7877c3 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 12:07:19 +0100 Subject: [PATCH 39/44] Describe the symbol upload in the CHANGELOG by what it does for users --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3cbc86c1..50d29fce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Features -- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and debug symbols upload from the `UnityFramework` target. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) ### Dependencies From cac9cef0c28ab45c7ee7d63f1d31a40f4b59d276 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 12:10:53 +0100 Subject: [PATCH 40/44] Merge the Android and iOS Unity as a Library entries in the CHANGELOG --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b422ec2b..80457458f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,7 @@ ### Features -- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) -- Added experimental support for Unity as a Library on Android. With `AndroidNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-android SDK that the app already started instead of bundling and starting its own. When the game runs in its own process, it starts sentry-android there and continues the app's trace. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) +- Added experimental support for Unity as a Library on Android and iOS. With `AndroidNativeInitializationType` or `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-android or sentry-cocoa SDK that the app already started instead of bundling and starting its own. On Android, when the game runs in its own process, it starts sentry-android there and continues the app's trace. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885), [#2895](https://github.com/getsentry/sentry-unity/pull/2895)) ### Dependencies From bef9a742fa2224c75bbd0e5895694804a22bf5a6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 17:25:50 +0100 Subject: [PATCH 41/44] Keep the app's user when the game can't read it on resume --- src/Sentry.Unity.Android/SentryJava.cs | 38 +++++++++++++------ .../SentryNativeAndroid.cs | 6 ++- .../SentryNativeAndroidTests.cs | 35 +++++++++++++++++ .../TestSentryJava.cs | 7 +++- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index ee617dc9c..2afdfb24d 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -18,7 +18,7 @@ internal interface ISentryJava public string? GetBaggageHeader(); public string? GetMainProcessTraceHeader(); public SentryUser? GetUser(); - public SentryUser? GetMainProcessUser(); + public bool TryGetMainProcessUser(out SentryUser? user); public string? GetInstallationId(); public bool? CrashedLastRun(); public void Close(); @@ -333,13 +333,15 @@ public void Init(SentryUnityOptions options) return null; } - // The user the app's SDK in the main process persisted. - public SentryUser? GetMainProcessUser() + // The user the app's SDK in the main process persisted, null when it has none. False when the file can't be read, + // e.g. while the app's SDK rewrites it. + public bool TryGetMainProcessUser(out SentryUser? user) { + user = null; if (!MainThreadData.IsMainThread()) { - _logger?.LogError("Calling GetMainProcessUser() on Android SDK requires running on MainThread"); - return null; + _logger?.LogError("Calling TryGetMainProcessUser() on Android SDK requires running on MainThread"); + return false; } try @@ -347,15 +349,21 @@ public void Init(SentryUnityOptions options) using var sentry = GetSentryJava(); using var hub = sentry.CallStatic("getCurrentHub"); using var options = hub.Call("getOptions"); - using var user = ReadMainProcessFile(options, ".scope-cache/user.json", "io.sentry.protocol.User"); - return ToSentryUser(user); + using var javaUser = ReadMainProcessFile(options, ".scope-cache/user.json", "io.sentry.protocol.User", out var failed); + if (failed) + { + return false; + } + + user = ToSentryUser(javaUser); + return true; } catch (Exception e) { - _logger?.LogError(e, "Calling 'SentryJava.GetMainProcessUser' failed."); + _logger?.LogError(e, "Calling 'SentryJava.TryGetMainProcessUser' failed."); } - return null; + return false; } private static SentryUser? ToSentryUser(AndroidJavaObject? user) => user is null @@ -376,8 +384,13 @@ public void Init(SentryUnityOptions options) // A file that the app's SDK in the main process persisted to its cache directory, next to ours: same DSN hash, // default 'sentry' directory. - private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className) + private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className) => + ReadMainProcessFile(options, path, className, out _); + + // Failed when the file is there but can't be read, e.g. empty or partly written. + private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className, out bool failed) { + failed = false; try { using var cacheDir = new AndroidJavaObject("java.io.File", options.Call("getCacheDirPath")); @@ -398,7 +411,9 @@ public void Init(SentryUnityOptions options) using var serializer = options.Call("getSerializer"); using var javaClass = new AndroidJavaClass("java.lang.Class"); using var type = javaClass.CallStatic("forName", className); - return serializer.Call("deserialize", reader, type); + var value = serializer.Call("deserialize", reader, type); + failed = value is null; + return value; } finally { @@ -410,6 +425,7 @@ public void Init(SentryUnityOptions options) _logger?.LogError(e, "Reading '{0}' persisted by the main process failed.", path); } + failed = true; return null; } diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index e0766fa87..70e736fc2 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -202,7 +202,11 @@ void Refresh() sentryJava.SetTrace(header.TraceId, header.SpanId); } - appUser = sentryJava.GetMainProcessUser(); + // A file that can't be read is being rewritten, so the previous user still applies. + if (sentryJava.TryGetMainProcessUser(out var user)) + { + appUser = user; + } } Refresh(); diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index c56349865..7cdbc94a9 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -304,6 +304,41 @@ public void Configure_ExternalNotInitializedInGameProcess_StopsRefreshingTraceOn Assert.AreEqual("75302ac48a024bde9a3b3734a82e36c8", _testSentryJava.Trace?.TraceId.ToString()); } + [Test] + public void Configure_ExternalNotInitializedInGameProcess_KeepsUserWhenReadFailsOnResume() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessUser = new SentryUser { Id = "app-user" }; + + SentryNativeAndroid.Configure(options); + _testSentryJava.MainProcessUserReadable = false; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreEqual("app-user", ProcessEvent(options).User.Id); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_DropsUserWhenAppLoggedOut() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessUser = new SentryUser { Id = "app-user" }; + + SentryNativeAndroid.Configure(options); + _testSentryJava.MainProcessUser = null; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreNotEqual("app-user", ProcessEvent(options).User.Id); + } + + private static SentryEvent ProcessEvent(SentryUnityOptions options) => + options.GetAllEventProcessors().OfType().Single().Process(new SentryEvent { User = new SentryUser() }); + [Test] public void Configure_External_UsesAppReleaseAndEnvironment() { diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index bc33d338e..32c2d6ba4 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -20,6 +20,7 @@ internal class TestSentryJava : ISentryJava public string? MainProcessTraceHeader { get; set; } public SentryUser? User { get; set; } public SentryUser? MainProcessUser { get; set; } + public bool MainProcessUserReadable { get; set; } = true; public (SentryId TraceId, SpanId SpanId)? Trace { get; private set; } public bool? IsEnabled() => Enabled; @@ -42,7 +43,11 @@ public void Init(SentryUnityOptions options) public SentryUser? GetUser() => User; - public SentryUser? GetMainProcessUser() => MainProcessUser; + public bool TryGetMainProcessUser(out SentryUser? user) + { + user = MainProcessUserReadable ? MainProcessUser : null; + return MainProcessUserReadable; + } public string? GetInstallationId() => InstallationId; From e39c71005da69d250747a0fb9ffcf2f748e21409 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 17:47:47 +0100 Subject: [PATCH 42/44] Load the Android integration in the Editor so its tests run --- .../Runtime/Sentry.Unity.Android.dll.meta | 4 +- package/Runtime/Sentry.Unity.Android.dll.meta | 97 +++++++++++++++++++ test/Scripts.Tests/test-plugin-platforms.ps1 | 8 +- 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 package/Runtime/Sentry.Unity.Android.dll.meta diff --git a/package-dev/Runtime/Sentry.Unity.Android.dll.meta b/package-dev/Runtime/Sentry.Unity.Android.dll.meta index 60106f4f9..a5fde1b78 100644 --- a/package-dev/Runtime/Sentry.Unity.Android.dll.meta +++ b/package-dev/Runtime/Sentry.Unity.Android.dll.meta @@ -17,7 +17,7 @@ PluginImporter: enabled: 0 settings: Exclude Android: 0 - Exclude Editor: 1 + Exclude Editor: 0 Exclude Linux64: 1 Exclude Lumin: 1 Exclude OSXUniversal: 1 @@ -40,7 +40,7 @@ PluginImporter: - first: Editor: Editor second: - enabled: 0 + enabled: 1 settings: CPU: AnyCPU DefaultValueInitialized: true diff --git a/package/Runtime/Sentry.Unity.Android.dll.meta b/package/Runtime/Sentry.Unity.Android.dll.meta new file mode 100644 index 000000000..60106f4f9 --- /dev/null +++ b/package/Runtime/Sentry.Unity.Android.dll.meta @@ -0,0 +1,97 @@ +fileFormatVersion: 2 +guid: b92828d78a78242869a0fbbb954a1cc4 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 0 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude Lumin: 1 + Exclude OSXUniversal: 1 + Exclude WebGL: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Android: Android + second: + enabled: 1 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: None + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 0 + settings: + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/test/Scripts.Tests/test-plugin-platforms.ps1 b/test/Scripts.Tests/test-plugin-platforms.ps1 index 9604f9d57..db7ca9ef9 100644 --- a/test/Scripts.Tests/test-plugin-platforms.ps1 +++ b/test/Scripts.Tests/test-plugin-platforms.ps1 @@ -71,9 +71,10 @@ $AliasedDependencyScopes = @( ) # How package-dev deviates. The dev package keeps the test assemblies, which scripts/pack.ps1 excludes -# from the release, and the iOS bridge stays editor-loadable for the editor-only -# Sentry.Unity.iOS.Tests assembly that references it. +# from the release, and the Android and iOS integrations stay editor-loadable for the editor-only +# Sentry.Unity.Android.Tests and Sentry.Unity.iOS.Tests assemblies that reference them. $DevOnlyPluginScopes = @{ + "Runtime/Sentry.Unity.Android.dll" = "Android, Editor" "Runtime/Sentry.Unity.iOS.dll" = "Editor, iOS, OSXUniversal" "Tests/Editor/Sentry.Unity.Editor.Tests.dll" = "Editor" "Tests/Editor/Sentry.Unity.Editor.iOS.Tests.dll" = "Editor" @@ -85,6 +86,7 @@ $DevOnlyPluginScopes = @{ # Files the package directory is allowed to override in the release, each validated against the # release table above. $ExpectedReleaseOverrides = @( + "Runtime/Sentry.Unity.Android.dll" "Runtime/Sentry.Unity.iOS.dll" ) @@ -308,7 +310,7 @@ else { } } - # package-dev carries the test assemblies and the editor-loadable iOS bridge. + # package-dev carries the test assemblies and the editor-loadable Android and iOS integrations. $devScopes = @{} foreach ($pair in $ExpectedPluginScopes.GetEnumerator()) { $devScopes[$pair.Key] = $pair.Value } foreach ($pair in $DevOnlyPluginScopes.GetEnumerator()) { $devScopes[$pair.Key] = $pair.Value } From ff30408dadb4089a8d3e1c39b566e9c0eef2b747 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 19:24:43 +0100 Subject: [PATCH 43/44] Skip app hang heartbeats while the game is paused --- CHANGELOG.md | 1 + .../SentryMonoBehaviour.AppHang.cs | 6 +++++- .../SentryMonoBehaviourAppHangTests.cs | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 155572f96..f1b1c36f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- App hang detection no longer reports a false app hang after the game moves to the background. It now stays paused while the game is in the background or out of focus. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) - The Linux native SDK now loads on distros with older glibc versions, such as Ubuntu 22.04 and Debian 12. ([#2894](https://github.com/getsentry/sentry-unity/pull/2894)) - Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and encoded on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) diff --git a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs index 44e92135c..0dac823e0 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs @@ -62,7 +62,11 @@ private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interva while (true) { yield return wait; - heartbeat(); + // Unity still renders a few frames after losing focus, and a heartbeat there would re-arm the paused detector. + if (_isRunning) + { + heartbeat(); + } } } } diff --git a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs index e8690ce88..cfae3bcbf 100644 --- a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs +++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs @@ -59,6 +59,25 @@ public IEnumerator StartAppHangHeartbeat_StopsWhenObjectDestroyed() Assert.AreEqual(countAfterDestroy, count); } + [UnityTest] + public IEnumerator StartAppHangHeartbeat_SkipsHeartbeatsWhilePaused() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, TimeSpan.FromSeconds(0.05)); + yield return null; + + sut.UpdatePauseStatus(true); + var countWhilePaused = count; + yield return new WaitForSecondsRealtime(0.12f); + Assert.AreEqual(countWhilePaused, count); + + sut.UpdatePauseStatus(false); + yield return new WaitForSecondsRealtime(0.12f); + Assert.Greater(count, countWhilePaused); + } + [Test] public void StartAppHangHeartbeat_PausesNativeDetectorWhenApplicationPauses() { From e6be7d123a3573e22cb003fea5ac46326dd6d9b6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 20:16:46 +0100 Subject: [PATCH 44/44] Re-arm app hang detection as soon as the game resumes --- .../SentryMonoBehaviour.AppHang.cs | 18 ++++++++++++ .../SentryMonoBehaviourAppHangTests.cs | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs index 0dac823e0..a7f0c542b 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs @@ -15,6 +15,8 @@ public partial class SentryMonoBehaviour private Coroutine? _appHangHeartbeat; private Action? _appHangPause; + private Action? _appHangResume; + private bool _appHangArmed; /// /// Starts the app-hang heartbeat on the main thread at a fixed 1-second interval. Arming is @@ -31,8 +33,22 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause) ApplicationPausing -= _appHangPause; } + if (_appHangResume is not null) + { + ApplicationResuming -= _appHangResume; + } + _appHangPause = pause; ApplicationPausing += pause; + // Re-arms right away instead of on the next tick, so a hang right after resuming is caught. + _appHangResume = () => + { + if (_appHangArmed) + { + heartbeat(); + } + }; + ApplicationResuming += _appHangResume; return StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval); } @@ -44,6 +60,7 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, TimeSpan interval) StopCoroutine(_appHangHeartbeat); } + _appHangArmed = false; _appHangHeartbeat = StartCoroutine(AppHangHeartbeatCoroutine(heartbeat, interval)); return _appHangHeartbeat; } @@ -57,6 +74,7 @@ private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interva // 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.Tests/SentryMonoBehaviourAppHangTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs index cfae3bcbf..4b0edf7f3 100644 --- a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs +++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs @@ -78,6 +78,35 @@ public IEnumerator StartAppHangHeartbeat_SkipsHeartbeatsWhilePaused() Assert.Greater(count, countWhilePaused); } + [UnityTest] + public IEnumerator StartAppHangHeartbeat_BeatsRightAwayOnResume() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, () => { }); + yield return null; + + sut.UpdatePauseStatus(true); + var countWhilePaused = count; + sut.UpdatePauseStatus(false); + + Assert.AreEqual(countWhilePaused + 1, count); + } + + [Test] + public void StartAppHangHeartbeat_DoesNotArmOnResumeBeforeFirstHeartbeat() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, () => { }); + sut.UpdatePauseStatus(true); + sut.UpdatePauseStatus(false); + + Assert.AreEqual(0, count); + } + [Test] public void StartAppHangHeartbeat_PausesNativeDetectorWhenApplicationPauses() {