diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cd1324cde..8e5eab33efa 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 diff --git a/Sources/Sentry/SentryAppStartMeasurementProvider.m b/Sources/Sentry/SentryAppStartMeasurementProvider.m index 7507fee26f6..63a0e8f5396 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/SentryBuildAppStartSpans.m b/Sources/Sentry/SentryBuildAppStartSpans.m index c8306595e77..06697d57b67 100644 --- a/Sources/Sentry/SentryBuildAppStartSpans.m +++ b/Sources/Sentry/SentryBuildAppStartSpans.m @@ -1,7 +1,9 @@ #import "SentryAppStartMeasurement.h" +#import "SentryLogC.h" #import "SentrySpanContext+Private.h" #import "SentrySpanId.h" #import "SentrySpanInternal.h" +#import "SentrySpanOperation.h" #import "SentrySwift.h" #import "SentryTraceOrigin.h" #import "SentryTracer.h" @@ -9,7 +11,9 @@ #if SENTRY_HAS_UIKIT -id +# pragma mark - Private + +static id sentryBuildAppStartSpan( SentryTracer *tracer, SentrySpanId *parentId, NSString *operation, NSString *description) { @@ -22,14 +26,19 @@ 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> * -sentryBuildAppStartSpans( - SentryTracer *tracer, SentryAppStartMeasurement *_Nullable appStartMeasurement) +/** + * 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) { return @[]; } @@ -39,14 +48,15 @@ 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: + SENTRY_LOG_ERROR(@"Unknown app start type, can't build app start spans"); return @[]; } @@ -55,40 +65,46 @@ 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]; + SentrySpanId *appStartSpanParentId; + if (isStandalone) { + appStartSpanParentId = tracer.spanId; + } else { + id appStartSpan + = sentryBuildAppStartSpan(tracer, tracer.spanId, operation, type); + [appStartSpan setStartTimestamp:appStartMeasurement.appStartTimestamp]; + [appStartSpan setTimestamp:appStartEndTimestamp]; + [appStartSpans addObject:appStartSpan]; + appStartSpanParentId = appStartSpan.spanId; + } if (!appStartMeasurement.isPreWarmed) { id premainSpan - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Pre Runtime Init"); + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, 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"); + tracer, appStartSpanParentId, 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"); + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"UIKit Init"); [appInitSpan setStartTimestamp:appStartMeasurement.moduleInitializationTimestamp]; [appInitSpan setTimestamp:appStartMeasurement.sdkStartTimestamp]; [appStartSpans addObject:appInitSpan]; id didFinishLaunching - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Application Init"); + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"Application Init"); [didFinishLaunching setStartTimestamp:appStartMeasurement.sdkStartTimestamp]; [didFinishLaunching setTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [appStartSpans addObject:didFinishLaunching]; id frameRenderSpan - = sentryBuildAppStartSpan(tracer, appStartSpan.spanId, operation, @"Initial Frame Render"); + = sentryBuildAppStartSpan(tracer, appStartSpanParentId, operation, @"Initial Frame Render"); [frameRenderSpan setStartTimestamp:appStartMeasurement.didFinishLaunchingTimestamp]; [frameRenderSpan setTimestamp:appStartEndTimestamp]; [appStartSpans addObject:frameRenderSpan]; @@ -96,4 +112,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 diff --git a/Sources/Sentry/SentrySpanOperation.m b/Sources/Sentry/SentrySpanOperation.m index f05fe168582..7b200c9ee4c 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/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 7b0f9729721..3e11fa19f7a 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -596,9 +596,22 @@ - (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 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 + // 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]; @@ -706,7 +719,9 @@ - (SentryTransaction *)toTransaction #if SENTRY_HAS_UIKIT [self addFrameStatistics]; - NSArray> *appStartSpans = sentryBuildAppStartSpans(self, appStartMeasurement); + NSArray> *appStartSpans = [self isStandaloneAppStartTransaction] + ? sentryBuildStandaloneAppStartSpans(self, appStartMeasurement) + : sentryBuildAppStartSpans(self, appStartMeasurement); capacity = _children.count + appStartSpans.count; #else capacity = _children.count; @@ -758,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) { diff --git a/Sources/Sentry/include/SentryAppStartMeasurementProvider.h b/Sources/Sentry/include/SentryAppStartMeasurementProvider.h index 2a7f1873072..53bcd7f47e5 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/SentryBuildAppStartSpans.h b/Sources/Sentry/include/SentryBuildAppStartSpans.h index fad6b68a73e..1d02987c363 100644 --- a/Sources/Sentry/include/SentryBuildAppStartSpans.h +++ b/Sources/Sentry/include/SentryBuildAppStartSpans.h @@ -8,9 +8,39 @@ NS_ASSUME_NONNULL_BEGIN #if SENTRY_HAS_UIKIT +/** + * 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 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); + #endif // SENTRY_HAS_UIKIT NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 932724b90d4..0b3085062cd 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/SentrySpanOperation.h b/Sources/Sentry/include/SentrySpanOperation.h index 969ac752255..df3a2066ba4 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/Sentry/include/SentryTracerConfiguration.h b/Sources/Sentry/include/SentryTracerConfiguration.h index 31bb1c62cb0..60be3224231 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,17 @@ 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 in + * @c SentryAppStartMeasurementProvider. + * + * Default is nil. + */ +@property (nonatomic, strong, nullable) SentryAppStartMeasurement *appStartMeasurement; +#endif // SENTRY_HAS_UIKIT + + (SentryTracerConfiguration *)configurationWithBlock: (void (^)(SentryTracerConfiguration *configuration))block; diff --git a/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift b/Sources/Swift/Helper/SentryEnabledFeaturesBuilder.swift index 55b8106dc3f..3f28013c14b 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/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift new file mode 100644 index 00000000000..4c592804a29 --- /dev/null +++ b/Sources/Swift/Integrations/AppStartTracking/AppStartReportingStrategy.swift @@ -0,0 +1,74 @@ +@_implementationOnly import _SentryPrivate + +#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) +} + +/// Attaches app start data to the first UIViewController transaction (default behavior). +struct AttachToTransactionStrategy: AppStartReportingStrategy { + func report(_ 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 StandaloneTransactionStrategy: AppStartReportingStrategy { + func report(_ 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 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 + || 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 94cd47d18a0..1bad5147d6f 100644 --- a/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift +++ b/Sources/Swift/Integrations/AppStartTracking/SentryAppStartTracker.swift @@ -33,6 +33,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener let appStateManager: SentryAppStateManager private let framesTracker: SentryFramesTracker private let enablePreWarmedAppStartTracing: Bool + private let reportingStrategy: AppStartReportingStrategy private var previousAppState: SentryAppState? private var wasInBackground = false @@ -52,6 +53,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener appStateManager: SentryAppStateManager, framesTracker: SentryFramesTracker, enablePreWarmedAppStartTracing: Bool, + enableStandaloneAppStartTracing: Bool, dateProvider: SentryCurrentDateProvider, sysctlWrapper: SentrySysctl, appStartInfoProvider: AppStartInfoProvider @@ -60,6 +62,9 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener self.appStateManager = appStateManager self.framesTracker = framesTracker self.enablePreWarmedAppStartTracing = enablePreWarmedAppStartTracing + self.reportingStrategy = enableStandaloneAppStartTracing + ? StandaloneTransactionStrategy() + : AttachToTransactionStrategy() self.previousAppState = appStateManager.loadPreviousAppState() self.dateProvider = dateProvider self.didFinishLaunchingTimestamp = dateProvider.date() @@ -230,7 +235,7 @@ public final class SentryAppStartTracker: NSObject, SentryFramesTrackerListener didFinishLaunchingTimestamp: finalDidFinishLaunchingTimestamp ) - SentrySDKInternal.setAppStartMeasurement(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/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index b6f9ddb4ec8..29df9da16c4 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 3ad274ffec0..72e02440563 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/Helper/SentryEnabledFeaturesBuilderTests.swift b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift index acba24b5bbd..955e6856fed 100644 --- a/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift +++ b/Tests/SentryTests/Helper/SentryEnabledFeaturesBuilderTests.swift @@ -195,4 +195,41 @@ 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")) + } + + 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/AppStartReportingStrategyTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift new file mode 100644 index 00000000000..41e8e816b18 --- /dev/null +++ b/Tests/SentryTests/Integrations/Performance/AppStartTracking/AppStartReportingStrategyTests.swift @@ -0,0 +1,311 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) + +class AppStartReportingStrategyTests: XCTestCase { + + 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: "AppStartReportingStrategyTests") + options.tracesSampleRate = 1 + let client = TestClient(options: options) + 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) + + let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) + XCTAssertEqual(stored.type, .cold) + XCTAssertEqual(stored.duration, measurement.duration) + } + + func testReport_whenWarmStart_shouldSetMeasurementOnGlobalStatic() throws { + addTeardownBlock { SentrySDKInternal.setAppStartMeasurement(nil) } + let measurement = createMeasurement(type: .warm) + + AttachToTransactionStrategy().report(measurement) + + let stored = try XCTUnwrap(SentrySDKInternal.getAppStartMeasurement()) + XCTAssertEqual(stored.type, .warm) + } + + // MARK: - StandaloneTransactionStrategy + + func testReport_whenSDKNotEnabled_shouldNotCaptureTransaction() { + let hub = createHub() + // Don't set hub on SDK — isEnabled returns false + let measurement = createMeasurement(type: .cold) + + StandaloneTransactionStrategy().report(measurement) + + XCTAssertTrue(hub.capturedEventsWithScopes.invocations.isEmpty) + } + + func testReport_whenColdStart_shouldCaptureTransaction() throws { + let hub = setCurrentHub() + let measurement = createMeasurement(type: .cold) + + StandaloneTransactionStrategy().report(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, "app.start.cold") + XCTAssertEqual(traceContext["origin"] as? String, "auto.app.start") + } + + func testReport_whenWarmStart_shouldCaptureTransaction() throws { + let hub = setCurrentHub() + let measurement = createMeasurement(type: .warm) + + StandaloneTransactionStrategy().report(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, "app.start.warm") + } + + func testReport_whenUnknownStartType_shouldNotCaptureTransaction() { + let hub = setCurrentHub() + let measurement = createMeasurement(type: .unknown) + + StandaloneTransactionStrategy().report(measurement) + + XCTAssertTrue(hub.capturedTransactionsWithScope.invocations.isEmpty) + } + + func testReport_whenColdStart_shouldNotSetGlobalStatic() { + _ = setCurrentHub() + let measurement = createMeasurement(type: .cold) + + StandaloneTransactionStrategy().report(measurement) + + XCTAssertNil(SentrySDKInternal.getAppStartMeasurement()) + } + + // MARK: - StandaloneTransactionStrategy Integration Tests + + private func setUpIntegrationHub() -> TestHub { + addTeardownBlock { clearTestState() } + + 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: "AppStartReportingStrategyTests") + options.tracesSampleRate = 1 + let client = TestClient(options: options) + let hub = TestHub(client: client, andScope: Scope()) + SentrySDKInternal.setCurrentHub(hub) + return hub + } + + func testReport_whenColdStart_shouldAddAppStartMeasurement() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold, duration: 0.5) + + StandaloneTransactionStrategy().report(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 testReport_whenWarmStart_shouldAddAppStartMeasurement() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .warm, duration: 0.3) + + StandaloneTransactionStrategy().report(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 testReport_whenColdStart_shouldAddDebugMeta() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold) + + StandaloneTransactionStrategy().report(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]]) + 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 testReport_whenColdStart_shouldSetStartTimeToAppStartTimestamp() throws { + let hub = setUpIntegrationHub() + let measurement = createMeasurement(type: .cold) + + StandaloneTransactionStrategy().report(measurement) + + let serialized = try XCTUnwrap(hub.capturedTransactionsWithScope.invocations.first?.transaction) + let startTimestamp = try XCTUnwrap(serialized["start_timestamp"] as? TimeInterval) + XCTAssertEqual( + startTimestamp, + measurement.appStartTimestamp.timeIntervalSince1970, + accuracy: 0.001 + ) + } + + /// 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 testReport_whenWarmNotPrewarmed_shouldContainCorrectSpans() 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) + ) + + StandaloneTransactionStrategy().report(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, ["app.start.warm"]) + + 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 testIsStandaloneAppStartTransaction_whenColdStartWithAutoOrigin_shouldReturnTrue() { + XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: "app.start.cold", + origin: "auto.app.start" + )) + } + + func testIsStandaloneAppStartTransaction_whenWarmStartWithAutoOrigin_shouldReturnTrue() { + XCTAssertTrue(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: "app.start.warm", + origin: "auto.app.start" + )) + } + + func testIsStandaloneAppStartTransaction_whenUILoadWithAutoOrigin_shouldReturnFalse() { + XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: "ui.load", + origin: "auto.app.start" + )) + } + + func testIsStandaloneAppStartTransaction_whenColdStartWithManualOrigin_shouldReturnFalse() { + XCTAssertFalse(StandaloneAppStartTransactionHelper.isStandaloneAppStartTransaction( + operation: "app.start.cold", + 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) diff --git a/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/AppStartTracking/SentryAppStartTrackerTests.swift index d3153c0baf4..3251fade96b 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 { @@ -38,6 +39,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 +93,7 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { appStateManager: appStateManager, framesTracker: framesTracker, enablePreWarmedAppStartTracing: enablePreWarmedAppStartTracing, + enableStandaloneAppStartTracing: enableStandaloneAppStartTracing, dateProvider: SentryDependencyContainer.sharedInstance().dateProvider, sysctlWrapper: SentryDependencyContainer.sharedInstance().sysctlWrapper, appStartInfoProvider: appStartInfoProvider @@ -341,10 +344,58 @@ class SentryAppStartTrackerTests: NotificationCenterTestCase { fixture.fileManager.moveAppStateToPreviousAppState() hybridAppStart() - + assertValidHybridStart(type: .warm) } - + + func testStart_whenStandaloneAppStartTracingAndSDKNotEnabled_shouldDropAppStart() { + fixture.enableStandaloneAppStartTracing = true + startApp(callDisplayLink: true) + + // The standalone handler guards on SentrySDK.isEnabled. Since the SDK is not + // fully started in this test, the measurement is dropped. + assertNoAppStartUp() + } + + func testStart_whenStandaloneAppStartTracingDisabled_shouldSetAppStartMeasurement() { + fixture.enableStandaloneAppStartTracing = false + startApp(callDisplayLink: true) + + 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()) + + // 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) { fixture.fileManager.store(appState) } diff --git a/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift b/Tests/SentryTests/Transaction/SentryBuildAppStartSpansTests.swift index 45f0eba6a8b..113cc2c687b 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() { @@ -17,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") @@ -206,6 +236,132 @@ class SentryBuildAppStartSpansTests: XCTestCase { ) } + func testBuildStandaloneAppStartSpans_whenColdNotPrewarmed_shouldNotIncludeGroupingSpan() { + // 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 = 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") + 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 testBuildStandaloneAppStartSpans_whenPrewarmed_shouldNotIncludeGroupingSpan() { + // 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 = 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") + 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") diff --git a/Tests/SentryTests/Transaction/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift index ba6e3b6b575..4211df94c63 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 // 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,105 @@ 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 testFinish_whenStandaloneAppStart_shouldUseConfigurationMeasurement() 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(spans.count, 5) + + 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 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. + let globalMeasurement = fixture.getAppStartMeasurement(type: .warm) + SentrySDKInternal.setAppStartMeasurement(globalMeasurement) + + 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 = configMeasurement + }) + ) + sut.origin = SentryTraceOriginAutoAppStart + sut.finish() + + // The config measurement must be used (cold), verified via the transaction measurement key. + try assertMeasurements(["app_start_cold": ["value": fixture.appStartDuration * 1_000]]) + + // 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"]) + } + + 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 { diff --git a/sdk_api.json b/sdk_api.json index 1aca035b64f..0b076162e45 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": [ {