diff --git a/CHANGELOG.md b/CHANGELOG.md index cc014a7b1..199b378f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Features +- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) - The SDK now supports Linux ARM64 dedicated servers (Unity 6000.3 and newer, IL2CPP). The native SDK for both backends ships for `x86_64` and `ARM64`, and the build picks the one matching the player. ([#2894](https://github.com/getsentry/sentry-unity/pull/2894)) ### Dependencies diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index cf68019e4..2c25f2a79 100644 --- a/docs/agent-guides/platform-apple.md +++ b/docs/agent-guides/platform-apple.md @@ -19,12 +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 (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 - 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. +- 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 6631804da..fefb76eee 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridge.m +++ b/package-dev/Plugins/iOS/SentryNativeBridge.m @@ -241,6 +241,15 @@ void SentryNativeBridgeUnsetUser() return cString; } +// Only the app's own SDK provides these, see SentryNativeBridgeExternal.m. +char *_Nullable SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } + +char *_Nullable SentryNativeBridgeGetBaggageHeader() { return NULL; } + +char *_Nullable SentryNativeBridgeGetUser() { return NULL; } + void SentryNativeBridgeSetTrace(const char *traceId, const char *spanId) { if (traceId == NULL || spanId == NULL) { diff --git a/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m new file mode 100644 index 000000000..e6d0e1bd6 --- /dev/null +++ b/package-dev/Plugins/iOS/SentryNativeBridgeExternal.m @@ -0,0 +1,391 @@ +#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, 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; +} + +#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]) { + ((void (*)(id, SEL, id))objc_msgSend)(target, selector, argument); + } +} + +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) +{ + 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)) +{ + SentryCallVoid(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); + } + + SentryCallVoid(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) { + SentryCallVoid2(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) { + SentryCallVoid2(scope, @selector(setTagValue:forKey:), valueString, keyString); + }); +} + +void SentryNativeBridgeUnsetTag(const char *key) +{ + if (key == NULL) { + return; + } + + NSString *keyString = [NSString stringWithUTF8String:key]; + + SentryConfigureScope(^(id scope) { + SentryCallVoid(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); + SentryCallVoid2(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); + SentryCallVoid2(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/iOS/SentryNativeBridgeNoOp.m b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m index dc52c16e0..21dda35de 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -32,6 +32,14 @@ void SentryNativeBridgeUnsetUser() { } char *SentryNativeBridgeGetInstallationId() { return NULL; } +char *_Nullable SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *_Nullable SentryNativeBridgeGetTraceHeader() { return NULL; } + +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 0f034de92..3d3361e38 100644 --- a/package-dev/Plugins/macOS/SentryNativeBridge.m +++ b/package-dev/Plugins/macOS/SentryNativeBridge.m @@ -357,6 +357,15 @@ void SentryNativeBridgeUnsetUser() return cString; } +// External initialization is iOS only. +char *SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *SentryNativeBridgeGetTraceHeader() { return NULL; } + +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.Editor.iOS/BuildPostProcess.cs b/src/Sentry.Unity.Editor.iOS/BuildPostProcess.cs index 1285ed51d..710486308 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)) @@ -91,6 +91,29 @@ 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."); + } + } + 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) @@ -115,21 +138,35 @@ 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)); + 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); - sentryXcodeProject.AddSentryFramework(); + if (bundlesSdk) + { + sentryXcodeProject.AddSentryFramework(); + } + else + { + sentryXcodeProject.SetDebugInformationFormat(); + } sentryXcodeProject.AddSentryNativeBridge(); if (options.IosNativeInitializationType is NativeInitializationType.BuildTime) @@ -171,7 +208,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) { @@ -213,10 +252,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/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs b/src/Sentry.Unity.Editor.iOS/SentryXcodeProject.cs index 9ab59ab55..f9b060aa3 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; @@ -17,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"; @@ -110,19 +112,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) @@ -145,19 +142,31 @@ 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)"); 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."); @@ -179,24 +188,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 +215,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 +246,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.iOS/ExternalNativeScopeObserver.cs b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs new file mode 100644 index 000000000..1c874f34a --- /dev/null +++ b/src/Sentry.Unity.iOS/ExternalNativeScopeObserver.cs @@ -0,0 +1,18 @@ +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 +{ + public ExternalNativeScopeObserver(string name, SentryOptions options) : base(name, options) { } + + public override void SetUserImpl(SentryUser user) { } + + public override void UnsetUserImpl() { } + + public override void SetTraceImpl(SentryId traceId, SpanId spanId) { } + + public override void SetEnvironmentImpl(string? environment) { } +} diff --git a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs index 2f5596a5e..1a1b9b902 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -146,6 +146,18 @@ 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 = "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 3d6293238..fc4f33a5c 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -30,20 +30,45 @@ 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"); return; } - options.ScopeObserver = new NativeScopeObserver("iOS", options); + options.ScopeObserver = isExternal + ? new ExternalNativeScopeObserver("iOS", options) + : new NativeScopeObserver("iOS", options); + if (isExternal) + { + options.UseAppReleaseAndEnvironment(() => SentryCocoaBridgeProxy.GetOption("releaseName"), + () => SentryCocoaBridgeProxy.GetOption("dist"), () => SentryCocoaBridgeProxy.GetOption("environment")); + NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader, + SentryCocoaBridgeProxy.GetBaggageHeader); + NativeAppUser.Use(options, () => NativeAppUser.FromJson(SentryCocoaBridgeProxy.GetUser())); + + if (options.AutoSessionTracking) + { + Logger?.LogDebug("Disabling session tracking - the app tracks sessions."); + options.AutoSessionTracking = false; + } + } } else { @@ -55,7 +80,8 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf options.ScopeObserver = new NativeScopeObserver("macOS", options); } - if (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 @@ -63,7 +89,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 +106,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/NativeAppUser.cs b/src/Sentry.Unity/NativeAppUser.cs new file mode 100644 index 000000000..610c30393 --- /dev/null +++ b/src/Sentry.Unity/NativeAppUser.cs @@ -0,0 +1,119 @@ +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 +{ + // 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 + // game set. + private volatile SentryUser? _default; + 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(Initialize); + SentryMonoBehaviour.Instance.ApplicationResuming += () => + { + if (hub.IsEnabled) + { + hub.ConfigureScope(Apply); + } + }; + } + + public SentryEvent Process(SentryEvent @event) + { + if (IsOwnedByApp(@event.User)) + { + 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)) + { + var user = GetUser(); + _applied = user; + scope.User = user.Clone(); + } + } + + // The app's user, or the SDK's default user 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 + : _default ?? new SentryUser { Id = _options.DefaultUserId }; + } + + // 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.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? 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; + + 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/src/Sentry.Unity/NativePropagationContext.cs b/src/Sentry.Unity/NativePropagationContext.cs new file mode 100644 index 000000000..f1983cf9c --- /dev/null +++ b/src/Sentry.Unity/NativePropagationContext.cs @@ -0,0 +1,129 @@ +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. +/// 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 DynamicSamplingContext? _dynamicSamplingContext; + private bool _dynamicSamplingContextRead; + + 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, 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, _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; + public SpanId? ParentSpanId => null; + public bool IsSampled => _traceHeader?.IsSampled ?? false; + public double? SampleRate => null; + public double? SampleRand => null; + // 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); + + // 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)) + { + 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/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/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/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index f829f5689..799c94fac 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. This is experimental. /// public NativeInitializationType IosNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -491,6 +493,31 @@ 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; } + // 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 (!ReleaseOverridden && Release == DefaultRelease && getRelease() is { } release) + { + DiagnosticLogger?.LogDebug("Using the app's release: {0}", release); + Release = release; + Distribution ??= getDistribution(); + } + + if (!EnvironmentOverridden && 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. /// @@ -626,6 +653,9 @@ or RuntimePlatform.PS5 ? "editor" : "production"; + DefaultRelease = Release; + DefaultEnvironment = Environment; + CaptureStructuredLogsForLogType = new Dictionary { { LogType.Log, false }, @@ -750,4 +780,12 @@ 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. 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. Experimental: the + /// behavior may change between releases. + /// + External, } diff --git a/test/Scripts.Tests/package-release.zip.snapshot b/test/Scripts.Tests/package-release.zip.snapshot index c07306eea..7f03c416f 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/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" diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 445f682bd..2ef0243b9 100644 --- a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs +++ b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs @@ -100,6 +100,28 @@ 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)); + 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 + } + [Test] public void AddSentryToXcodeProject_NativeSupportEnabledAndInitializationTypeBuildTime_SetsUpSdkAndAddsAutoInitialize() { @@ -186,6 +208,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() { @@ -205,6 +245,68 @@ 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 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() + { + 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() { @@ -252,17 +354,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] 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 4cd486e4c..6903cf79a 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() { @@ -147,6 +159,19 @@ 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 AddBuildPhaseSymbolUpload_PhaseAlreadyAdded_LogsAndDoesNotAddAgain() { diff --git a/test/Sentry.Unity.Tests/NativeAppUserTests.cs b/test/Sentry.Unity.Tests/NativeAppUserTests.cs new file mode 100644 index 000000000..e0fb83a6e --- /dev/null +++ b/test/Sentry.Unity.Tests/NativeAppUserTests.cs @@ -0,0 +1,196 @@ +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 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() + { + 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 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 = sut.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() + { + 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)); +} diff --git a/test/Sentry.Unity.Tests/NativePropagationContextTests.cs b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs new file mode 100644 index 000000000..5db426c66 --- /dev/null +++ b/test/Sentry.Unity.Tests/NativePropagationContextTests.cs @@ -0,0 +1,245 @@ +using System.Linq; +using NUnit.Framework; +using Sentry.Internal; +using Sentry.Unity.Tests.SharedClasses; +using Sentry.Unity.Tests.Stubs; + +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 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, () => + { + _baggageHeaderReads++; + return _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() + { + _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 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() + { + _traceHeader = $"{TraceId}-{SpanId}-1"; + + var baggage = GetOptions().ExternalPropagationContext!.Snapshot().GetBaggageHeader(); + + 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_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() + { + _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)] + 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/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 0959fa01c..aa5ad642e 100644 --- a/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs +++ b/test/Sentry.Unity.Tests/SentryUnityOptionsTests.cs @@ -112,6 +112,63 @@ 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_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() + { + 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")] 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) {