From a56a7b2729ac97e9176ca8cff2c117a5f8f9ce64 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Thu, 10 Jul 2025 14:56:44 -0400 Subject: [PATCH 1/5] New runloop observer --- .../SentrySampleShared/SentrySDKWrapper.swift | 2 +- Sentry.xcodeproj/project.pbxproj | 4 + Sources/Sentry/SentryDependencyContainer.m | 20 +- Sources/Sentry/include/SentryPrivate.h | 1 + Sources/Swift/RunloopObserver.swift | 172 ++++++++++++++++++ 5 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 Sources/Swift/RunloopObserver.swift diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 827eb53132..a42cc01de6 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -43,7 +43,7 @@ public struct SentrySDKWrapper { } options.beforeCaptureScreenshot = { _ in !SentrySDKOverrides.Other.rejectScreenshots.boolValue } options.beforeCaptureViewHierarchy = { _ in !SentrySDKOverrides.Other.rejectViewHierarchy.boolValue } - options.debug = !SentrySDKOverrides.Special.disableDebugMode.boolValue + options.debug = false // !SentrySDKOverrides.Special.disableDebugMode.boolValue #if !os(macOS) && !os(watchOS) && !os(visionOS) if #available(iOS 16.0, *), !SentrySDKOverrides.SessionReplay.disableSessionReplay.boolValue { diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 2d48a568ea..661dc2607e 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 /* RunloopObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE2DABB2E1F55C100262307 /* RunloopObserver.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 /* RunloopObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunloopObserver.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 /* RunloopObserver.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 /* RunloopObserver.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..2a4fabd248 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -85,6 +85,22 @@ @interface SentryDependencyContainer () @property (nonatomic, strong) id anrTracker; +@property (nonatomic, strong) SentryDependencyScope *scope; + +@property (nonatomic, strong) RunLoopObserverObjcBridge *observer; + +@end + +@interface SentryDebugImageProviderWorkaround: NSObject + +@end + +@implementation SentryDebugImageProviderWorkaround + +- (NSArray * _Nonnull)getDebugImagesFromCacheFor:(NSArray * _Nullable)threads { + return [SentryDependencyContainer.sharedInstance.debugImageProvider getDebugImagesFromCacheForThreads:threads]; +} + @end @implementation SentryDependencyContainer @@ -163,7 +179,9 @@ - (instancetype)init _random = [[SentryRandom alloc] init]; _threadWrapper = [[SentryThreadWrapper alloc] init]; _binaryImageCache = [[SentryBinaryImageCache alloc] init]; - _dateProvider = [[SentryDefaultCurrentDateProvider alloc] init]; + _scope = [[SentryDependencyScope alloc] initWithOptions:SentrySDK.options debugImageCache:[[SentryDebugImageProviderWorkaround alloc] init]]; + _observer = [[RunLoopObserverObjcBridge alloc] initWithDependencies:_scope]; + _dateProvider = _scope.dateProvider; _notificationCenterWrapper = [NSNotificationCenter defaultCenter]; #if SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 7952e76b8f..78df6c2cef 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -31,3 +31,4 @@ #import "SentrySpanOperation.h" #import "SentryTraceHeader.h" #import "SentryTraceOrigin.h" +#import "SentryThreadInspector.h" diff --git a/Sources/Swift/RunloopObserver.swift b/Sources/Swift/RunloopObserver.swift new file mode 100644 index 0000000000..8d0460ba50 --- /dev/null +++ b/Sources/Swift/RunloopObserver.swift @@ -0,0 +1,172 @@ +@_implementationOnly import _SentryPrivate + +protocol ThreadInspector { + func getCurrentThreadsWithStackTrace() -> [SentryThread] +} + +@objc @_spi(Private) public protocol DebugImageCache { + func getDebugImagesFromCache(for threads: [SentryThread]?) -> [DebugMeta] +} + +protocol ThreadInspectorProviding { + var threadInspector: ThreadInspector { get } +} + +protocol SentryCurrentDateProviding { + var dateProvider: SentryCurrentDateProvider { get } +} + +protocol DebugImageCacheProviding { + var debugImageCache: DebugImageCache { get } +} + +typealias RunLoopObserverDependencies = SentryCurrentDateProviding & ThreadInspectorProviding & DebugImageCacheProviding + +final class RunloopObserver { + let dependencies: RunLoopObserverDependencies + init(dependencies: RunLoopObserverDependencies, minHangTime: TimeInterval) { + self.dependencies = dependencies + self.lastFrameTime = 0 + self.minHangTime = minHangTime + } + + // This queue is used to detect main thread hangs, they need to be detected on a background thread + // since the main thread is hanging. + let queue = DispatchQueue(label: "io.sentry.runloop-observer-checker") + var semaphore = DispatchSemaphore(value: 0) + let minHangTime: TimeInterval + + // MARK: Main queue + + var lastFrameTime: TimeInterval + var running = false + var frameStatistics = [(startTime: TimeInterval, delayTime: 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 } + + switch activity { + case .beforeWaiting: + updateFrameStatistics() + running = false + case .afterWaiting, .beforeSources: + updateFrameStatistics() + semaphore = DispatchSemaphore(value: 0) + running = true + let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(minHangTime * 1_000)) + let localSemaphore = semaphore + queue.async { [weak self] in + let result = localSemaphore.wait(timeout: timeout) + switch result { + case .timedOut: + print("[HANG] Timeout, hang detected") + self?.hangStarted() + case .success: + break + } + } + // print("[HANG] Woken up") + default: + fatalError() + } + } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + func updateFrameStatistics() { + dispatchPrecondition(condition: .onQueue(.main)) + + let currentTime = dependencies.dateProvider.systemUptime() + defer { + lastFrameTime = currentTime + } + // Only consider frames that were within 2x the minHangTime + frameStatistics = frameStatistics.filter { $0.startTime > currentTime - minHangTime * 2 } + + semaphore.signal() + if running { + let expectedFrameTime = lastFrameTime + 1.0 / 60.0 + let frameDelay = currentTime - expectedFrameTime + if frameDelay > minHangTime { + print("[HANG] Hang detected \(frameDelay)s") + queue.async { [weak self] in + self?.recordHang(duration: frameDelay) + } + frameStatistics.removeAll() + } else if frameDelay > 0 { + frameStatistics.append((startTime: lastFrameTime, delayTime: frameDelay)) + } + let totalTime = frameStatistics.map({ $0.delayTime }).reduce(0, +) + if totalTime > minHangTime * 0.99 { + print("[HANG] Detected non-blocking hang") + // TODO: Keep on recording until blocking period is over (or some max time) + // TODO: Get stacktraces from when the individual blocking events occured + // TODO: Send each event + } + } + } + + // MARK: Background queue + + var threads: [SentryThread]? + + func hangStarted() { + dispatchPrecondition(condition: .onQueue(queue)) + + // TODO: Write to disk to record fatal hangs on app start + + // Record threads at start of hang + threads = dependencies.threadInspector.getCurrentThreadsWithStackTrace() + } + + func recordHang(duration: TimeInterval) { + dispatchPrecondition(condition: .onQueue(queue)) + + guard let threads, !threads.isEmpty else { + return + } + + let event = Event() + SentryLevelBridge.setBreadcrumbLevelOn(event, level: SentryLevel.error.rawValue) + let exceptionType = SentryAppHangTypeMapper.getExceptionType(anrType: .fullyBlocking) + let exception = Exception(value: "App hanging for \(duration) seconds.", type: exceptionType) + let mechanism = Mechanism(type: "AppHang") + exception.mechanism = mechanism + exception.stacktrace = threads[0].stacktrace + exception.stacktrace?.snapshot = true + + threads.forEach { $0.current = false } + threads[0].current = true + + event.exceptions = [exception] + event.threads = threads + + event.debugMeta = dependencies.debugImageCache.getDebugImagesFromCache(for: event.threads) + SentrySDK.capture(event: event) + } +} + +@objc +@_spi(Private) public final class RunLoopObserverObjcBridge: NSObject { + @_spi(Private) @objc public init(dependencies: SentryDependencyScope) { + observer = RunloopObserver(dependencies: dependencies, minHangTime: 2) + observer.start() + } + let observer: RunloopObserver + +} + +@objc +@_spi(Private) public class SentryDependencyScope: NSObject, SentryCurrentDateProviding, DebugImageCacheProviding, ThreadInspectorProviding { + @objc @_spi(Private) public init(options: Options, debugImageCache: DebugImageCache) { + self.threadInspector = SentryThreadInspector(options: options) + self.debugImageCache = debugImageCache + } + + @_spi(Private) @objc public let dateProvider: SentryCurrentDateProvider = SentryDefaultCurrentDateProvider() + let threadInspector: ThreadInspector + let debugImageCache: DebugImageCache +} + +extension SentryThreadInspector: ThreadInspector { } From ce3b754c75e2e56668ac7aa91cc697ef773e6d87 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Fri, 11 Jul 2025 16:54:50 -0400 Subject: [PATCH 2/5] Updates --- Sources/Sentry/SentryDependencyContainer.m | 24 ++- Sources/Sentry/SentrySDK.m | 1 + .../HybridPublic/SentryDependencyContainer.h | 3 + Sources/Sentry/include/SentryPrivate.h | 2 +- Sources/Swift/RunloopObserver.swift | 176 ++++++++++-------- 5 files changed, 116 insertions(+), 90 deletions(-) diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index 2a4fabd248..534b151759 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -85,21 +85,13 @@ @interface SentryDependencyContainer () @property (nonatomic, strong) id anrTracker; -@property (nonatomic, strong) SentryDependencyScope *scope; - -@property (nonatomic, strong) RunLoopObserverObjcBridge *observer; - @end -@interface SentryDebugImageProviderWorkaround: NSObject +@interface SentryDebugImageProvider () @end -@implementation SentryDebugImageProviderWorkaround - -- (NSArray * _Nonnull)getDebugImagesFromCacheFor:(NSArray * _Nullable)threads { - return [SentryDependencyContainer.sharedInstance.debugImageProvider getDebugImagesFromCacheForThreads:threads]; -} +@interface SentryThreadInspector () @end @@ -179,9 +171,7 @@ - (instancetype)init _random = [[SentryRandom alloc] init]; _threadWrapper = [[SentryThreadWrapper alloc] init]; _binaryImageCache = [[SentryBinaryImageCache alloc] init]; - _scope = [[SentryDependencyScope alloc] initWithOptions:SentrySDK.options debugImageCache:[[SentryDebugImageProviderWorkaround alloc] init]]; - _observer = [[RunLoopObserverObjcBridge alloc] initWithDependencies:_scope]; - _dateProvider = _scope.dateProvider; + _dateProvider = [[SentryDefaultCurrentDateProvider alloc] init]; _notificationCenterWrapper = [NSNotificationCenter defaultCenter]; #if SENTRY_HAS_UIKIT @@ -268,6 +258,14 @@ - (SentryCrash *)crashReporter SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK [[SentryCrash alloc] initWithBasePath:SentrySDK.options.cacheDirectoryPath]); } +- (RunLoopObserverObjcBridge *)observer SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK +{ + SENTRY_LAZY_INIT(_observer, + [[RunLoopObserverObjcBridge alloc] initWithDateProvider:self.dateProvider + threadInspector:self.threadInspector + debugImageCache:self.debugImageProvider]); +} + - (id)getANRTracker:(NSTimeInterval)timeout SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK { diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index c1b8c6dc2a..87d15077ad 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -240,6 +240,7 @@ + (void)startWithOptions:(SentryOptions *)options NSLog(@"[SENTRY] [WARNING] SentrySDK not started. Running from Xcode preview."); return; } + [SentryDependencyContainer.sharedInstance.observer start]; [SentrySDKLogSupport configure:options.debug diagnosticLevel:options.diagnosticLevel]; diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index 0bc6ab2a74..de85f2c483 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -46,6 +46,7 @@ @class SentryUIViewControllerPerformanceTracker; @class SentryWatchdogTerminationScopeObserver; @class SentryWatchdogTerminationAttributesProcessor; +@class RunLoopObserverObjcBridge; @class SentryWatchdogTerminationBreadcrumbProcessor; #endif // SENTRY_UIKIT_AVAILABLE @@ -122,6 +123,8 @@ SENTRY_NO_INIT @property (nonatomic, strong) id dispatchQueueProvider; @property (nonatomic, strong) SentryNSTimerFactory *timerFactory; +@property (nonatomic, strong) RunLoopObserverObjcBridge *observer; + @property (nonatomic, strong) SentrySwizzleWrapper *swizzleWrapper; #if SENTRY_UIKIT_AVAILABLE @property (nonatomic, strong) SentryFramesTracker *framesTracker; diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 78df6c2cef..98499f9397 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -29,6 +29,6 @@ #import "SentrySession.h" #import "SentrySpanDataKey.h" #import "SentrySpanOperation.h" +#import "SentryThreadInspector.h" #import "SentryTraceHeader.h" #import "SentryTraceOrigin.h" -#import "SentryThreadInspector.h" diff --git a/Sources/Swift/RunloopObserver.swift b/Sources/Swift/RunloopObserver.swift index 8d0460ba50..ecd8c347a3 100644 --- a/Sources/Swift/RunloopObserver.swift +++ b/Sources/Swift/RunloopObserver.swift @@ -1,46 +1,55 @@ @_implementationOnly import _SentryPrivate - -protocol ThreadInspector { - func getCurrentThreadsWithStackTrace() -> [SentryThread] -} - -@objc @_spi(Private) public protocol DebugImageCache { - func getDebugImagesFromCache(for threads: [SentryThread]?) -> [DebugMeta] -} - -protocol ThreadInspectorProviding { - var threadInspector: ThreadInspector { get } -} - -protocol SentryCurrentDateProviding { - var dateProvider: SentryCurrentDateProvider { get } -} - -protocol DebugImageCacheProviding { - var debugImageCache: DebugImageCache { get } -} - -typealias RunLoopObserverDependencies = SentryCurrentDateProviding & ThreadInspectorProviding & DebugImageCacheProviding - -final class RunloopObserver { - let dependencies: RunLoopObserverDependencies - init(dependencies: RunLoopObserverDependencies, minHangTime: TimeInterval) { - self.dependencies = dependencies +#if canImport(UIKit) && !SENTRY_NO_UIKIT +import UIKit +#endif + +final class RunLoopObserver { + + private let dateProvider: SentryCurrentDateProvider + private let threadInspector: ThreadInspector + private let debugImageCache: DebugImageCache + + init( + dateProvider: SentryCurrentDateProvider, + threadInspector: ThreadInspector, + debugImageCache: DebugImageCache, + minHangTime: TimeInterval) { + self.dateProvider = dateProvider + self.threadInspector = threadInspector + self.debugImageCache = debugImageCache self.lastFrameTime = 0 self.minHangTime = minHangTime +#if canImport(UIKit) && !SENTRY_NO_UIKIT + var maxFPS = 60.0 + if #available(iOS 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 } // This queue is used to detect main thread hangs, they need to be detected on a background thread // since the main thread is hanging. - let queue = DispatchQueue(label: "io.sentry.runloop-observer-checker") - var semaphore = DispatchSemaphore(value: 0) - let minHangTime: TimeInterval + 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 - var lastFrameTime: TimeInterval - var running = false - var frameStatistics = [(startTime: TimeInterval, delayTime: TimeInterval)]() + 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 @@ -54,7 +63,7 @@ final class RunloopObserver { updateFrameStatistics() semaphore = DispatchSemaphore(value: 0) running = true - let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(minHangTime * 1_000)) + let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int((expectedFrameDuration + thresholdForFrameStacktrace) * 1_000)) let localSemaphore = semaphore queue.async { [weak self] in let result = localSemaphore.wait(timeout: timeout) @@ -66,7 +75,6 @@ final class RunloopObserver { break } } - // print("[HANG] Woken up") default: fatalError() } @@ -74,10 +82,10 @@ final class RunloopObserver { CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) } - func updateFrameStatistics() { + private func updateFrameStatistics() { dispatchPrecondition(condition: .onQueue(.main)) - let currentTime = dependencies.dateProvider.systemUptime() + let currentTime = dateProvider.systemUptime() defer { lastFrameTime = currentTime } @@ -86,41 +94,54 @@ final class RunloopObserver { semaphore.signal() if running { - let expectedFrameTime = lastFrameTime + 1.0 / 60.0 - let frameDelay = currentTime - expectedFrameTime - if frameDelay > minHangTime { - print("[HANG] Hang detected \(frameDelay)s") - queue.async { [weak self] in - self?.recordHang(duration: frameDelay) - } - frameStatistics.removeAll() - } else if frameDelay > 0 { + 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, +) - if totalTime > minHangTime * 0.99 { - print("[HANG] Detected non-blocking hang") - // TODO: Keep on recording until blocking period is over (or some max time) - // TODO: Get stacktraces from when the individual blocking events occured - // TODO: Send each event + if totalTime > minHangTime { + print("[HANG] Hang detected \(totalTime)") + maxHangTime = max(maxHangTime ?? 0, totalTime) + // print("[HANG] Hang max \(maxHangTime ?? 0)") + } else { + if let maxHangTime { + // The hang has ended + print("[HANG] Hang reporting \(maxHangTime)") + // 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. + let type: SentryANRType = frameStatistics.count > 0 ? .nonFullyBlocking : .fullyBlocking + queue.async { [weak self] in + self?.recordHang(duration: maxHangTime, type: type) + } + } + maxHangTime = nil } } } // MARK: Background queue - var threads: [SentryThread]? + private var threads: [SentryThread]? - func hangStarted() { + private func hangStarted() { dispatchPrecondition(condition: .onQueue(queue)) - // TODO: Write to disk to record fatal hangs on app start - - // Record threads at start of hang - threads = dependencies.threadInspector.getCurrentThreadsWithStackTrace() + // TOD: Write to disk to record fatal hangs on app start + // Record threads when the hang is first detected + threads = threadInspector.getCurrentThreadsWithStackTrace() } - func recordHang(duration: TimeInterval) { + private func recordHang(duration: TimeInterval, type: SentryANRType) { dispatchPrecondition(condition: .onQueue(queue)) guard let threads, !threads.isEmpty else { @@ -129,8 +150,8 @@ final class RunloopObserver { let event = Event() SentryLevelBridge.setBreadcrumbLevelOn(event, level: SentryLevel.error.rawValue) - let exceptionType = SentryAppHangTypeMapper.getExceptionType(anrType: .fullyBlocking) - let exception = Exception(value: "App hanging for \(duration) seconds.", type: exceptionType) + 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") exception.mechanism = mechanism exception.stacktrace = threads[0].stacktrace @@ -142,31 +163,34 @@ final class RunloopObserver { event.exceptions = [exception] event.threads = threads - event.debugMeta = dependencies.debugImageCache.getDebugImagesFromCache(for: event.threads) + event.debugMeta = debugImageCache.getDebugImagesFromCacheFor(threads: event.threads) SentrySDK.capture(event: event) } } @objc @_spi(Private) public final class RunLoopObserverObjcBridge: NSObject { - @_spi(Private) @objc public init(dependencies: SentryDependencyScope) { - observer = RunloopObserver(dependencies: dependencies, minHangTime: 2) - observer.start() + + private let observer: RunLoopObserver + + @objc public init( + dateProvider: SentryCurrentDateProvider, + threadInspector: ThreadInspector, + debugImageCache: DebugImageCache) { + observer = RunLoopObserver(dateProvider: dateProvider, + threadInspector: threadInspector, + debugImageCache: debugImageCache, minHangTime: 2) } - let observer: RunloopObserver -} - -@objc -@_spi(Private) public class SentryDependencyScope: NSObject, SentryCurrentDateProviding, DebugImageCacheProviding, ThreadInspectorProviding { - @objc @_spi(Private) public init(options: Options, debugImageCache: DebugImageCache) { - self.threadInspector = SentryThreadInspector(options: options) - self.debugImageCache = debugImageCache + @objc public func start() { + observer.start() } +} - @_spi(Private) @objc public let dateProvider: SentryCurrentDateProvider = SentryDefaultCurrentDateProvider() - let threadInspector: ThreadInspector - let debugImageCache: DebugImageCache +@objc @_spi(Private) public protocol ThreadInspector { + func getCurrentThreadsWithStackTrace() -> [SentryThread] } -extension SentryThreadInspector: ThreadInspector { } +@objc @_spi(Private) public protocol DebugImageCache { + func getDebugImagesFromCacheFor(threads: [SentryThread]?) -> [DebugMeta] +} From 2acc941bfe4cc0665676138d419925a213f5d8c9 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Mon, 14 Jul 2025 11:58:30 -0400 Subject: [PATCH 3/5] Updates --- Sources/Sentry/SentryDependencyContainer.m | 3 +- .../SentryDependencyContainerSwiftHelper.m | 12 ++ .../SentryDependencyContainerSwiftHelper.h | 5 + Sources/Swift/RunloopObserver.swift | 119 ++++++++++++------ 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index 534b151759..a18cc5b94c 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -263,7 +263,8 @@ - (RunLoopObserverObjcBridge *)observer SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_L SENTRY_LAZY_INIT(_observer, [[RunLoopObserverObjcBridge alloc] initWithDateProvider:self.dateProvider threadInspector:self.threadInspector - debugImageCache:self.debugImageProvider]); + debugImageCache:self.debugImageProvider + fileManager:self.fileManager]); } - (id)getANRTracker:(NSTimeInterval)timeout diff --git a/Sources/Sentry/SentryDependencyContainerSwiftHelper.m b/Sources/Sentry/SentryDependencyContainerSwiftHelper.m index 8ee4bad1dd..80970b6c3a 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,13 @@ + (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]; + } +} + @end diff --git a/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h b/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h index 4f41b45d6b..e3ed8bff1e 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,8 @@ NS_ASSUME_NONNULL_BEGIN + (void)dispatchSyncOnMainQueue:(void (^)(void))block; ++ (void)applyScopeTo:(SentryEvent *)event; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/RunloopObserver.swift b/Sources/Swift/RunloopObserver.swift index ecd8c347a3..51373bd75d 100644 --- a/Sources/Swift/RunloopObserver.swift +++ b/Sources/Swift/RunloopObserver.swift @@ -8,15 +8,18 @@ final class RunLoopObserver { private let dateProvider: SentryCurrentDateProvider private let threadInspector: ThreadInspector private let debugImageCache: DebugImageCache + private let fileManager: SentryFileManager init( dateProvider: SentryCurrentDateProvider, threadInspector: ThreadInspector, debugImageCache: DebugImageCache, + fileManager: SentryFileManager, minHangTime: TimeInterval) { self.dateProvider = dateProvider self.threadInspector = threadInspector self.debugImageCache = debugImageCache + self.fileManager = fileManager self.lastFrameTime = 0 self.minHangTime = minHangTime #if canImport(UIKit) && !SENTRY_NO_UIKIT @@ -32,6 +35,7 @@ final class RunLoopObserver { #endif expectedFrameDuration = 1.0 / maxFPS thresholdForFrameStacktrace = expectedFrameDuration * 0.5 + // TODO: Check for stored app hang } // This queue is used to detect main thread hangs, they need to be detected on a background thread @@ -55,25 +59,16 @@ final class RunLoopObserver { 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: - updateFrameStatistics() running = false case .afterWaiting, .beforeSources: - updateFrameStatistics() semaphore = DispatchSemaphore(value: 0) running = true - let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int((expectedFrameDuration + thresholdForFrameStacktrace) * 1_000)) let localSemaphore = semaphore queue.async { [weak self] in - let result = localSemaphore.wait(timeout: timeout) - switch result { - case .timedOut: - print("[HANG] Timeout, hang detected") - self?.hangStarted() - case .success: - break - } + self?.waitForHang(semaphore: localSemaphore, started: started, isStarting: true) } default: fatalError() @@ -82,13 +77,10 @@ final class RunLoopObserver { CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) } - private func updateFrameStatistics() { + private func updateFrameStatistics() -> Double { dispatchPrecondition(condition: .onQueue(.main)) let currentTime = dateProvider.systemUptime() - defer { - lastFrameTime = currentTime - } // Only consider frames that were within 2x the minHangTime frameStatistics = frameStatistics.filter { $0.startTime > currentTime - minHangTime * 2 } @@ -104,10 +96,17 @@ final class RunLoopObserver { 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 { print("[HANG] Hang detected \(totalTime)") - maxHangTime = max(maxHangTime ?? 0, totalTime) - // print("[HANG] Hang max \(maxHangTime ?? 0)") + 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) + fileManager.storeAppHang(event) + } } else { if let maxHangTime { // The hang has ended @@ -119,36 +118,75 @@ final class RunLoopObserver { // 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. - let type: SentryANRType = frameStatistics.count > 0 ? .nonFullyBlocking : .fullyBlocking queue.async { [weak self] in - self?.recordHang(duration: maxHangTime, type: type) + guard let self, let threads = threads, !threads.isEmpty else { return } + let event = makeEvent(duration: maxHangTime, threads: threads, type: type) + SentrySDK.capture(event: event) } } maxHangTime = nil } } + lastFrameTime = currentTime + return currentTime } // MARK: Background queue - + + private var blockingDuration: TimeInterval? private var threads: [SentryThread]? - private func hangStarted() { + private func waitForHang(semaphore: DispatchSemaphore, started: TimeInterval, isStarting: Bool) { dispatchPrecondition(condition: .onQueue(queue)) - - // TOD: Write to disk to record fatal hangs on app start - // Record threads when the hang is first detected - threads = threadInspector.getCurrentThreadsWithStackTrace() + + 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 recordHang(duration: TimeInterval, type: SentryANRType) { + // TODO: Only write hang if it's long enough + // TODO: Need to clear hang details after the hang ends + // Problem: If we are detecting a multiple runloop hang, which then turns into a single long hang + // we might want to add the total time of that long hang to what is on disk from the multiple runloop hang + // Or we could not do that and just say we only overwrite what is on disk if the hang exceeds the time + // of the multiple runloop hang. + // Could have two paths, fullyBlocking only used when the semaphore times out, we keep tracking in memory until + // it exceeds the threshold then we write to disk. + // Non fully blocking only writes when the runloop finishes if it exceeds the threshold. + // Sampled stacktrace should be kept separate from time, because time for nonFullyBlocking is kep on main thread + // time for fullyBlocking is kept on background thread + + // TODO: Not using should sample + private func continueHang(started: TimeInterval, isStarting: Bool) { dispatchPrecondition(condition: .onQueue(queue)) - - guard let threads, !threads.isEmpty else { - return + + if isStarting { + // A hang lasts a while, but we only support showing 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)) } - let event = Event() + } + + // Safe to call from any thread + private func makeEvent(duration: TimeInterval, threads: [SentryThread], type: SentryANRType) -> Event { + var 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) @@ -156,15 +194,12 @@ final class RunLoopObserver { exception.mechanism = mechanism exception.stacktrace = threads[0].stacktrace exception.stacktrace?.snapshot = true - - threads.forEach { $0.current = false } - threads[0].current = true - + exception.stacktrace?.snapshot = true event.exceptions = [exception] event.threads = threads - event.debugMeta = debugImageCache.getDebugImagesFromCacheFor(threads: event.threads) - SentrySDK.capture(event: event) + SentryDependencyContainerSwiftHelper.applyScope(to: event) + return event } } @@ -176,10 +211,14 @@ final class RunLoopObserver { @objc public init( dateProvider: SentryCurrentDateProvider, threadInspector: ThreadInspector, - debugImageCache: DebugImageCache) { - observer = RunLoopObserver(dateProvider: dateProvider, - threadInspector: threadInspector, - debugImageCache: debugImageCache, minHangTime: 2) + debugImageCache: DebugImageCache, + fileManager: SentryFileManager) { + observer = RunLoopObserver( + dateProvider: dateProvider, + threadInspector: threadInspector, + debugImageCache: debugImageCache, + fileManager: fileManager, + minHangTime: 2) } @objc public func start() { From 7f1cf81c2db036768ea820c66954aeb1cf2099c4 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Mon, 14 Jul 2025 13:16:23 -0400 Subject: [PATCH 4/5] Updates --- Sources/Sentry/SentryDependencyContainer.m | 7 +- .../SentryDependencyContainerSwiftHelper.m | 5 ++ .../SentryDependencyContainerSwiftHelper.h | 2 + Sources/Swift/RunloopObserver.swift | 73 ++++++++++++++----- 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index a18cc5b94c..98fc5e4113 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -95,6 +95,10 @@ @interface SentryThreadInspector () @end +@interface SentryCrashWrapper () + +@end + @implementation SentryDependencyContainer static SentryDependencyContainer *instance; @@ -264,7 +268,8 @@ - (RunLoopObserverObjcBridge *)observer SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_L [[RunLoopObserverObjcBridge alloc] initWithDateProvider:self.dateProvider threadInspector:self.threadInspector debugImageCache:self.debugImageProvider - fileManager:self.fileManager]); + fileManager:self.fileManager + crashWrapper:self.crashWrapper]); } - (id)getANRTracker:(NSTimeInterval)timeout diff --git a/Sources/Sentry/SentryDependencyContainerSwiftHelper.m b/Sources/Sentry/SentryDependencyContainerSwiftHelper.m index 80970b6c3a..0d432cdc15 100644 --- a/Sources/Sentry/SentryDependencyContainerSwiftHelper.m +++ b/Sources/Sentry/SentryDependencyContainerSwiftHelper.m @@ -31,4 +31,9 @@ + (void)applyScopeTo:(SentryEvent *)event } } ++ (void)captureFatalAppHangEvent:(SentryEvent *)event +{ + [SentrySDK captureFatalAppHangEvent:event]; +} + @end diff --git a/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h b/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h index e3ed8bff1e..46fe80903b 100644 --- a/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h +++ b/Sources/Sentry/include/SentryDependencyContainerSwiftHelper.h @@ -26,6 +26,8 @@ NS_ASSUME_NONNULL_BEGIN + (void)applyScopeTo:(SentryEvent *)event; ++ (void)captureFatalAppHangEvent:(SentryEvent *)event; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/RunloopObserver.swift b/Sources/Swift/RunloopObserver.swift index 51373bd75d..4ae69f48e2 100644 --- a/Sources/Swift/RunloopObserver.swift +++ b/Sources/Swift/RunloopObserver.swift @@ -4,22 +4,27 @@ import UIKit #endif final class RunLoopObserver { + + 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 @@ -35,7 +40,7 @@ final class RunLoopObserver { #endif expectedFrameDuration = 1.0 / maxFPS thresholdForFrameStacktrace = expectedFrameDuration * 0.5 - // TODO: Check for stored app hang + captureStoredAppHang() } // This queue is used to detect main thread hangs, they need to be detected on a background thread @@ -131,6 +136,43 @@ final class RunLoopObserver { 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? @@ -151,20 +193,7 @@ final class RunLoopObserver { break } } - - // TODO: Only write hang if it's long enough - // TODO: Need to clear hang details after the hang ends - // Problem: If we are detecting a multiple runloop hang, which then turns into a single long hang - // we might want to add the total time of that long hang to what is on disk from the multiple runloop hang - // Or we could not do that and just say we only overwrite what is on disk if the hang exceeds the time - // of the multiple runloop hang. - // Could have two paths, fullyBlocking only used when the semaphore times out, we keep tracking in memory until - // it exceeds the threshold then we write to disk. - // Non fully blocking only writes when the runloop finishes if it exceeds the threshold. - // Sampled stacktrace should be kept separate from time, because time for nonFullyBlocking is kep on main thread - // time for fullyBlocking is kept on background thread - - // TODO: Not using should sample + private func continueHang(started: TimeInterval, isStarting: Bool) { dispatchPrecondition(condition: .onQueue(queue)) @@ -186,11 +215,15 @@ final class RunLoopObserver { // Safe to call from any thread private func makeEvent(duration: TimeInterval, threads: [SentryThread], type: SentryANRType) -> Event { - var event = 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. + mechanism.data = [Self.SentryANRMechanismDataAppHangDuration: "\(duration) seconds"] exception.mechanism = mechanism exception.stacktrace = threads[0].stacktrace exception.stacktrace?.snapshot = true @@ -212,12 +245,14 @@ final class RunLoopObserver { dateProvider: SentryCurrentDateProvider, threadInspector: ThreadInspector, debugImageCache: DebugImageCache, - fileManager: SentryFileManager) { + fileManager: SentryFileManager, + crashWrapper: CrashWrapper) { observer = RunLoopObserver( dateProvider: dateProvider, threadInspector: threadInspector, debugImageCache: debugImageCache, fileManager: fileManager, + crashWrapper: crashWrapper, minHangTime: 2) } @@ -233,3 +268,7 @@ final class RunLoopObserver { @objc @_spi(Private) public protocol DebugImageCache { func getDebugImagesFromCacheFor(threads: [SentryThread]?) -> [DebugMeta] } + +@objc @_spi(Private) public protocol CrashWrapper { + var crashedLastLaunch: Bool { get } +} From 0590db97b4c199f2cf585ddd2320544d5773441f Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Mon, 14 Jul 2025 16:06:48 -0400 Subject: [PATCH 5/5] Add options --- .../SentrySampleShared/SentrySDKWrapper.swift | 2 +- Sentry.xcodeproj/project.pbxproj | 8 +-- Sources/Sentry/SentryDependencyContainer.m | 14 ++--- Sources/Sentry/SentrySDK.m | 29 +++++++--- .../HybridPublic/SentryDependencyContainer.h | 4 +- Sources/Sentry/include/SentryPrivate.h | 1 - ...unloopObserver.swift => HangTracker.swift} | 58 ++++++++++++++----- Sources/Swift/SentryExperimentalOptions.swift | 2 + 8 files changed, 79 insertions(+), 39 deletions(-) rename Sources/Swift/{RunloopObserver.swift => HangTracker.swift} (80%) diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index a42cc01de6..827eb53132 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -43,7 +43,7 @@ public struct SentrySDKWrapper { } options.beforeCaptureScreenshot = { _ in !SentrySDKOverrides.Other.rejectScreenshots.boolValue } options.beforeCaptureViewHierarchy = { _ in !SentrySDKOverrides.Other.rejectViewHierarchy.boolValue } - options.debug = false // !SentrySDKOverrides.Special.disableDebugMode.boolValue + options.debug = !SentrySDKOverrides.Special.disableDebugMode.boolValue #if !os(macOS) && !os(watchOS) && !os(visionOS) if #available(iOS 16.0, *), !SentrySDKOverrides.SessionReplay.disableSessionReplay.boolValue { diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 661dc2607e..54604139da 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1098,7 +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 /* RunloopObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE2DABB2E1F55C100262307 /* RunloopObserver.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 */ @@ -2377,7 +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 /* RunloopObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunloopObserver.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 = ""; }; @@ -4184,7 +4184,7 @@ D800942328F82E8D005D3943 /* Swift */ = { isa = PBXGroup; children = ( - FAE2DABB2E1F55C100262307 /* RunloopObserver.swift */, + FAE2DABB2E1F55C100262307 /* HangTracker.swift */, FA67DCF32DDBD4EA00896B02 /* Core */, D8CAC02D2BA0663E00E38F34 /* Integrations */, 621D9F2D2B9B030E003D94DE /* Helper */, @@ -5569,7 +5569,7 @@ 63FE70FD20DA4C1000CDBAE8 /* SentryCrashCachedData.c in Sources */, A8F17B2E2901765900990B25 /* SentryRequest.m in Sources */, 7BE1E33424F7E3CB009D3AD0 /* SentryMigrateSessionInit.m in Sources */, - FAE2DABC2E1F55C500262307 /* RunloopObserver.swift 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 98fc5e4113..60b7d9d654 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -262,14 +262,14 @@ - (SentryCrash *)crashReporter SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK [[SentryCrash alloc] initWithBasePath:SentrySDK.options.cacheDirectoryPath]); } -- (RunLoopObserverObjcBridge *)observer SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK +- (HangTrackerObjcBridge *)hangTracker SENTRY_THREAD_SANITIZER_DOUBLE_CHECKED_LOCK { - SENTRY_LAZY_INIT(_observer, - [[RunLoopObserverObjcBridge alloc] initWithDateProvider:self.dateProvider - threadInspector:self.threadInspector - debugImageCache:self.debugImageProvider - fileManager:self.fileManager - crashWrapper:self.crashWrapper]); + 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 diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index 87d15077ad..b0140ff62f 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -240,7 +240,6 @@ + (void)startWithOptions:(SentryOptions *)options NSLog(@"[SENTRY] [WARNING] SentrySDK not started. Running from Xcode preview."); return; } - [SentryDependencyContainer.sharedInstance.observer start]; [SentrySDKLogSupport configure:options.debug diagnosticLevel:options.diagnosticLevel]; @@ -288,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 @@ -603,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 de85f2c483..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; @@ -46,7 +47,6 @@ @class SentryUIViewControllerPerformanceTracker; @class SentryWatchdogTerminationScopeObserver; @class SentryWatchdogTerminationAttributesProcessor; -@class RunLoopObserverObjcBridge; @class SentryWatchdogTerminationBreadcrumbProcessor; #endif // SENTRY_UIKIT_AVAILABLE @@ -123,7 +123,7 @@ SENTRY_NO_INIT @property (nonatomic, strong) id dispatchQueueProvider; @property (nonatomic, strong) SentryNSTimerFactory *timerFactory; -@property (nonatomic, strong) RunLoopObserverObjcBridge *observer; +@property (nonatomic, strong) HangTrackerObjcBridge *hangTracker; @property (nonatomic, strong) SentrySwizzleWrapper *swizzleWrapper; #if SENTRY_UIKIT_AVAILABLE diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index 98499f9397..7952e76b8f 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -29,6 +29,5 @@ #import "SentrySession.h" #import "SentrySpanDataKey.h" #import "SentrySpanOperation.h" -#import "SentryThreadInspector.h" #import "SentryTraceHeader.h" #import "SentryTraceOrigin.h" diff --git a/Sources/Swift/RunloopObserver.swift b/Sources/Swift/HangTracker.swift similarity index 80% rename from Sources/Swift/RunloopObserver.swift rename to Sources/Swift/HangTracker.swift index 4ae69f48e2..e9902cda46 100644 --- a/Sources/Swift/RunloopObserver.swift +++ b/Sources/Swift/HangTracker.swift @@ -3,7 +3,21 @@ import UIKit #endif -final class RunLoopObserver { +// 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" @@ -27,9 +41,9 @@ final class RunLoopObserver { self.crashWrapper = crashWrapper self.lastFrameTime = 0 self.minHangTime = minHangTime -#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if canImport(UIKit) && !SENTRY_NO_UIKIT && !os(visionOS) && !os(watchOS) var maxFPS = 60.0 - if #available(iOS 13.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 { @@ -40,7 +54,7 @@ final class RunLoopObserver { #endif expectedFrameDuration = 1.0 / maxFPS thresholdForFrameStacktrace = expectedFrameDuration * 0.5 - captureStoredAppHang() + captureStoredAppHang() } // This queue is used to detect main thread hangs, they need to be detected on a background thread @@ -52,6 +66,7 @@ final class RunLoopObserver { // MARK: Main queue + private var observer: CFRunLoopObserver? private var semaphore = DispatchSemaphore(value: 0) private var lastFrameTime: TimeInterval private var running = false @@ -79,9 +94,16 @@ final class RunLoopObserver { 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)) @@ -103,19 +125,19 @@ final class RunLoopObserver { let totalTime = frameStatistics.map({ $0.delayTime }).reduce(0, +) let type: SentryANRType = frameStatistics.count > 0 ? .nonFullyBlocking : .fullyBlocking if totalTime > minHangTime { - print("[HANG] Hang detected \(totalTime)") + 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) + let event = makeEvent(duration: maxTime, threads: threads, type: type, addMechanismData: true) fileManager.storeAppHang(event) } } else { if let maxHangTime { // The hang has ended - print("[HANG] Hang reporting \(maxHangTime)") + 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. @@ -125,7 +147,7 @@ final class RunLoopObserver { // 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) + let event = makeEvent(duration: maxHangTime, threads: threads, type: type, addMechanismData: false) SentrySDK.capture(event: event) } } @@ -198,7 +220,7 @@ final class RunLoopObserver { dispatchPrecondition(condition: .onQueue(queue)) if isStarting { - // A hang lasts a while, but we only support showing the stacktrace when it was first detected + // 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 @@ -208,13 +230,13 @@ final class RunLoopObserver { 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)) + 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) -> Event { + 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) @@ -223,7 +245,9 @@ final class RunLoopObserver { // 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. - mechanism.data = [Self.SentryANRMechanismDataAppHangDuration: "\(duration) seconds"] + if addMechanismData { + mechanism.data = [Self.SentryANRMechanismDataAppHangDuration: "\(duration) seconds"] + } exception.mechanism = mechanism exception.stacktrace = threads[0].stacktrace exception.stacktrace?.snapshot = true @@ -237,9 +261,9 @@ final class RunLoopObserver { } @objc -@_spi(Private) public final class RunLoopObserverObjcBridge: NSObject { +@_spi(Private) public final class HangTrackerObjcBridge: NSObject { - private let observer: RunLoopObserver + private let observer: HangTracker @objc public init( dateProvider: SentryCurrentDateProvider, @@ -247,7 +271,7 @@ final class RunLoopObserver { debugImageCache: DebugImageCache, fileManager: SentryFileManager, crashWrapper: CrashWrapper) { - observer = RunLoopObserver( + observer = HangTracker( dateProvider: dateProvider, threadInspector: threadInspector, debugImageCache: debugImageCache, @@ -259,6 +283,10 @@ final class RunLoopObserver { @objc public func start() { observer.start() } + + @objc public func stop() { + observer.stop() + } } @objc @_spi(Private) public protocol ThreadInspector { 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. */