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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee836469d..1100c3fb0 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. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) + ## 4.11.0 ### Fixes diff --git a/docs/agent-guides/platform-apple.md b/docs/agent-guides/platform-apple.md index cf68019e4..5f08eaa7e 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: 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 - 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..d00ceb5ac 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; + } + + loadStatus = 1; + } while (false); + } + return loadStatus; +} -int SentryNativeBridgeIsEnabled() { return [SentryObjCSDK isEnabled] ? 1 : 0; } +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,126 @@ 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]; - [scope setUser:user]; - }]; + [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]; + }); } 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 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 +487,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 +504,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 +512,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..96d5c2873 100644 --- a/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m +++ b/package-dev/Plugins/iOS/SentryNativeBridgeNoOp.m @@ -32,6 +32,10 @@ void SentryNativeBridgeUnsetUser() { } char *SentryNativeBridgeGetInstallationId() { return NULL; } +char *_Nullable SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *_Nullable SentryNativeBridgeGetTraceHeader() { 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..7329e3130 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,11 @@ void SentryNativeBridgeUnsetUser() return cString; } +// External initialization is iOS only. +char *SentryNativeBridgeGetOption(const char *name) { return NULL; } + +char *SentryNativeBridgeGetTraceHeader() { 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..44e95a008 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)) @@ -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.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..a3e5d35c7 100644 --- a/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs +++ b/src/Sentry.Unity.iOS/SentryCocoaBridgeProxy.cs @@ -146,6 +146,12 @@ 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 = "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..18885093f 100644 --- a/src/Sentry.Unity.iOS/SentryNativeCocoa.cs +++ b/src/Sentry.Unity.iOS/SentryNativeCocoa.cs @@ -30,20 +30,42 @@ 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) + { + UseAppReleaseAndEnvironment(options); + NativePropagationContext.Use(options, SentryCocoaBridgeProxy.GetTraceHeader); + + if (options.AutoSessionTracking) + { + Logger?.LogDebug("Disabling session tracking - the app tracks sessions."); + options.AutoSessionTracking = false; + } + } } else { @@ -55,7 +77,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 +86,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 +103,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(); @@ -94,6 +125,23 @@ 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/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 95249276a..be51d2c35 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; @@ -489,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. /// @@ -624,6 +630,9 @@ or RuntimePlatform.PS5 ? "editor" : "production"; + DefaultRelease = Release; + DefaultEnvironment = Environment; + CaptureStructuredLogsForLogType = new Dictionary { { LogType.Log, false }, @@ -748,4 +757,11 @@ 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. + /// + External, } 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 diff --git a/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs b/test/Sentry.Unity.Editor.iOS.Tests/BuildPostProcessorTests.cs index 445f682bd..b5478760d 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() { @@ -186,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()); + } +}