diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 2d48a568ea..54604139da 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1098,6 +1098,7 @@ FAB359982E05D7E90083D5E3 /* SentryEventSwiftHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = FAB359972E05D7E90083D5E3 /* SentryEventSwiftHelper.h */; }; FAB3599A2E05D8080083D5E3 /* SentryEventSwiftHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = FAB359992E05D8080083D5E3 /* SentryEventSwiftHelper.m */; }; FAC62B652E15A4100003909D /* SentrySDKThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC62B642E15A40C0003909D /* SentrySDKThreadTests.swift */; }; + FAE2DABC2E1F55C500262307 /* HangTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE2DABB2E1F55C100262307 /* HangTracker.swift */; }; FAEC270E2DF3526000878871 /* SentryUserFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */; }; FAEC273D2DF3933A00878871 /* NSData+Unzip.m in Sources */ = {isa = PBXBuildFile; fileRef = FAEC273C2DF3933200878871 /* NSData+Unzip.m */; }; /* End PBXBuildFile section */ @@ -2376,6 +2377,7 @@ FAB359972E05D7E90083D5E3 /* SentryEventSwiftHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryEventSwiftHelper.h; path = include/SentryEventSwiftHelper.h; sourceTree = ""; }; FAB359992E05D8080083D5E3 /* SentryEventSwiftHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryEventSwiftHelper.m; sourceTree = ""; }; FAC62B642E15A40C0003909D /* SentrySDKThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySDKThreadTests.swift; sourceTree = ""; }; + FAE2DABB2E1F55C100262307 /* HangTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HangTracker.swift; sourceTree = ""; }; FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserFeedback.swift; sourceTree = ""; }; FAEC273C2DF3933200878871 /* NSData+Unzip.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSData+Unzip.m"; sourceTree = ""; }; FAEC273E2DF393E000878871 /* NSData+Unzip.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSData+Unzip.h"; sourceTree = ""; }; @@ -4182,6 +4184,7 @@ D800942328F82E8D005D3943 /* Swift */ = { isa = PBXGroup; children = ( + FAE2DABB2E1F55C100262307 /* HangTracker.swift */, FA67DCF32DDBD4EA00896B02 /* Core */, D8CAC02D2BA0663E00E38F34 /* Integrations */, 621D9F2D2B9B030E003D94DE /* Helper */, @@ -5566,6 +5569,7 @@ 63FE70FD20DA4C1000CDBAE8 /* SentryCrashCachedData.c in Sources */, A8F17B2E2901765900990B25 /* SentryRequest.m in Sources */, 7BE1E33424F7E3CB009D3AD0 /* SentryMigrateSessionInit.m in Sources */, + FAE2DABC2E1F55C500262307 /* HangTracker.swift in Sources */, D80299502BA83A88000F0081 /* SentryPixelBuffer.swift in Sources */, 15E0A8F22411A45A00F044E3 /* SentrySession.m in Sources */, 844EDCE62947DC3100C86F34 /* SentryNSTimerFactory.m in Sources */, diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index b3157648cc..60b7d9d654 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -87,6 +87,18 @@ @interface SentryDependencyContainer () @end +@interface SentryDebugImageProvider () + +@end + +@interface SentryThreadInspector () + +@end + +@interface SentryCrashWrapper () + +@end + @implementation SentryDependencyContainer static SentryDependencyContainer *instance; @@ -250,6 +262,16 @@ - (SentryCrash *)crashReporter SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK [[SentryCrash alloc] initWithBasePath:SentrySDK.options.cacheDirectoryPath]); } +- (HangTrackerObjcBridge *)hangTracker SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK +{ + SENTRY_LAZY_INIT(_hangTracker, + [[HangTrackerObjcBridge alloc] initWithDateProvider:self.dateProvider + threadInspector:self.threadInspector + debugImageCache:self.debugImageProvider + fileManager:self.fileManager + crashWrapper:self.crashWrapper]); +} + - (id)getANRTracker:(NSTimeInterval)timeout SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK { diff --git a/Sources/Sentry/SentryDependencyContainerSwiftHelper.m b/Sources/Sentry/SentryDependencyContainerSwiftHelper.m index 8ee4bad1dd..0d432cdc15 100644 --- a/Sources/Sentry/SentryDependencyContainerSwiftHelper.m +++ b/Sources/Sentry/SentryDependencyContainerSwiftHelper.m @@ -1,5 +1,8 @@ #import "SentryDependencyContainerSwiftHelper.h" #import "SentryDependencyContainer.h" +#import "SentrySDK+Private.h" +#import "SentryScope+Private.h" +#import "SentryScope.h" #import "SentrySwift.h" #import "SentryUIApplication.h" @@ -19,4 +22,18 @@ + (void)dispatchSyncOnMainQueue:(void (^)(void))block [SentryDependencyContainer.sharedInstance.dispatchQueueWrapper dispatchSyncOnMainQueue:block]; } ++ (void)applyScopeTo:(SentryEvent *)event +{ + SentryScope *scope = [SentrySDK currentHub].scope; + SentryOptions *options = SentrySDK.options; + if (scope != nil && options != nil) { + [scope applyToEvent:event maxBreadcrumb:options.maxBreadcrumbs]; + } +} + ++ (void)captureFatalAppHangEvent:(SentryEvent *)event +{ + [SentrySDK captureFatalAppHangEvent:event]; +} + @end diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index c1b8c6dc2a..b0140ff62f 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -287,6 +287,10 @@ + (void)startWithOptions:(SentryOptions *)options [SentryCrashWrapper.sharedInstance startBinaryImageCache]; [SentryDependencyContainer.sharedInstance.binaryImageCache start]; + if (options.experimental.enableRunLoopObserverAppHangs) { + [SentryDependencyContainer.sharedInstance.hangTracker start]; + } + [SentrySDK installIntegrations]; #if SENTRY_TARGET_PROFILING_SUPPORTED @@ -602,20 +606,28 @@ + (void)reportFullyDisplayed + (void)pauseAppHangTracking { - SentryANRTrackingIntegration *anrTrackingIntegration - = (SentryANRTrackingIntegration *)[SentrySDK.currentHub - getInstalledIntegration:[SentryANRTrackingIntegration class]]; + if (currentHub.client.options.experimental.enableRunLoopObserverAppHangs) { + [SentryDependencyContainer.sharedInstance.hangTracker stop]; + } else { + SentryANRTrackingIntegration *anrTrackingIntegration + = (SentryANRTrackingIntegration *)[SentrySDK.currentHub + getInstalledIntegration:[SentryANRTrackingIntegration class]]; - [anrTrackingIntegration pauseAppHangTracking]; + [anrTrackingIntegration pauseAppHangTracking]; + } } + (void)resumeAppHangTracking { - SentryANRTrackingIntegration *anrTrackingIntegration - = (SentryANRTrackingIntegration *)[SentrySDK.currentHub - getInstalledIntegration:[SentryANRTrackingIntegration class]]; + if (currentHub.client.options.experimental.enableRunLoopObserverAppHangs) { + [SentryDependencyContainer.sharedInstance.hangTracker start]; + } else { + SentryANRTrackingIntegration *anrTrackingIntegration + = (SentryANRTrackingIntegration *)[SentrySDK.currentHub + getInstalledIntegration:[SentryANRTrackingIntegration class]]; - [anrTrackingIntegration resumeAppHangTracking]; + [anrTrackingIntegration resumeAppHangTracking]; + } } + (void)flush:(NSTimeInterval)timeout diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index 0bc6ab2a74..add77def94 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -24,6 +24,7 @@ @class SentryScopePersistentStore; @class SentryOptions; @class SentrySessionTracker; +@class HangTrackerObjcBridge; @class SentryGlobalEventProcessor; @protocol SentryANRTracker; @@ -122,6 +123,8 @@ SENTRY_NO_INIT @property (nonatomic, strong) id dispatchQueueProvider; @property (nonatomic, strong) SentryNSTimerFactory *timerFactory; +@property (nonatomic, strong) HangTrackerObjcBridge *hangTracker; + @property (nonatomic, strong) SentrySwizzleWrapper *swizzleWrapper; #if SENTRY_UIKIT_AVAILABLE @property (nonatomic, strong) SentryFramesTracker *framesTracker; diff --git a/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h b/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h index 4f41b45d6b..46fe80903b 100644 --- a/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h +++ b/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h @@ -5,6 +5,9 @@ # import #endif // SENTRY_HAS_UIKIT +@class SentryScope; +@class SentryOptions; + NS_ASSUME_NONNULL_BEGIN // Some Swift code needs to access SentryDependencyContainer. To @@ -21,6 +24,10 @@ NS_ASSUME_NONNULL_BEGIN + (void)dispatchSyncOnMainQueue:(void (^)(void))block; ++ (void)applyScopeTo:(SentryEvent *)event; + ++ (void)captureFatalAppHangEvent:(SentryEvent *)event; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/HangTracker.swift b/Sources/Swift/HangTracker.swift new file mode 100644 index 0000000000..e9902cda46 --- /dev/null +++ b/Sources/Swift/HangTracker.swift @@ -0,0 +1,302 @@ +@_implementationOnly import _SentryPrivate +#if canImport(UIKit) && !SENTRY_NO_UIKIT +import UIKit +#endif + +// A hang is parameterized by the its minimum duration, HangDuration, and is defined by a period of time lasting longer than HangDuration*2 when the delay +// of all frames started during that time sums to more than HangDuration. If there is only one frame during this time that is delayed then it is a "fullyBlocking" +// hang, otherwise it is a "nonFullyBlocking" hang. +// +// We measure late frames using a runloop observer. Any frame that is more than 50% delayed has its stacktrace sampled once, when we first detect it is delayed. +// After the hang ends (or the app restarts in the case of fatal hangs) the most recently recorded stacktrace is used to report the hang. Since a hang is not one +// stacktrace, like a crash, but rather a period of time, it would make sense to talk about a flamegraph sampled during the hang but the API does not support this. +// Note that stacktraces are always sampled from a background thread, since it’s the main thread that gets sampled. +// +// There are two ways we trigger saving hang data. One is on the main thread, when the hang is over. When each frame finishes rendering we collect its delay and +// if the sum of delays during the last HangDuration*2 is greather than the threshold, we report the last stack trace as a hang. The other way is on a background +// thread. Once a frame is delayed more than 50% we sample the thread and start tracking it. If the total delay becomes more than our threshold the event +// is recorded. If the hang never ends (thus transfering us back to the previous main thread case) then the hang is saved on the filesystem and +// sent as a fatal hang the next time the app is launched. +final class HangTracker { + + static let SentryANRMechanismDataAppHangDuration = "app_hang_duration" + + private let dateProvider: SentryCurrentDateProvider + private let threadInspector: ThreadInspector + private let debugImageCache: DebugImageCache + private let fileManager: SentryFileManager + private let crashWrapper: CrashWrapper + + init( + dateProvider: SentryCurrentDateProvider, + threadInspector: ThreadInspector, + debugImageCache: DebugImageCache, + fileManager: SentryFileManager, + crashWrapper: CrashWrapper, + minHangTime: TimeInterval) { + self.dateProvider = dateProvider + self.threadInspector = threadInspector + self.debugImageCache = debugImageCache + self.fileManager = fileManager + self.crashWrapper = crashWrapper + self.lastFrameTime = 0 + self.minHangTime = minHangTime +#if canImport(UIKit) && !SENTRY_NO_UIKIT && !os(visionOS) && !os(watchOS) + var maxFPS = 60.0 + if #available(iOS 13.0, tvOS 13.0, *) { + let window = UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow } + maxFPS = Double(window?.screen.maximumFramesPerSecond ?? 60) + } else { + maxFPS = Double(UIScreen.main.maximumFramesPerSecond) + } +#else + let maxFPS: Double = 60.0 + #endif + expectedFrameDuration = 1.0 / maxFPS + thresholdForFrameStacktrace = expectedFrameDuration * 0.5 + captureStoredAppHang() + } + + // This queue is used to detect main thread hangs, they need to be detected on a background thread + // since the main thread is hanging. + private let queue = DispatchQueue(label: "io.sentry.runloop-observer-checker") + private let minHangTime: TimeInterval + private let expectedFrameDuration: TimeInterval + private let thresholdForFrameStacktrace: TimeInterval + + // MARK: Main queue + + private var observer: CFRunLoopObserver? + private var semaphore = DispatchSemaphore(value: 0) + private var lastFrameTime: TimeInterval + private var running = false + private var frameStatistics = [(startTime: TimeInterval, delayTime: TimeInterval)]() + // Keeps track of how long the current hang has been running for + // Set to nil after the current hang ends + private var maxHangTime: TimeInterval? + + func start() { + let observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.afterWaiting.rawValue | CFRunLoopActivity.beforeSources.rawValue, true, CFIndex(INT_MAX)) { [weak self] _, activity in + guard let self else { return } + + let started = updateFrameStatistics() + switch activity { + case .beforeWaiting: + running = false + case .afterWaiting, .beforeSources: + semaphore = DispatchSemaphore(value: 0) + running = true + let localSemaphore = semaphore + queue.async { [weak self] in + self?.waitForHang(semaphore: localSemaphore, started: started, isStarting: true) + } + default: + fatalError() + } + } + self.observer = observer + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + func stop() { + guard let observer else { return } + + CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + private func updateFrameStatistics() -> Double { + dispatchPrecondition(condition: .onQueue(.main)) + + let currentTime = dateProvider.systemUptime() + // Only consider frames that were within 2x the minHangTime + frameStatistics = frameStatistics.filter { $0.startTime > currentTime - minHangTime * 2 } + + semaphore.signal() + if running { + let frameDuration = currentTime - lastFrameTime + let frameDelay = frameDuration - expectedFrameDuration + // A hang is characterized by the % of a time period that the app is rendering late frames + // We use 50% of `minHangTime * 2` as the threshold for reporting a hang. + // Once this threshold is crossed, any frame that was > 50% late is considered a hanging frame. + // If a single frames delay is > minHangTime, it is considered a "fullyBlocking" hang. + if frameDelay > 0 { + frameStatistics.append((startTime: lastFrameTime, delayTime: frameDelay)) + } + let totalTime = frameStatistics.map({ $0.delayTime }).reduce(0, +) + let type: SentryANRType = frameStatistics.count > 0 ? .nonFullyBlocking : .fullyBlocking + if totalTime > minHangTime { + SentrySDKLog.debug("Detected app hang for \(totalTime) seconds") + let maxTime = max(maxHangTime ?? 0, totalTime) + maxHangTime = maxTime + // Update on disk hang + queue.async { [weak self] in + guard let self, let threads = threads, !threads.isEmpty else { return } + let event = makeEvent(duration: maxTime, threads: threads, type: type, addMechanismData: true) + fileManager.storeAppHang(event) + } + } else { + if let maxHangTime { + // The hang has ended + SentrySDKLog.debug("Reporting app hang with \(maxHangTime) seconds") + // Note: A non fully blocking hang always has multiple stacktraces + // because it is composed of multpile delayed frames. Each delayed frame has a stacktrace. + // We only support sending one stacktrace per event so we take the most recent one. + // Another option would be to generate one event for each delayed frame in the + // non fully blocking hang. Maybe we will eventually support something like + // "scroll hitches" and report each time a frame is dropped rather than an + // overal hang event with just one stacktrace. + queue.async { [weak self] in + guard let self, let threads = threads, !threads.isEmpty else { return } + let event = makeEvent(duration: maxHangTime, threads: threads, type: type, addMechanismData: false) + SentrySDK.capture(event: event) + } + } + maxHangTime = nil + } + } + lastFrameTime = currentTime + return currentTime + } + + func captureStoredAppHang() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self, let event = fileManager.readAppHangEvent() else { return } + + fileManager.deleteAppHangEvent() + if crashWrapper.crashedLastLaunch { + // The app crashed during an ongoing app hang. Capture the stored app hang as it is. + // We already applied the scope. We use an empty scope to avoid overwriting exising + // fields on the event. + SentrySDK.capture(event: event, scope: Scope()) + } else { + // Fatal App Hang + // We can't differ if the watchdog or the user terminated the app, because when the main + // thread is blocked we don't receive the applicationWillTerminate notification. Further + // investigations are required to validate if we somehow can differ between watchdog or + // user terminations; see https://github.com/getsentry/sentry-cocoa/issues/4845. + guard let exceptions = event.exceptions, let exception = exceptions.first, exceptions.count == 1 else { + SentrySDKLog.warning("The stored app hang event is expected to have exactly one exception, so we don't capture it.") + return + } + + SentryLevelBridge.setBreadcrumbLevelOn(event, level: SentryLevel.fatal.rawValue) + event.exceptions?.first?.mechanism?.handled = false + let fatalExceptionType = SentryAppHangTypeMapper.getFatalExceptionType(nonFatalErrorType: exception.type) + event.exceptions?.first?.type = fatalExceptionType + + var mechanismData = exception.mechanism?.data + let durationInfo = mechanismData?[Self.SentryANRMechanismDataAppHangDuration] as? String ?? "over \(minHangTime) seconds" + mechanismData?.removeValue(forKey: Self.SentryANRMechanismDataAppHangDuration) + event.exceptions?.first?.value = "The user or the OS watchdog terminated your app while it blocked the main thread for \(durationInfo)" + event.exceptions?.first?.mechanism?.data = mechanismData + SentryDependencyContainerSwiftHelper.captureFatalAppHang(event) + + } + } + } + + // MARK: Background queue + + private var blockingDuration: TimeInterval? + private var threads: [SentryThread]? + + private func waitForHang(semaphore: DispatchSemaphore, started: TimeInterval, isStarting: Bool) { + dispatchPrecondition(condition: .onQueue(queue)) + + let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int((expectedFrameDuration + thresholdForFrameStacktrace) * 1_000)) + let result = semaphore.wait(timeout: timeout) + switch result { + case .timedOut: + semaphore.signal() + print("[HANG] Timeout, hang detected") + continueHang(started: started, isStarting: isStarting) + waitForHang(semaphore: semaphore, started: started, isStarting: false) + case .success: + break + } + } + + private func continueHang(started: TimeInterval, isStarting: Bool) { + dispatchPrecondition(condition: .onQueue(queue)) + + if isStarting { + // A hang lasts a while, we show the stacktrace when it was first detected + threads = threadInspector.getCurrentThreadsWithStackTrace() + threads?.forEach { $0.current = false } + threads?[0].current = true + } + let duration = dateProvider.systemUptime() - started + blockingDuration = duration + if let threads, !threads.isEmpty, duration > minHangTime { + // Hangs detected in the background are always fully blocking + // Otherwise we'd be detecting them on the main thread. + fileManager.storeAppHang(makeEvent(duration: duration, threads: threads, type: .fullyBlocking, addMechanismData: true)) + } + + } + + // Safe to call from any thread + private func makeEvent(duration: TimeInterval, threads: [SentryThread], type: SentryANRType, addMechanismData: Bool) -> Event { + let event = Event() + SentryLevelBridge.setBreadcrumbLevelOn(event, level: SentryLevel.error.rawValue) + let exceptionType = SentryAppHangTypeMapper.getExceptionType(anrType: type) + let exception = Exception(value: String(format: "App hanging for %.3f seconds.", duration), type: exceptionType) + let mechanism = Mechanism(type: "AppHang") + // We only temporarily store the app hang duration info, so we can change the error message + // when either sending a normal or fatal app hang event. Otherwise, we would have to rely on + // string parsing to retrieve the app hang duration info from the error message. + if addMechanismData { + mechanism.data = [Self.SentryANRMechanismDataAppHangDuration: "\(duration) seconds"] + } + exception.mechanism = mechanism + exception.stacktrace = threads[0].stacktrace + exception.stacktrace?.snapshot = true + exception.stacktrace?.snapshot = true + event.exceptions = [exception] + event.threads = threads + event.debugMeta = debugImageCache.getDebugImagesFromCacheFor(threads: event.threads) + SentryDependencyContainerSwiftHelper.applyScope(to: event) + return event + } +} + +@objc +@_spi(Private) public final class HangTrackerObjcBridge: NSObject { + + private let observer: HangTracker + + @objc public init( + dateProvider: SentryCurrentDateProvider, + threadInspector: ThreadInspector, + debugImageCache: DebugImageCache, + fileManager: SentryFileManager, + crashWrapper: CrashWrapper) { + observer = HangTracker( + dateProvider: dateProvider, + threadInspector: threadInspector, + debugImageCache: debugImageCache, + fileManager: fileManager, + crashWrapper: crashWrapper, + minHangTime: 2) + } + + @objc public func start() { + observer.start() + } + + @objc public func stop() { + observer.stop() + } +} + +@objc @_spi(Private) public protocol ThreadInspector { + func getCurrentThreadsWithStackTrace() -> [SentryThread] +} + +@objc @_spi(Private) public protocol DebugImageCache { + func getDebugImagesFromCacheFor(threads: [SentryThread]?) -> [DebugMeta] +} + +@objc @_spi(Private) public protocol CrashWrapper { + var crashedLastLaunch: Bool { get } +} diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift index 926f5782b6..b56752d365 100644 --- a/Sources/Swift/SentryExperimentalOptions.swift +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -31,6 +31,8 @@ public class SentryExperimentalOptions: NSObject { */ public var enableUnhandledCPPExceptionsV2 = false + public var enableRunLoopObserverAppHangs = false + /** * Logs are considered beta. */