From a43e53579b3d1a188e9c4221856f31f017bbff91 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 16:16:38 +0100 Subject: [PATCH 01/50] ref: Extract getAppStartMeasurement into SentryAppStartMeasurementProvider Move the app start measurement retrieval logic from SentryTracer into a standalone SentryAppStartMeasurementProvider class to decouple it from the tracer and prepare for a future Swift rewrite. Agent transcript: https://claudescope.sentry.dev/share/-ySeYAQr0t_-TzVJYh7grS4nvSpo6f1NKAIEUc4__60 --- SentryTestUtils/Sources/ClearTestState.swift | 6 +- .../SentryAppStartMeasurementProvider.m | 10 ++-- Sources/Sentry/SentryTracer.m | 17 +++++- .../SentryAppStartMeasurementProvider.h | 17 ++---- ...ntryAppStartMeasurementProviderTests.swift | 55 ++++++++++++------- .../Transaction/SentryTracer+Test.h | 4 ++ 6 files changed, 66 insertions(+), 43 deletions(-) diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index 526a3f67e6..8b70123d41 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -46,9 +46,9 @@ class TestCleanup: NSObject { SentryDependencyContainer.reset() SentryPerformanceTracker.shared.clear() -#if os(iOS) || os(tvOS) || os(visionOS) - SentryAppStartMeasurementProvider.reset() -#endif // os(iOS) || os(tvOS) || os(visionOS) +#if os(iOS) || os(tvOS) + SentryTracer.resetAppStartMeasurementRead() +#endif // os(iOS) || os(tvOS) #if os(iOS) || os(macOS) _sentry_threadUnsafe_traceProfileTimeoutTimer = nil diff --git a/Sources/Sentry/SentryAppStartMeasurementProvider.m b/Sources/Sentry/SentryAppStartMeasurementProvider.m index 7507fee26f..0680c7d354 100644 --- a/Sources/Sentry/SentryAppStartMeasurementProvider.m +++ b/Sources/Sentry/SentryAppStartMeasurementProvider.m @@ -32,9 +32,10 @@ + (void)initialize } } -+ (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSString *)operation - startTimestamp: - (nullable NSDate *)startTimestamp ++ (nullable SentryAppStartMeasurement *) + appStartMeasurementForOperation:(NSString *)operation + startTimestamp:(nullable NSDate *)startTimestamp + profilerReferenceID:(nullable SentryId *)profilerReferenceID SENTRY_DISABLE_THREAD_SANITIZER("double-checked lock produce false alarms") { // Only send app start measurement for transactions generated by auto performance @@ -90,7 +91,8 @@ + (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSStrin return nil; } - SENTRY_LOG_DEBUG(@"Returning app start measurement."); + SENTRY_LOG_DEBUG(@"Returning app start measurements for tracer with profilerReferenceId %@", + profilerReferenceID.sentryIdString); return measurement; } diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 7b0f972972..c08c8fd266 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -596,9 +596,10 @@ - (BOOL)finishTracer:(SentrySpanStatus)unfinishedSpansFinishStatus shouldCleanUp [super finishWithStatus:_finishStatus]; } #if SENTRY_HAS_UIKIT - appStartMeasurement = - [SentryAppStartMeasurementProvider appStartMeasurementForOperation:self.operation - startTimestamp:self.startTimestamp]; + appStartMeasurement = [SentryAppStartMeasurementProvider + appStartMeasurementForOperation:self.operation + startTimestamp:self.startTimestamp + profilerReferenceID:self.profilerReferenceID]; if (appStartMeasurement != nil) { [self updateStartTime:appStartMeasurement.appStartTimestamp]; @@ -827,6 +828,16 @@ - (void)addFrameStatistics #endif // SENTRY_HAS_UIKIT +#if SENTRY_HAS_UIKIT +/** + * Internal. Only needed for testing. + */ ++ (void)resetAppStartMeasurementRead +{ + [SentryAppStartMeasurementProvider reset]; +} +#endif // SENTRY_HAS_UIKIT + + (nullable SentryTracer *)getTracer:(id _Nullable)span { if (span == nil) { diff --git a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h index 2a7f187307..c3a8856a5a 100644 --- a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h +++ b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h @@ -1,25 +1,18 @@ #import "SentryDefines.h" -// App start measurements are only relevant on platforms with UIKit (iOS, tvOS), where -// UIApplicationDidFinishLaunching defines the app start lifecycle. #if SENTRY_HAS_UIKIT NS_ASSUME_NONNULL_BEGIN @class SentryAppStartMeasurement; +@class SentryId; -/** - * Provides the app start measurement for attaching to the first UI load transaction. - * - * This class still reads the measurement from @c SentrySDKInternal because multiple places in the - * SDK rely on that storage. Moving storage here would expand the scope of this refactoring; the - * goal is to extract the measurement-providing logic out of @c SentryTracer to reduce its size. - */ @interface SentryAppStartMeasurementProvider : NSObject -+ (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSString *)operation - startTimestamp: - (nullable NSDate *)startTimestamp; ++ (nullable SentryAppStartMeasurement *) + appStartMeasurementForOperation:(NSString *)operation + startTimestamp:(nullable NSDate *)startTimestamp + profilerReferenceID:(nullable SentryId *)profilerReferenceID; /** * Internal. Only needed for testing. diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 03f88c2723..7e80e7abc6 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -1,15 +1,14 @@ @_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils import XCTest -#if os(iOS) || os(tvOS) || os(visionOS) +#if os(iOS) || os(tvOS) class SentryAppStartMeasurementProviderTests: XCTestCase { override func tearDown() { super.tearDown() - SentryAppStartMeasurementProvider.reset() - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = false - SentrySDKInternal.setAppStartMeasurement(nil) + clearTestState() } // MARK: - Happy Path @@ -25,7 +24,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: SentryId() ) // -- Assert -- @@ -46,7 +46,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: "custom", - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -68,7 +69,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -81,7 +83,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: Date() + startTimestamp: Date(), + profilerReferenceID: nil ) // -- Assert -- @@ -101,14 +104,16 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) XCTAssertNotNil(first) // -- Act -- let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -128,7 +133,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) XCTAssertNotNil(first) @@ -138,7 +144,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Assert -- let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) XCTAssertNotNil(second) } @@ -159,7 +166,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -180,7 +188,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -200,7 +209,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -220,7 +230,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -240,7 +251,8 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart + startTimestamp: transactionStart, + profilerReferenceID: nil ) // -- Assert -- @@ -268,16 +280,17 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- for _ in 0.. Date: Mon, 9 Mar 2026 17:40:51 +0100 Subject: [PATCH 02/50] ref: Remove resetAppStartMeasurementRead from SentryTracer Call SentryAppStartMeasurementProvider.reset() directly from ClearTestState and the provider tests instead of routing through SentryTracer's forwarding method. Agent transcript: https://claudescope.sentry.dev/share/L1InLC1w4Xm6HgEp2bHk-sJ2wtr9fRfQ08BpwGE4JSs --- SentryTestUtils/Sources/ClearTestState.swift | 2 +- Sources/Sentry/SentryTracer.m | 10 ---------- .../SentryAppStartMeasurementProviderTests.swift | 5 +++-- Tests/SentryTests/Transaction/SentryTracer+Test.h | 4 ---- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index 8b70123d41..8f806d478e 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -47,7 +47,7 @@ class TestCleanup: NSObject { SentryPerformanceTracker.shared.clear() #if os(iOS) || os(tvOS) - SentryTracer.resetAppStartMeasurementRead() + SentryAppStartMeasurementProvider.reset() #endif // os(iOS) || os(tvOS) #if os(iOS) || os(macOS) diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index c08c8fd266..04e724f9db 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -828,16 +828,6 @@ - (void)addFrameStatistics #endif // SENTRY_HAS_UIKIT -#if SENTRY_HAS_UIKIT -/** - * Internal. Only needed for testing. - */ -+ (void)resetAppStartMeasurementRead -{ - [SentryAppStartMeasurementProvider reset]; -} -#endif // SENTRY_HAS_UIKIT - + (nullable SentryTracer *)getTracer:(id _Nullable)span { if (span == nil) { diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 7e80e7abc6..91b94785e9 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -1,5 +1,4 @@ @_spi(Private) @testable import Sentry -@_spi(Private) import SentryTestUtils import XCTest #if os(iOS) || os(tvOS) @@ -8,7 +7,9 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { override func tearDown() { super.tearDown() - clearTestState() + SentryAppStartMeasurementProvider.reset() + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = false + SentrySDKInternal.setAppStartMeasurement(nil) } // MARK: - Happy Path diff --git a/Tests/SentryTests/Transaction/SentryTracer+Test.h b/Tests/SentryTests/Transaction/SentryTracer+Test.h index b6c48dd72e..232a3f745f 100644 --- a/Tests/SentryTests/Transaction/SentryTracer+Test.h +++ b/Tests/SentryTests/Transaction/SentryTracer+Test.h @@ -4,10 +4,6 @@ NS_ASSUME_NONNULL_BEGIN @interface SentryTracer (Test) -#if SENTRY_HAS_UIKIT -+ (void)resetAppStartMeasurementRead; -#endif // SENTRY_HAS_UIKIT - - (void)updateStartTime:(NSDate *)startTime; @end From ba85ba9786ee31091eabb2f11e2e145565313a41 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 09:35:08 +0100 Subject: [PATCH 03/50] ref: Clean up app start measurement provider API Remove unused profilerReferenceID parameter, add header docs, and fix stale TSan suppression. Agent transcript: https://claudescope.sentry.dev/share/YCbJaiw4eo47tyRGdPdNfyWa3VZueptJtPoevH1S97A --- .../Sentry/SentryAppStartMeasurementProvider.m | 10 ++++------ Sources/Sentry/SentryTracer.m | 7 +++---- .../include/SentryAppStartMeasurementProvider.h | 17 ++++++++++++----- ...SentryAppStartMeasurementProviderTests.swift | 13 ------------- 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/Sources/Sentry/SentryAppStartMeasurementProvider.m b/Sources/Sentry/SentryAppStartMeasurementProvider.m index 0680c7d354..7507fee26f 100644 --- a/Sources/Sentry/SentryAppStartMeasurementProvider.m +++ b/Sources/Sentry/SentryAppStartMeasurementProvider.m @@ -32,10 +32,9 @@ + (void)initialize } } -+ (nullable SentryAppStartMeasurement *) - appStartMeasurementForOperation:(NSString *)operation - startTimestamp:(nullable NSDate *)startTimestamp - profilerReferenceID:(nullable SentryId *)profilerReferenceID ++ (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSString *)operation + startTimestamp: + (nullable NSDate *)startTimestamp SENTRY_DISABLE_THREAD_SANITIZER("double-checked lock produce false alarms") { // Only send app start measurement for transactions generated by auto performance @@ -91,8 +90,7 @@ + (void)initialize return nil; } - SENTRY_LOG_DEBUG(@"Returning app start measurements for tracer with profilerReferenceId %@", - profilerReferenceID.sentryIdString); + SENTRY_LOG_DEBUG(@"Returning app start measurement."); return measurement; } diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 04e724f9db..7b0f972972 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -596,10 +596,9 @@ - (BOOL)finishTracer:(SentrySpanStatus)unfinishedSpansFinishStatus shouldCleanUp [super finishWithStatus:_finishStatus]; } #if SENTRY_HAS_UIKIT - appStartMeasurement = [SentryAppStartMeasurementProvider - appStartMeasurementForOperation:self.operation - startTimestamp:self.startTimestamp - profilerReferenceID:self.profilerReferenceID]; + appStartMeasurement = + [SentryAppStartMeasurementProvider appStartMeasurementForOperation:self.operation + startTimestamp:self.startTimestamp]; if (appStartMeasurement != nil) { [self updateStartTime:appStartMeasurement.appStartTimestamp]; diff --git a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h index c3a8856a5a..2a7f187307 100644 --- a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h +++ b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h @@ -1,18 +1,25 @@ #import "SentryDefines.h" +// App start measurements are only relevant on platforms with UIKit (iOS, tvOS), where +// UIApplicationDidFinishLaunching defines the app start lifecycle. #if SENTRY_HAS_UIKIT NS_ASSUME_NONNULL_BEGIN @class SentryAppStartMeasurement; -@class SentryId; +/** + * Provides the app start measurement for attaching to the first UI load transaction. + * + * This class still reads the measurement from @c SentrySDKInternal because multiple places in the + * SDK rely on that storage. Moving storage here would expand the scope of this refactoring; the + * goal is to extract the measurement-providing logic out of @c SentryTracer to reduce its size. + */ @interface SentryAppStartMeasurementProvider : NSObject -+ (nullable SentryAppStartMeasurement *) - appStartMeasurementForOperation:(NSString *)operation - startTimestamp:(nullable NSDate *)startTimestamp - profilerReferenceID:(nullable SentryId *)profilerReferenceID; ++ (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSString *)operation + startTimestamp: + (nullable NSDate *)startTimestamp; /** * Internal. Only needed for testing. diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 91b94785e9..9ab690d2b6 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -26,7 +26,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: SentryId() ) // -- Assert -- @@ -48,7 +47,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: "custom", startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -71,7 +69,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -85,7 +82,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: Date(), - profilerReferenceID: nil ) // -- Assert -- @@ -106,7 +102,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) XCTAssertNotNil(first) @@ -114,7 +109,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -135,7 +129,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) XCTAssertNotNil(first) @@ -146,7 +139,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) XCTAssertNotNil(second) } @@ -168,7 +160,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -190,7 +181,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -211,7 +201,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -232,7 +221,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- @@ -253,7 +241,6 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, startTimestamp: transactionStart, - profilerReferenceID: nil ) // -- Assert -- From ec544532847863f6babfef429468e93f3440cc06 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:04:33 +0100 Subject: [PATCH 04/50] fix: Remove leftover profilerReferenceID in concurrency test Agent transcript: https://claudescope.sentry.dev/share/F7n3parCPAfcG8axuTkl_e5rqRhTpI6T0S_tdcKY0CA --- .../SentryAppStartMeasurementProviderTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 9ab690d2b6..7adee88283 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -271,8 +271,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { queue.async { let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, - profilerReferenceID: nil + startTimestamp: transactionStart ) resultsLock.lock() results.append(result) From 4d7bfa8977ccd30d740b6ea8f9ac59caf07653c8 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:05:40 +0100 Subject: [PATCH 05/50] fix: Fix compilation errors in app start measurement provider tests Fix trailing closure ambiguity with DispatchWorkItem and remove non-existent profilerReferenceID parameter. Agent transcript: https://claudescope.sentry.dev/share/boilLz21_S-f8uu_dcXvext-dAi2MAY8xkbK2zEafW4 --- .../SentryAppStartMeasurementProviderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 7adee88283..7ecad6836f 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -268,7 +268,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- for _ in 0.. Date: Thu, 5 Mar 2026 09:52:34 +0100 Subject: [PATCH 06/50] feat: Add enableStandaloneAppStartTracing option Add experimental option to send standalone app start transactions instead of attaching app start data to the first UIViewController transaction. Uses a strategy pattern via AppStartMeasurementHandler protocol. The standalone handler is currently a no-op placeholder; actual transaction logic will be added in a follow-up. Refs: #6883 Agent transcript: https://claudescope.sentry.dev/share/KZR39vRrsVDqgpm56ONILZFlu1u_gpBhAnK7QrpyOH0 --- .../SentryAppStartTracker.swift | 26 ++++++++++++++++++- Sources/Swift/SentryDependencyContainer.swift | 1 + Sources/Swift/SentryExperimentalOptions.swift | 4 +++ .../SentryAppStartTrackerTests.swift | 20 ++++++++++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 94cd47d18a..653a353c72 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -8,6 +8,25 @@ protocol AppStartInfoProvider { func isActivePrewarm() -> Bool } +protocol AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) +} + +/// Attaches app start data to the first UIViewController transaction (default behavior). +struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) { + SentrySDKInternal.setAppStartMeasurement(measurement) + } +} + +/// Placeholder for sending a standalone app start transaction. Currently a no-op; +/// the actual implementation will be added in a follow-up. +struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) { + // no-op: standalone app start transaction logic will be implemented here + } +} + extension SentryAppStartTrackerHelper: AppStartInfoProvider {} /// Tracks cold and warm app start time for iOS, tvOS, and Mac Catalyst. The logic for the different @@ -33,6 +52,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener let appStateManager: SentryAppStateManager private let framesTracker: SentryFramesTracker private let enablePreWarmedAppStartTracing: Bool + private let measurementHandler: AppStartMeasurementHandler private var previousAppState: SentryAppState? private var wasInBackground = false @@ -52,6 +72,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener appStateManager: SentryAppStateManager, framesTracker: SentryFramesTracker, enablePreWarmedAppStartTracing: Bool, + enableStandaloneAppStartTracing: Bool, dateProvider: SentryCurrentDateProvider, sysctlWrapper: SentrySysctl, appStartInfoProvider: AppStartInfoProvider @@ -60,6 +81,9 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener self.appStateManager = appStateManager self.framesTracker = framesTracker self.enablePreWarmedAppStartTracing = enablePreWarmedAppStartTracing + self.measurementHandler = enableStandaloneAppStartTracing + ? SendStandaloneAppStartTransaction() + : AttachAppStartMeasurementHandler() self.previousAppState = appStateManager.loadPreviousAppState() self.dateProvider = dateProvider self.didFinishLaunchingTimestamp = dateProvider.date() @@ -230,7 +254,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener didFinishLaunchingTimestamp: finalDidFinishLaunchingTimestamp ) - SentrySDKInternal.setAppStartMeasurement(appStartMeasurement) + self.measurementHandler.handle(appStartMeasurement) } // With only running this once we know that the process is a new one when the following diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index b6f9ddb4ec..29df9da16c 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -258,6 +258,7 @@ extension SentryFileManager: SentryFileManagerProtocol { } appStateManager: appStateManager, framesTracker: framesTracker, enablePreWarmedAppStartTracing: options.enablePreWarmedAppStartTracing, + enableStandaloneAppStartTracing: options.experimental.enableStandaloneAppStartTracing, dateProvider: dateProvider, sysctlWrapper: sysctlWrapper, appStartInfoProvider: appStartInfoProvider diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift index 3ad274ffec..72e0244056 100644 --- a/Sources/Swift/SentryExperimentalOptions.swift +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -42,6 +42,10 @@ public final class SentryExperimentalOptions: NSObject { /// When enabled, the SDK uses a more efficient mechanism for detecting watchdog terminations. public var enableWatchdogTerminationsV2 = false + /// When enabled, the SDK sends a standalone app start transaction instead of attaching app + /// start data to the first UIViewController transaction. + public var enableStandaloneAppStartTracing = false + // swiftlint:disable:next missing_docs @_spi(Private) public func validateOptions(_ options: [String: Any]?) { } diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index d3153c0baf..2037066533 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -38,6 +38,7 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { let framesTracker: SentryFramesTracker let dispatchQueue = TestSentryDispatchQueueWrapper() var enablePreWarmedAppStartTracing = true + var enableStandaloneAppStartTracing = false var appStartInfoProvider: TestAppStartInfoProvider let appStartDuration: TimeInterval = 0.4 @@ -91,6 +92,7 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { appStateManager: appStateManager, framesTracker: framesTracker, enablePreWarmedAppStartTracing: enablePreWarmedAppStartTracing, + enableStandaloneAppStartTracing: enableStandaloneAppStartTracing, dateProvider: SentryDependencyContainer.sharedInstance().dateProvider, sysctlWrapper: SentryDependencyContainer.sharedInstance().sysctlWrapper, appStartInfoProvider: appStartInfoProvider @@ -341,10 +343,24 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { fixture.fileManager.moveAppStateToPreviousAppState() hybridAppStart() - + assertValidHybridStart(type: .warm) } - + + func testStandaloneAppStartTracing_DoesNotSetAppStartMeasurement() { + fixture.enableStandaloneAppStartTracing = true + startApp(callDisplayLink: true) + + assertNoAppStartUp() + } + + func testStandaloneAppStartTracingDisabled_SetsAppStartMeasurement() { + fixture.enableStandaloneAppStartTracing = false + startApp(callDisplayLink: true) + + assertValidStart(type: .cold, expectedDuration: 0.45) + } + private func store(appState: SentryAppState) { fixture.fileManager.store(appState) } From b31cbf51be754788ba15d8a30fc18ab4ad47afa2 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 11:24:12 +0100 Subject: [PATCH 07/50] feat: Implement standalone app start transaction Create a real tracer with app.start.cold/warm operation that reuses the existing tracer pipeline for span building, measurements, context, debug images, and profiling. - Add SentrySpanOperationAppStartCold/Warm constants - Relax getAppStartMeasurement to accept app start ops - Skip intermediate root span for standalone transactions - Enable option in iOS sample app for validation Refs: #6883 Agent transcript: https://claudescope.sentry.dev/share/UaCuhStnxjQCwKH46hOsPqgeHsckPh15A0Flqw8JOPc --- .../SentrySampleShared/SentrySDKWrapper.swift | 5 +-- Sources/Sentry/SentryBuildAppStartSpans.m | 43 ++++++++++++------- Sources/Sentry/SentrySpanOperation.m | 3 ++ Sources/Sentry/include/SentrySpanOperation.h | 3 ++ .../SentryAppStartTracker.swift | 30 +++++++++++-- .../SentryAppStartTrackerTests.swift | 6 ++- 6 files changed, 66 insertions(+), 24 deletions(-) diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 11a2b85ab0..aa508d7ff8 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -181,10 +181,7 @@ public struct SentrySDKWrapper { // Experimental features options.enableFileManagerSwizzling = !SentrySDKOverrides.Other.disableFileManagerSwizzling.boolValue options.experimental.enableUnhandledCPPExceptionsV2 = true - -#if os(macOS) && !SENTRY_NO_UI_FRAMEWORK - options.enableUncaughtNSExceptionReporting = true -#endif + options.experimental.enableStandaloneAppStartTracing = true } func configureInitialScope(scope: Scope, options: Options) -> Scope { diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index c8306595e7..eee2629e52 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -2,6 +2,7 @@ #import "SentrySpanContext+Private.h" #import "SentrySpanId.h" #import "SentrySpanInternal.h" +#import "SentrySpanOperation.h" #import "SentrySwift.h" #import "SentryTraceOrigin.h" #import "SentryTracer.h" @@ -55,40 +56,52 @@ NSDate *appStartEndTimestamp = [appStartMeasurement.appStartTimestamp dateByAddingTimeInterval:appStartMeasurement.duration]; - id appStartSpan = sentryBuildAppStartSpan(tracer, tracer.spanId, operation, type); - [appStartSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; - [appStartSpan setTimestamp:appStartEndTimestamp]; - - [appStartSpans addObject:appStartSpan]; + // For standalone app start transactions the transaction itself is the root span, + // so we skip creating the intermediate "Cold Start" / "Warm Start" span. + BOOL isStandaloneAppStartTransaction = + [tracer.operation isEqualToString:SentrySpanOperationAppStartCold] + || [tracer.operation isEqualToString:SentrySpanOperationAppStartWarm]; + + SentrySpanId *childParentId; + if (isStandaloneAppStartTransaction) { + childParentId = tracer.spanId; + } else { + SentrySpanInternal *appStartSpan + = sentryBuildAppStartSpan(tracer, tracer.spanId, operation, type); + [appStartSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; + [appStartSpan setTimestamp:appStartEndTimestamp]; + [appStartSpans addObject:appStartSpan]; + childParentId = appStartSpan.spanId; + } if (!appStartMeasurement.isPreWarmed) { - id premainSpan - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Pre Runtime Init"); + SentrySpanInternal *premainSpan + = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Pre Runtime Init"); [premainSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; [premainSpan setTimestamp:appStartMeasurement.runtimeInitTimestamp]; [appStartSpans addObject:premainSpan]; - id runtimeInitSpan = sentryBuildAppStartSpan( - tracer, appStartSpan.spanId, operation, @"Runtime Init to Pre Main Initializers"); + SentrySpanInternal *runtimeInitSpan = sentryBuildAppStartSpan( + tracer, childParentId, operation, @"Runtime Init to Pre Main Initializers"); [runtimeInitSpan setStartTimestamp:appStartMeasurement.runtimeInitTimestamp]; [runtimeInitSpan setTimestamp:appStartMeasurement.moduleInitializationTimestamp]; [appStartSpans addObject:runtimeInitSpan]; } - id appInitSpan - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"UIKit Init"); + SentrySpanInternal *appInitSpan + = sentryBuildAppStartSpan(tracer, childParentId, operation, @"UIKit Init"); [appInitSpan setStartTimestamp:appStartMeasurement.moduleInitializationTimestamp]; [appInitSpan setTimestamp:appStartMeasurement.sdkStartTimestamp]; [appStartSpans addObject:appInitSpan]; - id didFinishLaunching - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Application Init"); + SentrySpanInternal *didFinishLaunching + = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Application Init"); [didFinishLaunching setStartTimestamp:appStartMeasurement.sdkStartTimestamp]; [didFinishLaunching setTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [appStartSpans addObject:didFinishLaunching]; - id frameRenderSpan - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Initial Frame Render"); + SentrySpanInternal *frameRenderSpan + = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Initial Frame Render"); [frameRenderSpan setStartTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [frameRenderSpan setTimestamp:appStartEndTimestamp]; [appStartSpans addObject:frameRenderSpan]; diff --git a/Sources/Sentry/SentrySpanOperation.m b/Sources/Sentry/SentrySpanOperation.m index f05fe16858..7b200c9ee4 100644 --- a/Sources/Sentry/SentrySpanOperation.m +++ b/Sources/Sentry/SentrySpanOperation.m @@ -15,6 +15,9 @@ NSString *const SentrySpanOperationUiAction = @"ui.action"; NSString *const SentrySpanOperationUiActionClick = @"ui.action.click"; +NSString *const SentrySpanOperationAppStartCold = @"app.start.cold"; +NSString *const SentrySpanOperationAppStartWarm = @"app.start.warm"; + NSString *const SentrySpanOperationUiLoad = @"ui.load"; NSString *const SentrySpanOperationUiLoadInitialDisplay = @"ui.load.initial_display"; NSString *const SentrySpanOperationUiLoadFullDisplay = @"ui.load.full_display"; diff --git a/Sources/Sentry/include/SentrySpanOperation.h b/Sources/Sentry/include/SentrySpanOperation.h index 969ac75225..df3a2066ba 100644 --- a/Sources/Sentry/include/SentrySpanOperation.h +++ b/Sources/Sentry/include/SentrySpanOperation.h @@ -36,6 +36,9 @@ SENTRY_EXTERN NSString *const SentrySpanOperationNetworkRequestOperation; SENTRY_EXTERN NSString *const SentrySpanOperationUiAction; SENTRY_EXTERN NSString *const SentrySpanOperationUiActionClick; +SENTRY_EXTERN NSString *const SentrySpanOperationAppStartCold; +SENTRY_EXTERN NSString *const SentrySpanOperationAppStartWarm; + SENTRY_EXTERN NSString *const SentrySpanOperationUiLoad; SENTRY_EXTERN NSString *const SentrySpanOperationUiLoadInitialDisplay; diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 653a353c72..466a8082f5 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -19,11 +19,35 @@ struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { } } -/// Placeholder for sending a standalone app start transaction. Currently a no-op; -/// the actual implementation will be added in a follow-up. +/// Sends a standalone app start transaction by storing the measurement on SentrySDKInternal +/// and creating a tracer that satisfies getAppStartMeasurement's requirements. The existing +/// tracer pipeline then handles span building, measurements, context, debug images, and profiling. struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { func handle(_ measurement: SentryAppStartMeasurement) { - // no-op: standalone app start transaction logic will be implemented here + let operation: String + let name: String + + switch measurement.type { + case .cold: + operation = SentrySpanOperationAppStartCold + name = "app_start_cold" + case .warm: + operation = SentrySpanOperationAppStartWarm + name = "app_start_warm" + default: + return + } + + // Store the measurement where the tracer's getAppStartMeasurement reads it from. + SentrySDKInternal.setAppStartMeasurement(measurement) + + let context = TransactionContext(name: name, operation: operation) + + let hub = SentrySDKInternal.currentHub() + let tracer = hub.startTransaction(transactionContext: context) + tracer.origin = SentryTraceOriginAutoAppStart + + tracer.finish() } } diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index 2037066533..28c6709a30 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -347,11 +347,13 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { assertValidHybridStart(type: .warm) } - func testStandaloneAppStartTracing_DoesNotSetAppStartMeasurement() { + func testStandaloneAppStartTracing_SetsAppStartMeasurement() { fixture.enableStandaloneAppStartTracing = true startApp(callDisplayLink: true) - assertNoAppStartUp() + // The standalone handler stores the measurement on SentrySDKInternal so the + // tracer's existing getAppStartMeasurement flow can consume it. + assertValidStart(type: .cold, expectedDuration: 0.45) } func testStandaloneAppStartTracingDisabled_SetsAppStartMeasurement() { From 2cad1fb4eaa6919f2dd23b49dd5f4ad5fb5e91db Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 12:00:34 +0100 Subject: [PATCH 08/50] ref: Pass standalone flag to sentryBuildAppStartSpans Move the standalone app start detection from SentryBuildAppStartSpans to SentryTracer and pass it as a BOOL parameter. Use span operation constants instead of string literals. Agent transcript: https://claudescope.sentry.dev/share/QyuEyImC81GP83tCrqUbp939ejQ3-3y_0TcwnvcejsA --- Sources/Sentry/SentryBuildAppStartSpans.m | 17 +++++++---------- Sources/Sentry/SentryTracer.m | 6 +++++- .../Sentry/include/SentryBuildAppStartSpans.h | 5 +++-- .../SentryAppStartTracker.swift | 10 +++++----- .../SentryBuildAppStartSpansTests.swift | 10 +++++----- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index eee2629e52..4c9713925e 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -26,9 +26,10 @@ return [[SentrySpanInternal alloc] initWithTracer:tracer context:context framesTracker:nil]; } -NSArray> * -sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +NSArray * +sentryBuildAppStartSpans(SentryTracer *tracer, + SentryAppStartMeasurement *_Nullable appStartMeasurement, + BOOL isStandaloneAppStartTransaction) { if (appStartMeasurement == nil) { @@ -40,11 +41,11 @@ switch (appStartMeasurement.type) { case SentryAppStartTypeCold: - operation = @"app.start.cold"; + operation = SentrySpanOperationAppStartCold; type = @"Cold Start"; break; case SentryAppStartTypeWarm: - operation = @"app.start.warm"; + operation = SentrySpanOperationAppStartWarm; type = @"Warm Start"; break; default: @@ -56,13 +57,9 @@ NSDate *appStartEndTimestamp = [appStartMeasurement.appStartTimestamp dateByAddingTimeInterval:appStartMeasurement.duration]; + SentrySpanId *childParentId; // For standalone app start transactions the transaction itself is the root span, // so we skip creating the intermediate "Cold Start" / "Warm Start" span. - BOOL isStandaloneAppStartTransaction = - [tracer.operation isEqualToString:SentrySpanOperationAppStartCold] - || [tracer.operation isEqualToString:SentrySpanOperationAppStartWarm]; - - SentrySpanId *childParentId; if (isStandaloneAppStartTransaction) { childParentId = tracer.spanId; } else { diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 7b0f972972..9c3987bb17 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -706,7 +706,11 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT [self addFrameStatistics]; - NSArray> *appStartSpans = sentryBuildAppStartSpans(self, appStartMeasurement); + BOOL isStandaloneAppStart = + [self.operation isEqualToString:SentrySpanOperationAppStartCold] + || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]; + NSArray *appStartSpans + = sentryBuildAppStartSpans(self, appStartMeasurement, isStandaloneAppStart); capacity = _children.count + appStartSpans.count; #else capacity = _children.count; diff --git a/Sources/Sentry/include/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index fad6b68a73..de4c1dd49e 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -8,8 +8,9 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT -NSArray> *sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement); +NSArray *sentryBuildAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement, + BOOL isStandaloneAppStartTransaction); #endif // SENTRY_HAS_UIKIT diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 466a8082f5..a31401a579 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -30,10 +30,10 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { switch measurement.type { case .cold: operation = SentrySpanOperationAppStartCold - name = "app_start_cold" + name = "App Start Cold" case .warm: operation = SentrySpanOperationAppStartWarm - name = "app_start_warm" + name = "App Start Warm" default: return } @@ -44,10 +44,10 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { let context = TransactionContext(name: name, operation: operation) let hub = SentrySDKInternal.currentHub() - let tracer = hub.startTransaction(transactionContext: context) - tracer.origin = SentryTraceOriginAutoAppStart + let transaction = hub.startTransaction(transactionContext: context) + transaction.origin = SentryTraceOriginAutoAppStart - tracer.finish() + transaction.finish() } } diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index 45f0eba6a8..14e752552a 100644 --- a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift +++ b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift @@ -11,7 +11,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { let appStartMeasurement: SentryAppStartMeasurement? = nil // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) // Assert XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") @@ -34,7 +34,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) // Assert XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") @@ -57,7 +57,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) // Assert XCTAssertEqual(result.count, 6, "Number of spans do not match") @@ -140,7 +140,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) // Assert XCTAssertEqual(result.count, 6, "Number of spans do not match") @@ -223,7 +223,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) // Assert XCTAssertEqual(result.count, 4, "Number of spans do not match") From 244ad480096d6dda9dca4afcf13bbd14feeb135f Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 12:03:59 +0100 Subject: [PATCH 09/50] ref: Extract isStandaloneAppStartTransaction method Deduplicate the app start operation check in SentryTracer into a single method reused in toTransaction and getAppStartMeasurement. Agent transcript: https://claudescope.sentry.dev/share/bffTUQ5TAcp6oTyLqR8SSSjLLG4pSUsZ8l7TB7qaq1s --- Sources/Sentry/SentryTracer.m | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 9c3987bb17..17984850c3 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -699,6 +699,12 @@ - (void)updateStartTime:(NSDate *)startTime _startTimeChanged = YES; } +- (BOOL)isStandaloneAppStartTransaction +{ + return [self.operation isEqualToString:SentrySpanOperationAppStartCold] + || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]; +} + - (SentryTransaction *)toTransaction { @@ -706,11 +712,8 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT [self addFrameStatistics]; - BOOL isStandaloneAppStart = - [self.operation isEqualToString:SentrySpanOperationAppStartCold] - || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]; - NSArray *appStartSpans - = sentryBuildAppStartSpans(self, appStartMeasurement, isStandaloneAppStart); + NSArray *appStartSpans = sentryBuildAppStartSpans( + self, appStartMeasurement, [self isStandaloneAppStartTransaction]); capacity = _children.count + appStartSpans.count; #else capacity = _children.count; From 6427ad539f811ae15ea97ababecb017603cb3862 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 12:05:23 +0100 Subject: [PATCH 10/50] ref: Check origin in isStandaloneAppStartTransaction Also verify the trace origin is auto.app_start to more precisely identify standalone app start transactions. Agent transcript: https://claudescope.sentry.dev/share/HhoCIcB8Nh0dEd16RtFdllS_ZIxK393KCEPaOUiPp5s --- Sources/Sentry/SentryTracer.m | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 17984850c3..e86a853b1c 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -17,6 +17,7 @@ #import "SentrySwift.h" #import "SentryTime.h" #import "SentryTraceContext+Private.h" +#import "SentryTraceOrigin.h" #import "SentryTraceContext.h" #import "SentryTracer+Private.h" #import "SentryTracerConfiguration.h" @@ -701,8 +702,9 @@ - (void)updateStartTime:(NSDate *)startTime - (BOOL)isStandaloneAppStartTransaction { - return [self.operation isEqualToString:SentrySpanOperationAppStartCold] - || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]; + return ([self.operation isEqualToString:SentrySpanOperationAppStartCold] + || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]) + && [self.origin isEqualToString:SentryTraceOriginAutoAppStart]; } - (SentryTransaction *)toTransaction From 683fc132a3d41f2eb301cefa83ea81f28f4a3520 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 12:11:11 +0100 Subject: [PATCH 11/50] ref: Move standalone app start check to Swift helper Add StandaloneAppStartTransactionHelper to centralize the logic for identifying standalone app start transactions next to the code that creates them. Agent transcript: https://claudescope.sentry.dev/share/th338Y1YxxM5471m3BNGV5Az6QKNTNQzAyhCyZ9MmuM --- Sources/Sentry/SentryTracer.m | 9 +++++---- .../AppStartTracking/SentryAppStartTracker.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index e86a853b1c..390e0367e4 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -17,7 +17,6 @@ #import "SentrySwift.h" #import "SentryTime.h" #import "SentryTraceContext+Private.h" -#import "SentryTraceOrigin.h" #import "SentryTraceContext.h" #import "SentryTracer+Private.h" #import "SentryTracerConfiguration.h" @@ -700,12 +699,14 @@ - (void)updateStartTime:(NSDate *)startTime _startTimeChanged = YES; } +#if SENTRY_HAS_UIKIT - (BOOL)isStandaloneAppStartTransaction { - return ([self.operation isEqualToString:SentrySpanOperationAppStartCold] - || [self.operation isEqualToString:SentrySpanOperationAppStartWarm]) - && [self.origin isEqualToString:SentryTraceOriginAutoAppStart]; + return [StandaloneAppStartTransactionHelper + isStandaloneAppStartTransactionWithOperation:self.operation + origin:self.origin]; } +#endif // SENTRY_HAS_UIKIT - (SentryTransaction *)toTransaction { diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index a31401a579..110f21c214 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -51,6 +51,14 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { } } +@_spi(Private) @objc public class StandaloneAppStartTransactionHelper: NSObject { + @objc public static func isStandaloneAppStartTransaction(operation: String, origin: String) -> Bool { + return (operation == SentrySpanOperationAppStartCold + || operation == SentrySpanOperationAppStartWarm) + && origin == SentryTraceOriginAutoAppStart + } +} + extension SentryAppStartTrackerHelper: AppStartInfoProvider {} /// Tracks cold and warm app start time for iOS, tvOS, and Mac Catalyst. The logic for the different From 81c03f00f6ad86b6e205287c9173f7b3f5defd62 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 09:14:30 +0100 Subject: [PATCH 12/50] ref: Use SentrySpan protocol in app start spans Agent transcript: https://claudescope.sentry.dev/share/xXkgR2dl97mE2PWt7NruvtllULuWG4AsRUqKNBWTwmY --- Sources/Sentry/SentryBuildAppStartSpans.m | 35 ++++++++++--------- Sources/Sentry/SentryTracer.m | 2 +- .../Sentry/include/SentryBuildAppStartSpans.h | 2 +- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 4c9713925e..383f5f0035 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -10,8 +10,7 @@ #if SENTRY_HAS_UIKIT -id -sentryBuildAppStartSpan( +id sentryBuildAppStartSpan( SentryTracer *tracer, SentrySpanId *parentId, NSString *operation, NSString *description) { SentrySpanContext *context = @@ -23,10 +22,12 @@ origin:SentryTraceOriginAutoAppStart sampled:tracer.sampled]; + // Pass nil for the framesTracker because app start spans are created during launch, + // before the frames tracker is available. return [[SentrySpanInternal alloc] initWithTracer:tracer context:context framesTracker:nil]; } -NSArray * +NSArray> * sentryBuildAppStartSpans(SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction) @@ -57,48 +58,48 @@ NSDate *appStartEndTimestamp = [appStartMeasurement.appStartTimestamp dateByAddingTimeInterval:appStartMeasurement.duration]; - SentrySpanId *childParentId; + SentrySpanId *appStartSpanParentId; // For standalone app start transactions the transaction itself is the root span, // so we skip creating the intermediate "Cold Start" / "Warm Start" span. if (isStandaloneAppStartTransaction) { - childParentId = tracer.spanId; + appStartSpanParentId = tracer.spanId; } else { - SentrySpanInternal *appStartSpan + id appStartSpan = sentryBuildAppStartSpan(tracer, tracer.spanId, operation, type); [appStartSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; [appStartSpan setTimestamp:appStartEndTimestamp]; [appStartSpans addObject:appStartSpan]; - childParentId = appStartSpan.spanId; + appStartSpanParentId = appStartSpan.spanId; } if (!appStartMeasurement.isPreWarmed) { - SentrySpanInternal *premainSpan - = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Pre Runtime Init"); + id premainSpan + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"Pre Runtime Init"); [premainSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; [premainSpan setTimestamp:appStartMeasurement.runtimeInitTimestamp]; [appStartSpans addObject:premainSpan]; - SentrySpanInternal *runtimeInitSpan = sentryBuildAppStartSpan( - tracer, childParentId, operation, @"Runtime Init to Pre Main Initializers"); + id runtimeInitSpan = sentryBuildAppStartSpan( + tracer, appStartSpanParentId, operation, @"Runtime Init to Pre Main Initializers"); [runtimeInitSpan setStartTimestamp:appStartMeasurement.runtimeInitTimestamp]; [runtimeInitSpan setTimestamp:appStartMeasurement.moduleInitializationTimestamp]; [appStartSpans addObject:runtimeInitSpan]; } - SentrySpanInternal *appInitSpan - = sentryBuildAppStartSpan(tracer, childParentId, operation, @"UIKit Init"); + id appInitSpan + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"UIKit Init"); [appInitSpan setStartTimestamp:appStartMeasurement.moduleInitializationTimestamp]; [appInitSpan setTimestamp:appStartMeasurement.sdkStartTimestamp]; [appStartSpans addObject:appInitSpan]; - SentrySpanInternal *didFinishLaunching - = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Application Init"); + id didFinishLaunching + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"Application Init"); [didFinishLaunching setStartTimestamp:appStartMeasurement.sdkStartTimestamp]; [didFinishLaunching setTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [appStartSpans addObject:didFinishLaunching]; - SentrySpanInternal *frameRenderSpan - = sentryBuildAppStartSpan(tracer, childParentId, operation, @"Initial Frame Render"); + id frameRenderSpan + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"Initial Frame Render"); [frameRenderSpan setStartTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [frameRenderSpan setTimestamp:appStartEndTimestamp]; [appStartSpans addObject:frameRenderSpan]; diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 390e0367e4..4a4e77b349 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -715,7 +715,7 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT [self addFrameStatistics]; - NSArray *appStartSpans = sentryBuildAppStartSpans( + NSArray> *appStartSpans = sentryBuildAppStartSpans( self, appStartMeasurement, [self isStandaloneAppStartTransaction]); capacity = _children.count + appStartSpans.count; #else diff --git a/Sources/Sentry/include/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index de4c1dd49e..eb4c43815a 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -8,7 +8,7 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT -NSArray *sentryBuildAppStartSpans( +NSArray> *sentryBuildAppStartSpans( SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction); From 51dc82b453dcc8ed8aecce167514efd3d1bb099b Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 5 Mar 2026 09:22:00 +0100 Subject: [PATCH 13/50] fix linter --- Sources/Sentry/SentryBuildAppStartSpans.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 383f5f0035..83b73f5950 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -10,7 +10,8 @@ #if SENTRY_HAS_UIKIT -id sentryBuildAppStartSpan( +id +sentryBuildAppStartSpan( SentryTracer *tracer, SentrySpanId *parentId, NSString *operation, NSString *description) { SentrySpanContext *context = From d4918cbc0171ebcba2733a02daec40bc9159d6f1 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 09:56:50 +0100 Subject: [PATCH 14/50] test: Add standalone app start span building tests Agent transcript: https://claudescope.sentry.dev/share/pdKSDSkCaothxFJDzOiGaAa5Xx0Y25-3FAXUPTQBrMk --- .../SentryBuildAppStartSpansTests.swift | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index 14e752552a..7060e6244d 100644 --- a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift +++ b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift @@ -2,6 +2,7 @@ import XCTest #if canImport(UIKit) && !os(watchOS) +// swiftlint:disable file_length type_body_length function_body_length class SentryBuildAppStartSpansTests: XCTestCase { func testSentryBuildAppStartSpans_appStartMeasurementIsNil_shouldNotReturnAnySpans() { @@ -206,6 +207,132 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) } + func testStandalone_coldNotPrewarmed_noGroupingSpan() { + // Arrange + let context = SpanContext(operation: "operation") + let tracer = SentryTracer(context: context, framesTracker: nil) + let appStartMeasurement = SentryAppStartMeasurement( + type: SentryAppStartType.cold, + isPreWarmed: false, + appStartTimestamp: Date(timeIntervalSince1970: 1_000), + runtimeInitSystemTimestamp: 1_100, + duration: 935, + runtimeInitTimestamp: Date(timeIntervalSince1970: 1_300), + moduleInitializationTimestamp: Date(timeIntervalSince1970: 1_400), + sdkStartTimestamp: Date(timeIntervalSince1970: 1_500), + didFinishLaunchingTimestamp: Date(timeIntervalSince1970: 1_600) + ) + + // Act + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, true) + + // Assert — no intermediate "Cold Start" span, all 5 children parent to tracer + XCTAssertEqual(result.count, 5, "Number of spans do not match") + assertSpan( + span: result[0], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.cold", + expectedDescription: "Pre Runtime Init", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_000), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_300), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[1], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.cold", + expectedDescription: "Runtime Init to Pre Main Initializers", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_300), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_400), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[2], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.cold", + expectedDescription: "UIKit Init", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_400), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_500), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[3], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.cold", + expectedDescription: "Application Init", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_500), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_600), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[4], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.cold", + expectedDescription: "Initial Frame Render", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_600), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_935), + expectedSampled: tracer.sampled + ) + } + + func testStandalone_prewarmed_noGroupingSpan() { + // Arrange + let context = SpanContext(operation: "operation") + let tracer = SentryTracer(context: context, framesTracker: nil) + let appStartMeasurement = SentryAppStartMeasurement( + type: SentryAppStartType.warm, + isPreWarmed: true, + appStartTimestamp: Date(timeIntervalSince1970: 1_000), + runtimeInitSystemTimestamp: 1_100, + duration: 935, + runtimeInitTimestamp: Date(timeIntervalSince1970: 1_300), + moduleInitializationTimestamp: Date(timeIntervalSince1970: 1_400), + sdkStartTimestamp: Date(timeIntervalSince1970: 1_500), + didFinishLaunchingTimestamp: Date(timeIntervalSince1970: 1_600) + ) + + // Act + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, true) + + // Assert — no grouping span, no pre-runtime spans, all 3 children parent to tracer + XCTAssertEqual(result.count, 3, "Number of spans do not match") + assertSpan( + span: result[0], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.warm", + expectedDescription: "UIKit Init", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_400), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_500), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[1], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.warm", + expectedDescription: "Application Init", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_500), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_600), + expectedSampled: tracer.sampled + ) + assertSpan( + span: result[2], + expectedTraceId: tracer.traceId.sentryIdString, + expectedParentSpanId: tracer.spanId.sentrySpanIdString, + expectedOperation: "app.start.warm", + expectedDescription: "Initial Frame Render", + expectedStartTimestamp: Date(timeIntervalSince1970: 1_600), + expectedEndTimestamp: Date(timeIntervalSince1970: 1_935), + expectedSampled: tracer.sampled + ) + } + func testSentryBuildAppStartSpans_appStartMeasurementIsPreWarmed_shouldIncludePreRuntimeSpans() { // Arrange let context = SpanContext(operation: "operation") From db3ef7e243895cfe5e84c7ae399f766b775199c4 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 10:14:40 +0100 Subject: [PATCH 15/50] feat: Guard standalone app start transaction on SDK enabled Agent transcript: https://claudescope.sentry.dev/share/ybqp4mPXWwKgONVEqEjXEB2fOJpPadgxjoVveErVLJg --- .../AppStartTracking/SentryAppStartTracker.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 110f21c214..173797001f 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -38,6 +38,11 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { return } + guard SentrySDK.isEnabled else { + SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") + return + } + // Store the measurement where the tracer's getAppStartMeasurement reads it from. SentrySDKInternal.setAppStartMeasurement(measurement) @@ -51,7 +56,9 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { } } +/// Helper to identify standalone app start transactions from ObjC code. @_spi(Private) @objc public class StandaloneAppStartTransactionHelper: NSObject { + /// Returns `true` when the operation and origin match a standalone app start transaction. @objc public static func isStandaloneAppStartTransaction(operation: String, origin: String) -> Bool { return (operation == SentrySpanOperationAppStartCold || operation == SentrySpanOperationAppStartWarm) From 8211d934cc02b8ba05413d7e0b13d1b0a5022b4e Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 10:46:11 +0100 Subject: [PATCH 16/50] ref: Pass app start measurement via tracer configuration Avoids race conditions with global static by passing the measurement directly to the tracer. Agent transcript: https://claudescope.sentry.dev/share/5dFraoe7qfSofcTRtjfi59JzjPmHtWPyBK9YLzp61_g --- .../SentryAppStartMeasurementProvider.m | 7 +++ Sources/Sentry/SentryTracer.m | 14 ++++- .../SentryAppStartMeasurementProvider.h | 7 +++ Sources/Sentry/include/SentryPrivate.h | 1 + .../include/SentryTracerConfiguration.h | 11 ++++ .../SentryAppStartTracker.swift | 27 ++++++--- .../SentryAppStartTrackerTests.swift | 9 +-- .../Transaction/SentryTracerTests.swift | 57 ++++++++++++++++++- 8 files changed, 114 insertions(+), 19 deletions(-) diff --git a/Sources/Sentry/SentryAppStartMeasurementProvider.m b/Sources/Sentry/SentryAppStartMeasurementProvider.m index 7507fee26f..63a0e8f539 100644 --- a/Sources/Sentry/SentryAppStartMeasurementProvider.m +++ b/Sources/Sentry/SentryAppStartMeasurementProvider.m @@ -94,6 +94,13 @@ + (nullable SentryAppStartMeasurement *)appStartMeasurementForOperation:(NSStrin return measurement; } ++ (void)markAsRead +{ + @synchronized(appStartMeasurementLock) { + appStartMeasurementRead = YES; + } +} + + (void)reset { @synchronized(appStartMeasurementLock) { diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 4a4e77b349..46c9834e5e 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -596,9 +596,17 @@ - (BOOL)finishTracer:(SentrySpanStatus)unfinishedSpansFinishStatus shouldCleanUp [super finishWithStatus:_finishStatus]; } #if SENTRY_HAS_UIKIT - appStartMeasurement = - [SentryAppStartMeasurementProvider appStartMeasurementForOperation:self.operation - startTimestamp:self.startTimestamp]; + // Standalone app start transactions carry their measurement directly in the configuration, + // bypassing the global static and its associated locking/timing checks. + if ([self isStandaloneAppStartTransaction] && _configuration.appStartMeasurement != nil) { + appStartMeasurement = _configuration.appStartMeasurement; + // Mark as read so no UIViewController transaction picks up the global static too. + [SentryAppStartMeasurementProvider markAsRead]; + } else { + appStartMeasurement = + [SentryAppStartMeasurementProvider appStartMeasurementForOperation:self.operation + startTimestamp:self.startTimestamp]; + } if (appStartMeasurement != nil) { [self updateStartTime:appStartMeasurement.appStartTimestamp]; diff --git a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h index 2a7f187307..53bcd7f47e 100644 --- a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h +++ b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h @@ -21,6 +21,13 @@ NS_ASSUME_NONNULL_BEGIN startTimestamp: (nullable NSDate *)startTimestamp; +/** + * Marks the app start measurement as read so subsequent calls to + * @c appStartMeasurementForOperation:startTimestamp: return @c nil. + * Used by standalone app start transactions that carry their own measurement. + */ ++ (void)markAsRead; + /** * Internal. Only needed for testing. */ diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 932724b90d..0b3085062c 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -89,6 +89,7 @@ #import "SentryTraceHeader.h" #import "SentryTraceOrigin.h" #import "SentryTraceProfiler.h" +#import "SentryTracerConfiguration.h" #import "SentryUIViewControllerSwizzlingHelper.h" #import "SentryUncaughtNSExceptions.h" #import "SentryWatchdogTerminationBreadcrumbProcessor.h" diff --git a/Sources/Sentry/include/SentryTracerConfiguration.h b/Sources/Sentry/include/SentryTracerConfiguration.h index 31bb1c62cb..a0e76c8ab6 100644 --- a/Sources/Sentry/include/SentryTracerConfiguration.h +++ b/Sources/Sentry/include/SentryTracerConfiguration.h @@ -8,6 +8,7 @@ NS_ASSUME_NONNULL_BEGIN +@class SentryAppStartMeasurement; @class SentryDispatchQueueWrapper; @class SentryProfileOptions; @class SentrySamplerDecision; @@ -52,6 +53,16 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic) NSTimeInterval idleTimeout; +#if SENTRY_HAS_UIKIT +/** + * The app start measurement to attach to this tracer. + * When set, the tracer uses this instead of reading from the global static. + * + * Default is nil. + */ +@property (nonatomic, strong, nullable) SentryAppStartMeasurement *appStartMeasurement; +#endif // SENTRY_HAS_UIKIT + + (SentryTracerConfiguration *)configurationWithBlock: (void (^)(SentryTracerConfiguration *configuration))block; diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 173797001f..b649f7e03a 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -19,9 +19,9 @@ struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { } } -/// Sends a standalone app start transaction by storing the measurement on SentrySDKInternal -/// and creating a tracer that satisfies getAppStartMeasurement's requirements. The existing -/// tracer pipeline then handles span building, measurements, context, debug images, and profiling. +/// Sends a standalone app start transaction by passing the measurement directly via the tracer +/// configuration. The existing tracer pipeline then handles span building, measurements, context, +/// debug images, and profiling. struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { func handle(_ measurement: SentryAppStartMeasurement) { let operation: String @@ -43,16 +43,25 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { return } - // Store the measurement where the tracer's getAppStartMeasurement reads it from. - SentrySDKInternal.setAppStartMeasurement(measurement) - let context = TransactionContext(name: name, operation: operation) + // Pass the measurement directly to the tracer via configuration instead of storing + // it on the global static. This avoids race conditions where a UIViewController + // transaction could consume the measurement first. + let configuration = SentryTracerConfiguration(block: { config in + config.appStartMeasurement = measurement + }) + let hub = SentrySDKInternal.currentHub() - let transaction = hub.startTransaction(transactionContext: context) - transaction.origin = SentryTraceOriginAutoAppStart + let tracer = hub.startTransaction( + with: context, + bindToScope: false, + customSamplingContext: [:], + configuration: configuration + ) + tracer.origin = SentryTraceOriginAutoAppStart - transaction.finish() + tracer.finish() } } diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index 28c6709a30..1dc26ca459 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -2,6 +2,7 @@ @_spi(Private) import SentryTestUtils import XCTest +// swiftlint:disable file_length type_body_length #if os(iOS) || os(tvOS) class TestAppStartInfoProvider: AppStartInfoProvider { @@ -347,13 +348,13 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { assertValidHybridStart(type: .warm) } - func testStandaloneAppStartTracing_SetsAppStartMeasurement() { + func testStandaloneAppStartTracing_SDKNotEnabled_DropsAppStart() { fixture.enableStandaloneAppStartTracing = true startApp(callDisplayLink: true) - // The standalone handler stores the measurement on SentrySDKInternal so the - // tracer's existing getAppStartMeasurement flow can consume it. - assertValidStart(type: .cold, expectedDuration: 0.45) + // The standalone handler guards on SentrySDK.isEnabled. Since the SDK is not + // fully started in this test, the measurement is dropped. + assertNoAppStartUp() } func testStandaloneAppStartTracingDisabled_SetsAppStartMeasurement() { diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index ba6e3b6b57..2e9f831aab 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -3,7 +3,7 @@ import _SentryPrivate @_spi(Private) import SentryTestUtils import XCTest -// swiftlint:disable file_length +// swiftlint:disable file_length type_body_length force_unwrapping // We are aware that the tracer has a lot of logic and we should maybe // move some of it to other classes. class SentryTracerTests: XCTestCase { @@ -957,13 +957,64 @@ class SentryTracerTests: XCTestCase { func testNoAppStartTransaction_AddsNoDebugMeta() { whenFinishingAutoUITransaction(startTimestamp: 5) - + XCTAssertEqual(self.fixture.hub.capturedEventsWithScopes.count, 1) let serializedTransaction = fixture.hub.capturedEventsWithScopes.first?.event.serialize() - + XCTAssertNil(serializedTransaction?["debug_meta"]) } + func testStandaloneAppStart_UsesConfigurationMeasurement() throws { + let appStartMeasurement = fixture.getAppStartMeasurement(type: .cold) + + let context = TransactionContext(name: "App Start Cold", operation: fixture.appStartColdOperation) + let sut = fixture.hub.startTransaction( + with: context, + bindToScope: false, + customSamplingContext: [:], + configuration: SentryTracerConfiguration(block: { + $0.appStartMeasurement = appStartMeasurement + }) + ) + sut.origin = SentryTraceOriginAutoAppStart + sut.finish() + + // The global static must remain untouched. + XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) + + let serializedTransaction = try XCTUnwrap(fixture.hub.capturedEventsWithScopes.first).event.serialize() + + // Verify cold app start measurement is attached + try assertMeasurements(["app_start_cold": ["value": fixture.appStartDuration * 1_000]]) + + // Standalone transactions have no intermediate grouping span, so 5 child spans + // (not 6) for a non-prewarmed cold start. + let spans = try XCTUnwrap(serializedTransaction["spans"] as? [[String: Any]]) + XCTAssertEqual(5, spans.count) + } + + func testStandaloneAppStart_DoesNotConsumeGlobalMeasurement() throws { + let globalMeasurement = fixture.getAppStartMeasurement(type: .warm) + SentrySDKInternal.setAppStartMeasurement(globalMeasurement) + + // A standalone tracer with its own measurement should not touch the global static. + let ownMeasurement = fixture.getAppStartMeasurement(type: .cold) + let context = TransactionContext(name: "App Start Cold", operation: fixture.appStartColdOperation) + let sut = fixture.hub.startTransaction( + with: context, + bindToScope: false, + customSamplingContext: [:], + configuration: SentryTracerConfiguration(block: { + $0.appStartMeasurement = ownMeasurement + }) + ) + sut.origin = SentryTraceOriginAutoAppStart + sut.finish() + + // The global measurement must still be available for a UIViewController transaction. + XCTAssertNotNil(SentrySDKInternal.getAppStartMeasurement()) + } + #endif // os(iOS) || os(tvOS) func testMeasurementOnChildSpan_SetTwice_OverwritesMeasurement() throws { From 89686cad48f3404943705adcf6c0f147b1f15890 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 10:50:57 +0100 Subject: [PATCH 17/50] test: Assert span names and operations in standalone test Agent transcript: https://claudescope.sentry.dev/share/X8DACobiqDRtZfVbeaQw4zLHsDROh6I5nJeKDL5zUEs --- .../SentryTests/Transaction/SentryTracerTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index 2e9f831aab..aa6f2cf57b 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -991,6 +991,18 @@ class SentryTracerTests: XCTestCase { // (not 6) for a non-prewarmed cold start. let spans = try XCTUnwrap(serializedTransaction["spans"] as? [[String: Any]]) XCTAssertEqual(5, spans.count) + + let spanDescriptions = spans.compactMap { $0["description"] as? String } + let spanOperations = spans.compactMap { $0["op"] as? String } + + XCTAssertEqual(spanDescriptions, [ + "Pre Runtime Init", + "Runtime Init to Pre Main Initializers", + "UIKit Init", + "Application Init", + "Initial Frame Render" + ]) + XCTAssertEqual(Set(spanOperations), ["app.start.cold"]) } func testStandaloneAppStart_DoesNotConsumeGlobalMeasurement() throws { From 985fdd03aed8f993b2daa96430ed2bb3c28318ab Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 10:56:41 +0100 Subject: [PATCH 18/50] test: Improve standalone app start global measurement test --- .../Transaction/SentryTracerTests.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index aa6f2cf57b..1380c478c9 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -1005,26 +1005,32 @@ class SentryTracerTests: XCTestCase { XCTAssertEqual(Set(spanOperations), ["app.start.cold"]) } - func testStandaloneAppStart_DoesNotConsumeGlobalMeasurement() throws { + func testStandaloneAppStart_BothConfigAndGlobalSet_ConfigWins_GlobalUntouched() throws { + // In practice only one of config or global should be set, never both. This test + // verifies that if both happen to be set, the config measurement wins and the + // global static remains untouched for a potential UIViewController transaction. let globalMeasurement = fixture.getAppStartMeasurement(type: .warm) SentrySDKInternal.setAppStartMeasurement(globalMeasurement) - // A standalone tracer with its own measurement should not touch the global static. - let ownMeasurement = fixture.getAppStartMeasurement(type: .cold) + let configMeasurement = fixture.getAppStartMeasurement(type: .cold) let context = TransactionContext(name: "App Start Cold", operation: fixture.appStartColdOperation) let sut = fixture.hub.startTransaction( with: context, bindToScope: false, customSamplingContext: [:], configuration: SentryTracerConfiguration(block: { - $0.appStartMeasurement = ownMeasurement + $0.appStartMeasurement = configMeasurement }) ) sut.origin = SentryTraceOriginAutoAppStart sut.finish() - // The global measurement must still be available for a UIViewController transaction. - XCTAssertNotNil(SentrySDKInternal.getAppStartMeasurement()) + // The config measurement must be used (cold), verified via the transaction measurement key. + try assertMeasurements(["app_start_cold": ["value": fixture.appStartDuration * 1_000]]) + + // The global measurement must still be the original warm one. + let remainingMeasurement = SentrySDKInternal.getAppStartMeasurement() + XCTAssertEqual(remainingMeasurement?.type, .warm) } #endif // os(iOS) || os(tvOS) From 78e9ab3d020a66ec5224a6f321b06542212fc9fd Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 9 Mar 2026 11:06:01 +0100 Subject: [PATCH 19/50] fix: Mark app start measurement as read in standalone path --- .../SentryTests/Transaction/SentryTracerTests.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index 1380c478c9..78e377e9f5 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -1005,10 +1005,10 @@ class SentryTracerTests: XCTestCase { XCTAssertEqual(Set(spanOperations), ["app.start.cold"]) } - func testStandaloneAppStart_BothConfigAndGlobalSet_ConfigWins_GlobalUntouched() throws { + func testStandaloneAppStart_BothConfigAndGlobalSet_ConfigWins_GlobalMarkedAsRead() throws { // In practice only one of config or global should be set, never both. This test // verifies that if both happen to be set, the config measurement wins and the - // global static remains untouched for a potential UIViewController transaction. + // global static is marked as read so no UIViewController transaction consumes it. let globalMeasurement = fixture.getAppStartMeasurement(type: .warm) SentrySDKInternal.setAppStartMeasurement(globalMeasurement) @@ -1028,9 +1028,12 @@ class SentryTracerTests: XCTestCase { // The config measurement must be used (cold), verified via the transaction measurement key. try assertMeasurements(["app_start_cold": ["value": fixture.appStartDuration * 1_000]]) - // The global measurement must still be the original warm one. - let remainingMeasurement = SentrySDKInternal.getAppStartMeasurement() - XCTAssertEqual(remainingMeasurement?.type, .warm) + // A subsequent ui.load transaction must not get the app start measurement + // because the standalone transaction marked it as read. + whenFinishingAutoUITransaction(startTimestamp: 5) + let secondTransaction = try XCTUnwrap(fixture.hub.capturedEventsWithScopes.last).event.serialize() + let measurements = secondTransaction["measurements"] as? [String: Any] + XCTAssertNil(measurements?["app_start_warm"]) } #endif // os(iOS) || os(tvOS) From df35dee2ea271a43878c7469a53459c84f29dd1a Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:15:32 +0100 Subject: [PATCH 20/50] feat: Add changelog entry for standalone app start tracing Agent transcript: https://claudescope.sentry.dev/share/ba0jEl1_wCrMsWbUA4zabNE04Mmzh_nfAkAW9tgonjQ --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a869256072..94ffbcfd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Show feedback form on device shake (#7579) - Enable via `config.useShakeGesture = true` in `SentryUserFeedbackConfiguration` - Uses UIKit's built-in shake detection — no special permissions required +- Add standalone app start tracing as an experimental option (#7660). - Add package traits for UI framework opt-out (#7578). When building from source with Swift 6.1+ (using `Package@swift-6.1.swift`), you can enable the `NoUIFramework` trait to avoid linking UIKit or AppKit. Use this for command-line tools, headless server contexts, or other environments where UI frameworks are unavailable. In Xcode 26.4 and later, add the Sentry package as a dependency and the `SentrySPM` product, then enable the `NoUIFramework` trait on the package reference (Package Dependencies → select Sentry → Traits). From e0b4ea46427caedb3cb75634e5a23345b29dee45 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:19:37 +0100 Subject: [PATCH 21/50] ref: Clarify markAsRead comment as safeguard Agent transcript: https://claudescope.sentry.dev/share/fqJhtFeYZ9DtRHb8ih_eyjFV4RGWdWcn3s78XrkEa14 --- Sources/Sentry/SentryBuildAppStartSpans.m | 3 +-- Sources/Sentry/SentryTracer.m | 3 ++- Sources/Sentry/include/SentryBuildAppStartSpans.h | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 83b73f5950..b7fe74a8ea 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -30,8 +30,7 @@ NSArray> * sentryBuildAppStartSpans(SentryTracer *tracer, - SentryAppStartMeasurement *_Nullable appStartMeasurement, - BOOL isStandaloneAppStartTransaction) + SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction) { if (appStartMeasurement == nil) { diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 46c9834e5e..b971a840c3 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -600,7 +600,8 @@ - (BOOL)finishTracer:(SentrySpanStatus)unfinishedSpansFinishStatus shouldCleanUp // bypassing the global static and its associated locking/timing checks. if ([self isStandaloneAppStartTransaction] && _configuration.appStartMeasurement != nil) { appStartMeasurement = _configuration.appStartMeasurement; - // Mark as read so no UIViewController transaction picks up the global static too. + // Safeguard: this shouldn't normally happen, but mark as read so no UIViewController + // transaction picks up the global static too. [SentryAppStartMeasurementProvider markAsRead]; } else { appStartMeasurement = diff --git a/Sources/Sentry/include/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index eb4c43815a..6e713f1b23 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -8,9 +8,8 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT -NSArray> *sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement, - BOOL isStandaloneAppStartTransaction); +NSArray> *sentryBuildAppStartSpans(SentryTracer *tracer, + SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction); #endif // SENTRY_HAS_UIKIT From 8106a6474db5e0e3a8aa0a95f575e171fe0e6214 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:20:12 +0100 Subject: [PATCH 22/50] fix: Remove trailing commas unsupported by CI Swift version Agent transcript: https://claudescope.sentry.dev/share/00P909_-BDK3dCrAuYM5Af-B_KTSr_v7lzsB0GQwwoU --- ...ntryAppStartMeasurementProviderTests.swift | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 7ecad6836f..d2b0a9f909 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -25,7 +25,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -46,7 +46,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: "custom", - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -68,7 +68,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -81,7 +81,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: Date(), + startTimestamp: Date() ) // -- Assert -- @@ -101,14 +101,14 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) XCTAssertNotNil(first) // -- Act -- let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -128,7 +128,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { let first = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) XCTAssertNotNil(first) @@ -138,7 +138,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Assert -- let second = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) XCTAssertNotNil(second) } @@ -159,7 +159,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -180,7 +180,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -200,7 +200,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -220,7 +220,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- @@ -240,7 +240,7 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { // -- Act -- let result = SentryAppStartMeasurementProvider.appStartMeasurement( forOperation: SentrySpanOperationUiLoad, - startTimestamp: transactionStart, + startTimestamp: transactionStart ) // -- Assert -- From 661d309a5379c29bf8ef691a47f76dd144987821 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:24:18 +0100 Subject: [PATCH 23/50] ref: Move SDK enabled guard to top of handle method Agent transcript: https://claudescope.sentry.dev/share/tkPlEyIjqSYEShjUHMrL6S6cBVYyMin7qK6sJD18qVQ --- .../AppStartTracking/SentryAppStartTracker.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index b649f7e03a..9c283d2c85 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -24,6 +24,11 @@ struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { /// debug images, and profiling. struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { func handle(_ measurement: SentryAppStartMeasurement) { + guard SentrySDK.isEnabled else { + SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") + return + } + let operation: String let name: String @@ -38,11 +43,6 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { return } - guard SentrySDK.isEnabled else { - SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") - return - } - let context = TransactionContext(name: name, operation: operation) // Pass the measurement directly to the tracer via configuration instead of storing From 462b02820cc33b0b6027b03f83e947e26f57739d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:26:00 +0100 Subject: [PATCH 24/50] ref: Log error for unknown app start type Agent transcript: https://claudescope.sentry.dev/share/mG1o7cKbkd2funBFYw_0u66qKeEKNUcOacZmHbiIGZA --- .../Integrations/AppStartTracking/SentryAppStartTracker.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 9c283d2c85..ef77450e75 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -40,6 +40,7 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { operation = SentrySpanOperationAppStartWarm name = "App Start Warm" default: + SentrySDKLog.error("Unknown app start type \(measurement.type.rawValue), can't report standalone app start transaction") return } From 0c44cc8baa568fd3b5936ae166f6c665125a601d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:27:13 +0100 Subject: [PATCH 25/50] ref: Reference SentryAppStartMeasurementProvider in comment Agent transcript: https://claudescope.sentry.dev/share/8vyg59iyPrkFeOg3HI9hxC6ZtWZFmfTqEQecOcyeE_0 --- Sources/Sentry/include/SentryTracerConfiguration.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Sentry/include/SentryTracerConfiguration.h b/Sources/Sentry/include/SentryTracerConfiguration.h index a0e76c8ab6..60be322423 100644 --- a/Sources/Sentry/include/SentryTracerConfiguration.h +++ b/Sources/Sentry/include/SentryTracerConfiguration.h @@ -56,7 +56,8 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT /** * The app start measurement to attach to this tracer. - * When set, the tracer uses this instead of reading from the global static. + * When set, the tracer uses this instead of reading from the global static in + * @c SentryAppStartMeasurementProvider. * * Default is nil. */ From 868d72664674bdc7b6d734022d307f82e8bb8a3a Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:28:52 +0100 Subject: [PATCH 26/50] ref: Log error for unknown app start type in span builder Agent transcript: https://claudescope.sentry.dev/share/OtOgMkDinr5QDIR_tb4LKHhjY2IpVW4MnuYMhpXAGJw --- Sources/Sentry/SentryBuildAppStartSpans.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index b7fe74a8ea..28bd265a30 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -1,4 +1,5 @@ #import "SentryAppStartMeasurement.h" +#import "SentryLogC.h" #import "SentrySpanContext+Private.h" #import "SentrySpanId.h" #import "SentrySpanInternal.h" @@ -50,6 +51,8 @@ type = @"Warm Start"; break; default: + SENTRY_LOG_ERROR(@"Unknown app start type %lu, can't build app start spans", + (unsigned long)appStartMeasurement.type); return @[]; } From a0c07a6e1f94cc8b40b09f83e40f3d2ac718dcd0 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:29:22 +0100 Subject: [PATCH 27/50] ref: Simplify unknown app start type log message --- Sources/Sentry/SentryBuildAppStartSpans.m | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 28bd265a30..3c6ec58ee0 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -51,8 +51,7 @@ type = @"Warm Start"; break; default: - SENTRY_LOG_ERROR(@"Unknown app start type %lu, can't build app start spans", - (unsigned long)appStartMeasurement.type); + SENTRY_LOG_ERROR(@"Unknown app start type, can't build app start spans"); return @[]; } From fc55e129fe70066c5dd592cc0641dc35faed508c Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:29:59 +0100 Subject: [PATCH 28/50] ref: Simplify unknown app start type log in Swift --- .../Integrations/AppStartTracking/SentryAppStartTracker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index ef77450e75..690f4b4614 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -40,7 +40,7 @@ struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { operation = SentrySpanOperationAppStartWarm name = "App Start Warm" default: - SentrySDKLog.error("Unknown app start type \(measurement.type.rawValue), can't report standalone app start transaction") + SentrySDKLog.error("Unknown app start type, can't report standalone app start transaction") return } From a1e88d146ef1a6a1bb8ad732cc8c2f7b08e24883 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:35:53 +0100 Subject: [PATCH 29/50] ref: Extract AppStartMeasurementHandler into own file Agent transcript: https://claudescope.sentry.dev/share/WfMKkT8pkZED8hKTh8ooyjvNQ-Wdv-XBpQmlBFK1ONI --- .../AppStartMeasurementHandler.swift | 73 +++++++++++++++++++ .../SentryAppStartTracker.swift | 68 ----------------- 2 files changed, 73 insertions(+), 68 deletions(-) create mode 100644 Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift diff --git a/Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift b/Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift new file mode 100644 index 0000000000..7ff1c21a9d --- /dev/null +++ b/Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift @@ -0,0 +1,73 @@ +@_implementationOnly import _SentryPrivate + +#if (os(iOS) || os(tvOS) || os(visionOS)) && !SENTRY_NO_UI_FRAMEWORK + +protocol AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) +} + +/// Attaches app start data to the first UIViewController transaction (default behavior). +struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) { + SentrySDKInternal.setAppStartMeasurement(measurement) + } +} + +/// Sends a standalone app start transaction by passing the measurement directly via the tracer +/// configuration. The existing tracer pipeline then handles span building, measurements, context, +/// debug images, and profiling. +struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { + func handle(_ measurement: SentryAppStartMeasurement) { + guard SentrySDK.isEnabled else { + SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") + return + } + + let operation: String + let name: String + + switch measurement.type { + case .cold: + operation = SentrySpanOperationAppStartCold + name = "App Start Cold" + case .warm: + operation = SentrySpanOperationAppStartWarm + name = "App Start Warm" + default: + SentrySDKLog.error("Unknown app start type, can't report standalone app start transaction") + return + } + + let context = TransactionContext(name: name, operation: operation) + + // Pass the measurement directly to the tracer via configuration instead of storing + // it on the global static. This avoids race conditions where a UIViewController + // transaction could consume the measurement first. + let configuration = SentryTracerConfiguration(block: { config in + config.appStartMeasurement = measurement + }) + + let hub = SentrySDKInternal.currentHub() + let tracer = hub.startTransaction( + with: context, + bindToScope: false, + customSamplingContext: [:], + configuration: configuration + ) + tracer.origin = SentryTraceOriginAutoAppStart + + tracer.finish() + } +} + +/// Helper to identify standalone app start transactions from ObjC code. +@_spi(Private) @objc public class StandaloneAppStartTransactionHelper: NSObject { + /// Returns `true` when the operation and origin match a standalone app start transaction. + @objc public static func isStandaloneAppStartTransaction(operation: String, origin: String) -> Bool { + return (operation == SentrySpanOperationAppStartCold + || operation == SentrySpanOperationAppStartWarm) + && origin == SentryTraceOriginAutoAppStart + } +} + +#endif // (os(iOS) || os(tvOS) || os(visionOS)) && !SENTRY_NO_UI_FRAMEWORK diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 690f4b4614..642f5b10e0 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -8,74 +8,6 @@ protocol AppStartInfoProvider { func isActivePrewarm() -> Bool } -protocol AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) -} - -/// Attaches app start data to the first UIViewController transaction (default behavior). -struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) { - SentrySDKInternal.setAppStartMeasurement(measurement) - } -} - -/// Sends a standalone app start transaction by passing the measurement directly via the tracer -/// configuration. The existing tracer pipeline then handles span building, measurements, context, -/// debug images, and profiling. -struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) { - guard SentrySDK.isEnabled else { - SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") - return - } - - let operation: String - let name: String - - switch measurement.type { - case .cold: - operation = SentrySpanOperationAppStartCold - name = "App Start Cold" - case .warm: - operation = SentrySpanOperationAppStartWarm - name = "App Start Warm" - default: - SentrySDKLog.error("Unknown app start type, can't report standalone app start transaction") - return - } - - let context = TransactionContext(name: name, operation: operation) - - // Pass the measurement directly to the tracer via configuration instead of storing - // it on the global static. This avoids race conditions where a UIViewController - // transaction could consume the measurement first. - let configuration = SentryTracerConfiguration(block: { config in - config.appStartMeasurement = measurement - }) - - let hub = SentrySDKInternal.currentHub() - let tracer = hub.startTransaction( - with: context, - bindToScope: false, - customSamplingContext: [:], - configuration: configuration - ) - tracer.origin = SentryTraceOriginAutoAppStart - - tracer.finish() - } -} - -/// Helper to identify standalone app start transactions from ObjC code. -@_spi(Private) @objc public class StandaloneAppStartTransactionHelper: NSObject { - /// Returns `true` when the operation and origin match a standalone app start transaction. - @objc public static func isStandaloneAppStartTransaction(operation: String, origin: String) -> Bool { - return (operation == SentrySpanOperationAppStartCold - || operation == SentrySpanOperationAppStartWarm) - && origin == SentryTraceOriginAutoAppStart - } -} - extension SentryAppStartTrackerHelper: AppStartInfoProvider {} /// Tracks cold and warm app start time for iOS, tvOS, and Mac Catalyst. The logic for the different From 529e78a9499daa2bf546e527a133f1d27544c359 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:45:06 +0100 Subject: [PATCH 30/50] test: Add tests for AppStartMeasurementHandler --- .../AppStartMeasurementHandlerTests.swift | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift new file mode 100644 index 0000000000..bfc5fabd12 --- /dev/null +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -0,0 +1,143 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) + +class AppStartMeasurementHandlerTests: XCTestCase { + + override func tearDown() { + super.tearDown() + clearTestState() + } + + private func createMeasurement(type: SentryAppStartType, duration: TimeInterval = 0.5) -> SentryAppStartMeasurement { + let dateProvider = TestCurrentDateProvider() + let appStart = dateProvider.date() + return SentryAppStartMeasurement( + type: type, + isPreWarmed: false, + appStartTimestamp: appStart, + runtimeInitSystemTimestamp: dateProvider.systemTime(), + duration: duration, + runtimeInitTimestamp: appStart.addingTimeInterval(0.05), + moduleInitializationTimestamp: appStart.addingTimeInterval(0.1), + sdkStartTimestamp: appStart.addingTimeInterval(0.15), + didFinishLaunchingTimestamp: appStart.addingTimeInterval(0.3) + ) + } + + private func createHub() -> TestHub { + let options = Options() + options.dsn = TestConstants.dsnAsString(username: "AppStartMeasurementHandlerTests") + options.tracesSampleRate = 1 + let client = TestClient(options: options) + return TestHub(client: client, andScope: Scope()) + } + + // MARK: - AttachAppStartMeasurementHandler + + func testAttach_SetsMeasurementOnGlobalStatic() throws { + let measurement = createMeasurement(type: .cold) + + AttachAppStartMeasurementHandler().handle(measurement) + + let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) + XCTAssertEqual(stored.type, .cold) + XCTAssertEqual(stored.duration, measurement.duration) + } + + func testAttach_WarmStart_SetsMeasurementOnGlobalStatic() throws { + let measurement = createMeasurement(type: .warm) + + AttachAppStartMeasurementHandler().handle(measurement) + + let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) + XCTAssertEqual(stored.type, .warm) + } + + // MARK: - SendStandaloneAppStartTransaction + + func testSendStandalone_SDKNotEnabled_DoesNotCaptureTransaction() { + let hub = createHub() + // Don't set hub on SDK — isEnabled returns false + let measurement = createMeasurement(type: .cold) + + SendStandaloneAppStartTransaction().handle(measurement) + + XCTAssertTrue(hub.capturedEventsWithScopes.invocations.isEmpty) + } + + func testSendStandalone_ColdStart_CapturesTransaction() throws { + let hub = createHub() + SentrySDKInternal.setCurrentHub(hub) + let measurement = createMeasurement(type: .cold) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + XCTAssertEqual(serialized["transaction"] as? String, "App Start Cold") + + let contexts = try XCTUnwrap(serialized["contexts"] as? [String: Any]) + let traceContext = try XCTUnwrap(contexts["trace"] as? [String: Any]) + XCTAssertEqual(traceContext["op"] as? String, SentrySpanOperationAppStartCold) + XCTAssertEqual(traceContext["origin"] as? String, SentryTraceOriginAutoAppStart) + } + + func testSendStandalone_WarmStart_CapturesTransaction() throws { + let hub = createHub() + SentrySDKInternal.setCurrentHub(hub) + let measurement = createMeasurement(type: .warm) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + XCTAssertEqual(serialized["transaction"] as? String, "App Start Warm") + + let contexts = try XCTUnwrap(serialized["contexts"] as? [String: Any]) + let traceContext = try XCTUnwrap(contexts["trace"] as? [String: Any]) + XCTAssertEqual(traceContext["op"] as? String, SentrySpanOperationAppStartWarm) + } + + func testSendStandalone_DoesNotSetGlobalStatic() { + let hub = createHub() + SentrySDKInternal.setCurrentHub(hub) + let measurement = createMeasurement(type: .cold) + + SendStandaloneAppStartTransaction().handle(measurement) + + XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) + } + + // MARK: - StandaloneAppStartTransactionHelper + + func testHelper_ColdStartWithAutoOrigin_ReturnsTrue() { + XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: SentrySpanOperationAppStartCold, + origin: SentryTraceOriginAutoAppStart + )) + } + + func testHelper_WarmStartWithAutoOrigin_ReturnsTrue() { + XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: SentrySpanOperationAppStartWarm, + origin: SentryTraceOriginAutoAppStart + )) + } + + func testHelper_UILoadWithAutoOrigin_ReturnsFalse() { + XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: "ui.load", + origin: SentryTraceOriginAutoAppStart + )) + } + + func testHelper_ColdStartWithManualOrigin_ReturnsFalse() { + XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: SentrySpanOperationAppStartCold, + origin: "manual" + )) + } +} + +#endif // os(iOS) || os(tvOS) From ed652f56681dc7b8d3d67fef07e2e64e4cdb9464 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:48:41 +0100 Subject: [PATCH 31/50] ref: Split sentryBuildAppStartSpans into two methods --- Sources/Sentry/SentryBuildAppStartSpans.m | 30 ++++++++++++++----- Sources/Sentry/SentryTracer.m | 5 ++-- .../Sentry/include/SentryBuildAppStartSpans.h | 17 +++++++++-- .../SentryBuildAppStartSpansTests.swift | 14 ++++----- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 3c6ec58ee0..d2058d28e8 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -11,7 +11,7 @@ #if SENTRY_HAS_UIKIT -id +static id sentryBuildAppStartSpan( SentryTracer *tracer, SentrySpanId *parentId, NSString *operation, NSString *description) { @@ -29,9 +29,13 @@ return [[SentrySpanInternal alloc] initWithTracer:tracer context:context framesTracker:nil]; } -NSArray> * -sentryBuildAppStartSpans(SentryTracer *tracer, - SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction) +/** + * Internal helper that builds the app start child spans. When @c isStandalone is @c YES the + * intermediate grouping span is omitted and children are parented directly to the tracer. + */ +static NSArray> * +sentryBuildAppStartSpansInternal(SentryTracer *tracer, + SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandalone) { if (appStartMeasurement == nil) { @@ -61,9 +65,7 @@ dateByAddingTimeInterval:appStartMeasurement.duration]; SentrySpanId *appStartSpanParentId; - // For standalone app start transactions the transaction itself is the root span, - // so we skip creating the intermediate "Cold Start" / "Warm Start" span. - if (isStandaloneAppStartTransaction) { + if (isStandalone) { appStartSpanParentId = tracer.spanId; } else { id appStartSpan @@ -109,4 +111,18 @@ return appStartSpans; } +NSArray> * +sentryBuildAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, NO); +} + +NSArray> * +sentryBuildStandaloneAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, YES); +} + #endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index b971a840c3..9b939096de 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -724,8 +724,9 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT [self addFrameStatistics]; - NSArray> *appStartSpans = sentryBuildAppStartSpans( - self, appStartMeasurement, [self isStandaloneAppStartTransaction]); + NSArray> *appStartSpans = [self isStandaloneAppStartTransaction] + ? sentryBuildStandaloneAppStartSpans(self, appStartMeasurement) + : sentryBuildAppStartSpans(self, appStartMeasurement); capacity = _children.count + appStartSpans.count; #else capacity = _children.count; diff --git a/Sources/Sentry/include/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index 6e713f1b23..cdd640311d 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -8,8 +8,21 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT -NSArray> *sentryBuildAppStartSpans(SentryTracer *tracer, - SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandaloneAppStartTransaction); +/** + * Builds app start child spans and attaches them under an intermediate grouping span + * ("Cold Start" / "Warm Start") parented to the tracer. Used when app start data is + * attached to a UIViewController transaction. + */ +NSArray> *sentryBuildAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement); + +/** + * Builds app start child spans parented directly to the tracer, without the intermediate + * grouping span. Used for standalone app start transactions where the transaction itself + * represents the app start. + */ +NSArray> *sentryBuildStandaloneAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement); #endif // SENTRY_HAS_UIKIT diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index 7060e6244d..f2c7b0847f 100644 --- a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift +++ b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift @@ -12,7 +12,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { let appStartMeasurement: SentryAppStartMeasurement? = nil // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) // Assert XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") @@ -35,7 +35,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) // Assert XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") @@ -58,7 +58,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) // Assert XCTAssertEqual(result.count, 6, "Number of spans do not match") @@ -141,7 +141,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) // Assert XCTAssertEqual(result.count, 6, "Number of spans do not match") @@ -224,7 +224,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, true) + let result = sentryBuildStandaloneAppStartSpans(tracer, appStartMeasurement) // Assert — no intermediate "Cold Start" span, all 5 children parent to tracer XCTAssertEqual(result.count, 5, "Number of spans do not match") @@ -297,7 +297,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, true) + let result = sentryBuildStandaloneAppStartSpans(tracer, appStartMeasurement) // Assert — no grouping span, no pre-runtime spans, all 3 children parent to tracer XCTAssertEqual(result.count, 3, "Number of spans do not match") @@ -350,7 +350,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) // Act - let result = sentryBuildAppStartSpans(tracer, appStartMeasurement, false) + let result = sentryBuildAppStartSpans(tracer, appStartMeasurement) // Assert XCTAssertEqual(result.count, 4, "Number of spans do not match") From 27995063bf703b592cb4025e2f2fabfc8a981e97 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:53:25 +0100 Subject: [PATCH 32/50] test: Add integration tests for standalone app start --- .../AppStartMeasurementHandlerTests.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index bfc5fabd12..117bcbed3e 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -109,6 +109,81 @@ class AppStartMeasurementHandlerTests: XCTestCase { XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) } + // MARK: - SendStandaloneAppStartTransaction Integration Tests + + private func setUpIntegrationHub() -> TestHub { + let dateProvider = TestCurrentDateProvider() + SentryDependencyContainer.sharedInstance().dateProvider = dateProvider + + let debugImageProvider = TestDebugImageProvider() + debugImageProvider.debugImages = [TestData.debugImage] + SentryDependencyContainer.sharedInstance().debugImageProvider = debugImageProvider + + let displayLinkWrapper = TestDisplayLinkWrapper(dateProvider: dateProvider) + SentryDependencyContainer.sharedInstance().framesTracker.setDisplayLinkWrapper(displayLinkWrapper) + SentryDependencyContainer.sharedInstance().framesTracker.start() + displayLinkWrapper.call() + + let options = Options() + options.dsn = TestConstants.dsnAsString(username: "AppStartMeasurementHandlerTests") + options.tracesSampleRate = 1 + let client = TestClient(options: options) + let hub = TestHub(client: client, andScope: Scope()) + SentrySDKInternal.setCurrentHub(hub) + return hub + } + + func testSendStandalone_ColdStart_AddsAppStartMeasurement() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold, duration: 0.5) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let measurements = try XCTUnwrap(serialized["measurements"] as? [String: Any]) + let appStartCold = try XCTUnwrap(measurements["app_start_cold"] as? [String: Any]) + XCTAssertEqual(appStartCold["value"] as? Double, 500) + } + + func testSendStandalone_WarmStart_AddsAppStartMeasurement() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .warm, duration: 0.3) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let measurements = try XCTUnwrap(serialized["measurements"] as? [String: Any]) + let appStartWarm = try XCTUnwrap(measurements["app_start_warm"] as? [String: Any]) + XCTAssertEqual(appStartWarm["value"] as? Double, 300) + } + + func testSendStandalone_AddsDebugMeta() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let debugMeta = try XCTUnwrap(serialized["debug_meta"] as? [String: Any]) + let images = try XCTUnwrap(debugMeta["images"] as? [[String: Any]]) + XCTAssertFalse(images.isEmpty) + } + + func testSendStandalone_SetsStartTimeToAppStartTimestamp() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold) + + SendStandaloneAppStartTransaction().handle(measurement) + + let event = try XCTUnwrap(hub.capturedEventsWithScopes.invocations.first?.event) + let eventStartTimestamp = try XCTUnwrap(event.startTimestamp) + XCTAssertEqual( + eventStartTimestamp.timeIntervalSinceReferenceDate, + measurement.appStartTimestamp.timeIntervalSinceReferenceDate, + accuracy: 0.001 + ) + } + // MARK: - StandaloneAppStartTransactionHelper func testHelper_ColdStartWithAutoOrigin_ReturnsTrue() { From 6a7e6778f4bec0703c03ef39202bcd60d167b3d1 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:54:18 +0100 Subject: [PATCH 33/50] test: Add missing import for _SentryPrivate --- .../AppStartTracking/AppStartMeasurementHandlerTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index 117bcbed3e..45651edc64 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -1,3 +1,4 @@ +import _SentryPrivate @_spi(Private) @testable import Sentry @_spi(Private) import SentryTestUtils import XCTest From 8ba35104574030b3693a5137a093090d4a5e7d06 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 10:57:41 +0100 Subject: [PATCH 34/50] test: Replace clearTestState with targeted cleanup --- .../AppStartTracking/AppStartMeasurementHandlerTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index 45651edc64..6f13a2c523 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -9,7 +9,9 @@ class AppStartMeasurementHandlerTests: XCTestCase { override func tearDown() { super.tearDown() - clearTestState() + SentrySDKInternal.setCurrentHub(nil) + SentrySDKInternal.setAppStartMeasurement(nil) + SentryAppStartMeasurementProvider.reset() } private func createMeasurement(type: SentryAppStartType, duration: TimeInterval = 0.5) -> SentryAppStartMeasurement { From 5106df674d7eb0cd186472cf299b51280f18534d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 11:00:43 +0100 Subject: [PATCH 35/50] test: Add span validation for standalone app start --- .../AppStartMeasurementHandlerTests.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index 6f13a2c523..9dc182510a 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -187,6 +187,68 @@ class AppStartMeasurementHandlerTests: XCTestCase { ) } + /// Verifies the full span tree for a warm non-prewarmed standalone app start transaction. + /// More span edge cases (cold, prewarmed, grouping span) are covered in SentryBuildAppStartSpansTests. + func testSendStandalone_WarmNotPrewarmed_ContainsCorrectSpans() throws { + let hub = setUpIntegrationHub() + let dateProvider = TestCurrentDateProvider() + let appStart = dateProvider.date() + let measurement = SentryAppStartMeasurement( + type: .warm, + isPreWarmed: false, + appStartTimestamp: appStart, + runtimeInitSystemTimestamp: dateProvider.systemTime(), + duration: 0.5, + runtimeInitTimestamp: appStart.addingTimeInterval(0.05), + moduleInitializationTimestamp: appStart.addingTimeInterval(0.1), + sdkStartTimestamp: appStart.addingTimeInterval(0.15), + didFinishLaunchingTimestamp: appStart.addingTimeInterval(0.3) + ) + + SendStandaloneAppStartTransaction().handle(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let spans = try XCTUnwrap(serialized["spans"] as? [[String: Any]]) + + // Warm non-prewarmed standalone: no grouping span, includes pre-runtime spans → 5 child spans + XCTAssertEqual(spans.count, 5) + + let descriptions = spans.compactMap { $0["description"] as? String } + XCTAssertEqual(descriptions, [ + "Pre Runtime Init", + "Runtime Init to Pre Main Initializers", + "UIKit Init", + "Application Init", + "Initial Frame Render" + ]) + + let operations = Set(spans.compactMap { $0["op"] as? String }) + XCTAssertEqual(operations, [SentrySpanOperationAppStartWarm]) + + let appStartInterval = appStart.timeIntervalSince1970 + + // Pre Runtime Init: appStart → runtimeInit + assertSpanTimestamps(spans[0], + expectedStart: appStartInterval, + expectedEnd: appStartInterval + 0.05) + // Runtime Init to Pre Main Initializers: runtimeInit → moduleInit + assertSpanTimestamps(spans[1], + expectedStart: appStartInterval + 0.05, + expectedEnd: appStartInterval + 0.1) + // UIKit Init: moduleInit → sdkStart + assertSpanTimestamps(spans[2], + expectedStart: appStartInterval + 0.1, + expectedEnd: appStartInterval + 0.15) + // Application Init: sdkStart → didFinishLaunching + assertSpanTimestamps(spans[3], + expectedStart: appStartInterval + 0.15, + expectedEnd: appStartInterval + 0.3) + // Initial Frame Render: didFinishLaunching → appStart + duration + assertSpanTimestamps(spans[4], + expectedStart: appStartInterval + 0.3, + expectedEnd: appStartInterval + 0.5) + } + // MARK: - StandaloneAppStartTransactionHelper func testHelper_ColdStartWithAutoOrigin_ReturnsTrue() { @@ -216,6 +278,21 @@ class AppStartMeasurementHandlerTests: XCTestCase { origin: "manual" )) } + + // MARK: - Helpers + + private func assertSpanTimestamps( + _ span: [String: Any], + expectedStart: TimeInterval, + expectedEnd: TimeInterval, + file: StaticString = #file, + line: UInt = #line + ) { + let start = span["start_timestamp"] as? TimeInterval ?? 0 + let end = span["timestamp"] as? TimeInterval ?? 0 + XCTAssertEqual(start, expectedStart, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(end, expectedEnd, accuracy: 0.001, file: file, line: line) + } } #endif // os(iOS) || os(tvOS) From 58d04f06af86e3c66cdee220eca1cd45f831837e Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 11:45:31 +0100 Subject: [PATCH 36/50] fix: Fix flaky tests by restoring clearTestState and using serialized data --- .../AppStartMeasurementHandlerTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index 9dc182510a..11d3ba4b92 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -9,9 +9,9 @@ class AppStartMeasurementHandlerTests: XCTestCase { override func tearDown() { super.tearDown() - SentrySDKInternal.setCurrentHub(nil) - SentrySDKInternal.setAppStartMeasurement(nil) - SentryAppStartMeasurementProvider.reset() + // The integration tests modify SentryDependencyContainer global state + // (debugImageProvider, framesTracker, hub), so we need the full cleanup. + clearTestState() } private func createMeasurement(type: SentryAppStartType, duration: TimeInterval = 0.5) -> SentryAppStartMeasurement { @@ -178,11 +178,11 @@ class AppStartMeasurementHandlerTests: XCTestCase { SendStandaloneAppStartTransaction().handle(measurement) - let event = try XCTUnwrap(hub.capturedEventsWithScopes.invocations.first?.event) - let eventStartTimestamp = try XCTUnwrap(event.startTimestamp) + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let startTimestamp = try XCTUnwrap(serialized["start_timestamp"] as? TimeInterval) XCTAssertEqual( - eventStartTimestamp.timeIntervalSinceReferenceDate, - measurement.appStartTimestamp.timeIntervalSinceReferenceDate, + startTimestamp, + measurement.appStartTimestamp.timeIntervalSince1970, accuracy: 0.001 ) } From d11bf49f36450b1ce08cb0220bb5d1b03fb108d3 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 11:52:21 +0100 Subject: [PATCH 37/50] feat: Track standalone app start tracing in enabled features Agent transcript: https://claudescope.sentry.dev/share/C9BhqxF3zTINpiHqWP4NXyggeHdocpb2_Vr61uNyjWk --- .../Helper/SentryEnabledFeaturesBuilder.swift | 3 +++ .../SentryEnabledFeaturesBuilderTests.swift | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift b/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift index 55b8106dc3..3f28013c14 100644 --- a/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift +++ b/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift @@ -49,6 +49,9 @@ import Foundation if options.experimental.enableMetrics { features.append("metrics") } + if options.experimental.enableStandaloneAppStartTracing { + features.append("standaloneAppStartTracing") + } return features } diff --git a/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift index acba24b5bb..69d9bad460 100644 --- a/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift +++ b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift @@ -195,4 +195,30 @@ final class SentryEnabledFeaturesBuilderTests: XCTestCase { // -- Assert -- XCTAssertFalse(features.contains("metrics")) } + + func testEnableStandaloneAppStartTracing_isEnabled_shouldAddFeature() throws { + // -- Arrange -- + let options = Options() + + options.experimental.enableStandaloneAppStartTracing = true + + // -- Act -- + let features = SentryEnabledFeaturesBuilder.getEnabledFeatures(options: options) + + // -- Assert -- + XCTAssertTrue(features.contains("standaloneAppStartTracing")) + } + + func testEnableStandaloneAppStartTracing_isDisabled_shouldNotAddFeature() throws { + // -- Arrange -- + let options = Options() + + options.experimental.enableStandaloneAppStartTracing = false + + // -- Act -- + let features = SentryEnabledFeaturesBuilder.getEnabledFeatures(options: options) + + // -- Assert -- + XCTAssertFalse(features.contains("standaloneAppStartTracing")) + } } From c7d58ca2d2e5df8c125d700de21b957afb3a692d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 12:04:26 +0100 Subject: [PATCH 38/50] ref: address PR review comments - Move public functions to top of SentryBuildAppStartSpans.m - Expand SentryTracer comment on race condition avoidance - Use string literals instead of constants in tests - Assert specific debug image properties in test - Add span tree examples to header doc comments Agent transcript: https://claudescope.sentry.dev/share/4RQyqJ6qxJ3uLrCpOW-w1LjUpmtGerVFCS6DIZgKRXE --- Sources/Sentry/SentryBuildAppStartSpans.m | 36 +++++++++++-------- Sources/Sentry/SentryTracer.m | 6 +++- .../Sentry/include/SentryBuildAppStartSpans.h | 29 +++++++++++---- .../AppStartMeasurementHandlerTests.swift | 27 +++++++------- 4 files changed, 65 insertions(+), 33 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index d2058d28e8..99ccf68b6f 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -11,6 +11,28 @@ #if SENTRY_HAS_UIKIT +// Forward declarations of static helpers. +static id sentryBuildAppStartSpan( + SentryTracer *, SentrySpanId *, NSString *, NSString *); +static NSArray> *sentryBuildAppStartSpansInternal( + SentryTracer *, SentryAppStartMeasurement *_Nullable, BOOL); + +NSArray> * +sentryBuildAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, NO); +} + +NSArray> * +sentryBuildStandaloneAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, YES); +} + +# pragma mark - Private + static id sentryBuildAppStartSpan( SentryTracer *tracer, SentrySpanId *parentId, NSString *operation, NSString *description) @@ -111,18 +133,4 @@ return appStartSpans; } -NSArray> * -sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) -{ - return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, NO); -} - -NSArray> * -sentryBuildStandaloneAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) -{ - return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, YES); -} - #endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 9b939096de..129eaf9405 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -597,7 +597,11 @@ - (BOOL)finishTracer:(SentrySpanStatus)unfinishedSpansFinishStatus shouldCleanUp } #if SENTRY_HAS_UIKIT // Standalone app start transactions carry their measurement directly in the configuration, - // bypassing the global static and its associated locking/timing checks. + // bypassing the global static in SentryAppStartMeasurementProvider. The main advantage is + // avoiding a race condition between the app start tracker producing the measurement and + // the first UIViewController transaction consuming it. We don't change the existing + // UIViewController/AppStart path below because it's bulletproof and we'll likely remove + // it once standalone app start tracing is stable. if ([self isStandaloneAppStartTransaction] && _configuration.appStartMeasurement != nil) { appStartMeasurement = _configuration.appStartMeasurement; // Safeguard: this shouldn't normally happen, but mark as read so no UIViewController diff --git a/Sources/Sentry/include/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index cdd640311d..1d02987c36 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -9,17 +9,34 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT /** - * Builds app start child spans and attaches them under an intermediate grouping span - * ("Cold Start" / "Warm Start") parented to the tracer. Used when app start data is - * attached to a UIViewController transaction. + * Builds app start spans for a UIViewController transaction. An intermediate grouping span + * ("Cold Start" / "Warm Start") is inserted as parent for the phase spans: + * + * @code + * UIViewController (op: ui.load) ← tracer + * └─ Cold Start (op: app.start.cold) ← grouping span + * ├─ Pre Runtime Init + * ├─ Runtime Init to Pre Main Initializers + * ├─ UIKit Init + * ├─ Application Init + * └─ Initial Frame Render + * @endcode */ NSArray> *sentryBuildAppStartSpans( SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement); /** - * Builds app start child spans parented directly to the tracer, without the intermediate - * grouping span. Used for standalone app start transactions where the transaction itself - * represents the app start. + * Builds app start spans for a standalone app start transaction. Phase spans are parented + * directly to the tracer (no intermediate grouping span): + * + * @code + * App Start Cold (op: app.start.cold) ← tracer + * ├─ Pre Runtime Init + * ├─ Runtime Init to Pre Main Initializers + * ├─ UIKit Init + * ├─ Application Init + * └─ Initial Frame Render + * @endcode */ NSArray> *sentryBuildStandaloneAppStartSpans( SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement); diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift index 11d3ba4b92..1ba15d413e 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift @@ -1,4 +1,3 @@ -import _SentryPrivate @_spi(Private) @testable import Sentry @_spi(Private) import SentryTestUtils import XCTest @@ -83,8 +82,8 @@ class AppStartMeasurementHandlerTests: XCTestCase { let contexts = try XCTUnwrap(serialized["contexts"] as? [String: Any]) let traceContext = try XCTUnwrap(contexts["trace"] as? [String: Any]) - XCTAssertEqual(traceContext["op"] as? String, SentrySpanOperationAppStartCold) - XCTAssertEqual(traceContext["origin"] as? String, SentryTraceOriginAutoAppStart) + XCTAssertEqual(traceContext["op"] as? String, "app.start.cold") + XCTAssertEqual(traceContext["origin"] as? String, "auto.app.start") } func testSendStandalone_WarmStart_CapturesTransaction() throws { @@ -99,7 +98,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { let contexts = try XCTUnwrap(serialized["contexts"] as? [String: Any]) let traceContext = try XCTUnwrap(contexts["trace"] as? [String: Any]) - XCTAssertEqual(traceContext["op"] as? String, SentrySpanOperationAppStartWarm) + XCTAssertEqual(traceContext["op"] as? String, "app.start.warm") } func testSendStandalone_DoesNotSetGlobalStatic() { @@ -169,7 +168,11 @@ class AppStartMeasurementHandlerTests: XCTestCase { let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let debugMeta = try XCTUnwrap(serialized["debug_meta"] as? [String: Any]) let images = try XCTUnwrap(debugMeta["images"] as? [[String: Any]]) - XCTAssertFalse(images.isEmpty) + let image = try XCTUnwrap(images.first) + let expectedImage = TestData.debugImage + XCTAssertEqual(image["code_file"] as? String, expectedImage.codeFile) + XCTAssertEqual(image["image_addr"] as? String, expectedImage.imageAddress) + XCTAssertEqual(image["image_vmaddr"] as? String, expectedImage.imageVmAddress) } func testSendStandalone_SetsStartTimeToAppStartTimestamp() throws { @@ -223,7 +226,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { ]) let operations = Set(spans.compactMap { $0["op"] as? String }) - XCTAssertEqual(operations, [SentrySpanOperationAppStartWarm]) + XCTAssertEqual(operations, ["app.start.warm"]) let appStartInterval = appStart.timeIntervalSince1970 @@ -253,28 +256,28 @@ class AppStartMeasurementHandlerTests: XCTestCase { func testHelper_ColdStartWithAutoOrigin_ReturnsTrue() { XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( - operation: SentrySpanOperationAppStartCold, - origin: SentryTraceOriginAutoAppStart + operation: "app.start.cold", + origin: "auto.app.start" )) } func testHelper_WarmStartWithAutoOrigin_ReturnsTrue() { XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( - operation: SentrySpanOperationAppStartWarm, - origin: SentryTraceOriginAutoAppStart + operation: "app.start.warm", + origin: "auto.app.start" )) } func testHelper_UILoadWithAutoOrigin_ReturnsFalse() { XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( operation: "ui.load", - origin: SentryTraceOriginAutoAppStart + origin: "auto.app.start" )) } func testHelper_ColdStartWithManualOrigin_ReturnsFalse() { XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( - operation: SentrySpanOperationAppStartCold, + operation: "app.start.cold", origin: "manual" )) } From 15b9564754377f26f2bbb787f7fa2ed814e11a40 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 12:00:18 +0100 Subject: [PATCH 39/50] fix: Add visionOS to platform guards in clearTestState The platform guards for app start cleanup in clearTestState were missing os(visionOS), causing appStartMeasurementRead to never reset between tests on visionOS. This made tests that depend on app start measurement data fail when run after other tests that consume the measurement. Agent transcript: https://claudescope.sentry.dev/share/XRVuiHsfZrr_PPrwGDvT9RkHPcp6AxGP1tIDaq2H4CU --- SentryTestUtils/Sources/ClearTestState.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index 8f806d478e..526a3f67e6 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -46,9 +46,9 @@ class TestCleanup: NSObject { SentryDependencyContainer.reset() SentryPerformanceTracker.shared.clear() -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) SentryAppStartMeasurementProvider.reset() -#endif // os(iOS) || os(tvOS) +#endif // os(iOS) || os(tvOS) || os(visionOS) #if os(iOS) || os(macOS) _sentry_threadUnsafe_traceProfileTimeoutTimer = nil From 027eef291c74fc2cbd514b22339830a0ec8a991f Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 10 Mar 2026 12:06:31 +0100 Subject: [PATCH 40/50] build: update public API after adding experimental option Agent transcript: https://claudescope.sentry.dev/share/q8e4vEz2TdMPQVUddVouvkTqbmczr4sbno1eCaKYJUo --- sdk_api.json | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/sdk_api.json b/sdk_api.json index 1aca035b64..0b076162e4 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -35923,6 +35923,82 @@ "printedName": "enableSessionReplayInUnreliableEnvironment", "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(py)enableSessionReplayInUnreliableEnvironment" }, + { + "accessors": [ + { + "accessorKind": "get", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC31enableStandaloneAppStartTracingSbvg", + "moduleName": "Sentry", + "name": "Get", + "printedName": "Get()", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)enableStandaloneAppStartTracing" + }, + { + "accessorKind": "set", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declAttributes": [ + "Final", + "ObjC" + ], + "declKind": "Accessor", + "implicit": true, + "kind": "Accessor", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC31enableStandaloneAppStartTracingSbvs", + "moduleName": "Sentry", + "name": "Set", + "printedName": "Set()", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)setEnableStandaloneAppStartTracing:" + } + ], + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declAttributes": [ + "Final", + "HasStorage", + "ObjC" + ], + "declKind": "Var", + "hasStorage": true, + "kind": "Var", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC31enableStandaloneAppStartTracingSbvp", + "moduleName": "Sentry", + "name": "enableStandaloneAppStartTracing", + "printedName": "enableStandaloneAppStartTracing", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(py)enableStandaloneAppStartTracing" + }, { "accessors": [ { From 98fbbed1e0b44484a8c24cb886251cd8f6d58247 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:28:17 +0100 Subject: [PATCH 41/50] changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e06a0bdb6..563bfb8fac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add standalone app start tracing as an experimental option (#7660), enable it via `options.experimental.enableStandaloneAppStartTracing = true`/ + ## 9.7.0 ### Features @@ -7,7 +13,6 @@ - Show feedback form on device shake (#7579) - Enable via `config.useShakeGesture = true` in `SentryUserFeedbackConfiguration` - Uses UIKit's built-in shake detection — no special permissions required -- Add standalone app start tracing as an experimental option (#7660). - Add package traits for UI framework opt-out (#7578). When building from source with Swift 6.1+ (using `Package@swift-6.1.swift`), you can enable the `NoUIFramework` trait to avoid linking UIKit or AppKit. Use this for command-line tools, headless server contexts, or other environments where UI frameworks are unavailable. In Xcode 26.4 and later, add the Sentry package as a dependency and the `SentrySPM` product, then enable the `NoUIFramework` trait on the package reference (Package Dependencies → select Sentry → Traits). From 8b3d6f21be08d4c40ecbd6bffe8220ef45d6e014 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:31:41 +0100 Subject: [PATCH 42/50] ref: Remove forward declarations in SentryBuildAppStartSpans Reorder static helpers before public functions so forward declarations are unnecessary. Agent transcript: https://claudescope.sentry.dev/share/YxFRa6oUZjO-FbIdZ6tJm3NS3crlLEOkaJhSoyTlnnI --- Sources/Sentry/SentryBuildAppStartSpans.m | 36 ++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 99ccf68b6f..6713e337cd 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -11,26 +11,6 @@ #if SENTRY_HAS_UIKIT -// Forward declarations of static helpers. -static id sentryBuildAppStartSpan( - SentryTracer *, SentrySpanId *, NSString *, NSString *); -static NSArray> *sentryBuildAppStartSpansInternal( - SentryTracer *, SentryAppStartMeasurement *_Nullable, BOOL); - -NSArray> * -sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) -{ - return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, NO); -} - -NSArray> * -sentryBuildStandaloneAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) -{ - return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, YES); -} - # pragma mark - Private static id @@ -133,4 +113,20 @@ return appStartSpans; } +# pragma mark - Public + +NSArray> * +sentryBuildAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, NO); +} + +NSArray> * +sentryBuildStandaloneAppStartSpans( + SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +{ + return sentryBuildAppStartSpansInternal(tracer, appStartMeasurement, YES); +} + #endif // SENTRY_HAS_UIKIT From 48192750bd87d083cd7bce627626f2b5d4327dea Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:32:37 +0100 Subject: [PATCH 43/50] ref: Move isStandaloneAppStartTransaction to UIKit section Group it with the other SENTRY_HAS_UIKIT methods at the bottom of the file. Agent transcript: https://claudescope.sentry.dev/share/l1p-jS5wvAvglbGGB7D_m6tnGYaagdHBQCTAddXclW0 --- Sources/Sentry/SentryTracer.m | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 129eaf9405..3e11fa19f7 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -712,15 +712,6 @@ - (void)updateStartTime:(NSDate *)startTime _startTimeChanged = YES; } -#if SENTRY_HAS_UIKIT -- (BOOL)isStandaloneAppStartTransaction -{ - return [StandaloneAppStartTransactionHelper - isStandaloneAppStartTransactionWithOperation:self.operation - origin:self.origin]; -} -#endif // SENTRY_HAS_UIKIT - - (SentryTransaction *)toTransaction { @@ -782,6 +773,13 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT +- (BOOL)isStandaloneAppStartTransaction +{ + return [StandaloneAppStartTransactionHelper + isStandaloneAppStartTransactionWithOperation:self.operation + origin:self.origin]; +} + - (void)addAppStartMeasurements:(SentryTransaction *)transaction { if (appStartMeasurement != nil && appStartMeasurement.type != SentryAppStartTypeUnknown) { From d0d815e4c22a66d18096a2fcd8e416bbf6c2a67c Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:43:03 +0100 Subject: [PATCH 44/50] ref: Rename AppStartMeasurementHandler to AppStartReportingStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Protocol: AppStartMeasurementHandler → AppStartReportingStrategy - AttachAppStartMeasurementHandler → AttachToTransactionStrategy - SendStandaloneAppStartTransaction → StandaloneTransactionStrategy - Method: handle() → report() - Variable: measurementHandler → reportingStrategy --- ....swift => AppStartReportingStrategy.swift} | 12 +++---- .../SentryAppStartTracker.swift | 10 +++--- ...t => AppStartReportingStrategyTests.swift} | 34 +++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) rename Sources/Swift/Integrations/AppStartTracking/{AppStartMeasurementHandler.swift => AppStartReportingStrategy.swift} (87%) rename Tests/SentryTests/Integrations/Performance/AppStartTracking/{AppStartMeasurementHandlerTests.swift => AppStartReportingStrategyTests.swift} (92%) diff --git a/Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift similarity index 87% rename from Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift rename to Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift index 7ff1c21a9d..f02711fdda 100644 --- a/Sources/Swift/Integrations/AppStartTracking/AppStartMeasurementHandler.swift +++ b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift @@ -2,13 +2,13 @@ #if (os(iOS) || os(tvOS) || os(visionOS)) && !SENTRY_NO_UI_FRAMEWORK -protocol AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) +protocol AppStartReportingStrategy { + func report(_ measurement: SentryAppStartMeasurement) } /// Attaches app start data to the first UIViewController transaction (default behavior). -struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) { +struct AttachToTransactionStrategy: AppStartReportingStrategy { + func report(_ measurement: SentryAppStartMeasurement) { SentrySDKInternal.setAppStartMeasurement(measurement) } } @@ -16,8 +16,8 @@ struct AttachAppStartMeasurementHandler: AppStartMeasurementHandler { /// Sends a standalone app start transaction by passing the measurement directly via the tracer /// configuration. The existing tracer pipeline then handles span building, measurements, context, /// debug images, and profiling. -struct SendStandaloneAppStartTransaction: AppStartMeasurementHandler { - func handle(_ measurement: SentryAppStartMeasurement) { +struct StandaloneTransactionStrategy: AppStartReportingStrategy { + func report(_ measurement: SentryAppStartMeasurement) { guard SentrySDK.isEnabled else { SentrySDKLog.warning("SDK is not enabled, dropping standalone app start transaction") return diff --git a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift index 642f5b10e0..1bad5147d6 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -33,7 +33,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener let appStateManager: SentryAppStateManager private let framesTracker: SentryFramesTracker private let enablePreWarmedAppStartTracing: Bool - private let measurementHandler: AppStartMeasurementHandler + private let reportingStrategy: AppStartReportingStrategy private var previousAppState: SentryAppState? private var wasInBackground = false @@ -62,9 +62,9 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener self.appStateManager = appStateManager self.framesTracker = framesTracker self.enablePreWarmedAppStartTracing = enablePreWarmedAppStartTracing - self.measurementHandler = enableStandaloneAppStartTracing - ? SendStandaloneAppStartTransaction() - : AttachAppStartMeasurementHandler() + self.reportingStrategy = enableStandaloneAppStartTracing + ? StandaloneTransactionStrategy() + : AttachToTransactionStrategy() self.previousAppState = appStateManager.loadPreviousAppState() self.dateProvider = dateProvider self.didFinishLaunchingTimestamp = dateProvider.date() @@ -235,7 +235,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener didFinishLaunchingTimestamp: finalDidFinishLaunchingTimestamp ) - self.measurementHandler.handle(appStartMeasurement) + self.reportingStrategy.report(appStartMeasurement) } // With only running this once we know that the process is a new one when the following diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift similarity index 92% rename from Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift rename to Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift index 1ba15d413e..0986ad39a4 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartMeasurementHandlerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift @@ -4,7 +4,7 @@ import XCTest #if os(iOS) || os(tvOS) -class AppStartMeasurementHandlerTests: XCTestCase { +class AppStartReportingStrategyTests: XCTestCase { override func tearDown() { super.tearDown() @@ -31,18 +31,18 @@ class AppStartMeasurementHandlerTests: XCTestCase { private func createHub() -> TestHub { let options = Options() - options.dsn = TestConstants.dsnAsString(username: "AppStartMeasurementHandlerTests") + options.dsn = TestConstants.dsnAsString(username: "AppStartReportingStrategyTests") options.tracesSampleRate = 1 let client = TestClient(options: options) return TestHub(client: client, andScope: Scope()) } - // MARK: - AttachAppStartMeasurementHandler + // MARK: - AttachToTransactionStrategy func testAttach_SetsMeasurementOnGlobalStatic() throws { let measurement = createMeasurement(type: .cold) - AttachAppStartMeasurementHandler().handle(measurement) + AttachToTransactionStrategy().report(measurement) let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) XCTAssertEqual(stored.type, .cold) @@ -52,20 +52,20 @@ class AppStartMeasurementHandlerTests: XCTestCase { func testAttach_WarmStart_SetsMeasurementOnGlobalStatic() throws { let measurement = createMeasurement(type: .warm) - AttachAppStartMeasurementHandler().handle(measurement) + AttachToTransactionStrategy().report(measurement) let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) XCTAssertEqual(stored.type, .warm) } - // MARK: - SendStandaloneAppStartTransaction + // MARK: - StandaloneTransactionStrategy func testSendStandalone_SDKNotEnabled_DoesNotCaptureTransaction() { let hub = createHub() // Don't set hub on SDK — isEnabled returns false let measurement = createMeasurement(type: .cold) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) XCTAssertTrue(hub.capturedEventsWithScopes.invocations.isEmpty) } @@ -75,7 +75,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .cold) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) XCTAssertEqual(serialized["transaction"] as? String, "App Start Cold") @@ -91,7 +91,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .warm) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) XCTAssertEqual(serialized["transaction"] as? String, "App Start Warm") @@ -106,12 +106,12 @@ class AppStartMeasurementHandlerTests: XCTestCase { SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .cold) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) } - // MARK: - SendStandaloneAppStartTransaction Integration Tests + // MARK: - StandaloneTransactionStrategy Integration Tests private func setUpIntegrationHub() -> TestHub { let dateProvider = TestCurrentDateProvider() @@ -127,7 +127,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { displayLinkWrapper.call() let options = Options() - options.dsn = TestConstants.dsnAsString(username: "AppStartMeasurementHandlerTests") + options.dsn = TestConstants.dsnAsString(username: "AppStartReportingStrategyTests") options.tracesSampleRate = 1 let client = TestClient(options: options) let hub = TestHub(client: client, andScope: Scope()) @@ -139,7 +139,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold, duration: 0.5) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let measurements = try XCTUnwrap(serialized["measurements"] as? [String: Any]) @@ -151,7 +151,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .warm, duration: 0.3) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let measurements = try XCTUnwrap(serialized["measurements"] as? [String: Any]) @@ -163,7 +163,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let debugMeta = try XCTUnwrap(serialized["debug_meta"] as? [String: Any]) @@ -179,7 +179,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let startTimestamp = try XCTUnwrap(serialized["start_timestamp"] as? TimeInterval) @@ -208,7 +208,7 @@ class AppStartMeasurementHandlerTests: XCTestCase { didFinishLaunchingTimestamp: appStart.addingTimeInterval(0.3) ) - SendStandaloneAppStartTransaction().handle(measurement) + StandaloneTransactionStrategy().report(measurement) let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) let spans = try XCTUnwrap(serialized["spans"] as? [[String: Any]]) From f525749739457ab27679210da939bf57a98b4e89 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:48:49 +0100 Subject: [PATCH 45/50] fix: Address review feedback for standalone app start tracing - Fix trailing slash typo in changelog entry - Restore enableUncaughtNSExceptionReporting in sample app - Make StandaloneAppStartTransactionHelper final - Add test for unknown app start type in StandaloneTransactionStrategy - Restore visionOS platform guard in measurement provider tests - Rename tests to follow test_when_should convention - Remove extra blank line in sentryBuildAppStartSpansInternal --- CHANGELOG.md | 2 +- .../SentrySampleShared/SentrySDKWrapper.swift | 4 ++ Sources/Sentry/SentryBuildAppStartSpans.m | 1 - .../AppStartReportingStrategy.swift | 3 +- .../AppStartReportingStrategyTests.swift | 40 ++++++++++++------- ...ntryAppStartMeasurementProviderTests.swift | 2 +- 6 files changed, 33 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563bfb8fac..8e5eab33ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add standalone app start tracing as an experimental option (#7660), enable it via `options.experimental.enableStandaloneAppStartTracing = true`/ +- Add standalone app start tracing as an experimental option (#7660), enable it via `options.experimental.enableStandaloneAppStartTracing = true` ## 9.7.0 diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index aa508d7ff8..316b914fac 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -182,6 +182,10 @@ public struct SentrySDKWrapper { options.enableFileManagerSwizzling = !SentrySDKOverrides.Other.disableFileManagerSwizzling.boolValue options.experimental.enableUnhandledCPPExceptionsV2 = true options.experimental.enableStandaloneAppStartTracing = true + +#if os(macOS) && !SENTRY_NO_UI_FRAMEWORK + options.enableUncaughtNSExceptionReporting = true +#endif } func configureInitialScope(scope: Scope, options: Options) -> Scope { diff --git a/Sources/Sentry/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index 6713e337cd..06697d57b6 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -39,7 +39,6 @@ sentryBuildAppStartSpansInternal(SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement, BOOL isStandalone) { - if (appStartMeasurement == nil) { return @[]; } diff --git a/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift index f02711fdda..4c592804a2 100644 --- a/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift +++ b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift @@ -2,6 +2,7 @@ #if (os(iOS) || os(tvOS) || os(visionOS)) && !SENTRY_NO_UI_FRAMEWORK +/// Determines how a completed app start measurement is reported to Sentry. protocol AppStartReportingStrategy { func report(_ measurement: SentryAppStartMeasurement) } @@ -61,7 +62,7 @@ struct StandaloneTransactionStrategy: AppStartReportingStrategy { } /// Helper to identify standalone app start transactions from ObjC code. -@_spi(Private) @objc public class StandaloneAppStartTransactionHelper: NSObject { +@_spi(Private) @objc public final class StandaloneAppStartTransactionHelper: NSObject { /// Returns `true` when the operation and origin match a standalone app start transaction. @objc public static func isStandaloneAppStartTransaction(operation: String, origin: String) -> Bool { return (operation == SentrySpanOperationAppStartCold diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift index 0986ad39a4..16ee6b9294 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift @@ -39,7 +39,7 @@ class AppStartReportingStrategyTests: XCTestCase { // MARK: - AttachToTransactionStrategy - func testAttach_SetsMeasurementOnGlobalStatic() throws { + func testReport_whenColdStart_shouldSetMeasurementOnGlobalStatic() throws { let measurement = createMeasurement(type: .cold) AttachToTransactionStrategy().report(measurement) @@ -49,7 +49,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(stored.duration, measurement.duration) } - func testAttach_WarmStart_SetsMeasurementOnGlobalStatic() throws { + func testReport_whenWarmStart_shouldSetMeasurementOnGlobalStatic() throws { let measurement = createMeasurement(type: .warm) AttachToTransactionStrategy().report(measurement) @@ -60,7 +60,7 @@ class AppStartReportingStrategyTests: XCTestCase { // MARK: - StandaloneTransactionStrategy - func testSendStandalone_SDKNotEnabled_DoesNotCaptureTransaction() { + func testReport_whenSDKNotEnabled_shouldNotCaptureTransaction() { let hub = createHub() // Don't set hub on SDK — isEnabled returns false let measurement = createMeasurement(type: .cold) @@ -70,7 +70,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertTrue(hub.capturedEventsWithScopes.invocations.isEmpty) } - func testSendStandalone_ColdStart_CapturesTransaction() throws { + func testReport_whenColdStart_shouldCaptureTransaction() throws { let hub = createHub() SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .cold) @@ -86,7 +86,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(traceContext["origin"] as? String, "auto.app.start") } - func testSendStandalone_WarmStart_CapturesTransaction() throws { + func testReport_whenWarmStart_shouldCaptureTransaction() throws { let hub = createHub() SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .warm) @@ -101,7 +101,17 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(traceContext["op"] as? String, "app.start.warm") } - func testSendStandalone_DoesNotSetGlobalStatic() { + func testReport_whenUnknownStartType_shouldNotCaptureTransaction() { + let hub = createHub() + SentrySDKInternal.setCurrentHub(hub) + let measurement = createMeasurement(type: .unknown) + + StandaloneTransactionStrategy().report(measurement) + + XCTAssertTrue(hub.capturedTransactionsWithScope.invocations.isEmpty) + } + + func testReport_whenColdStart_shouldNotSetGlobalStatic() { let hub = createHub() SentrySDKInternal.setCurrentHub(hub) let measurement = createMeasurement(type: .cold) @@ -135,7 +145,7 @@ class AppStartReportingStrategyTests: XCTestCase { return hub } - func testSendStandalone_ColdStart_AddsAppStartMeasurement() throws { + func testReport_whenColdStart_shouldAddAppStartMeasurement() throws { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold, duration: 0.5) @@ -147,7 +157,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(appStartCold["value"] as? Double, 500) } - func testSendStandalone_WarmStart_AddsAppStartMeasurement() throws { + func testReport_whenWarmStart_shouldAddAppStartMeasurement() throws { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .warm, duration: 0.3) @@ -159,7 +169,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(appStartWarm["value"] as? Double, 300) } - func testSendStandalone_AddsDebugMeta() throws { + func testReport_whenColdStart_shouldAddDebugMeta() throws { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold) @@ -175,7 +185,7 @@ class AppStartReportingStrategyTests: XCTestCase { XCTAssertEqual(image["image_vmaddr"] as? String, expectedImage.imageVmAddress) } - func testSendStandalone_SetsStartTimeToAppStartTimestamp() throws { + func testReport_whenColdStart_shouldSetStartTimeToAppStartTimestamp() throws { let hub = setUpIntegrationHub() let measurement = createMeasurement(type: .cold) @@ -192,7 +202,7 @@ class AppStartReportingStrategyTests: XCTestCase { /// Verifies the full span tree for a warm non-prewarmed standalone app start transaction. /// More span edge cases (cold, prewarmed, grouping span) are covered in SentryBuildAppStartSpansTests. - func testSendStandalone_WarmNotPrewarmed_ContainsCorrectSpans() throws { + func testReport_whenWarmNotPrewarmed_shouldContainCorrectSpans() throws { let hub = setUpIntegrationHub() let dateProvider = TestCurrentDateProvider() let appStart = dateProvider.date() @@ -254,28 +264,28 @@ class AppStartReportingStrategyTests: XCTestCase { // MARK: - StandaloneAppStartTransactionHelper - func testHelper_ColdStartWithAutoOrigin_ReturnsTrue() { + func testIsStandaloneAppStartTransaction_whenColdStartWithAutoOrigin_shouldReturnTrue() { XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( operation: "app.start.cold", origin: "auto.app.start" )) } - func testHelper_WarmStartWithAutoOrigin_ReturnsTrue() { + func testIsStandaloneAppStartTransaction_whenWarmStartWithAutoOrigin_shouldReturnTrue() { XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( operation: "app.start.warm", origin: "auto.app.start" )) } - func testHelper_UILoadWithAutoOrigin_ReturnsFalse() { + func testIsStandaloneAppStartTransaction_whenUILoadWithAutoOrigin_shouldReturnFalse() { XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( operation: "ui.load", origin: "auto.app.start" )) } - func testHelper_ColdStartWithManualOrigin_ReturnsFalse() { + func testIsStandaloneAppStartTransaction_whenColdStartWithManualOrigin_shouldReturnFalse() { XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( operation: "app.start.cold", origin: "manual" diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index d2b0a9f909..30dda44fa8 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -1,7 +1,7 @@ @_spi(Private) @testable import Sentry import XCTest -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) class SentryAppStartMeasurementProviderTests: XCTestCase { From 9d19a2a9156a98d93cd6ff0ffa49b4a34a4e6962 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:56:35 +0100 Subject: [PATCH 46/50] test: Use targeted cleanup instead of blanket clearTestState Replace the class-level tearDown with per-test cleanup: - AttachToTransactionStrategy tests: reset app start measurement - StandaloneTransactionStrategy tests: reset current hub - Integration tests: full clearTestState via setUpIntegrationHub - Helper tests: no cleanup needed --- .../AppStartReportingStrategyTests.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift index 16ee6b9294..41e8e816b1 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift @@ -6,13 +6,6 @@ import XCTest class AppStartReportingStrategyTests: XCTestCase { - override func tearDown() { - super.tearDown() - // The integration tests modify SentryDependencyContainer global state - // (debugImageProvider, framesTracker, hub), so we need the full cleanup. - clearTestState() - } - private func createMeasurement(type: SentryAppStartType, duration: TimeInterval = 0.5) -> SentryAppStartMeasurement { let dateProvider = TestCurrentDateProvider() let appStart = dateProvider.date() @@ -37,9 +30,17 @@ class AppStartReportingStrategyTests: XCTestCase { return TestHub(client: client, andScope: Scope()) } + private func setCurrentHub() -> TestHub { + let hub = createHub() + SentrySDKInternal.setCurrentHub(hub) + addTeardownBlock { SentrySDKInternal.setCurrentHub(nil) } + return hub + } + // MARK: - AttachToTransactionStrategy func testReport_whenColdStart_shouldSetMeasurementOnGlobalStatic() throws { + addTeardownBlock { SentrySDKInternal.setAppStartMeasurement(nil) } let measurement = createMeasurement(type: .cold) AttachToTransactionStrategy().report(measurement) @@ -50,6 +51,7 @@ class AppStartReportingStrategyTests: XCTestCase { } func testReport_whenWarmStart_shouldSetMeasurementOnGlobalStatic() throws { + addTeardownBlock { SentrySDKInternal.setAppStartMeasurement(nil) } let measurement = createMeasurement(type: .warm) AttachToTransactionStrategy().report(measurement) @@ -71,8 +73,7 @@ class AppStartReportingStrategyTests: XCTestCase { } func testReport_whenColdStart_shouldCaptureTransaction() throws { - let hub = createHub() - SentrySDKInternal.setCurrentHub(hub) + let hub = setCurrentHub() let measurement = createMeasurement(type: .cold) StandaloneTransactionStrategy().report(measurement) @@ -87,8 +88,7 @@ class AppStartReportingStrategyTests: XCTestCase { } func testReport_whenWarmStart_shouldCaptureTransaction() throws { - let hub = createHub() - SentrySDKInternal.setCurrentHub(hub) + let hub = setCurrentHub() let measurement = createMeasurement(type: .warm) StandaloneTransactionStrategy().report(measurement) @@ -102,8 +102,7 @@ class AppStartReportingStrategyTests: XCTestCase { } func testReport_whenUnknownStartType_shouldNotCaptureTransaction() { - let hub = createHub() - SentrySDKInternal.setCurrentHub(hub) + let hub = setCurrentHub() let measurement = createMeasurement(type: .unknown) StandaloneTransactionStrategy().report(measurement) @@ -112,8 +111,7 @@ class AppStartReportingStrategyTests: XCTestCase { } func testReport_whenColdStart_shouldNotSetGlobalStatic() { - let hub = createHub() - SentrySDKInternal.setCurrentHub(hub) + _ = setCurrentHub() let measurement = createMeasurement(type: .cold) StandaloneTransactionStrategy().report(measurement) @@ -124,6 +122,8 @@ class AppStartReportingStrategyTests: XCTestCase { // MARK: - StandaloneTransactionStrategy Integration Tests private func setUpIntegrationHub() -> TestHub { + addTeardownBlock { clearTestState() } + let dateProvider = TestCurrentDateProvider() SentryDependencyContainer.sharedInstance().dateProvider = dateProvider From 973cc2f6912319067d07b3ce3bfdfb89bda62aee Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 10:59:15 +0100 Subject: [PATCH 47/50] test: Fix test conventions from develop-docs review - Rename tests to follow test_when_should naming convention - Fix assertion order: value first, expected second - Remove unnecessary force_unwrapping swiftlint disable --- .../AppStartTracking/SentryAppStartTrackerTests.swift | 4 ++-- .../Transaction/SentryBuildAppStartSpansTests.swift | 4 ++-- Tests/SentryTests/Transaction/SentryTracerTests.swift | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index 1dc26ca459..178a3d40ed 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -348,7 +348,7 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { assertValidHybridStart(type: .warm) } - func testStandaloneAppStartTracing_SDKNotEnabled_DropsAppStart() { + func testStart_whenStandaloneAppStartTracingAndSDKNotEnabled_shouldDropAppStart() { fixture.enableStandaloneAppStartTracing = true startApp(callDisplayLink: true) @@ -357,7 +357,7 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { assertNoAppStartUp() } - func testStandaloneAppStartTracingDisabled_SetsAppStartMeasurement() { + func testStart_whenStandaloneAppStartTracingDisabled_shouldSetAppStartMeasurement() { fixture.enableStandaloneAppStartTracing = false startApp(callDisplayLink: true) diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index f2c7b0847f..e3d3ea7b5b 100644 --- a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift +++ b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift @@ -207,7 +207,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) } - func testStandalone_coldNotPrewarmed_noGroupingSpan() { + func testBuildStandaloneAppStartSpans_whenColdNotPrewarmed_shouldNotIncludeGroupingSpan() { // Arrange let context = SpanContext(operation: "operation") let tracer = SentryTracer(context: context, framesTracker: nil) @@ -280,7 +280,7 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) } - func testStandalone_prewarmed_noGroupingSpan() { + func testBuildStandaloneAppStartSpans_whenPrewarmed_shouldNotIncludeGroupingSpan() { // Arrange let context = SpanContext(operation: "operation") let tracer = SentryTracer(context: context, framesTracker: nil) diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index 78e377e9f5..99ffcbd3f3 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -3,7 +3,7 @@ import _SentryPrivate @_spi(Private) import SentryTestUtils import XCTest -// swiftlint:disable file_length type_body_length force_unwrapping +// swiftlint:disable file_length type_body_length // We are aware that the tracer has a lot of logic and we should maybe // move some of it to other classes. class SentryTracerTests: XCTestCase { @@ -964,7 +964,7 @@ class SentryTracerTests: XCTestCase { XCTAssertNil(serializedTransaction?["debug_meta"]) } - func testStandaloneAppStart_UsesConfigurationMeasurement() throws { + func testFinish_whenStandaloneAppStart_shouldUseConfigurationMeasurement() throws { let appStartMeasurement = fixture.getAppStartMeasurement(type: .cold) let context = TransactionContext(name: "App Start Cold", operation: fixture.appStartColdOperation) @@ -990,7 +990,7 @@ class SentryTracerTests: XCTestCase { // Standalone transactions have no intermediate grouping span, so 5 child spans // (not 6) for a non-prewarmed cold start. let spans = try XCTUnwrap(serializedTransaction["spans"] as? [[String: Any]]) - XCTAssertEqual(5, spans.count) + XCTAssertEqual(spans.count, 5) let spanDescriptions = spans.compactMap { $0["description"] as? String } let spanOperations = spans.compactMap { $0["op"] as? String } @@ -1005,7 +1005,7 @@ class SentryTracerTests: XCTestCase { XCTAssertEqual(Set(spanOperations), ["app.start.cold"]) } - func testStandaloneAppStart_BothConfigAndGlobalSet_ConfigWins_GlobalMarkedAsRead() throws { + func testFinish_whenBothConfigAndGlobalAppStartSet_shouldUseConfigAndMarkGlobalAsRead() throws { // In practice only one of config or global should be set, never both. This test // verifies that if both happen to be set, the config measurement wins and the // global static is marked as read so no UIViewController transaction consumes it. From 3ac8825b93b85de54d99505b9bd7c52e8370ec1d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 11:00:59 +0100 Subject: [PATCH 48/50] test: Add integration test for standalone app start tracing Verify the full path from SentryAppStartTracker through StandaloneTransactionStrategy captures a transaction with the correct name and operation, and does not set the global static. --- .../SentryAppStartTrackerTests.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index 178a3d40ed..51ec932732 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -364,6 +364,26 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { assertValidStart(type: .cold, expectedDuration: 0.45) } + func testStart_whenStandaloneAppStartTracingEnabled_shouldCaptureTransaction() throws { + fixture.options.tracesSampleRate = 1 + let client = TestClient(options: fixture.options) + let hub = TestHub(client: client, andScope: Scope()) + SentrySDKInternal.setCurrentHub(hub) + + fixture.enableStandaloneAppStartTracing = true + startApp(callDisplayLink: true) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + XCTAssertEqual(serialized["transaction"] as? String, "App Start Cold") + + let contexts = try XCTUnwrap(serialized["contexts"] as? [String: Any]) + let traceContext = try XCTUnwrap(contexts["trace"] as? [String: Any]) + XCTAssertEqual(traceContext["op"] as? String, "app.start.cold") + + // The global static must not be set — standalone bypasses it. + XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) + } + private func store(appState: SentryAppState) { fixture.fileManager.store(appState) } From ade227c4244add9c55323796daeb347ab8c2f070 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 11:23:09 +0100 Subject: [PATCH 49/50] test: Add coverage for standalone app start edge cases Cover nil config measurement, nil measurement input, unknown start type, enhanced integration assertions, and default feature flag behavior. Agent transcript: https://claudescope.sentry.dev/share/DHthmP4l-WXLLOSec8mR7LkoGRxh5KdqGZSeF3UaXik --- .../SentryEnabledFeaturesBuilderTests.swift | 11 +++++++ .../SentryAppStartTrackerTests.swift | 12 ++++++++ .../SentryBuildAppStartSpansTests.swift | 29 +++++++++++++++++++ .../Transaction/SentryTracerTests.swift | 20 +++++++++++++ 4 files changed, 72 insertions(+) diff --git a/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift index 69d9bad460..955e6856fe 100644 --- a/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift +++ b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift @@ -221,4 +221,15 @@ final class SentryEnabledFeaturesBuilderTests: XCTestCase { // -- Assert -- XCTAssertFalse(features.contains("standaloneAppStartTracing")) } + + func testEnableStandaloneAppStartTracing_whenDefault_shouldNotAddFeature() throws { + // -- Arrange -- + let options = Options() + + // -- Act -- + let features = SentryEnabledFeaturesBuilder.getEnabledFeatures(options: options) + + // -- Assert -- + XCTAssertFalse(features.contains("standaloneAppStartTracing")) + } } diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index 51ec932732..3251fade96 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift @@ -382,6 +382,18 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { // The global static must not be set — standalone bypasses it. XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) + + // Verify the transaction contains app start child spans (standalone = no grouping span). + let spans = try XCTUnwrap(serialized["spans"] as? [[String: Any]]) + XCTAssertEqual(spans.count, 5) + + let descriptions = spans.compactMap { $0["description"] as? String } + XCTAssertTrue(descriptions.contains("Pre Runtime Init")) + XCTAssertTrue(descriptions.contains("Initial Frame Render")) + + // Verify the app start measurement is attached. + let measurements = try XCTUnwrap(serialized["measurements"] as? [String: Any]) + XCTAssertNotNil(measurements["app_start_cold"]) } private func store(appState: SentryAppState) { diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index e3d3ea7b5b..113cc2c687 100644 --- a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift +++ b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift @@ -18,6 +18,35 @@ class SentryBuildAppStartSpansTests: XCTestCase { XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") } + func testBuildStandaloneAppStartSpans_whenMeasurementIsNil_shouldNotReturnAnySpans() { + let context = SpanContext(operation: "operation") + let tracer = SentryTracer(context: context, framesTracker: nil) + + let result = sentryBuildStandaloneAppStartSpans(tracer, nil) + + XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") + } + + func testBuildStandaloneAppStartSpans_whenUnknownType_shouldNotReturnAnySpans() { + let context = SpanContext(operation: "operation") + let tracer = SentryTracer(context: context, framesTracker: nil) + let appStartMeasurement = SentryAppStartMeasurement( + type: SentryAppStartType.unknown, + isPreWarmed: false, + appStartTimestamp: Date(timeIntervalSince1970: 1_000), + runtimeInitSystemTimestamp: 1_100, + duration: 1_200, + runtimeInitTimestamp: Date(timeIntervalSince1970: 1_300), + moduleInitializationTimestamp: Date(timeIntervalSince1970: 1_400), + sdkStartTimestamp: Date(timeIntervalSince1970: 1_500), + didFinishLaunchingTimestamp: Date(timeIntervalSince1970: 1_600) + ) + + let result = sentryBuildStandaloneAppStartSpans(tracer, appStartMeasurement) + + XCTAssertTrue(result.isEmpty, "Expected no spans but got \(result.count)") + } + func testSentryBuildAppStartSpans_appStartMeasurementIsNotColdOrWarm_shouldNotReturnAnySpans() { // Arrange let context = SpanContext(operation: "operation") diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index 99ffcbd3f3..4211df94c6 100644 --- a/Tests/SentryTests/Transaction/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -1036,6 +1036,26 @@ class SentryTracerTests: XCTestCase { XCTAssertNil(measurements?["app_start_warm"]) } + func testFinish_whenStandaloneAppStartWithNilConfigMeasurement_shouldNotAddAppStartSpans() throws { + let context = TransactionContext(name: "App Start Cold", operation: fixture.appStartColdOperation) + // No appStartMeasurement set on the configuration. + let sut = fixture.hub.startTransaction( + with: context, + bindToScope: false, + customSamplingContext: [:], + configuration: SentryTracerConfiguration.`default` + ) + sut.origin = SentryTraceOriginAutoAppStart + sut.finish() + + let serializedTransaction = try XCTUnwrap(fixture.hub.capturedEventsWithScopes.first).event.serialize() + let spans = try XCTUnwrap(serializedTransaction["spans"] as? [[String: Any]]) + XCTAssertTrue(spans.isEmpty, "Standalone transaction with nil config measurement should have no app start spans") + + let measurements = serializedTransaction["measurements"] as? [String: Any] + XCTAssertNil(measurements?["app_start_cold"], "Should not have app start measurement") + } + #endif // os(iOS) || os(tvOS) func testMeasurementOnChildSpan_SetTwice_OverwritesMeasurement() throws { From a243fa7a96bfefb84087fae81d06910f296aec1a Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 12 Mar 2026 11:39:47 +0100 Subject: [PATCH 50/50] fix: Remove standalone tracing from sample, fix endif comment Remove enableStandaloneAppStartTracing from sample app since the feature isn't ready for general use yet. Restore visionOS in #endif comment to match the opening platform guard. Agent transcript: https://claudescope.sentry.dev/share/vz0QO06DfJpC3-St_cH3Br5qoqAuE2iHNNDFfK-M7uQ --- .../SentrySampleShared/SentrySDKWrapper.swift | 1 - .../SentryAppStartMeasurementProviderTests.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 316b914fac..11a2b85ab0 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -181,7 +181,6 @@ public struct SentrySDKWrapper { // Experimental features options.enableFileManagerSwizzling = !SentrySDKOverrides.Other.disableFileManagerSwizzling.boolValue options.experimental.enableUnhandledCPPExceptionsV2 = true - options.experimental.enableStandaloneAppStartTracing = true #if os(macOS) && !SENTRY_NO_UI_FRAMEWORK options.enableUncaughtNSExceptionReporting = true diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift index 30dda44fa8..03f88c2723 100644 --- a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartMeasurementProviderTests.swift @@ -304,4 +304,4 @@ class SentryAppStartMeasurementProviderTests: XCTestCase { } } -#endif // os(iOS) || os(tvOS) +#endif // os(iOS) || os(tvOS) || os(visionOS)