From e0abb7e49c3133b01a2ad0307415661289c2d99e Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 12 Aug 2024 15:30:24 +0200 Subject: [PATCH] chore: Duplicate ANRTracker classes (#4262) This is the first step for #3492, which is part of the EPIC AppHang improvements #4261. --- Sentry.xcodeproj/project.pbxproj | 24 ++ Sources/Sentry/SentryANRTrackerV2.m | 210 +++++++++++++++ .../Sentry/SentryANRTrackingIntegrationV2.m | 130 ++++++++++ Sources/Sentry/SentryBaseIntegration.m | 12 + Sources/Sentry/SentryDependencyContainer.m | 19 ++ Sources/Sentry/SentryOptions.m | 4 + .../HybridPublic/SentryDependencyContainer.h | 3 + Sources/Sentry/include/SentryANRTrackerV2.h | 34 +++ .../include/SentryANRTrackingIntegrationV2.h | 18 ++ .../Sentry/include/SentryBaseIntegration.h | 1 + .../Sentry/include/SentryOptions+Private.h | 2 + .../Helper/SentryTestThreadWrapper.swift | 2 + .../ANR/SentryANRTrackerV2Tests.swift | 239 ++++++++++++++++++ .../SentryANRTrackingIntegrationV2Tests.swift | 229 +++++++++++++++++ Tests/SentryTests/SentryOptionsTest.m | 5 + .../SentryTests/SentryTests-Bridging-Header.h | 2 + 16 files changed, 934 insertions(+) create mode 100644 Sources/Sentry/SentryANRTrackerV2.m create mode 100644 Sources/Sentry/SentryANRTrackingIntegrationV2.m create mode 100644 Sources/Sentry/include/SentryANRTrackerV2.h create mode 100644 Sources/Sentry/include/SentryANRTrackingIntegrationV2.h create mode 100644 Tests/SentryTests/Integrations/ANR/SentryANRTrackerV2Tests.swift create mode 100644 Tests/SentryTests/Integrations/ANR/SentryANRTrackingIntegrationV2Tests.swift diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 8fca179e12b..d19c857a7a9 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -76,6 +76,11 @@ 620203B22C59025E0008317C /* SentryFileContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620203B12C59025E0008317C /* SentryFileContents.swift */; }; 620379DB2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h in Headers */ = {isa = PBXBuildFile; fileRef = 620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */; }; 620379DD2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m in Sources */ = {isa = PBXBuildFile; fileRef = 620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */; }; + 621A9D5A2C64F18900B5D7D6 /* SentryANRTrackingIntegrationV2.h in Headers */ = {isa = PBXBuildFile; fileRef = 621A9D592C64F18900B5D7D6 /* SentryANRTrackingIntegrationV2.h */; }; + 621A9D5C2C64F1AE00B5D7D6 /* SentryANRTrackingIntegrationV2.m in Sources */ = {isa = PBXBuildFile; fileRef = 621A9D5B2C64F1AE00B5D7D6 /* SentryANRTrackingIntegrationV2.m */; }; + 621A9D5F2C64F3BC00B5D7D6 /* SentryANRTrackingIntegrationV2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621A9D5D2C64F3B700B5D7D6 /* SentryANRTrackingIntegrationV2Tests.swift */; }; + 621AE74B2C626C230012E730 /* SentryANRTrackerV2.h in Headers */ = {isa = PBXBuildFile; fileRef = 621AE74A2C626C230012E730 /* SentryANRTrackerV2.h */; }; + 621AE74D2C626C510012E730 /* SentryANRTrackerV2.m in Sources */ = {isa = PBXBuildFile; fileRef = 621AE74C2C626C510012E730 /* SentryANRTrackerV2.m */; }; 621D9F2F2B9B0320003D94DE /* SentryCurrentDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */; }; 621F61F12BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621F61F02BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift */; }; 62262B862BA1C46D004DA3DD /* SentryStatsdClient.h in Headers */ = {isa = PBXBuildFile; fileRef = 62262B852BA1C46D004DA3DD /* SentryStatsdClient.h */; }; @@ -130,6 +135,7 @@ 62E081AB29ED4322000F69FC /* SentryBreadcrumbTestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E081AA29ED4322000F69FC /* SentryBreadcrumbTestDelegate.swift */; }; 62E146D02BAAE47600ED34FD /* LocalMetricsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E146CF2BAAE47600ED34FD /* LocalMetricsAggregator.swift */; }; 62E146D22BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E146D12BAAF55B00ED34FD /* LocalMetricsAggregatorTests.swift */; }; + 62EF86A12C626D39004E058B /* SentryANRTrackerV2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621AE74E2C626CF70012E730 /* SentryANRTrackerV2Tests.swift */; }; 62F05D2B2C0DB1F100916E3F /* SentryLogTestHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 62F05D2A2C0DB1F100916E3F /* SentryLogTestHelper.m */; }; 62F226B729A37C120038080D /* SentryBooleanSerialization.m in Sources */ = {isa = PBXBuildFile; fileRef = 62F226B629A37C120038080D /* SentryBooleanSerialization.m */; }; 62F4DDA12C04CB9700588890 /* SentryBaggageSerializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F4DDA02C04CB9700588890 /* SentryBaggageSerializationTests.swift */; }; @@ -1051,6 +1057,12 @@ 620203B12C59025E0008317C /* SentryFileContents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFileContents.swift; sourceTree = ""; }; 620379DA2AFE1415005AC0C1 /* SentryBuildAppStartSpans.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryBuildAppStartSpans.h; path = include/SentryBuildAppStartSpans.h; sourceTree = ""; }; 620379DC2AFE1432005AC0C1 /* SentryBuildAppStartSpans.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryBuildAppStartSpans.m; sourceTree = ""; }; + 621A9D592C64F18900B5D7D6 /* SentryANRTrackingIntegrationV2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryANRTrackingIntegrationV2.h; path = include/SentryANRTrackingIntegrationV2.h; sourceTree = ""; }; + 621A9D5B2C64F1AE00B5D7D6 /* SentryANRTrackingIntegrationV2.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryANRTrackingIntegrationV2.m; sourceTree = ""; }; + 621A9D5D2C64F3B700B5D7D6 /* SentryANRTrackingIntegrationV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryANRTrackingIntegrationV2Tests.swift; sourceTree = ""; }; + 621AE74A2C626C230012E730 /* SentryANRTrackerV2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryANRTrackerV2.h; path = include/SentryANRTrackerV2.h; sourceTree = ""; }; + 621AE74C2C626C510012E730 /* SentryANRTrackerV2.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryANRTrackerV2.m; sourceTree = ""; }; + 621AE74E2C626CF70012E730 /* SentryANRTrackerV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryANRTrackerV2Tests.swift; sourceTree = ""; }; 621D9F2E2B9B0320003D94DE /* SentryCurrentDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCurrentDateProvider.swift; sourceTree = ""; }; 621F61F02BEA073A005E654F /* SentryEnabledFeaturesBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryEnabledFeaturesBuilder.swift; sourceTree = ""; }; 62262B852BA1C46D004DA3DD /* SentryStatsdClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryStatsdClient.h; path = include/SentryStatsdClient.h; sourceTree = ""; }; @@ -2834,6 +2846,10 @@ 7B127B0E27CF6F4700A71ED2 /* SentryANRTrackingIntegration.m */, 7BCFA71427D0BAB7008C662C /* SentryANRTracker.h */, 7BCFA71527D0BB50008C662C /* SentryANRTracker.m */, + 621A9D592C64F18900B5D7D6 /* SentryANRTrackingIntegrationV2.h */, + 621A9D5B2C64F1AE00B5D7D6 /* SentryANRTrackingIntegrationV2.m */, + 621AE74A2C626C230012E730 /* SentryANRTrackerV2.h */, + 621AE74C2C626C510012E730 /* SentryANRTrackerV2.m */, ); name = ANR; sourceTree = ""; @@ -2842,7 +2858,9 @@ isa = PBXGroup; children = ( 7B2A70D727D5F07F008B0D15 /* SentryANRTrackerTests.swift */, + 621AE74E2C626CF70012E730 /* SentryANRTrackerV2Tests.swift */, 7BFA69F527E0840400233199 /* SentryANRTrackingIntegrationTests.swift */, + 621A9D5D2C64F3B700B5D7D6 /* SentryANRTrackingIntegrationV2Tests.swift */, ); path = ANR; sourceTree = ""; @@ -3987,6 +4005,7 @@ 03F84D2727DD414C008FE43F /* SentryMachLogging.hpp in Headers */, 63295AF51EF3C7DB002D4490 /* SentryNSDictionarySanitize.h in Headers */, D8739D172BEEA33F007D2F66 /* SentryLevelHelper.h in Headers */, + 621A9D5A2C64F18900B5D7D6 /* SentryANRTrackingIntegrationV2.h in Headers */, 8E4A037825F6F52100000D77 /* SentrySampleDecision.h in Headers */, 63FE717920DA4C1100CDBAE8 /* SentryCrashReportStore.h in Headers */, 0AAE202128ED9BCC00D0CD80 /* SentryReachability.h in Headers */, @@ -4085,6 +4104,7 @@ 7BC852332458802C005A70F0 /* SentryDataCategoryMapper.h in Headers */, 7BDB03B7251364F800BAE198 /* SentryDispatchQueueWrapper.h in Headers */, 7BF9EF842722D07B00B5BBEF /* SentryObjCRuntimeWrapper.h in Headers */, + 621AE74B2C626C230012E730 /* SentryANRTrackerV2.h in Headers */, 639889B71EDECFA800EA7442 /* SentryBreadcrumbTracker.h in Headers */, 632331F9240506DF008D91D6 /* SentryScope+Private.h in Headers */, D8603DD8284F894C000E1227 /* SentryBaggage.h in Headers */, @@ -4493,6 +4513,7 @@ 7B7D873624864C9D00D2ECFF /* SentryCrashDefaultMachineContextWrapper.m in Sources */, 63FE712F20DA4C1100CDBAE8 /* SentryCrashSysCtl.c in Sources */, 7B3B473825D6CC7E00D01640 /* SentryNSError.m in Sources */, + 621AE74D2C626C510012E730 /* SentryANRTrackerV2.m in Sources */, D8ACE3C82762187200F5A213 /* SentryNSDataTracker.m in Sources */, 7BE3C77D2446112C00A38442 /* SentryRateLimitParser.m in Sources */, 51B15F7E2BE88A7C0026A2F2 /* URLSessionTaskHelper.swift in Sources */, @@ -4547,6 +4568,7 @@ 8ECC674725C23A20000E2BF6 /* SentrySpanContext.m in Sources */, 7B18DE4228D9F794004845C6 /* SentryNSNotificationCenterWrapper.m in Sources */, 639FCFA91EBC80CC00778193 /* SentryFrame.m in Sources */, + 621A9D5C2C64F1AE00B5D7D6 /* SentryANRTrackingIntegrationV2.m in Sources */, D858FA672A29EAB3002A3503 /* SentryBinaryImageCache.m in Sources */, D8AFC0572BDA895400118BE1 /* UIRedactBuilder.swift in Sources */, 8E564AEA267AF22600FE117D /* SentryNetworkTracker.m in Sources */, @@ -4912,6 +4934,7 @@ D855AD62286ED6A4002573E1 /* SentryCrashTests.m in Sources */, D8AFC0012BD252B900118BE1 /* SentryOnDemandReplayTests.swift in Sources */, 0A9415BA28F96CAC006A5DD1 /* TestSentryReachability.swift in Sources */, + 62EF86A12C626D39004E058B /* SentryANRTrackerV2Tests.swift in Sources */, D880E3A728573E87008A90DB /* SentryBaggageTests.swift in Sources */, 7B16FD022654F86B008177D3 /* SentrySysctlTests.swift in Sources */, 7BAF3DB5243C743E008A5414 /* SentryClientTests.swift in Sources */, @@ -4995,6 +5018,7 @@ 7BB7E7C729267A28004BF96B /* EmptyIntegration.swift in Sources */, 7B965728268321CD00C66E25 /* SentryCrashScopeObserverTests.swift in Sources */, 626866742BA89683006995EA /* BucketMetricsAggregatorTests.swift in Sources */, + 621A9D5F2C64F3BC00B5D7D6 /* SentryANRTrackingIntegrationV2Tests.swift in Sources */, 7BD86ECB264A6DB5005439DB /* TestSysctl.swift in Sources */, D861301C2BB5A267004C0F5E /* SentrySessionReplayTests.swift in Sources */, 7B0DC73428869BF40039995F /* NSMutableDictionarySentryTests.swift in Sources */, diff --git a/Sources/Sentry/SentryANRTrackerV2.m b/Sources/Sentry/SentryANRTrackerV2.m new file mode 100644 index 00000000000..10a6c75bdac --- /dev/null +++ b/Sources/Sentry/SentryANRTrackerV2.m @@ -0,0 +1,210 @@ +#import "SentryANRTrackerV2.h" +#import "SentryCrashWrapper.h" +#import "SentryDependencyContainer.h" +#import "SentryDispatchQueueWrapper.h" +#import "SentryLog.h" +#import "SentrySwift.h" +#import "SentryThreadWrapper.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, SentryANRTrackerState) { + kSentryANRTrackerNotRunning = 1, + kSentryANRTrackerRunning, + kSentryANRTrackerStarting, + kSentryANRTrackerStopping +}; + +@interface +SentryANRTrackerV2 () + +@property (nonatomic, strong) SentryCrashWrapper *crashWrapper; +@property (nonatomic, strong) SentryDispatchQueueWrapper *dispatchQueueWrapper; +@property (nonatomic, strong) SentryThreadWrapper *threadWrapper; +@property (nonatomic, strong) NSHashTable> *listeners; +@property (nonatomic, assign) NSTimeInterval timeoutInterval; + +@end + +@implementation SentryANRTrackerV2 { + NSObject *threadLock; + SentryANRTrackerState state; +} + +- (instancetype)initWithTimeoutInterval:(NSTimeInterval)timeoutInterval + crashWrapper:(SentryCrashWrapper *)crashWrapper + dispatchQueueWrapper:(SentryDispatchQueueWrapper *)dispatchQueueWrapper + threadWrapper:(SentryThreadWrapper *)threadWrapper +{ + if (self = [super init]) { + self.timeoutInterval = timeoutInterval; + self.crashWrapper = crashWrapper; + self.dispatchQueueWrapper = dispatchQueueWrapper; + self.threadWrapper = threadWrapper; + self.listeners = [NSHashTable weakObjectsHashTable]; + threadLock = [[NSObject alloc] init]; + state = kSentryANRTrackerNotRunning; + } + return self; +} + +- (void)detectANRs +{ + NSUUID *threadID = [NSUUID UUID]; + + @synchronized(threadLock) { + [self.threadWrapper threadStarted:threadID]; + + if (state != kSentryANRTrackerStarting) { + [self.threadWrapper threadFinished:threadID]; + return; + } + + NSThread.currentThread.name = @"io.sentry.app-hang-tracker"; + state = kSentryANRTrackerRunning; + } + + __block atomic_int ticksSinceUiUpdate = 0; + __block BOOL reported = NO; + + NSInteger reportThreshold = 5; + NSTimeInterval sleepInterval = self.timeoutInterval / reportThreshold; + + SentryCurrentDateProvider *dateProvider = SentryDependencyContainer.sharedInstance.dateProvider; + + // Canceling the thread can take up to sleepInterval. + while (YES) { + @synchronized(threadLock) { + if (state != kSentryANRTrackerRunning) { + break; + } + } + + NSDate *blockDeadline = [[dateProvider date] dateByAddingTimeInterval:self.timeoutInterval]; + + atomic_fetch_add_explicit(&ticksSinceUiUpdate, 1, memory_order_relaxed); + + [self.dispatchQueueWrapper dispatchAsyncOnMainQueue:^{ + atomic_store_explicit(&ticksSinceUiUpdate, 0, memory_order_relaxed); + + if (reported) { + SENTRY_LOG_WARN(@"ANR stopped."); + + // The ANR stopped, don't block the main thread with calling ANRStopped listeners. + // While the ANR code reports an ANR and collects the stack trace, the ANR might + // stop simultaneously. In that case, the ANRs stack trace would contain the + // following code running on the main thread. To avoid this, we offload work to a + // background thread. + [self.dispatchQueueWrapper dispatchAsyncWithBlock:^{ [self ANRStopped]; }]; + } + + reported = NO; + }]; + + [self.threadWrapper sleepForTimeInterval:sleepInterval]; + + // The blockDeadline should be roughly executed after the timeoutInterval even if there is + // an ANR. If the app gets suspended this thread could sleep and wake up again. To avoid + // false positives, we don't report ANRs if the delta is too big. + NSTimeInterval deltaFromNowToBlockDeadline = + [[dateProvider date] timeIntervalSinceDate:blockDeadline]; + + if (deltaFromNowToBlockDeadline >= self.timeoutInterval) { + SENTRY_LOG_DEBUG( + @"Ignoring ANR because the delta is too big: %f.", deltaFromNowToBlockDeadline); + continue; + } + + if (atomic_load_explicit(&ticksSinceUiUpdate, memory_order_relaxed) >= reportThreshold + && !reported) { + reported = YES; + + if (![self.crashWrapper isApplicationInForeground]) { + SENTRY_LOG_DEBUG(@"Ignoring ANR because the app is in the background"); + continue; + } + + SENTRY_LOG_WARN(@"ANR detected."); + [self ANRDetected]; + } + } + + @synchronized(threadLock) { + state = kSentryANRTrackerNotRunning; + [self.threadWrapper threadFinished:threadID]; + } +} + +- (void)ANRDetected +{ + NSArray *localListeners; + @synchronized(self.listeners) { + localListeners = [self.listeners allObjects]; + } + + for (id target in localListeners) { + [target anrDetected]; + } +} + +- (void)ANRStopped +{ + NSArray *targets; + @synchronized(self.listeners) { + targets = [self.listeners allObjects]; + } + + for (id target in targets) { + [target anrStopped]; + } +} + +- (void)addListener:(id)listener +{ + @synchronized(self.listeners) { + [self.listeners addObject:listener]; + + @synchronized(threadLock) { + if (self.listeners.count > 0 && state == kSentryANRTrackerNotRunning) { + if (state == kSentryANRTrackerNotRunning) { + state = kSentryANRTrackerStarting; + [NSThread detachNewThreadSelector:@selector(detectANRs) + toTarget:self + withObject:nil]; + } + } + } + } +} + +- (void)removeListener:(id)listener +{ + @synchronized(self.listeners) { + [self.listeners removeObject:listener]; + + if (self.listeners.count == 0) { + [self stop]; + } + } +} + +- (void)clear +{ + @synchronized(self.listeners) { + [self.listeners removeAllObjects]; + [self stop]; + } +} + +- (void)stop +{ + @synchronized(threadLock) { + SENTRY_LOG_INFO(@"Stopping ANR detection"); + state = kSentryANRTrackerStopping; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryANRTrackingIntegrationV2.m b/Sources/Sentry/SentryANRTrackingIntegrationV2.m new file mode 100644 index 00000000000..f738729e328 --- /dev/null +++ b/Sources/Sentry/SentryANRTrackingIntegrationV2.m @@ -0,0 +1,130 @@ +#import "SentryANRTrackingIntegrationV2.h" +#import "SentryANRTrackerV2.h" +#import "SentryClient+Private.h" +#import "SentryCrashMachineContext.h" +#import "SentryCrashWrapper.h" +#import "SentryDependencyContainer.h" +#import "SentryDispatchQueueWrapper.h" +#import "SentryEvent.h" +#import "SentryException.h" +#import "SentryHub+Private.h" +#import "SentryLog.h" +#import "SentryMechanism.h" +#import "SentrySDK+Private.h" +#import "SentryStacktrace.h" +#import "SentryThread.h" +#import "SentryThreadInspector.h" +#import "SentryThreadWrapper.h" +#import "SentryUIApplication.h" +#import + +#if SENTRY_HAS_UIKIT +# import +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface +SentryANRTrackingIntegrationV2 () + +@property (nonatomic, strong) SentryANRTrackerV2 *tracker; +@property (nonatomic, strong) SentryOptions *options; +@property (atomic, assign) BOOL reportAppHangs; + +@end + +@implementation SentryANRTrackingIntegrationV2 + +- (BOOL)installWithOptions:(SentryOptions *)options +{ + if (![super installWithOptions:options]) { + return NO; + } + + self.tracker = + [SentryDependencyContainer.sharedInstance getANRTrackerV2:options.appHangTimeoutInterval]; + + [self.tracker addListener:self]; + self.options = options; + self.reportAppHangs = YES; + + return YES; +} + +- (SentryIntegrationOption)integrationOptions +{ + return kIntegrationOptionEnableAppHangTrackingV2 | kIntegrationOptionDebuggerNotAttached; +} + +- (void)pauseAppHangTracking +{ + self.reportAppHangs = NO; +} + +- (void)resumeAppHangTracking +{ + self.reportAppHangs = YES; +} + +- (void)uninstall +{ + [self.tracker removeListener:self]; +} + +- (void)dealloc +{ + [self uninstall]; +} + +- (void)anrDetected +{ + if (self.reportAppHangs == NO) { + SENTRY_LOG_DEBUG(@"AppHangTracking paused. Ignoring reported app hang.") + return; + } + +#if SENTRY_HAS_UIKIT + // If the app is not active, the main thread may be blocked or too busy. + // Since there is no UI for the user to interact, there is no need to report app hang. + if (SentryDependencyContainer.sharedInstance.application.applicationState + != UIApplicationStateActive) { + return; + } +#endif + SentryThreadInspector *threadInspector = SentrySDK.currentHub.getClient.threadInspector; + + NSArray *threads = [threadInspector getCurrentThreadsWithStackTrace]; + + if (threads.count == 0) { + SENTRY_LOG_WARN(@"Getting current thread returned an empty list. Can't create AppHang " + @"event without a stacktrace."); + return; + } + + NSString *message = [NSString stringWithFormat:@"App hanging for at least %li ms.", + (long)(self.options.appHangTimeoutInterval * 1000)]; + SentryEvent *event = [[SentryEvent alloc] initWithLevel:kSentryLevelError]; + SentryException *sentryException = + [[SentryException alloc] initWithValue:message type:SentryANRExceptionTypeV2]; + + sentryException.mechanism = [[SentryMechanism alloc] initWithType:@"AppHang"]; + sentryException.stacktrace = [threads[0] stacktrace]; + sentryException.stacktrace.snapshot = @(YES); + + [threads enumerateObjectsUsingBlock:^(SentryThread *_Nonnull obj, NSUInteger idx, + BOOL *_Nonnull stop) { obj.current = [NSNumber numberWithBool:idx == 0]; }]; + + event.exceptions = @[ sentryException ]; + event.threads = threads; + + [SentrySDK captureEvent:event]; +} + +- (void)anrStopped +{ + // We dont report when an ANR ends. +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/SentryBaseIntegration.m b/Sources/Sentry/SentryBaseIntegration.m index f37baf66e42..f6f7f6698d2 100644 --- a/Sources/Sentry/SentryBaseIntegration.m +++ b/Sources/Sentry/SentryBaseIntegration.m @@ -89,6 +89,18 @@ - (BOOL)shouldBeEnabledWithOptions:(SentryOptions *)options } } + if (integrationOptions & kIntegrationOptionEnableAppHangTrackingV2) { + if (!options.enableAppHangTrackingV2) { + [self logWithOptionName:@"enableAppHangTrackingV2"]; + return NO; + } + + if (options.appHangTimeoutInterval == 0) { + [self logWithReason:@"because appHangTimeoutInterval is 0"]; + return NO; + } + } + if ((integrationOptions & kIntegrationOptionEnableNetworkTracking) && !options.enableNetworkTracking) { [self logWithOptionName:@"enableNetworkTracking"]; diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index 5a53361c081..337d3236533 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -1,4 +1,5 @@ #import "SentryANRTracker.h" +#import "SentryANRTrackerV2.h" #import "SentryBinaryImageCache.h" #import "SentryDispatchFactory.h" #import "SentryDispatchQueueWrapper.h" @@ -339,6 +340,24 @@ - (SentryANRTracker *)getANRTracker:(NSTimeInterval)timeout return _anrTracker; } +- (SentryANRTrackerV2 *)getANRTrackerV2:(NSTimeInterval)timeout + SENTRY_DISABLE_THREAD_SANITIZER("double-checked lock produce false alarms") +{ + if (_anrTrackerV2 == nil) { + @synchronized(sentryDependencyContainerLock) { + if (_anrTrackerV2 == nil) { + _anrTrackerV2 = + [[SentryANRTrackerV2 alloc] initWithTimeoutInterval:timeout + crashWrapper:self.crashWrapper + dispatchQueueWrapper:self.dispatchQueueWrapper + threadWrapper:self.threadWrapper]; + } + } + } + + return _anrTrackerV2; +} + - (SentryNSProcessInfoWrapper *)processInfoWrapper SENTRY_DISABLE_THREAD_SANITIZER( "double-checked lock produce false alarms") { diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 2a1a995b971..707094ccfab 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -122,6 +122,7 @@ - (instancetype)init #endif // SENTRY_HAS_UIKIT self.enableAppHangTracking = YES; self.appHangTimeoutInterval = 2.0; + self.enableAppHangTrackingV2 = NO; self.enableAutoBreadcrumbTracking = YES; self.enableNetworkTracking = YES; self.enableFileIOTracing = YES; @@ -444,6 +445,9 @@ - (BOOL)validateOptions:(NSDictionary *)options self.appHangTimeoutInterval = [options[@"appHangTimeoutInterval"] doubleValue]; } + [self setBool:options[@"enableAppHangTrackingV2"] + block:^(BOOL value) { self->_enableAppHangTrackingV2 = value; }]; + [self setBool:options[@"enableNetworkTracking"] block:^(BOOL value) { self->_enableNetworkTracking = value; }]; diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index 390644cc5c8..db68e110ca7 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -1,6 +1,7 @@ #import "SentryDefines.h" @class SentryANRTracker; +@class SentryANRTrackerV2; @class SentryAppStateManager; @class SentryBinaryImageCache; @class SentryCrash; @@ -63,6 +64,7 @@ SENTRY_NO_INIT @property (nonatomic, strong) SentryNSNotificationCenterWrapper *notificationCenterWrapper; @property (nonatomic, strong) SentryDebugImageProvider *debugImageProvider; @property (nonatomic, strong) SentryANRTracker *anrTracker; +@property (nonatomic, strong) SentryANRTrackerV2 *anrTrackerV2; @property (nonatomic, strong) SentryNSProcessInfoWrapper *processInfoWrapper; @property (nonatomic, strong) SentrySystemWrapper *systemWrapper; @property (nonatomic, strong) SentryDispatchFactory *dispatchFactory; @@ -89,6 +91,7 @@ SENTRY_NO_INIT #endif // !TARGET_OS_WATCH - (SentryANRTracker *)getANRTracker:(NSTimeInterval)timeout; +- (SentryANRTrackerV2 *)getANRTrackerV2:(NSTimeInterval)timeout; #if SENTRY_HAS_METRIC_KIT @property (nonatomic, strong) SentryMXManager *metricKitManager API_AVAILABLE( diff --git a/Sources/Sentry/include/SentryANRTrackerV2.h b/Sources/Sentry/include/SentryANRTrackerV2.h new file mode 100644 index 00000000000..b7bb95dfe57 --- /dev/null +++ b/Sources/Sentry/include/SentryANRTrackerV2.h @@ -0,0 +1,34 @@ +#import "SentryDefines.h" + +@class SentryOptions, SentryCrashWrapper, SentryDispatchQueueWrapper, SentryThreadWrapper; + +NS_ASSUME_NONNULL_BEGIN + +@protocol SentryANRTrackerV2Delegate; + +@interface SentryANRTrackerV2 : NSObject +SENTRY_NO_INIT + +- (instancetype)initWithTimeoutInterval:(NSTimeInterval)timeoutInterval + crashWrapper:(SentryCrashWrapper *)crashWrapper + dispatchQueueWrapper:(SentryDispatchQueueWrapper *)dispatchQueueWrapper + threadWrapper:(SentryThreadWrapper *)threadWrapper; + +- (void)addListener:(id)listener; + +- (void)removeListener:(id)listener; + +// Function used for tests +- (void)clear; + +@end + +@protocol SentryANRTrackerV2Delegate + +- (void)anrDetected; + +- (void)anrStopped; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryANRTrackingIntegrationV2.h b/Sources/Sentry/include/SentryANRTrackingIntegrationV2.h new file mode 100644 index 00000000000..88a27b741ea --- /dev/null +++ b/Sources/Sentry/include/SentryANRTrackingIntegrationV2.h @@ -0,0 +1,18 @@ +#import "SentryANRTrackerV2.h" +#import "SentryBaseIntegration.h" +#import "SentrySwift.h" +#import + +NS_ASSUME_NONNULL_BEGIN + +static NSString *const SentryANRExceptionTypeV2 = @"App Hanging"; + +@interface SentryANRTrackingIntegrationV2 + : SentryBaseIntegration + +- (void)pauseAppHangTracking; +- (void)resumeAppHangTracking; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryBaseIntegration.h b/Sources/Sentry/include/SentryBaseIntegration.h index c8adad48fc9..fa803291fc3 100644 --- a/Sources/Sentry/include/SentryBaseIntegration.h +++ b/Sources/Sentry/include/SentryBaseIntegration.h @@ -23,6 +23,7 @@ typedef NS_OPTIONS(NSUInteger, SentryIntegrationOption) { kIntegrationOptionEnableCrashHandler = 1 << 16, kIntegrationOptionEnableMetricKit = 1 << 17, kIntegrationOptionEnableReplay = 1 << 18, + kIntegrationOptionEnableAppHangTrackingV2 = 1 << 19, }; @class SentryOptions; diff --git a/Sources/Sentry/include/SentryOptions+Private.h b/Sources/Sentry/include/SentryOptions+Private.h index 80570f9dcb5..839ca7e6c4f 100644 --- a/Sources/Sentry/include/SentryOptions+Private.h +++ b/Sources/Sentry/include/SentryOptions+Private.h @@ -17,6 +17,8 @@ SentryOptions () SENTRY_EXTERN BOOL sentry_isValidSampleRate(NSNumber *sampleRate); +@property (nonatomic, assign) BOOL enableAppHangTrackingV2; + @end NS_ASSUME_NONNULL_END diff --git a/Tests/SentryTests/Helper/SentryTestThreadWrapper.swift b/Tests/SentryTests/Helper/SentryTestThreadWrapper.swift index c4ce81c7300..6bc8de5a722 100644 --- a/Tests/SentryTests/Helper/SentryTestThreadWrapper.swift +++ b/Tests/SentryTests/Helper/SentryTestThreadWrapper.swift @@ -9,8 +9,10 @@ class SentryTestThreadWrapper: SentryThreadWrapper { var threadStartedInvocations = Invocations() var threadFinishedInvocations = Invocations() + public var blockWhenSleeping: () -> Void = {} override func sleep(forTimeInterval timeInterval: TimeInterval) { // Don't sleep. Do nothing. + blockWhenSleeping() } override func threadStarted(_ threadID: UUID) { diff --git a/Tests/SentryTests/Integrations/ANR/SentryANRTrackerV2Tests.swift b/Tests/SentryTests/Integrations/ANR/SentryANRTrackerV2Tests.swift new file mode 100644 index 00000000000..57c35133329 --- /dev/null +++ b/Tests/SentryTests/Integrations/ANR/SentryANRTrackerV2Tests.swift @@ -0,0 +1,239 @@ +@testable import Sentry +import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) +class SentryANRTrackerV2Tests: XCTestCase, SentryANRTrackerV2Delegate { + + private var sut: SentryANRTrackerV2! + private var fixture: Fixture! + private var anrDetectedExpectation: XCTestExpectation! + private var anrStoppedExpectation: XCTestExpectation! + private let waitTimeout: TimeInterval = 1.0 + + private class Fixture { + let timeoutInterval: TimeInterval = 5 + let currentDate = TestCurrentDateProvider() + let crashWrapper: TestSentryCrashWrapper + let dispatchQueue = TestSentryDispatchQueueWrapper() + let threadWrapper = SentryTestThreadWrapper() + + init() { + crashWrapper = TestSentryCrashWrapper.sharedInstance() + SentryDependencyContainer.sharedInstance().dateProvider = currentDate + } + } + + override func setUp() { + super.setUp() + + anrDetectedExpectation = expectation(description: "ANR Detection") + anrStoppedExpectation = expectation(description: "ANR Stopped") + anrStoppedExpectation.isInverted = true + + fixture = Fixture() + + sut = SentryANRTrackerV2( + timeoutInterval: fixture.timeoutInterval, + crashWrapper: fixture.crashWrapper, + dispatchQueueWrapper: fixture.dispatchQueue, + threadWrapper: fixture.threadWrapper) + } + + override func tearDown() { + super.tearDown() + sut.clear() + + wait(for: [fixture.threadWrapper.threadFinishedExpectation], timeout: 5) + XCTAssertEqual(0, fixture.threadWrapper.threads.count) + clearTestState() + } + + func start() { + sut.addListener(self) + } + + func testContinuousANR_OneReported() { + fixture.dispatchQueue.blockBeforeMainBlock = { + self.advanceTime(bySeconds: self.fixture.timeoutInterval) + return false + } + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation], timeout: waitTimeout) + } + + func testMultipleListeners() { + fixture.dispatchQueue.blockBeforeMainBlock = { + self.advanceTime(bySeconds: self.fixture.timeoutInterval) + return false + } + + let secondListener = SentryANRTrackerV2TestDelegate() + sut.addListener(secondListener) + + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation, secondListener.anrStoppedExpectation, secondListener.anrDetectedExpectation], timeout: waitTimeout) + } + + func testANRButAppInBackground_NoANR() { + anrDetectedExpectation.isInverted = true + fixture.crashWrapper.internalIsApplicationInForeground = false + + fixture.dispatchQueue.blockBeforeMainBlock = { + self.advanceTime(bySeconds: self.fixture.timeoutInterval) + return false + } + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation], timeout: waitTimeout) + } + + func testMultipleANRs_MultipleReported() { + anrDetectedExpectation.expectedFulfillmentCount = 3 + let expectedANRStoppedInvocations = 2 + anrStoppedExpectation.isInverted = false + anrStoppedExpectation.expectedFulfillmentCount = expectedANRStoppedInvocations + + fixture.dispatchQueue.blockBeforeMainBlock = { + self.advanceTime(bySeconds: self.fixture.timeoutInterval) + let invocations = self.fixture.dispatchQueue.blockOnMainInvocations.count + if [0, 10, 15, 25].contains(invocations) { + return true + } + + return false + } + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation], timeout: waitTimeout) + XCTAssertEqual(expectedANRStoppedInvocations, fixture.dispatchQueue.dispatchAsyncInvocations.count) + } + + func testAppSuspended_NoANR() { + // To avoid spamming the test logs + SentryLog.configure(true, diagnosticLevel: .error) + + anrDetectedExpectation.isInverted = true + fixture.dispatchQueue.blockBeforeMainBlock = { + let delta = self.fixture.timeoutInterval * 2 + self.advanceTime(bySeconds: delta) + return false + } + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation], timeout: waitTimeout) + + SentryLog.setTestDefaultLogLevel() + } + + func testRemoveListener_StopsReportingANRs() { + anrDetectedExpectation.isInverted = true + + let mainBlockExpectation = expectation(description: "Main Block") + + fixture.dispatchQueue.blockBeforeMainBlock = { + self.sut.removeListener(self) + mainBlockExpectation.fulfill() + return true + } + + start() + + wait(for: [anrDetectedExpectation, anrStoppedExpectation, mainBlockExpectation], timeout: waitTimeout) + } + + func testClear_StopsReportingANRs() { + let secondListener = SentryANRTrackerV2TestDelegate() + secondListener.anrDetectedExpectation.isInverted = true + anrDetectedExpectation.isInverted = true + + let mainBlockExpectation = expectation(description: "Main Block") + + //Having a second Listener may cause the tracker to execute more than once before the end of the test + mainBlockExpectation.assertForOverFulfill = false + + fixture.dispatchQueue.blockBeforeMainBlock = { + self.sut.clear() + mainBlockExpectation.fulfill() + return true + } + + sut.addListener(secondListener) + start() + wait(for: [anrDetectedExpectation, anrStoppedExpectation, mainBlockExpectation, secondListener.anrStoppedExpectation, secondListener.anrDetectedExpectation], timeout: waitTimeout) + + } + + func testNotRemovingDeallocatedListener_DoesNotRetainListener_AndStopsTracking() { + anrDetectedExpectation.isInverted = true + anrStoppedExpectation.isInverted = true + + // So ARC deallocates SentryANRTrackerTestDelegate + let addListenersCount = 10 + func addListeners() { + for _ in 0.. + + XCTAssertGreaterThan(addListenersCount, listeners?.count ?? addListenersCount) + + wait(for: [anrDetectedExpectation, anrStoppedExpectation], timeout: 0.0) + } + + func testClearDirectlyAfterStart() { + anrDetectedExpectation.isInverted = true + + let invocations = 10 + for _ in 0..= 1 + }.count + + XCTAssertTrue(threadsWithFrames > 1, "Not enough threads with frames") + } + } + + func testANRDetected_DetectingPaused_NoEventCaptured() { + givenInitializedTracker() + setUpThreadInspector() + sut.pauseAppHangTracking() + + Dynamic(sut).anrDetected() + + assertNoEventCaptured() + } + + func testANRDetected_DetectingPausedResumed_EventCaptured() throws { + givenInitializedTracker() + setUpThreadInspector() + sut.pauseAppHangTracking() + sut.resumeAppHangTracking() + + Dynamic(sut).anrDetected() + + try assertEventWithScopeCaptured { event, _, _ in + XCTAssertNotNil(event) + guard let ex = event?.exceptions?.first else { + XCTFail("ANR Exception not found") + return + } + + XCTAssertEqual(ex.mechanism?.type, "AppHang") + } + } + + func testCallPauseResumeOnMultipleThreads_DoesNotCrash() { + givenInitializedTracker() + + testConcurrentModifications(asyncWorkItems: 100, writeLoopCount: 10, writeWork: {_ in + self.sut.pauseAppHangTracking() + Dynamic(self.sut).anrDetected() + }, readWork: { + self.sut.resumeAppHangTracking() + Dynamic(self.sut).anrDetected() + }) + } + + func testANRDetected_ButNoThreads_EventNotCaptured() { + givenInitializedTracker() + setUpThreadInspector(addThreads: false) + + Dynamic(sut).anrDetected() + + assertNoEventCaptured() + } +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + func testANRDetected_ButBackground_EventNotCaptured() { + + class BackgroundSentryUIApplication: SentryUIApplication { + override var applicationState: UIApplication.State { .background } + } + + givenInitializedTracker() + setUpThreadInspector() + SentryDependencyContainer.sharedInstance().application = BackgroundSentryUIApplication() + + Dynamic(sut).anrDetected() + + assertNoEventCaptured() + } +#endif + + func testDealloc_CallsUninstall() { + givenInitializedTracker() + + // // So ARC deallocates the SentryANRTrackingIntegration + func initIntegration() { + self.crashWrapper.internalIsBeingTraced = false + let sut = SentryANRTrackingIntegration() + sut.install(with: self.options) + } + + initIntegration() + + let tracker = SentryDependencyContainer.sharedInstance().getANRTrackerV2(self.options.appHangTimeoutInterval) + + let listeners = Dynamic(tracker).listeners.asObject as? NSHashTable + + XCTAssertEqual(1, listeners?.count ?? 2) + } + + func testEventIsNotANR() { + XCTAssertFalse(Event().isAppHangEvent) + } + + private func givenInitializedTracker(isBeingTraced: Bool = false) { + givenSdkWithHub() + self.crashWrapper.internalIsBeingTraced = isBeingTraced + sut = SentryANRTrackingIntegrationV2() + sut.install(with: self.options) + } + + private func setUpThreadInspector(addThreads: Bool = true) { + let threadInspector = TestThreadInspector.instance + + if addThreads { + + let frame1 = Sentry.Frame() + frame1.function = "Second_frame_function" + + let thread1 = SentryThread(threadId: 0) + thread1.stacktrace = SentryStacktrace(frames: [frame1], registers: [:]) + thread1.current = true + + let frame2 = Sentry.Frame() + frame2.function = "main" + + let thread2 = SentryThread(threadId: 1) + thread2.stacktrace = SentryStacktrace(frames: [frame2], registers: [:]) + thread2.current = false + + threadInspector.allThreads = [ + thread2, + thread1 + ] + } else { + threadInspector.allThreads = [] + } + + SentrySDK.currentHub().getClient()?.threadInspector = threadInspector + } +} diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index 1b722f428d8..c0c25f03175 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -916,6 +916,11 @@ - (void)testEnableAppHangTracking [self testBooleanField:@"enableAppHangTracking" defaultValue:YES]; } +- (void)testEnableAppHangTrackingV2 +{ + [self testBooleanField:@"enableAppHangTrackingV2" defaultValue:NO]; +} + - (void)testDefaultAppHangsTimeout { SentryOptions *options = [self getValidOptions:@{}]; diff --git a/Tests/SentryTests/SentryTests-Bridging-Header.h b/Tests/SentryTests/SentryTests-Bridging-Header.h index 30a4f05994e..b60da6ae8e0 100644 --- a/Tests/SentryTests/SentryTests-Bridging-Header.h +++ b/Tests/SentryTests/SentryTests-Bridging-Header.h @@ -45,7 +45,9 @@ #import "PrivateSentrySDKOnly.h" #import "Sentry/Sentry-Swift.h" #import "SentryANRTracker.h" +#import "SentryANRTrackerV2.h" #import "SentryANRTrackingIntegration.h" +#import "SentryANRTrackingIntegrationV2.h" #import "SentryAppStartMeasurement.h" #import "SentryAppStartTracker.h" #import "SentryAppStartTrackingIntegration.h"