diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e961100fa..187552b3c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### Features +- Time to initial and full display (#2724) - Add `name` and `geo` to User (#2710) + ### Fixes - Correctly track and send GPU frame render data in profiles (#2823) diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index e48415a7928..2890126b793 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -31,9 +31,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options.attachScreenshot = true options.attachViewHierarchy = true options.environment = "test-app" + options.enableTimeToFullDisplay = true let isBenchmarking = ProcessInfo.processInfo.arguments.contains("--io.sentry.test.benchmarking") - options.enableAutoPerformanceTracing = !isBenchmarking // the benchmark test starts and stops a custom transaction using a UIButton, and automatic user interaction tracing stops the transaction that begins with that button press after the idle timeout elapses, stopping the profiler (only one profiler runs regardless of the number of concurrent transactions) options.enableUserInteractionTracing = !isBenchmarking diff --git a/Samples/iOS-Swift/iOS-Swift/ViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewController.swift index 2957163a562..01854a3b04b 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewController.swift @@ -52,7 +52,12 @@ class ViewController: UIViewController { uiTestNameLabel.text = uiTestName } } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/CoreDataViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/CoreDataViewController.swift index 9cc568fbee2..033bafddd2e 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/CoreDataViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/CoreDataViewController.swift @@ -122,7 +122,7 @@ class CoreDataViewController: UITableViewController { } catch let error as NSError { print("Could not fetch. \(error), \(error.userInfo)") } - + SentrySDK.reportFullyDisplayed() self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(requestNewPerson(_:))) } diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/LoremIpsumViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/LoremIpsumViewController.swift index 6bbd1f0cb46..b3a113e37ff 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/LoremIpsumViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/LoremIpsumViewController.swift @@ -14,6 +14,7 @@ class LoremIpsumViewController: UIViewController { if let contents = FileManager.default.contents(atPath: path) { DispatchQueue.main.async { self.textView.text = String(data: contents, encoding: .utf8) + SentrySDK.reportFullyDisplayed() } } } diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/NibViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/NibViewController.swift index 79c337b82c2..cdf881e7f8f 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/NibViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/NibViewController.swift @@ -22,6 +22,11 @@ class NibViewController: UIViewController { UIAssert.checkForViewControllerLifeCycle(span, viewController: "NibViewController") } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) } diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/PerformanceViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/PerformanceViewController.swift index aaca80224be..077b4606870 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/PerformanceViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/PerformanceViewController.swift @@ -23,6 +23,11 @@ class PerformanceViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startTest() diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/PermissionsViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/PermissionsViewController.swift index 9dc33f1d101..f2faff07562 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/PermissionsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/PermissionsViewController.swift @@ -56,6 +56,11 @@ class PermissionsViewController: UIViewController { @objc func requestLocationPermission() { locationManager.requestWhenInUseAuthorization() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } @objc func requestPushPermission() { UNUserNotificationCenter.current() diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/SplitViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/SplitViewController.swift index 60512bd9d14..d90d6d5d80e 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/SplitViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/SplitViewController.swift @@ -13,6 +13,11 @@ class SplitViewController: UISplitViewController { super.init(coder: coder) initialize() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } private func initialize() { self.modalPresentationStyle = .fullScreen @@ -24,6 +29,11 @@ class SplitRootViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } @IBAction func close() { parent?.dismiss(animated: false, completion: nil) @@ -57,6 +67,11 @@ class SecondarySplitViewController: UIViewController { spanObserver = createTransactionObserver(forCallback: assertTransaction) } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + SentrySDK.reportFullyDisplayed() + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/TableViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/TableViewController.swift index 239169237a9..806b08f7a0c 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/TableViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/TableViewController.swift @@ -9,6 +9,7 @@ class TableViewController: UITableViewController { super.viewDidLoad() spanObserver = createTransactionObserver(forCallback: assertTransaction) + SentrySDK.reportFullyDisplayed() } func assertTransaction(span: Span) { diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/TraceTestViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/TraceTestViewController.swift index 5c9fac037ed..dad0dde9499 100644 --- a/Samples/iOS-Swift/iOS-Swift/ViewControllers/TraceTestViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/TraceTestViewController.swift @@ -22,6 +22,7 @@ class TraceTestViewController: UIViewController { } let session = URLSession(configuration: URLSessionConfiguration.default) let dataTask = session.dataTask(with: imgUrl) { (data, _, error) in + //Simulated delay in the download DispatchQueue.main.async { if let err = error { SentrySDK.capture(error: err) @@ -29,6 +30,7 @@ class TraceTestViewController: UIViewController { self.imageView.image = UIImage(data: image) self.appendLifeCycleStep("GET https://sentry-brand.storage.googleapis.com/sentry-logo-black.png") } + SentrySDK.reportFullyDisplayed() } } diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 40880811b6d..175c4555124 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -767,6 +767,9 @@ D8B76B0828081461000A58C4 /* TestSentryScreenShot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B76B0728081461000A58C4 /* TestSentryScreenShot.swift */; }; D8BBD32728FD9FC00011F850 /* SentrySwift.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BBD32628FD9FBF0011F850 /* SentrySwift.h */; }; D8BD2E6829361A0F00D96C6A /* PrivatesHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BD2E67293619F600D96C6A /* PrivatesHeader.h */; settings = {ATTRIBUTES = (Private, ); }; }; + D8BFE37229A3782F002E73F3 /* SentryTimeToDisplayTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D8BFE37029A3782F002E73F3 /* SentryTimeToDisplayTracker.h */; }; + D8BFE37329A3782F002E73F3 /* SentryTimeToDisplayTracker.m in Sources */ = {isa = PBXBuildFile; fileRef = D8BFE37129A3782F002E73F3 /* SentryTimeToDisplayTracker.m */; }; + D8BFE37929A76666002E73F3 /* SentryTimeToDisplayTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BFE37729A76519002E73F3 /* SentryTimeToDisplayTrackerTest.swift */; }; D8C67E9B28000E24007E326E /* SentryUIApplication.h in Headers */ = {isa = PBXBuildFile; fileRef = D8C67E9928000E23007E326E /* SentryUIApplication.h */; }; D8C67E9C28000E24007E326E /* SentryScreenshot.h in Headers */ = {isa = PBXBuildFile; fileRef = D8C67E9A28000E23007E326E /* SentryScreenshot.h */; }; D8CB74152947246600A5F964 /* SentryEnvelopeAttachmentHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = D8CB74142947246600A5F964 /* SentryEnvelopeAttachmentHeader.h */; }; @@ -1675,6 +1678,9 @@ D8BBD32628FD9FBF0011F850 /* SentrySwift.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentrySwift.h; path = include/SentrySwift.h; sourceTree = ""; }; D8BD2E27292D1F7300D96C6A /* SDK.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SDK.xcconfig; sourceTree = ""; }; D8BD2E67293619F600D96C6A /* PrivatesHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = PrivatesHeader.h; path = include/HybridPublic/PrivatesHeader.h; sourceTree = ""; }; + D8BFE37029A3782F002E73F3 /* SentryTimeToDisplayTracker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTimeToDisplayTracker.h; path = include/SentryTimeToDisplayTracker.h; sourceTree = ""; }; + D8BFE37129A3782F002E73F3 /* SentryTimeToDisplayTracker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryTimeToDisplayTracker.m; sourceTree = ""; }; + D8BFE37729A76519002E73F3 /* SentryTimeToDisplayTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTimeToDisplayTrackerTest.swift; sourceTree = ""; }; D8C67E9928000E23007E326E /* SentryUIApplication.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryUIApplication.h; path = include/SentryUIApplication.h; sourceTree = ""; }; D8C67E9A28000E23007E326E /* SentryScreenshot.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SentryScreenshot.h; path = include/SentryScreenshot.h; sourceTree = ""; }; D8CB74142947246600A5F964 /* SentryEnvelopeAttachmentHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryEnvelopeAttachmentHeader.h; path = include/SentryEnvelopeAttachmentHeader.h; sourceTree = ""; }; @@ -2163,7 +2169,6 @@ 7BD7299B24654CD500EA3610 /* Helper */, 7B944FA924697E9700A10721 /* Integrations */, 7BBD18AF24517E5D00427C76 /* Networking */, - 7B42602C26302DE500B36EDD /* Performance */, 035E73C627D5661A005EEB11 /* Profiling */, 7B3D0474249A3D5800E106B6 /* Protocol */, 63FE71D220DA66C500CDBAE8 /* SentryCrash */, @@ -2545,16 +2550,6 @@ path = Protocol; sourceTree = ""; }; - 7B42602C26302DE500B36EDD /* Performance */ = { - isa = PBXGroup; - children = ( - 8EAC7FF7265C8910005B44E5 /* SentryTracerTests.swift */, - 7BBEB16026AEE5EF00C06C03 /* SentryTracer+Test.h */, - 7BEFB043270B0F630025F808 /* SentryTracerObjCTests.m */, - ); - path = Performance; - sourceTree = ""; - }; 7B634595280EB94200CFA05A /* UIEvents */ = { isa = PBXGroup; children = ( @@ -2585,6 +2580,9 @@ D88817DB26D72B7B00BF2251 /* SentryTraceStateTests.swift */, 7BE912AE272166DD00E49E62 /* SentryNoOpSpanTests.swift */, D880E3A628573E87008A90DB /* SentryBaggageTests.swift */, + 8EAC7FF7265C8910005B44E5 /* SentryTracerTests.swift */, + 7BBEB16026AEE5EF00C06C03 /* SentryTracer+Test.h */, + 7BEFB043270B0F630025F808 /* SentryTracerObjCTests.m */, D8137D52272B53070082656C /* TestSentrySpan.h */, D8137D53272B53070082656C /* TestSentrySpan.m */, ); @@ -2825,6 +2823,8 @@ 8EAE9809261E9F530073B6B3 /* SentryPerformanceTracker.h */, 8EBF870726140D37001A6853 /* SentryPerformanceTracker.m */, 0A4EDEA828D3461B00FA67CB /* SentryPerformanceTracker+Private.h */, + D8BFE37029A3782F002E73F3 /* SentryTimeToDisplayTracker.h */, + D8BFE37129A3782F002E73F3 /* SentryTimeToDisplayTracker.m */, ); name = UIViewController; sourceTree = ""; @@ -2946,6 +2946,7 @@ 8EA1ED0E2669152F00E62B98 /* SentryUIViewControllerPerformanceTrackerTests.swift */, 7BEF4956270C4B9D00F8F30E /* SentryUIViewControllerSwizzlingTests.swift */, 7BC9CD4326A99F660047518E /* SentryUIViewControllerSwizzling+Test.h */, + D8BFE37729A76519002E73F3 /* SentryTimeToDisplayTrackerTest.swift */, ); path = UIViewController; sourceTree = ""; @@ -3501,6 +3502,7 @@ 7B4E375525822C4500059C93 /* SentryAttachment.h in Headers */, 63FE716F20DA4C1100CDBAE8 /* SentryCrashCPU_Apple.h in Headers */, 639FCFA81EBC80CC00778193 /* SentryFrame.h in Headers */, + D8BFE37229A3782F002E73F3 /* SentryTimeToDisplayTracker.h in Headers */, 8E8C57A625EEFC43001CEEFA /* SentryTracesSampler.h in Headers */, 7B634599280EB9D100CFA05A /* SentryUIEventTrackingIntegration.h in Headers */, 63FE716D20DA4C1100CDBAE8 /* SentryCrashSysCtl.h in Headers */, @@ -3944,6 +3946,7 @@ 03F84D3827DD4191008FE43F /* SentryBacktrace.cpp in Sources */, 63FE712720DA4C1000CDBAE8 /* SentryCrashThread.c in Sources */, 7B127B0F27CF6F4700A71ED2 /* SentryANRTrackingIntegration.m in Sources */, + D8BFE37329A3782F002E73F3 /* SentryTimeToDisplayTracker.m in Sources */, 15360CCF2432777500112302 /* SentrySessionTracker.m in Sources */, 6334314320AD9AE40077E581 /* SentryMechanism.m in Sources */, 63FE70D320DA4C1000CDBAE8 /* SentryCrashMonitor_AppState.c in Sources */, @@ -4218,6 +4221,7 @@ 7B4E23B6251A07BD00060D68 /* SentryDispatchQueueWrapperTests.swift in Sources */, 63FE720720DA66EC00CDBAE8 /* SentryCrashReportFilter_Tests.m in Sources */, 7B569E002590EEF600B653FC /* SentryScope+Equality.m in Sources */, + D8BFE37929A76666002E73F3 /* SentryTimeToDisplayTrackerTest.swift in Sources */, 7BF536D424BEF255004FA6A2 /* SentryAssertions.swift in Sources */, 7BC6EC14255C415E0059822A /* SentryExceptionTests.swift in Sources */, 7B82722927A319E900F4BFF4 /* SentryAutoSessionTrackingIntegrationTests.swift in Sources */, @@ -4506,7 +4510,10 @@ ENABLE_STRICT_OBJC_MSGSEND = NO; GCC_C_LANGUAGE_STANDARD = "compiler-default"; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_SHADOW = YES; INFOPLIST_FILE = Sources/Sentry/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/SentryTestUtils/ClearTestState.swift b/SentryTestUtils/ClearTestState.swift index 44323459b6d..14f72adc610 100644 --- a/SentryTestUtils/ClearTestState.swift +++ b/SentryTestUtils/ClearTestState.swift @@ -25,6 +25,7 @@ class TestCleanup: NSObject { setenv("ActivePrewarm", "0", 1) SentryAppStartTracker.load() + SentryUIViewControllerPerformanceTracker.shared.enableWaitForFullDisplay = false #endif SentryDependencyContainer.reset() diff --git a/SentryTestUtils/SentryTestUtils-ObjC-BridgingHeader.h b/SentryTestUtils/SentryTestUtils-ObjC-BridgingHeader.h index 1b4d9d3101d..8cd4c670d5a 100644 --- a/SentryTestUtils/SentryTestUtils-ObjC-BridgingHeader.h +++ b/SentryTestUtils/SentryTestUtils-ObjC-BridgingHeader.h @@ -29,3 +29,4 @@ #import "SentryTransport.h" #import "SentryTransportAdapter.h" #import "SentryUIDeviceWrapper.h" +#import "SentryUIViewControllerPerformanceTracker.h" diff --git a/SentryTestUtils/TestCurrentDateProvider.swift b/SentryTestUtils/TestCurrentDateProvider.swift index 8e905ac3bae..e2f325689ed 100644 --- a/SentryTestUtils/TestCurrentDateProvider.swift +++ b/SentryTestUtils/TestCurrentDateProvider.swift @@ -4,9 +4,18 @@ import Foundation public class TestCurrentDateProvider: NSObject, CurrentDateProvider { private var internalDate = Date(timeIntervalSinceReferenceDate: 0) + + public var driftTimeForEveryRead = false public func date() -> Date { - internalDate + + defer { + if driftTimeForEveryRead { + internalDate = internalDate.addingTimeInterval(0.1) + } + } + + return internalDate } public func setDate(date: Date) { diff --git a/Sources/Sentry/Public/SentryHub.h b/Sources/Sentry/Public/SentryHub.h index caf5fc5bbb2..30c98b88b35 100644 --- a/Sources/Sentry/Public/SentryHub.h +++ b/Sources/Sentry/Public/SentryHub.h @@ -211,6 +211,13 @@ SENTRY_NO_INIT */ - (void)setUser:(SentryUser *_Nullable)user; +/** + * Reports to the ongoing UIViewController transaction + * that the screen contents are fully loaded and displayed, + * which will create a new span. + */ +- (void)reportFullyDisplayed; + /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified * timeout in seconds. If there is no internet connection, the function returns immediately. The SDK diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index 6f93464e3b3..59fb247f6e9 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -473,6 +473,18 @@ NS_SWIFT_NAME(Options) #endif +/** + * @warning This is an experimental feature and may still have bugs. + * @brief By enabling this, every UIViewController tracing transaction will wait + * for a call to @c SentrySDK.reportFullyDisplayed(). + * @discussion Use this in conjunction with @c enableUIViewControllerTracing. + * If @c SentrySDK.reportFullyDisplayed() is not called, the transaction will finish + * automatically after 30 seconds and the `Time to full display` Span will be + * finished with @c DeadlineExceeded status. + * @note Default value is `NO`. + */ +@property (nonatomic) BOOL enableTimeToFullDisplay; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/Public/SentrySDK.h b/Sources/Sentry/Public/SentrySDK.h index ec2a04547d9..8b5a67ffc26 100644 --- a/Sources/Sentry/Public/SentrySDK.h +++ b/Sources/Sentry/Public/SentrySDK.h @@ -273,6 +273,16 @@ SENTRY_NO_INIT */ + (void)crash; +/** + * Reports to the ongoing UIViewController transaction + * that the screen contents are fully loaded and displayed, + * which will create a new span. + * + * For more information see our documentation: + * https://docs.sentry.io/platforms/cocoa/performance/instrumentation/automatic-instrumentation/#time-to-full-display + */ ++ (void)reportFullyDisplayed; + /** * Waits synchronously for the SDK to flush out all queued and cached items for up to the specified * timeout in seconds. If there is no internet connection, the function returns immediately. The SDK diff --git a/Sources/Sentry/SentryFramesTracker.m b/Sources/Sentry/SentryFramesTracker.m index fa205cc7574..c57cadc2078 100644 --- a/Sources/Sentry/SentryFramesTracker.m +++ b/Sources/Sentry/SentryFramesTracker.m @@ -31,6 +31,7 @@ @property (nonatomic, strong, readonly) SentryDisplayLinkWrapper *displayLinkWrapper; @property (nonatomic, assign) CFTimeInterval previousFrameTimestamp; @property (nonatomic) uint64_t previousFrameSystemTimestamp; +@property (nonatomic, strong) NSHashTable> *listeners; # if SENTRY_TARGET_PROFILING_SUPPORTED @property (nonatomic, readwrite) SentryMutableFrameInfoTimeSeries *frozenFrameTimestamps; @property (nonatomic, readwrite) SentryMutableFrameInfoTimeSeries *slowFrameTimestamps; @@ -67,6 +68,7 @@ - (instancetype)initWithDisplayLinkWrapper:(SentryDisplayLinkWrapper *)displayLi if (self = [super init]) { _isRunning = NO; _displayLinkWrapper = displayLinkWrapper; + _listeners = [NSHashTable weakObjectsHashTable]; [self resetFrames]; } return self; @@ -114,6 +116,7 @@ - (void)displayLinkCallback if (self.previousFrameTimestamp == SentryPreviousFrameInitialValue) { self.previousFrameTimestamp = thisFrameTimestamp; self.previousFrameSystemTimestamp = thisFrameSystemTimestamp; + [self reportNewFrame]; return; } @@ -179,6 +182,19 @@ - (void)displayLinkCallback atomic_fetch_add_explicit(&_totalFrames, 1, SentryFramesMemoryOrder); self.previousFrameTimestamp = thisFrameTimestamp; self.previousFrameSystemTimestamp = thisFrameSystemTimestamp; + [self reportNewFrame]; +} + +- (void)reportNewFrame +{ + NSArray *localListeners; + @synchronized(self.listeners) { + localListeners = [self.listeners allObjects]; + } + + for (id listener in localListeners) { + [listener framesTrackerHasNewFrame]; + } } # if SENTRY_TARGET_PROFILING_SUPPORTED @@ -218,6 +234,21 @@ - (void)stop [self.displayLinkWrapper invalidate]; } +- (void)addListener:(id)listener +{ + + @synchronized(self.listeners) { + [self.listeners addObject:listener]; + } +} + +- (void)removeListener:(id)listener +{ + @synchronized(self.listeners) { + [self.listeners removeObject:listener]; + } +} + @end #endif diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 420986fd9c4..92a1f389e99 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -11,6 +11,7 @@ #import "SentryId.h" #import "SentryLog.h" #import "SentryNSTimerWrapper.h" +#import "SentryPerformanceTracker.h" #import "SentryProfilesSampler.h" #import "SentrySDK+Private.h" #import "SentrySamplingContext.h" @@ -21,6 +22,7 @@ #import "SentryTracesSampler.h" #import "SentryTransaction.h" #import "SentryTransactionContext+Private.h" +#import "SentryUIViewControllerPerformanceTracker.h" NS_ASSUME_NONNULL_BEGIN @@ -669,6 +671,17 @@ - (BOOL)envelopeContainsEventWithErrorOrHigher:(NSArray *) return NO; } +- (void)reportFullyDisplayed +{ +#if SENTRY_HAS_UIKIT + if (_client.options.enableTimeToFullDisplay) { + [SentryUIViewControllerPerformanceTracker.shared reportFullyDisplayed]; + } else { + SENTRY_LOG_DEBUG(@"The options `enableTimeToFullDisplay` is disabled."); + } +#endif +} + - (void)flush:(NSTimeInterval)timeout { SentryClient *client = _client; diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 4828aae3f3c..e3091e7053e 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -76,6 +76,7 @@ - (instancetype)init self.enableAutoPerformanceTracing = YES; self.enableCaptureFailedRequests = YES; self.environment = kSentryDefaultEnvironment; + self.enableTimeToFullDisplay = NO; _enableTracing = NO; _enableTracingManual = NO; @@ -336,6 +337,9 @@ - (BOOL)validateOptions:(NSDictionary *)options [self setBool:options[@"enableCaptureFailedRequests"] block:^(BOOL value) { self->_enableCaptureFailedRequests = value; }]; + [self setBool:options[@"enableTimeToFullDisplay"] + block:^(BOOL value) { self->_enableTimeToFullDisplay = value; }]; + #if SENTRY_HAS_UIKIT [self setBool:options[@"enableUIViewControllerTracing"] block:^(BOOL value) { self->_enableUIViewControllerTracing = value; }]; diff --git a/Sources/Sentry/SentryPerformanceTracker.m b/Sources/Sentry/SentryPerformanceTracker.m index bbd43663706..9751eb47aa8 100644 --- a/Sources/Sentry/SentryPerformanceTracker.m +++ b/Sources/Sentry/SentryPerformanceTracker.m @@ -211,7 +211,12 @@ - (void)finishSpan:(SentrySpanId *)spanId withStatus:(SentrySpanStatus)status id spanTracker; @synchronized(self.spans) { spanTracker = self.spans[spanId]; - [self.spans removeObjectForKey:spanId]; + // Hold reference for tracer until the tracer finishes because automatic + // tracers aren't referenced by anything else. + // callback to `tracerDidFinish` will release it. + if (![spanTracker isKindOfClass:SentryTracer.self]) { + [self.spans removeObjectForKey:spanId]; + } } [spanTracker finishWithStatus:status]; @@ -244,6 +249,13 @@ - (void)clear [self.spans removeAllObjects]; } +- (void)tracerDidFinish:(SentryTracer *)tracer +{ + @synchronized(self.spans) { + [self.spans removeObjectForKey:tracer.spanId]; + } +} + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryPerformanceTrackingIntegration.m b/Sources/Sentry/SentryPerformanceTrackingIntegration.m index 879df8f49ad..632fdb4a527 100644 --- a/Sources/Sentry/SentryPerformanceTrackingIntegration.m +++ b/Sources/Sentry/SentryPerformanceTrackingIntegration.m @@ -4,6 +4,7 @@ #import "SentryLog.h" #import "SentryNSProcessInfoWrapper.h" #import "SentrySubClassFinder.h" +#import "SentryUIViewControllerPerformanceTracker.h" #import "SentryUIViewControllerSwizzling.h" @interface @@ -42,6 +43,9 @@ - (BOOL)installWithOptions:(SentryOptions *)options processInfoWrapper:[[SentryNSProcessInfoWrapper alloc] init]]; [self.swizzling start]; + SentryUIViewControllerPerformanceTracker.shared.enableWaitForFullDisplay + = options.enableTimeToFullDisplay; + return YES; #else SENTRY_LOG_DEBUG(@"NO UIKit -> [SentryPerformanceTrackingIntegration start] does nothing."); diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index c5a5eb875f0..474c7d42003 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -383,6 +383,11 @@ + (void)installIntegrations } } ++ (void)reportFullyDisplayed +{ + [SentrySDK.currentHub reportFullyDisplayed]; +} + + (void)flush:(NSTimeInterval)timeout { [SentrySDK.currentHub flush:timeout]; diff --git a/Sources/Sentry/SentryTimeToDisplayTracker.m b/Sources/Sentry/SentryTimeToDisplayTracker.m new file mode 100644 index 00000000000..d0ed138d51d --- /dev/null +++ b/Sources/Sentry/SentryTimeToDisplayTracker.m @@ -0,0 +1,107 @@ +#import "SentryTimeToDisplayTracker.h" +#import "SentryCurrentDate.h" +#import "SentryFramesTracker.h" +#import "SentrySpan.h" +#import "SentrySpanContext.h" +#import "SentrySpanId.h" +#import "SentrySpanOperations.h" +#import "SentrySwift.h" +#import "SentryTracer.h" + +#if SENTRY_HAS_UIKIT + +@interface +SentryTimeToDisplayTracker () + +@property (nonatomic, weak) SentrySpan *initialDisplaySpan; +@property (nonatomic, weak) SentrySpan *fullDisplaySpan; + +@end + +@implementation SentryTimeToDisplayTracker { + BOOL _waitForFullDisplay; + BOOL _isReadyToDisplay; + BOOL _fullyDisplayedReported; + SentryFramesTracker *_frameTracker; + NSString *_controllerName; +} + +- (instancetype)initForController:(UIViewController *)controller + framesTracker:(SentryFramesTracker *)framestracker + waitForFullDisplay:(BOOL)waitForFullDisplay +{ + if (self = [super init]) { + _controllerName = [SwiftDescriptor getObjectClassName:controller]; + _waitForFullDisplay = waitForFullDisplay; + _frameTracker = framestracker; + + _isReadyToDisplay = NO; + _fullyDisplayedReported = NO; + } + return self; +} + +- (void)startForTracer:(SentryTracer *)tracer +{ + self.initialDisplaySpan = [tracer + startChildWithOperation:SentrySpanOperationUILoadInitialDisplay + description:[NSString stringWithFormat:@"%@ initial display", _controllerName]]; + + if (self.waitForFullDisplay) { + self.fullDisplaySpan = + [tracer startChildWithOperation:SentrySpanOperationUILoadFullDisplay + description:[NSString stringWithFormat:@"%@ full display", + _controllerName]]; + + // By concept TTID and TTFD spans should have the same beginning, + // which also should be the same of the transaction starting. + self.fullDisplaySpan.startTimestamp = tracer.startTimestamp; + } + + self.initialDisplaySpan.startTimestamp = tracer.startTimestamp; + + [_frameTracker addListener:self]; + [tracer setFinishCallback:^( + SentryTracer *_tracer) { [self trimTTFDIdNecessaryForTracer:_tracer]; }]; +} + +- (void)reportReadyToDisplay +{ + _isReadyToDisplay = YES; +} + +- (void)reportFullyDisplayed +{ + _fullyDisplayedReported = YES; + if (self.waitForFullDisplay && _isReadyToDisplay) { + [self.fullDisplaySpan finish]; + } +} + +- (void)framesTrackerHasNewFrame +{ + // The purpose of TTID and TTFD is to measure how long + // takes to the content of the screen to change. + // Thats why we need to wait for the next frame to be drawn. + if (_waitForFullDisplay && _fullyDisplayedReported && self.fullDisplaySpan.isFinished == NO) { + [self.fullDisplaySpan finish]; + } + if (_isReadyToDisplay && self.initialDisplaySpan.isFinished == NO) { + [self.initialDisplaySpan finish]; + [_frameTracker removeListener:self]; + } +} + +- (void)trimTTFDIdNecessaryForTracer:(SentryTracer *)tracer +{ + if (self.fullDisplaySpan.status != kSentrySpanStatusDeadlineExceeded) { + return; + } + + self.fullDisplaySpan.timestamp = self.initialDisplaySpan.timestamp; + self.fullDisplaySpan.spanDescription = + [NSString stringWithFormat:@"%@ - Deadline Exceeded", self.fullDisplaySpan.spanDescription]; +} + +@end +#endif diff --git a/Sources/Sentry/SentryTracer.m b/Sources/Sentry/SentryTracer.m index 44245d23f3a..ff73c082018 100644 --- a/Sources/Sentry/SentryTracer.m +++ b/Sources/Sentry/SentryTracer.m @@ -211,6 +211,11 @@ - (instancetype)initWithTransactionContext:(SentryTransactionContext *)transacti return self; } +- (nullable SentryTracer *)tracer +{ + return self; +} + - (void)dispatchIdleTimeout { if (_idleTimeoutBlock != nil) { @@ -267,6 +272,14 @@ - (void)startDeadlineTimer - (void)deadlineTimerFired { + SENTRY_LOG_DEBUG(@"Sentry tracer deadline fired"); + @synchronized(self) { + // This try to minimize a race condition with a proper call to `finishInternal`. + if (self.isFinished) { + return; + } + } + @synchronized(_children) { for (id span in _children) { if (![span isFinished]) @@ -410,8 +423,6 @@ - (void)finishWithStatus:(SentrySpanStatus)status SENTRY_LOG_DEBUG(@"Finished trace %@", self.traceContext.traceId.sentryIdString); self.wasFinishCalled = YES; _finishStatus = status; - [self cancelIdleTimeout]; - [self cancelDeadlineTimer]; [self canBeFinished]; } @@ -460,12 +471,23 @@ - (BOOL)hasUnfinishedChildSpansToWaitFor - (void)finishInternal { - // Keep existing status of auto generated transactions if set by the user. - if ([self isAutoGeneratedTransaction] && !self.wasFinishCalled - && self.status != kSentrySpanStatusUndefined) { - _finishStatus = self.status; + [self cancelDeadlineTimer]; + if (self.isFinished) { + return; + } + @synchronized(self) { + if (self.isFinished) { + return; + } + // Keep existing status of auto generated transactions if set by the user. + + if ([self isAutoGeneratedTransaction] && !self.wasFinishCalled + && self.status != kSentrySpanStatusUndefined) { + _finishStatus = self.status; + } + [super finishWithStatus:_finishStatus]; } - [super finishWithStatus:_finishStatus]; + [self.delegate tracerDidFinish:self]; if (self.finishCallback) { self.finishCallback(self); @@ -475,6 +497,22 @@ - (void)finishInternal self.finishCallback = nil; } + if (appStartMeasurement != nil) { + [self updateStartTime:appStartMeasurement.appStartTimestamp]; + } + + // Prewarming can execute code up to viewDidLoad of a UIViewController, and keep the app in the + // background. This can lead to auto-generated transactions lasting for minutes or even hours. + // Therefore, we drop transactions lasting longer than SENTRY_AUTO_TRANSACTION_MAX_DURATION. + NSTimeInterval transactionDuration = [self.timestamp timeIntervalSinceDate:self.startTimestamp]; + if ([self isAutoGeneratedTransaction] + && transactionDuration >= SENTRY_AUTO_TRANSACTION_MAX_DURATION) { + SENTRY_LOG_INFO(@"Auto generated transaction exceeded the max duration of %f seconds. Not " + @"capturing transaction.", + SENTRY_AUTO_TRANSACTION_MAX_DURATION); + return; + } + if (_hub == nil) { return; } @@ -502,25 +540,13 @@ - (void)finishInternal } } - if ([self hasIdleTimeout]) { + if ([self isAutoGeneratedTransaction]) { [self trimEndTimestamp]; } } SentryTransaction *transaction = [self toTransaction]; - // Prewarming can execute code up to viewDidLoad of a UIViewController, and keep the app in the - // background. This can lead to auto-generated transactions lasting for minutes or even hours. - // Therefore, we drop transactions lasting longer than SENTRY_AUTO_TRANSACTION_MAX_DURATION. - NSTimeInterval transactionDuration = [self.timestamp timeIntervalSinceDate:self.startTimestamp]; - if ([self isAutoGeneratedTransaction] - && transactionDuration >= SENTRY_AUTO_TRANSACTION_MAX_DURATION) { - SENTRY_LOG_INFO(@"Auto generated transaction exceeded the max duration of %f seconds. Not " - @"capturing transaction.", - SENTRY_AUTO_TRANSACTION_MAX_DURATION); - return; - } - #if SENTRY_TARGET_PROFILING_SUPPORTED if (self.isProfiling) { [self captureTransactionWithProfile:transaction]; @@ -554,9 +580,11 @@ - (void)trimEndTimestamp { NSDate *oldest = self.startTimestamp; - for (id childSpan in _children) { - if ([oldest compare:childSpan.timestamp] == NSOrderedAscending) { - oldest = childSpan.timestamp; + @synchronized(_children) { + for (id childSpan in _children) { + if ([oldest compare:childSpan.timestamp] == NSOrderedAscending) { + oldest = childSpan.timestamp; + } } } @@ -576,18 +604,15 @@ - (SentryTransaction *)toTransaction { NSArray> *appStartSpans = [self buildAppStartSpans]; - NSArray> *childrenCopy; - @synchronized(_children) { - [_children addObjectsFromArray:appStartSpans]; - childrenCopy = [_children copy]; - } + NSMutableArray> *spans = + [[NSMutableArray alloc] initWithCapacity:_children.count + appStartSpans.count]; - if (appStartMeasurement != nil) { - [self updateStartTime:appStartMeasurement.appStartTimestamp]; + @synchronized(_children) { + [spans addObjectsFromArray:_children]; } + [spans addObjectsFromArray:appStartSpans]; - SentryTransaction *transaction = [[SentryTransaction alloc] initWithTrace:self - children:childrenCopy]; + SentryTransaction *transaction = [[SentryTransaction alloc] initWithTrace:self children:spans]; transaction.transaction = self.transactionContext.name; #if SENTRY_TARGET_PROFILING_SUPPORTED transaction.startSystemTime = self.startSystemTime; @@ -599,7 +624,7 @@ - (SentryTransaction *)toTransaction [framesOfAllSpans addObjectsFromArray:[(SentrySpan *)self frames]]; } - for (SentrySpan *span in childrenCopy) { + for (SentrySpan *span in spans) { if (span.frames) { [framesOfAllSpans addObjectsFromArray:span.frames]; } diff --git a/Sources/Sentry/SentryUIViewControllerPerformanceTracker.m b/Sources/Sentry/SentryUIViewControllerPerformanceTracker.m index 11000c055ab..ebc6d09126a 100644 --- a/Sources/Sentry/SentryUIViewControllerPerformanceTracker.m +++ b/Sources/Sentry/SentryUIViewControllerPerformanceTracker.m @@ -1,4 +1,5 @@ #import "SentryUIViewControllerPerformanceTracker.h" +#import "SentryFramesTracker.h" #import "SentryHub.h" #import "SentryLog.h" #import "SentryPerformanceTracker+Private.h" @@ -7,15 +8,19 @@ #import "SentryScope.h" #import "SentrySpanId.h" #import "SentrySwift.h" +#import "SentryTimeToDisplayTracker.h" +#import "SentryTracer.h" #import #import #import +#if SENTRY_HAS_UIKIT + @interface SentryUIViewControllerPerformanceTracker () @property (nonatomic, strong) SentryPerformanceTracker *tracker; -@property (nonatomic, strong) SentryInAppLogic *inAppLogic; +@property (nullable, nonatomic, weak) SentryTimeToDisplayTracker *currentTTDTracker; @end @@ -35,14 +40,20 @@ - (instancetype)init self.tracker = SentryPerformanceTracker.shared; SentryOptions *options = [SentrySDK options]; - self.inAppLogic = [[SentryInAppLogic alloc] initWithInAppIncludes:options.inAppIncludes inAppExcludes:options.inAppExcludes]; + + _enableWaitForFullDisplay = NO; } return self; } -#if SENTRY_HAS_UIKIT +- (SentrySpan *)viewControllerPerformanceSpan:(UIViewController *)controller +{ + SentrySpanId *spanId + = objc_getAssociatedObject(controller, &SENTRY_UI_PERFORMANCE_TRACKER_SPAN_ID); + return [self.tracker getSpan:spanId]; +} - (void)viewControllerLoadView:(UIViewController *)controller callbackToOrigin:(void (^)(void))callbackToOrigin @@ -60,6 +71,7 @@ - (void)viewControllerLoadView:(UIViewController *)controller block:^{ SENTRY_LOG_DEBUG(@"Tracking loadView"); [self createTransaction:controller]; + [self createTimeToDisplay:controller]; [self measurePerformance:@"loadView" target:controller callbackToOrigin:callbackToOrigin]; @@ -109,6 +121,39 @@ - (void)createTransaction:(UIViewController *)controller } } +- (void)createTimeToDisplay:(UIViewController *)controller +{ + SentrySpan *vcSpan = [self viewControllerPerformanceSpan:controller]; + + if (![vcSpan isKindOfClass:[SentryTracer self]]) { + // Since TTID and TTFD are meant to the whole screen + // we will not track child view controllers + return; + } + + if (objc_getAssociatedObject(controller, &SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER)) { + // Already tracking time to display, not creating a new tracker. + // This may happen if user manually call `loadView` from a view controller more than once. + return; + } + + SentryTimeToDisplayTracker *ttdTracker = + [[SentryTimeToDisplayTracker alloc] initForController:controller + framesTracker:SentryFramesTracker.sharedInstance + waitForFullDisplay:self.enableWaitForFullDisplay]; + + objc_setAssociatedObject(controller, &SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER, ttdTracker, + OBJC_ASSOCIATION_ASSIGN); + [ttdTracker startForTracer:(SentryTracer *)vcSpan]; + + self.currentTTDTracker = ttdTracker; +} + +- (void)reportFullyDisplayed +{ + [self.currentTTDTracker reportFullyDisplayed]; +} + - (void)viewControllerViewWillAppear:(UIViewController *)controller callbackToOrigin:(void (^)(void))callbackToOrigin { @@ -132,6 +177,10 @@ - (void)viewControllerViewWillAppear:(UIViewController *)controller }; [self.tracker activateSpan:spanId duringBlock:duringBlock]; + + SentryTimeToDisplayTracker *ttdTracker + = objc_getAssociatedObject(controller, &SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER); + [ttdTracker reportReadyToDisplay]; }; [self limitOverride:@"viewWillAppear" @@ -165,6 +214,7 @@ - (void)viewControllerViewDidAppear:(UIViewController *)controller - (void)viewControllerViewWillDisappear:(UIViewController *)controller callbackToOrigin:(void (^)(void))callbackToOrigin { + [self finishTransaction:controller status:kSentrySpanStatusCancelled lifecycleMethod:@"viewWillDisappear" @@ -205,6 +255,7 @@ - (void)finishTransaction:(UIViewController *)controller // If we are still tracking this UIViewController finish the transaction // and remove associated span id. [self.tracker finishSpan:spanId withStatus:status]; + objc_setAssociatedObject(controller, &SENTRY_UI_PERFORMANCE_TRACKER_SPAN_ID, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }; @@ -343,6 +394,7 @@ - (void)measurePerformance:(NSString *)description inBlock:callbackToOrigin]; } } -#endif @end + +#endif diff --git a/Sources/Sentry/SentryUIViewControllerSwizzling.m b/Sources/Sentry/SentryUIViewControllerSwizzling.m index f43562a01eb..8539138db84 100644 --- a/Sources/Sentry/SentryUIViewControllerSwizzling.m +++ b/Sources/Sentry/SentryUIViewControllerSwizzling.m @@ -108,6 +108,7 @@ - (void)start } [self swizzleUIViewController]; + SentryUIViewControllerPerformanceTracker.shared.inAppLogic = self.inAppLogic; } - (id)findApp diff --git a/Sources/Sentry/include/SentryFramesTracker.h b/Sources/Sentry/include/SentryFramesTracker.h index c8278040b88..70bef9090e9 100644 --- a/Sources/Sentry/include/SentryFramesTracker.h +++ b/Sources/Sentry/include/SentryFramesTracker.h @@ -9,6 +9,12 @@ NS_ASSUME_NONNULL_BEGIN @class SentryTracer; +@protocol SentryFramesTrackerListener + +- (void)framesTrackerHasNewFrame; + +@end + /** * Tracks total, frozen and slow frames for iOS, tvOS, and Mac Catalyst. */ @@ -28,6 +34,10 @@ SENTRY_NO_INIT - (void)start; - (void)stop; +- (void)addListener:(id)listener; + +- (void)removeListener:(id)listener; + @end #endif diff --git a/Sources/Sentry/include/SentrySpanOperations.h b/Sources/Sentry/include/SentrySpanOperations.h index 7c64456948d..57eb606c98b 100644 --- a/Sources/Sentry/include/SentrySpanOperations.h +++ b/Sources/Sentry/include/SentrySpanOperations.h @@ -1,5 +1,7 @@ #import static NSString *const SentrySpanOperationUILoad = @"ui.load"; +static NSString *const SentrySpanOperationUILoadInitialDisplay = @"ui.load.initial_display"; +static NSString *const SentrySpanOperationUILoadFullDisplay = @"ui.load.full_display"; static NSString *const SentrySpanOperationUIAction = @"ui.action"; static NSString *const SentrySpanOperationUIActionClick = @"ui.action.click"; diff --git a/Sources/Sentry/include/SentryTimeToDisplayTracker.h b/Sources/Sentry/include/SentryTimeToDisplayTracker.h new file mode 100644 index 00000000000..6f419dd35a5 --- /dev/null +++ b/Sources/Sentry/include/SentryTimeToDisplayTracker.h @@ -0,0 +1,41 @@ +#import "SentryDefines.h" +#import + +#if SENTRY_HAS_UIKIT +# import + +@class SentrySpan, SentryTracer, SentryFramesTracker; + +NS_ASSUME_NONNULL_BEGIN + +/** + * @brief This is a class responsible for creating + * TTID and TTFD spans. + * @discussion This class creates the TTID and TTFD spans and make use of + * the @c SentryTracer wait for children feature to keep transaction open long + * enough to wait for a full display report if @c waitForFullDisplay is true. + */ +@interface SentryTimeToDisplayTracker : NSObject +SENTRY_NO_INIT + +@property (nullable, nonatomic, weak, readonly) SentrySpan *initialDisplaySpan; + +@property (nullable, nonatomic, weak, readonly) SentrySpan *fullDisplaySpan; + +@property (nonatomic, readonly) BOOL waitForFullDisplay; + +- (instancetype)initForController:(UIViewController *)controller + framesTracker:(SentryFramesTracker *)framestracker + waitForFullDisplay:(BOOL)waitForFullDisplay; + +- (void)startForTracer:(SentryTracer *)tracer; + +- (void)reportReadyToDisplay; + +- (void)reportFullyDisplayed; + +@end + +NS_ASSUME_NONNULL_END + +#endif diff --git a/Sources/Sentry/include/SentryTracer.h b/Sources/Sentry/include/SentryTracer.h index 1e1bd2377c9..300ea72a447 100644 --- a/Sources/Sentry/include/SentryTracer.h +++ b/Sources/Sentry/include/SentryTracer.h @@ -18,6 +18,11 @@ static NSTimeInterval const SentryTracerDefaultTimeout = 3.0; */ - (nullable id)activeSpanForTracer:(SentryTracer *)tracer; +/** + * Report that the tracer has finished. + */ +- (void)tracerDidFinish:(SentryTracer *)tracer; + @end @interface SentryTracer : SentrySpan diff --git a/Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h b/Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h index d6b7d1446bf..54c8382092f 100644 --- a/Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h +++ b/Sources/Sentry/include/SentryUIViewControllerPerformanceTracker.h @@ -3,7 +3,8 @@ #if SENTRY_HAS_UIKIT # import -#endif + +@class SentrySpan, SentryInAppLogic; NS_ASSUME_NONNULL_BEGIN @@ -16,14 +17,21 @@ static NSString *const SENTRY_UI_PERFORMANCE_TRACKER_LAYOUTSUBVIEW_SPAN_ID static NSString *const SENTRY_UI_PERFORMANCE_TRACKER_SPANS_IN_EXECUTION_SET = @"SENTRY_UI_PERFORMANCE_TRACKER_SPANS_IN_EXECUTION_SET"; +static NSString *const SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER + = @"SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER"; + /** * Class responsible to track UI performance. * This class is intended to be used in a swizzled context. */ @interface SentryUIViewControllerPerformanceTracker : NSObject -#if SENTRY_HAS_UIKIT + @property (nonatomic, readonly, class) SentryUIViewControllerPerformanceTracker *shared; +@property (nonatomic, strong) SentryInAppLogic *inAppLogic; + +@property (nonatomic) BOOL enableWaitForFullDisplay; + /** * Measures @c controller's @c loadView method. * This method starts a span that will be finished when @c viewControllerDidAppear:callBackToOrigin: @@ -90,7 +98,10 @@ static NSString *const SENTRY_UI_PERFORMANCE_TRACKER_SPANS_IN_EXECUTION_SET */ - (void)viewControllerViewDidLayoutSubViews:(UIViewController *)controller callbackToOrigin:(void (^)(void))callback; -#endif + +- (void)reportFullyDisplayed; + @end NS_ASSUME_NONNULL_END +#endif // SENTRY_HAS_UIKIT diff --git a/Tests/SentryTests/Integrations/Performance/FramesTracking/SentryFramesTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/FramesTracking/SentryFramesTrackerTests.swift index ed54ec0777f..080d75e9746 100644 --- a/Tests/SentryTests/Integrations/Performance/FramesTracking/SentryFramesTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/FramesTracking/SentryFramesTrackerTests.swift @@ -113,6 +113,57 @@ class SentryFramesTrackerTests: XCTestCase { try assert(slow: 0, frozen: 0) } + + func testAddListener() { + let sut = fixture.sut + let listener = FrameTrackerListener() + sut.start() + sut.add(listener) + + fixture.displayLinkWrapper.normalFrame() + + XCTAssertTrue(listener.newFrameReported) + } + + func testRemoveListener() { + let sut = fixture.sut + let listener = FrameTrackerListener() + sut.start() + sut.add(listener) + sut.remove(listener) + + fixture.displayLinkWrapper.normalFrame() + + XCTAssertFalse(listener.newFrameReported) + } + + func testReleasedListener() { + let sut = fixture.sut + var callbackCalls = 0 + sut.start() + + autoreleasepool { + let listener = FrameTrackerListener() + listener.callback = { + callbackCalls += 1 + } + sut.add(listener) + fixture.displayLinkWrapper.normalFrame() + } + + fixture.displayLinkWrapper.normalFrame() + + XCTAssertEqual(callbackCalls, 1) + } +} + +private class FrameTrackerListener: NSObject, SentryFramesTrackerListener { + var newFrameReported = false + var callback: (() -> Void)? + func framesTrackerHasNewFrame() { + newFrameReported = true + callback?() + } } private extension SentryFramesTrackerTests { diff --git a/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackerTests.swift index 386737a1bc2..b4bda6d7347 100644 --- a/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackerTests.swift @@ -206,12 +206,14 @@ class SentryPerformanceTrackerTests: XCTestCase { let spanId = startSpan(tracker: sut) let span = sut.getSpan(spanId) var blockCalled = false - + + XCTAssertEqual(getSpans(tracker: sut).count, 1) + sut.activateSpan(spanId) { blockCalled = true let childId = self.startSpan(tracker: sut) let child = sut.getSpan(childId) - + XCTAssertEqual(self.getSpans(tracker: sut).count, 2) XCTAssertFalse(span!.isFinished) XCTAssertFalse(child!.isFinished) @@ -220,13 +222,15 @@ class SentryPerformanceTrackerTests: XCTestCase { XCTAssertFalse(span!.isFinished) XCTAssertTrue(child!.isFinished) } - + + XCTAssertEqual(getSpans(tracker: sut).count, 1) sut.finishSpan(spanId) let status = Dynamic(span).finishStatus as SentrySpanStatus? XCTAssertEqual(status!, .ok) XCTAssertTrue(span!.isFinished) XCTAssertTrue(blockCalled) + XCTAssertEqual(getSpans(tracker: sut).count, 0) } func testFinishSpanWithStatus() { diff --git a/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackingIntegrationTests.swift b/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackingIntegrationTests.swift index 141d9df11b7..9b699d2c7f7 100644 --- a/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackingIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/Performance/SentryPerformanceTrackingIntegrationTests.swift @@ -65,6 +65,28 @@ class SentryPerformanceTrackingIntegrationTests: XCTestCase { XCTAssertFalse(result) XCTAssertNil(Dynamic(sut).swizzling.asObject) } + + func testConfigure_waitForDisplay() { + let sut = SentryPerformanceTrackingIntegration() + + let options = Options() + options.tracesSampleRate = 0.1 + options.enableTimeToFullDisplay = true + sut.install(with: options) + + XCTAssertTrue(SentryUIViewControllerPerformanceTracker.shared.enableWaitForFullDisplay) + } + + func testConfigure_dontWaitForDisplay() { + let sut = SentryPerformanceTrackingIntegration() + + let options = Options() + options.tracesSampleRate = 0.1 + options.enableTimeToFullDisplay = false + sut.install(with: options) + + XCTAssertFalse(SentryUIViewControllerPerformanceTracker.shared.enableWaitForFullDisplay) + } #endif } diff --git a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift new file mode 100644 index 00000000000..6d9f15abc0b --- /dev/null +++ b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift @@ -0,0 +1,229 @@ +import Foundation +import Sentry +import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + +class SentryTimeToDisplayTrackerTest: XCTestCase { + + private class Fixture { + let dateProvider: TestCurrentDateProvider = TestCurrentDateProvider() + var tracer: SentryTracer { SentryTracer(transactionContext: TransactionContext(operation: "Test Operation"), hub: nil) } + + var displayLinkWrapper = TestDisplayLinkWrapper() + var framesTracker: SentryFramesTracker + + init() { + framesTracker = SentryFramesTracker(displayLinkWrapper: displayLinkWrapper) + framesTracker.start() + } + + func getSut(for controller: UIViewController, waitForFullDisplay: Bool) -> SentryTimeToDisplayTracker { + return SentryTimeToDisplayTracker(for: controller, framesTracker: framesTracker, waitForFullDisplay: waitForFullDisplay) + } + } + + private let fixture = Fixture() + + override func setUp() { + super.setUp() + CurrentDate.setCurrentDateProvider(fixture.dateProvider) + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + func testReportInitialDisplay_notWaitingFullDisplay() throws { + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: false) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 7)) + let tracer = fixture.tracer + + sut.start(for: tracer) + XCTAssertEqual(tracer.children.count, 1) + XCTAssertEqual(Dynamic(fixture.framesTracker).listeners.count, 1) + + let ttidSpan = try XCTUnwrap(tracer.children.first, "Expected a TTID span") + XCTAssertEqual(ttidSpan.startTimestamp, fixture.dateProvider.date()) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + XCTAssertEqual(ttidSpan.timestamp, fixture.dateProvider.date()) + XCTAssertTrue(ttidSpan.isFinished) + XCTAssertEqual(ttidSpan.spanDescription, "UIViewController initial display") + XCTAssertEqual(ttidSpan.operation, SentrySpanOperationUILoadInitialDisplay) + + XCTAssertEqual(Dynamic(fixture.framesTracker).listeners.count, 0) + } + + func testReportNewFrame_notReadyToDisplay() throws { + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: false) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 7)) + + let tracer = fixture.tracer + + sut.start(for: tracer) + XCTAssertEqual(tracer.children.count, 1) + + let ttidSpan = try XCTUnwrap(tracer.children.first, "Expected a TTID span") + XCTAssertEqual(ttidSpan.startTimestamp, fixture.dateProvider.date()) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + fixture.displayLinkWrapper.normalFrame() + + XCTAssertNil(ttidSpan.timestamp) + XCTAssertFalse(ttidSpan.isFinished) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 12)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + XCTAssertEqual(ttidSpan.timestamp, fixture.dateProvider.date()) + XCTAssertTrue(ttidSpan.isFinished) + } + + func testreportInitialDisplay_waitForFullDisplay() { + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 7)) + + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + + sut.start(for: tracer) + XCTAssertEqual(tracer.children.count, 2) + + let ttidSpan = tracer.children.first + XCTAssertEqual(ttidSpan?.startTimestamp, fixture.dateProvider.date()) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + XCTAssertEqual(ttidSpan?.timestamp, fixture.dateProvider.date()) + XCTAssertTrue(ttidSpan?.isFinished ?? false) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11)) + sut.reportFullyDisplayed() + + XCTAssertEqual(ttidSpan?.timestamp, Date(timeIntervalSince1970: 9)) + XCTAssertTrue(ttidSpan?.isFinished ?? false) + XCTAssertEqual(tracer.children.count, 2) + } + + func testreportFullDisplay_notWaitingForFullDisplay() { + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: false) + let tracer = fixture.tracer + + sut.start(for: tracer) + + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + sut.reportFullyDisplayed() + + XCTAssertNil(sut.fullDisplaySpan) + XCTAssertEqual(tracer.children.count, 1) + } + + func testreportFullDisplay_waitingForFullDisplay() { + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + + sut.start(for: tracer) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 10)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11)) + sut.reportFullyDisplayed() + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 12)) + tracer.finish() + + XCTAssertNotNil(sut.fullDisplaySpan) + XCTAssertEqual(sut.fullDisplaySpan?.startTimestamp, Date(timeIntervalSince1970: 9)) + XCTAssertEqual(sut.fullDisplaySpan?.timestamp, Date(timeIntervalSince1970: 11)) + XCTAssertEqual(sut.fullDisplaySpan?.status, .ok) + + XCTAssertEqual(sut.fullDisplaySpan?.spanDescription, "UIViewController full display") + XCTAssertEqual(sut.fullDisplaySpan?.operation, SentrySpanOperationUILoadFullDisplay) + } + + func testReportFullDisplay_waitingForFullDisplay_notReadyToDisplay() { + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + + sut.start(for: tracer) + + fixture.displayLinkWrapper.normalFrame() + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11)) + sut.reportFullyDisplayed() + + XCTAssertFalse(sut.fullDisplaySpan?.isFinished ?? true) + } + + func testReportFullDisplay_expires() { + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + + sut.start(for: tracer) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 10)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11)) + sut.fullDisplaySpan?.finish(status: .deadlineExceeded) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 13)) + tracer.finish() + + XCTAssertEqual(sut.fullDisplaySpan?.startTimestamp, Date(timeIntervalSince1970: 9)) + XCTAssertEqual(sut.fullDisplaySpan?.timestamp, Date(timeIntervalSince1970: 10)) + XCTAssertEqual(sut.fullDisplaySpan?.spanDescription, "UIViewController full display - Deadline Exceeded") + } + + func testCheckInitialTime() { + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + fixture.dateProvider.driftTimeForEveryRead = true + + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + + sut.start(for: tracer) + + XCTAssertNotNil(sut.fullDisplaySpan) + XCTAssertEqual(sut.fullDisplaySpan?.startTimestamp, tracer.startTimestamp) + XCTAssertEqual(sut.initialDisplaySpan?.startTimestamp, tracer.startTimestamp) + } + + func testFullDisplay_reportedBefore_initialDisplay() { + let sut = fixture.getSut(for: UIViewController(), waitForFullDisplay: true) + let tracer = fixture.tracer + sut.start(for: tracer) + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 7)) + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9)) + sut.reportFullyDisplayed() + + fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11)) + sut.reportReadyToDisplay() + fixture.displayLinkWrapper.normalFrame() + + XCTAssertEqual(sut.initialDisplaySpan?.timestamp, fixture.dateProvider.date()) + XCTAssertEqual(sut.fullDisplaySpan?.timestamp, sut.initialDisplaySpan?.timestamp) + } +} + +#endif diff --git a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerPerformanceTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerPerformanceTrackerTests.swift index f6248fda4f8..dc62f9ad7d1 100644 --- a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerPerformanceTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerPerformanceTrackerTests.swift @@ -38,12 +38,16 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { let dateProvider = TestCurrentDateProvider() var viewControllerName: String! + + var inAppLogic: SentryInAppLogic { + return SentryInAppLogic(inAppIncludes: options.inAppIncludes, inAppExcludes: []) + } func getSut() -> SentryUIViewControllerPerformanceTracker { CurrentDate.setCurrentDateProvider(dateProvider) viewControllerName = SwiftDescriptor.getObjectClassName(viewController) - + SentryUIViewControllerPerformanceTracker.shared.inAppLogic = self.inAppLogic return SentryUIViewControllerPerformanceTracker.shared } } @@ -164,10 +168,12 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { callbackExpectation.fulfill() } XCTAssertFalse(tracer.isFinished) - + + reportFrame() + lifecycleEndingMethod(sut, viewController, tracker, callbackExpectation, tracer) - XCTAssertEqual(Dynamic(transactionSpan).children.asArray!.count, 7) + XCTAssertEqual(Dynamic(transactionSpan).children.asArray!.count, 8) XCTAssertTrue(tracer.isFinished) XCTAssertEqual(finishStatus.rawValue, tracer.status.rawValue) @@ -227,7 +233,7 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { callbackExpectation.fulfill() } try assertSpanDuration(span: lastSpan, expectedDuration: 1) - + reportFrame() advanceTime(bySeconds: 4) sut.viewControllerViewDidAppear(viewController) { @@ -240,6 +246,46 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { wait(for: [callbackExpectation], timeout: 0) } + + func testReportFullyDisplayed() { + let sut = fixture.getSut() + sut.enableWaitForFullDisplay = true + let viewController = fixture.viewController + let tracker = fixture.tracker + var tracer: SentryTracer? + + sut.viewControllerLoadView(viewController) { + let spans = self.getStack(tracker) + tracer = spans.first as? SentryTracer + } + + sut.reportFullyDisplayed() + reportFrame() + + XCTAssertTrue(tracer?.children[1].isFinished ?? false) + } + + func testSecondViewController() { + let sut = fixture.getSut() + let viewController = fixture.viewController + let viewController2 = TestViewController() + + sut.viewControllerLoadView(viewController) { + //Left empty on purpose + } + + let ttdTracker = Dynamic(sut).currentTTDTracker.asObject as? SentryTimeToDisplayTracker + XCTAssertNotNil(ttdTracker) + + sut.viewControllerLoadView(viewController2) { + //Left empty on purpose + } + + let secondTTDTracker = objc_getAssociatedObject(viewController2, SENTRY_UI_PERFORMANCE_TRACKER_TTD_TRACKER) + + XCTAssertEqual(ttdTracker, Dynamic(sut).currentTTDTracker.asObject) + XCTAssertNil(secondTTDTracker) + } func testTimeMeasurement_SkipLoadView() throws { let sut = fixture.getSut() @@ -312,6 +358,7 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { sut.viewControllerViewWillAppear(viewController) { //intentionally left empty. } + reportFrame() sut.viewControllerViewDidAppear(viewController) { //intentionally left empty. //Need to call viewControllerViewDidAppear to finish the transaction. @@ -324,7 +371,7 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { XCTAssertTrue(unwrappedTransactionSpan.isFinished) let children = try XCTUnwrap(Dynamic(unwrappedTransactionSpan).children.asArray) - XCTAssertEqual(children.count, 4) + XCTAssertEqual(children.count, 5) assertTrackerIsEmpty(tracker) } @@ -400,7 +447,7 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { unwrappedTransactionSpan = try XCTUnwrap(transactionSpan) XCTAssertFalse(unwrappedTransactionSpan.isFinished) - XCTAssertEqual(Dynamic(unwrappedTransactionSpan).children.asArray!.count, 2) + XCTAssertEqual(Dynamic(unwrappedTransactionSpan).children.asArray!.count, 3) wait(for: [callbackExpectation], timeout: 0) } @@ -449,10 +496,44 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { } let children = try XCTUnwrap(Dynamic(transactionSpan).children.asArray) - XCTAssertEqual(children.count, 2) + XCTAssertEqual(children.count, 3) wait(for: [callbackExpectation], timeout: 0) } + func test_waitForFullDisplay() { + let sut = fixture.getSut() + let tracker = fixture.tracker + let firstController = TestViewController() + + var tracer: SentryTracer? + + sut.enableWaitForFullDisplay = true + + //The first view controller creates a transaction + sut.viewControllerLoadView(firstController) { + tracer = self.getStack(tracker).first as? SentryTracer + } + XCTAssertEqual(tracer?.children.count, 3) + XCTAssertEqual(tracer?.children[1].operation, "ui.load.full_display") + } + + func test_dontWaitForFullDisplay() { + let sut = fixture.getSut() + let tracker = fixture.tracker + let firstController = TestViewController() + + var tracer: SentryTracer? + + sut.enableWaitForFullDisplay = false + + //The first view controller creates a transaction + sut.viewControllerLoadView(firstController) { + tracer = self.getStack(tracker).first as? SentryTracer + } + + XCTAssertEqual(tracer?.children.count, 2) + } + func test_captureAllAutomaticSpans() { let sut = fixture.getSut() let firstController = TestViewController() @@ -528,5 +609,9 @@ class SentryUIViewControllerPerformanceTrackerTests: XCTestCase { private func advanceTime(bySeconds: TimeInterval) { fixture.dateProvider.setDate(date: fixture.dateProvider.date().addingTimeInterval(bySeconds)) } + + private func reportFrame() { + Dynamic(SentryFramesTracker.sharedInstance()).displayLinkCallback() + } } #endif diff --git a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerSwizzlingTests.swift b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerSwizzlingTests.swift index 1251c9f5b14..382634e88fa 100644 --- a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerSwizzlingTests.swift +++ b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryUIViewControllerSwizzlingTests.swift @@ -82,7 +82,6 @@ class SentryUIViewControllerSwizzlingTests: XCTestCase { func testViewControllerWithoutLoadView_TransactionBoundToScope() { fixture.sut.start() - SentryPerformanceTracker.shared.clear() let controller = TestViewController() controller.loadView() XCTAssertNotNil(SentrySDK.span) diff --git a/Tests/SentryTests/Protocol/TestData.swift b/Tests/SentryTests/Protocol/TestData.swift index 5a8f8ceeadf..c2a6b0eba11 100644 --- a/Tests/SentryTests/Protocol/TestData.swift +++ b/Tests/SentryTests/Protocol/TestData.swift @@ -235,6 +235,10 @@ class TestData { static var dataAttachment: Attachment { return Attachment(data: "hello".data(using: .utf8)!, filename: "file.txt") } + + static var spanContext: SpanContext { + SpanContext(operation: "Test Context") + } enum SampleError: Error { case bestDeveloper @@ -324,4 +328,5 @@ class TestData { return SentryAppStartMeasurement(type: type, isPreWarmed: false, appStartTimestamp: appStartTimestamp, duration: appStartDuration, runtimeInitTimestamp: runtimeInit, moduleInitializationTimestamp: main, didFinishLaunchingTimestamp: didFinishLaunching) } + } diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index c7ff4e46d5a..fb354d61e19 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -695,7 +695,7 @@ class SentryHubTests: XCTestCase { assertNoEnvelopesCaptured() } - + func testCaptureEnvelope_WithSession() { let envelope = SentryEnvelope(session: SentrySession(releaseName: "")) sut.capture(envelope) @@ -704,6 +704,35 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(envelope, fixture.client.captureEnvelopeInvocations.first) } +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + func test_reportFullyDisplayed_enableTimeToFullDisplay_YES() { + fixture.options.enableTimeToFullDisplay = true + let sut = fixture.getSut(fixture.options) + + let testTTDTracker = TestTimeToDisplayTracker() + + Dynamic(SentryUIViewControllerPerformanceTracker.shared).currentTTDTracker = testTTDTracker + + sut.reportFullyDisplayed() + + XCTAssertTrue(testTTDTracker.registerFullDisplayCalled) + + } + + func test_reportFullyDisplayed_enableTimeToFullDisplay_NO() { + fixture.options.enableTimeToFullDisplay = false + let sut = fixture.getSut(fixture.options) + + let testTTDTracker = TestTimeToDisplayTracker() + + Dynamic(SentryUIViewControllerPerformanceTracker.shared).currentTTDTracker = testTTDTracker + + sut.reportFullyDisplayed() + + XCTAssertFalse(testTTDTracker.registerFullDisplayCalled) + } +#endif + private func addBreadcrumbThroughConfigureScope(_ hub: SentryHub) { hub.configureScope({ scope in scope.addBreadcrumb(self.fixture.crumb) @@ -901,3 +930,18 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(expected, span.sampled) } } + +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) +class TestTimeToDisplayTracker: SentryTimeToDisplayTracker { + + init() { + super.init(for: UIViewController(), framesTracker: SentryFramesTracker.sharedInstance(), waitForFullDisplay: false) + } + + var registerFullDisplayCalled = false + override func reportFullyDisplayed() { + registerFullDisplayCalled = true + } + +} +#endif diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index d0c3b034d90..8a57aac023b 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -325,6 +325,11 @@ - (void)testEnableCaptureFailedRequests [self testBooleanField:@"enableCaptureFailedRequests" defaultValue:YES]; } +- (void)testEnableTimeToFullDisplay +{ + [self testBooleanField:@"enableTimeToFullDisplay" defaultValue:NO]; +} + - (void)testFailedRequestStatusCodes { SentryHttpStatusCodeRange *httpStatusCodeRange = @@ -534,6 +539,7 @@ - (void)testNSNull_SetsDefaultValue @"sdk" : [NSNull null], @"enableCaptureFailedRequests" : [NSNull null], @"failedRequestStatusCodes" : [NSNull null], + @"enableTimeToFullDisplay" : [NSNull null], @"enableTracing" : [NSNull null] } didFailWithError:nil]; @@ -606,6 +612,8 @@ - (void)assertDefaultValues:(SentryOptions *)options XCTAssertEqual(500, range.min); XCTAssertEqual(599, range.max); + XCTAssertFalse(options.enableTimeToFullDisplay); + #if SENTRY_TARGET_PROFILING_SUPPORTED # pragma clang diagnostic push # pragma clang diagnostic ignored "-Wdeprecated-declarations" diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index d05e61405c8..eb615f4e929 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -523,6 +523,22 @@ class SentrySDKTests: XCTestCase { XCTAssertFalse(stateAfterStop!.isSDKRunning) } #endif + +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + func testReportFullyDisplayed() { + fixture.options.enableTimeToFullDisplay = true + + SentrySDK.start(options: fixture.options) + + let testTTDTracker = TestTimeToDisplayTracker() + + Dynamic(SentryUIViewControllerPerformanceTracker.shared).currentTTDTracker = testTTDTracker + + SentrySDK.reportFullyDisplayed() + + XCTAssertTrue(testTTDTracker.registerFullDisplayCalled) + } +#endif func testClose_SetsClientToNil() { SentrySDK.start { options in diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index c4563ccd8eb..f9377481e4e 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -187,10 +187,12 @@ #import "SentryEnvelopeAttachmentHeader.h" #import "SentryNSProcessInfoWrapper.h" #import "SentryPerformanceTracker+Testing.h" +#import "SentrySpanOperations.h" +#import "SentryTimeToDisplayTracker.h" #import "TestSentryViewHierarchy.h" - #if SENTRY_HAS_UIKIT # import "MockUIScene.h" # import "SentryUIEventTracker.h" # import "SentryUIEventTrackingIntegration.h" +# import "SentryUIViewControllerPerformanceTracker.h" #endif diff --git a/Tests/SentryTests/Performance/SentryTracer+Test.h b/Tests/SentryTests/Transaction/SentryTracer+Test.h similarity index 100% rename from Tests/SentryTests/Performance/SentryTracer+Test.h rename to Tests/SentryTests/Transaction/SentryTracer+Test.h diff --git a/Tests/SentryTests/Performance/SentryTracerObjCTests.m b/Tests/SentryTests/Transaction/SentryTracerObjCTests.m similarity index 100% rename from Tests/SentryTests/Performance/SentryTracerObjCTests.m rename to Tests/SentryTests/Transaction/SentryTracerObjCTests.m diff --git a/Tests/SentryTests/Performance/SentryTracerTests.swift b/Tests/SentryTests/Transaction/SentryTracerTests.swift similarity index 99% rename from Tests/SentryTests/Performance/SentryTracerTests.swift rename to Tests/SentryTests/Transaction/SentryTracerTests.swift index 2e870726a6d..10094c91636 100644 --- a/Tests/SentryTests/Performance/SentryTracerTests.swift +++ b/Tests/SentryTests/Transaction/SentryTracerTests.swift @@ -13,6 +13,11 @@ class SentryTracerTests: XCTestCase { func activeSpan(for tracer: SentryTracer) -> Span? { return activeSpan } + + var tracerDidFinishCalled = false + func tracerDidFinish(_ tracer: SentryTracer) { + tracerDidFinishCalled = true + } } private class Fixture { @@ -461,8 +466,8 @@ class SentryTracerTests: XCTestCase { XCTAssertEqual(0, fixture.hub.capturedEventsWithScopes.count) } - func testNonIdleTransaction_CallFinish_DoesNotTrimEndTimestamp() { - let sut = fixture.getSut() + func testAutomaticTransaction_CallFinish_DoesNotTrimEndTimestamp() { + let sut = fixture.getSut(waitForChildren: false) advanceTime(bySeconds: 1.0) let child = sut.startChild(operation: fixture.transactionOperation) @@ -501,7 +506,7 @@ class SentryTracerTests: XCTestCase { XCTAssertEqual(1, fixture.dispatchQueue.dispatchAfterInvocations.count) sut.finish() - XCTAssertEqual(2, fixture.dispatchQueue.dispatchCancelInvocations.count) + XCTAssertEqual(1, fixture.dispatchQueue.dispatchCancelInvocations.count) XCTAssertEqual(1, fixture.dispatchQueue.dispatchAfterInvocations.count) XCTAssertFalse(sut.isFinished)