From f81ace4f9ea4f4cba3c62f9208a0333382dfff7f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 16 Oct 2026 16:56:29 +0200 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 e2e908ac900d6dc4d332c027649cde4f357b5db1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:13:27 +0200 Subject: [PATCH 09/25] 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 10/25] 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 11/25] 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 1be5dc201abab9123670c0e241ccae5416b5619e Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:19:14 +0200 Subject: [PATCH 12/25] 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 9c0c3e93324e8d5f9cffbbe43c97269472694956 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:42:11 +0200 Subject: [PATCH 13/25] 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 91fbb28aa010f6bbcb62110b2b4539ae29cc050a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 23:50:33 +0200 Subject: [PATCH 14/25] 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 15/25] 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 ed325e16fedb9cf14c9ffc5c04b2d5dec1090b7b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 15:39:19 +0100 Subject: [PATCH 16/25] 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 88314aa2210eb2f70f7f360067bb178243aa8f66 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 16:32:24 +0100 Subject: [PATCH 17/25] 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 18/25] 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 19/25] 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 f334634fd5571fc6d791ba940790e56bcc14adeb Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 27 Oct 2026 20:26:32 +0100 Subject: [PATCH 20/25] 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 b66006433f6fe5ce80154967139bdc51a0fc8b23 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 12:03:02 +0100 Subject: [PATCH 21/25] 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 22/25] 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 4944c3ec403b8d5aa18c32f1512fe35a58d6eeaa Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 12:53:25 +0100 Subject: [PATCH 23/25] Replace the bridge on appended iOS exports so it matches the current options --- .../BuildPostProcess.cs | 6 ++- .../BuildPostProcessorTests.cs | 41 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index 02f57dbe6..d7377e136 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -241,10 +241,12 @@ internal static void CopyFile(string sourcePath, string targetPath, IDiagnosticL throw new FileNotFoundException($"Failed to find '{sourcePath}'"); } + // An appended export keeps the bridge of the previous build, which may not match the current options. if (File.Exists(targetPath)) { - logger?.LogDebug("'{0}' has already been copied to '{1}'", Path.GetFileName(targetPath), targetPath); - return; + logger?.LogDebug("Replacing '{0}' copied by a previous build.", targetPath); + File.SetAttributes(targetPath, FileAttributes.Normal); + File.Delete(targetPath); } if (File.Exists(sourcePath)) diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 8b17ae746..4eb36814b 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -263,6 +263,33 @@ public void AddSentryToXcodeProject_AppendBuildsAndSwitchToExternal_ThrowsBuildF BuildPostProcess.AddSentryToXcodeProject(options, null, new TestLogger(), _outputProjectPath)); } + [Test] + public void AddSentryToXcodeProject_AppendBuildsAndSwitchFromDisabledToExternal_ReplacesNoOpBridge() + { + var disabled = new SentryUnityOptions { Dsn = "https://k@h/p", IosNativeSupportEnabled = false }; + var external = new SentryUnityOptions { Dsn = "https://k@h/p", IosNativeInitializationType = NativeInitializationType.External }; + + BuildPostProcess.AddSentryToXcodeProject(disabled, null, new TestLogger(), _outputProjectPath); + BuildPostProcess.AddSentryToXcodeProject(external, null, new TestLogger(), _outputProjectPath); + + var bridgePath = Path.Combine(_outputProjectPath, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName); + StringAssert.Contains("SentrySDKInternal", File.ReadAllText(bridgePath)); + } + + [Test] + public void AddSentryToXcodeProject_AppendBuildsAndSwitchFromExternalToRuntime_ReplacesExternalBridge() + { + var external = new SentryUnityOptions { Dsn = "https://k@h/p", IosNativeInitializationType = NativeInitializationType.External }; + var runtime = new SentryUnityOptions { Dsn = "https://k@h/p", IosNativeInitializationType = NativeInitializationType.Runtime }; + + BuildPostProcess.AddSentryToXcodeProject(external, null, new TestLogger(), _outputProjectPath); + BuildPostProcess.AddSentryToXcodeProject(runtime, null, new TestLogger(), _outputProjectPath); + + var bridge = File.ReadAllText(Path.Combine(_outputProjectPath, "Libraries", SentryPackageInfo.GetName(), SentryXcodeProject.BridgeName)); + StringAssert.Contains("[SentryObjCSDK", bridge); + StringAssert.DoesNotContain("SentrySDKInternal", bridge); + } + [Test] public void CopyFrameworkToXcodeProject_CopyFramework_DirectoryExists() { @@ -310,17 +337,17 @@ public void CopyFile_CopyFile_FileExists() } [Test] - public void CopyFile_FileAlreadyExists_LogsSkipMessage() + public void CopyFile_FileAlreadyExists_ReplacesFile() { - var testLogger = new TestLogger(); var targetPath = Path.Combine(_outputProjectPath, "SomeDirectory", "Test.m"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)); + File.WriteAllText(targetPath, "previous build"); + File.SetAttributes(targetPath, FileAttributes.ReadOnly); + File.WriteAllText(_testFilePath, "current build"); - BuildPostProcess.CopyFile(_testFilePath, targetPath, testLogger); - BuildPostProcess.CopyFile(_testFilePath, targetPath, testLogger); + BuildPostProcess.CopyFile(_testFilePath, targetPath, new TestLogger()); - Assert.IsTrue(testLogger.Logs.Any(log => - log.logLevel == SentryLevel.Debug && - log.message.Contains("has already been copied to"))); + Assert.AreEqual("current build", File.ReadAllText(targetPath)); } [Test] From eb3f86509237c2df4f7de97300df602884a66239 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 13:48:03 +0100 Subject: [PATCH 24/25] Fail appended iOS exports that switch away from External with the upload phase still on UnityFramework --- src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs | 11 +++++++++++ .../BuildPostProcessorTests.cs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index d7377e136..710486308 100644 --- a/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs +++ b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs @@ -103,6 +103,17 @@ internal static void AddSentryToXcodeProject(SentryUnityOptions? options, "to the exported project. Select 'Replace' when exporting the project to create a clean project."); } } + else + { + // The upload phase a previous build added to `UnityFramework` would keep running with its old settings. + using var sentryXcodeProject = SentryXcodeProject.Open(pathToProject, logger); + if (sentryXcodeProject.UnityFrameworkTargetContainsSymbolUploadBuildPhase()) + { + throw new BuildFailedException( + "A previous build with the iOS native initialization type 'External' added the debug symbol upload " + + "to 'UnityFramework'. Select 'Replace' when exporting the project to create a clean project."); + } + } } internal static void SetupNoOpBridge(IDiagnosticLogger logger, string pathToProject) diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 4eb36814b..2ef0243b9 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -263,6 +263,23 @@ public void AddSentryToXcodeProject_AppendBuildsAndSwitchToExternal_ThrowsBuildF BuildPostProcess.AddSentryToXcodeProject(options, null, new TestLogger(), _outputProjectPath)); } + [Test] + public void AddSentryToXcodeProject_AppendBuildsAndSwitchFromExternal_ThrowsBuildFailedException() + { + using (var xcodeProject = SentryXcodeProject.Open(_outputProjectPath)) + { + xcodeProject.AddBuildPhaseSymbolUpload(new SentryCliOptions(), uploadFromUnityFramework: true); + } + var options = new SentryUnityOptions + { + Dsn = "https://k@h/p", + IosNativeInitializationType = NativeInitializationType.Runtime + }; + + Assert.Throws(() => + BuildPostProcess.AddSentryToXcodeProject(options, null, new TestLogger(), _outputProjectPath)); + } + [Test] public void AddSentryToXcodeProject_AppendBuildsAndSwitchFromDisabledToExternal_ReplacesNoOpBridge() { From 61cd0a61e925276fc66568886c2f619f64ba639b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 29 Oct 2026 14:25:57 +0100 Subject: [PATCH 25/25] Call void setters through a typed objc_msgSend in the External bridge --- .../Plugins/iOS/SentryNativeBridgeExternal.m | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m index 19cfd2477..e6d0e1bd6 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m @@ -51,7 +51,7 @@ // SentryLastRunStatus.didCrash static const NSInteger SentryLastRunStatusDidCrash = 2; -// The selectors are getters and setters, which don't return owned objects. +// The selectors are getters, which don't return owned objects. #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" static id SentryCall(id target, SEL selector) @@ -64,14 +64,22 @@ 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) +#pragma clang diagnostic pop + +// performSelector: is only for methods that return an object, so setters go through a typed objc_msgSend. +static void SentryCallVoid(id target, SEL selector, id argument) { if ([target respondsToSelector:selector]) { - [target performSelector:selector withObject:argument1 withObject:argument2]; + ((void (*)(id, SEL, id))objc_msgSend)(target, selector, argument); } } -#pragma clang diagnostic pop +static void SentryCallVoid2(id target, SEL selector, id argument1, id argument2) +{ + if ([target respondsToSelector:selector]) { + ((void (*)(id, SEL, id, id))objc_msgSend)(target, selector, argument1, argument2); + } +} static id SentryGetValue(id object, NSString *key) { @@ -140,7 +148,7 @@ void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { } static void SentryConfigureScope(void (^callback)(id)) { - SentryCallWith(SentrySDKClass, @selector(configureScope:), callback); + SentryCallVoid(SentrySDKClass, @selector(configureScope:), callback); } int SentryNativeBridgeCrashedLastRun() @@ -197,7 +205,7 @@ void SentryNativeBridgeAddBreadcrumb(const char *timestamp, const char *message, SentrySetValue(breadcrumb, @"data", data); } - SentryCallWith(scope, @selector(addBreadcrumb:), breadcrumb); + SentryCallVoid(scope, @selector(addBreadcrumb:), breadcrumb); }); } @@ -211,7 +219,7 @@ void SentryNativeBridgeSetExtra(const char *key, const char *value) NSString *valueString = _NSStringOrNil(value); SentryConfigureScope(^(id scope) { - SentryCallWith2(scope, @selector(setExtraValue:forKey:), valueString, keyString); + SentryCallVoid2(scope, @selector(setExtraValue:forKey:), valueString, keyString); }); } @@ -225,7 +233,7 @@ void SentryNativeBridgeSetTag(const char *key, const char *value) NSString *valueString = _NSStringOrNil(value); SentryConfigureScope(^(id scope) { - SentryCallWith2(scope, @selector(setTagValue:forKey:), valueString, keyString); + SentryCallVoid2(scope, @selector(setTagValue:forKey:), valueString, keyString); }); } @@ -238,7 +246,7 @@ void SentryNativeBridgeUnsetTag(const char *key) NSString *keyString = [NSString stringWithUTF8String:key]; SentryConfigureScope(^(id scope) { - SentryCallWith(scope, @selector(removeTagForKey:), keyString); + SentryCallVoid(scope, @selector(removeTagForKey:), keyString); }); } @@ -370,7 +378,7 @@ void SentryNativeBridgeWriteScope( // clang-format off gpu[@"vendor_id"] = _NSStringOrNil(GpuVendorId); gpu[@"multi_threaded_rendering"] = _NSBoolOrNil(GpuMultiThreadedRendering); gpu[@"graphics_shader_level"] = _NSStringOrNil(GpuGraphicsShaderLevel); - SentryCallWith2(scope, @selector(setContextValue:forKey:), gpu, @"gpu"); + SentryCallVoid2(scope, @selector(setContextValue:forKey:), gpu, @"gpu"); NSMutableDictionary *unity = [[NSMutableDictionary alloc] init]; unity[@"editor_version"] = _NSStringOrNil(EditorVersion); @@ -378,6 +386,6 @@ void SentryNativeBridgeWriteScope( // clang-format off 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"); + SentryCallVoid2(scope, @selector(setContextValue:forKey:), unity, @"unity"); }); }