From 0d85c4b1e8ac4ffa54fd5f6fa57629fb5a0b69b1 Mon Sep 17 00:00:00 2001 From: Maciek Grzybowski Date: Tue, 26 Mar 2024 09:06:34 +0100 Subject: [PATCH] RUM-3461 Write fatal App Hang to RUM data store, read upon restart --- Datadog/Datadog.xcodeproj/project.pbxproj | 54 +++++- .../RUM/AppHangsMonitoringTests.swift | 18 +- .../Sources/DataStore/DataStore.swift | 3 + DatadogRUM/Sources/Feature/RUMDataStore.swift | 76 ++++++++ DatadogRUM/Sources/Feature/RUMFeature.swift | 4 +- .../Instrumentation/AppHangs/AppHang.swift | 15 ++ .../AppHangs/AppHangsMonitor.swift | 99 ++++++++++ .../AppHangs/AppHangsObserver.swift | 119 ------------ .../AppHangs/AppHangsWatchdogThread.swift | 19 +- .../AppHangs/FatalAppHangsHandler.swift | 78 ++++++++ .../AppHangs/NonFatalAppHangsHandler.swift | 64 +++++++ .../AppHangs/ProcessIdentifier.swift | 19 ++ .../Instrumentation/RUMInstrumentation.swift | 16 +- DatadogRUM/Sources/RUMConfiguration.swift | 2 + .../RUMMonitor/Scopes/FatalErrorContext.swift | 42 ++++ .../Scopes/RUMScopeDependencies.swift | 43 ++++- .../RUMMonitor/Scopes/RUMSessionScope.swift | 12 +- .../RUMMonitor/Scopes/RUMViewScope.swift | 10 +- .../Utils/RUMOffViewEventsHandlingRule.swift | 2 +- .../AppHangs/AppHangsMonitorTests.swift | 180 ++++++++++++++++++ .../AppHangsWatchdogThreadTests.swift | 8 +- .../RUMInstrumentationTests.swift | 65 ++++++- DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift | 76 ++++++++ .../Scopes/RUMSessionScopeTests.swift | 2 +- DatadogRUM/Tests/RUMTests.swift | 5 +- .../Mocks/CoreMocks/FeatureScopeMock.swift | 2 +- TestUtilities/Mocks/DataStoreMock.swift | 35 ++++ TestUtilities/Mocks/TelemetryMocks.swift | 10 +- 28 files changed, 896 insertions(+), 182 deletions(-) create mode 100644 DatadogRUM/Sources/Feature/RUMDataStore.swift create mode 100644 DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift delete mode 100644 DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsObserver.swift create mode 100644 DatadogRUM/Sources/Instrumentation/AppHangs/FatalAppHangsHandler.swift create mode 100644 DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift create mode 100644 DatadogRUM/Sources/Instrumentation/AppHangs/ProcessIdentifier.swift create mode 100644 DatadogRUM/Sources/RUMMonitor/Scopes/FatalErrorContext.swift create mode 100644 DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift create mode 100644 TestUtilities/Mocks/DataStoreMock.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index f40d23f44e..77edae1cb1 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -299,13 +299,17 @@ 615A4A8924A34FD700233986 /* DDTracerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615A4A8824A34FD700233986 /* DDTracerTests.swift */; }; 615A4A8B24A3568900233986 /* OTSpan+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615A4A8A24A3568900233986 /* OTSpan+objc.swift */; }; 615A4A8D24A356A000233986 /* OTSpanContext+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615A4A8C24A356A000233986 /* OTSpanContext+objc.swift */; }; + 615B0F8B2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B0F8A2BB33C2800E9ED6C /* AppHangsMonitorTests.swift */; }; + 615B0F8C2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B0F8A2BB33C2800E9ED6C /* AppHangsMonitorTests.swift */; }; + 615B0F8E2BB33E0400E9ED6C /* DataStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B0F8D2BB33E0400E9ED6C /* DataStoreMock.swift */; }; + 615B0F8F2BB33E0400E9ED6C /* DataStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B0F8D2BB33E0400E9ED6C /* DataStoreMock.swift */; }; 615CC40C2694A56D0005F08C /* SwiftExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615CC40B2694A56D0005F08C /* SwiftExtensions.swift */; }; 615CC4102694A64D0005F08C /* SwiftExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615CC40F2694A64D0005F08C /* SwiftExtensionTests.swift */; }; 615CC4132695957C0005F08C /* CrashReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615CC4122695957C0005F08C /* CrashReportTests.swift */; }; 6167C79326665D6900D4CF07 /* E2EUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167C79226665D6900D4CF07 /* E2EUtils.swift */; }; 6167C7952666622800D4CF07 /* LoggingE2EHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167C7942666622800D4CF07 /* LoggingE2EHelpers.swift */; }; - 6167E6D32B7F8B3300C3CA2D /* AppHangsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D22B7F8B3300C3CA2D /* AppHangsObserver.swift */; }; - 6167E6D42B7F8B3300C3CA2D /* AppHangsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D22B7F8B3300C3CA2D /* AppHangsObserver.swift */; }; + 6167E6D32B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D22B7F8B3300C3CA2D /* AppHangsMonitor.swift */; }; + 6167E6D42B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D22B7F8B3300C3CA2D /* AppHangsMonitor.swift */; }; 6167E6D62B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D52B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift */; }; 6167E6D72B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D52B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift */; }; 6167E6DA2B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E6D92B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift */; }; @@ -343,6 +347,8 @@ 6167E72C2B84C72B00C3CA2D /* UIKitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E72B2B84C72B00C3CA2D /* UIKitHelpers.swift */; }; 6167E72D2B84C72B00C3CA2D /* UIKitHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6167E72B2B84C72B00C3CA2D /* UIKitHelpers.swift */; }; 616B668E259CC28E00968EE8 /* DDRUMMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616B668D259CC28E00968EE8 /* DDRUMMonitorTests.swift */; }; + 616F8C272BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616F8C262BB1CD990061EA53 /* ProcessIdentifier.swift */; }; + 616F8C282BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 616F8C262BB1CD990061EA53 /* ProcessIdentifier.swift */; }; 6170DC1C25C18729003AED5C /* PLCrashReporterPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6170DC1B25C18729003AED5C /* PLCrashReporterPlugin.swift */; }; 6172472725D673D7007085B3 /* CrashContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6172472625D673D7007085B3 /* CrashContextTests.swift */; }; 617247AF25DA9BEA007085B3 /* CrashReportingObjcHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 617247AE25DA9BEA007085B3 /* CrashReportingObjcHelpers.m */; }; @@ -383,6 +389,14 @@ 618F9843265BC486009959F8 /* E2EInstrumentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F9842265BC486009959F8 /* E2EInstrumentationTests.swift */; }; 618F984E265BC905009959F8 /* E2EConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F984D265BC905009959F8 /* E2EConfig.swift */; }; 618F984F265BC905009959F8 /* E2EConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F984D265BC905009959F8 /* E2EConfig.swift */; }; + 6194B92A2BB4116A00179430 /* RUMDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B9292BB4116A00179430 /* RUMDataStore.swift */; }; + 6194B92B2BB4116A00179430 /* RUMDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B9292BB4116A00179430 /* RUMDataStore.swift */; }; + 6194B92D2BB43F9C00179430 /* FatalErrorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B92C2BB43F9C00179430 /* FatalErrorContext.swift */; }; + 6194B92E2BB43F9C00179430 /* FatalErrorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B92C2BB43F9C00179430 /* FatalErrorContext.swift */; }; + 6194B9302BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B92F2BB451C100179430 /* NonFatalAppHangsHandler.swift */; }; + 6194B9312BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B92F2BB451C100179430 /* NonFatalAppHangsHandler.swift */; }; + 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B9322BB451DB00179430 /* FatalAppHangsHandler.swift */; }; + 6194B9342BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6194B9322BB451DB00179430 /* FatalAppHangsHandler.swift */; }; 6199362E265BA959009D7EA8 /* E2EAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6199362D265BA959009D7EA8 /* E2EAppDelegate.swift */; }; 61993637265BA95A009D7EA8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 61993636265BA95A009D7EA8 /* Assets.xcassets */; }; 6199363A265BA95A009D7EA8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 61993638265BA95A009D7EA8 /* LaunchScreen.storyboard */; }; @@ -2219,6 +2233,8 @@ 615A4A8824A34FD700233986 /* DDTracerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDTracerTests.swift; sourceTree = ""; }; 615A4A8A24A3568900233986 /* OTSpan+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OTSpan+objc.swift"; sourceTree = ""; }; 615A4A8C24A356A000233986 /* OTSpanContext+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OTSpanContext+objc.swift"; sourceTree = ""; }; + 615B0F8A2BB33C2800E9ED6C /* AppHangsMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsMonitorTests.swift; sourceTree = ""; }; + 615B0F8D2BB33E0400E9ED6C /* DataStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreMock.swift; sourceTree = ""; }; 615C3195251DD5080018781C /* UIKitRUMUserActionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMUserActionsHandlerTests.swift; sourceTree = ""; }; 615CC40B2694A56D0005F08C /* SwiftExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftExtensions.swift; sourceTree = ""; }; 615CC40F2694A64D0005F08C /* SwiftExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftExtensionTests.swift; sourceTree = ""; }; @@ -2231,7 +2247,7 @@ 6167ACBD251A0B410012B4D0 /* Example-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Example-Bridging-Header.h"; sourceTree = ""; }; 6167C79226665D6900D4CF07 /* E2EUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EUtils.swift; sourceTree = ""; }; 6167C7942666622800D4CF07 /* LoggingE2EHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingE2EHelpers.swift; sourceTree = ""; }; - 6167E6D22B7F8B3300C3CA2D /* AppHangsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsObserver.swift; sourceTree = ""; }; + 6167E6D22B7F8B3300C3CA2D /* AppHangsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsMonitor.swift; sourceTree = ""; }; 6167E6D52B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsWatchdogThread.swift; sourceTree = ""; }; 6167E6D92B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsWatchdogThreadTests.swift; sourceTree = ""; }; 6167E6DC2B811A8300C3CA2D /* AppHangsMonitoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHangsMonitoringTests.swift; sourceTree = ""; }; @@ -2256,6 +2272,7 @@ 616CCE12250A1868009FED46 /* RUMCommandSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMCommandSubscriber.swift; sourceTree = ""; }; 616CCE15250A467E009FED46 /* RUMInstrumentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMInstrumentation.swift; sourceTree = ""; }; 616F1FAF283E227100651A3A /* LogsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsFeature.swift; sourceTree = ""; }; + 616F8C262BB1CD990061EA53 /* ProcessIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessIdentifier.swift; sourceTree = ""; }; 6170DC1B25C18729003AED5C /* PLCrashReporterPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PLCrashReporterPlugin.swift; sourceTree = ""; }; 6170DC2B25C1883E003AED5C /* DatadogCrashReportingTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DatadogCrashReportingTests.xcconfig; sourceTree = ""; }; 6172472625D673D7007085B3 /* CrashContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashContextTests.swift; sourceTree = ""; }; @@ -2302,6 +2319,10 @@ 618F9844265BC486009959F8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 618F984C265BC53E009959F8 /* E2EInstrumentationTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = E2EInstrumentationTests.xcconfig; sourceTree = ""; }; 618F984D265BC905009959F8 /* E2EConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EConfig.swift; sourceTree = ""; }; + 6194B9292BB4116A00179430 /* RUMDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMDataStore.swift; sourceTree = ""; }; + 6194B92C2BB43F9C00179430 /* FatalErrorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FatalErrorContext.swift; sourceTree = ""; }; + 6194B92F2BB451C100179430 /* NonFatalAppHangsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonFatalAppHangsHandler.swift; sourceTree = ""; }; + 6194B9322BB451DB00179430 /* FatalAppHangsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FatalAppHangsHandler.swift; sourceTree = ""; }; 6194D51B287ECDC00091547D /* ConsoleLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLoggerTests.swift; sourceTree = ""; }; 6194E4B828785BFD00EB6307 /* RemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLogger.swift; sourceTree = ""; }; 6194E4BB2878AF7600EB6307 /* ConsoleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = ""; }; @@ -4431,8 +4452,11 @@ isa = PBXGroup; children = ( 61F930CA2BA213AC005F0EE2 /* AppHang.swift */, - 6167E6D22B7F8B3300C3CA2D /* AppHangsObserver.swift */, + 6167E6D22B7F8B3300C3CA2D /* AppHangsMonitor.swift */, + 6194B92F2BB451C100179430 /* NonFatalAppHangsHandler.swift */, + 6194B9322BB451DB00179430 /* FatalAppHangsHandler.swift */, 6167E6D52B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift */, + 616F8C262BB1CD990061EA53 /* ProcessIdentifier.swift */, ); path = AppHangs; sourceTree = ""; @@ -4440,6 +4464,7 @@ 6167E6D82B80047900C3CA2D /* AppHangs */ = { isa = PBXGroup; children = ( + 615B0F8A2BB33C2800E9ED6C /* AppHangsMonitorTests.swift */, 6167E6D92B8004A500C3CA2D /* AppHangsWatchdogThreadTests.swift */, ); path = AppHangs; @@ -4837,6 +4862,7 @@ isa = PBXGroup; children = ( 61494B7827F3522C0082BBCC /* Utils */, + 6194B92C2BB43F9C00179430 /* FatalErrorContext.swift */, 6122514727FDFF82004F5AE4 /* RUMScopeDependencies.swift */, 61C3E63D24BF1B91008053F2 /* RUMApplicationScope.swift */, 61C2C20624C098FC00C0321C /* RUMSessionScope.swift */, @@ -5608,6 +5634,7 @@ D257954C298ABB04008A1BE5 /* AttributesMocks.swift */, D2DA23C6298D5AC000C6C7E6 /* TelemetryMocks.swift */, 6167E7112B837F0B00C3CA2D /* BacktraceReportingMocks.swift */, + 615B0F8D2BB33E0400E9ED6C /* DataStoreMock.swift */, D2DA23C9298D5C1300C6C7E6 /* UIKitMocks.swift */, D2A7840229A536AD003B03BB /* PrintFunctionMock.swift */, D24C9C5129A7BD12002057CF /* SamplerMock.swift */, @@ -5691,6 +5718,7 @@ D25FF2ED29CC73240063802D /* RequestBuilder.swift */, D25FF2F329CC88060063802D /* RUMBaggageKeys.swift */, 3C0D5DEB2A54405A00446CF9 /* RUMViewEventsFilter.swift */, + 6194B9292BB4116A00179430 /* RUMDataStore.swift */, ); path = Feature; sourceTree = ""; @@ -8208,13 +8236,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6167E6D42B7F8B3300C3CA2D /* AppHangsObserver.swift in Sources */, + 6167E6D42B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */, D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */, D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */, D23F8E5529DDCD28001CFAE8 /* RUMEventSanitizer.swift in Sources */, D23F8E5729DDCD28001CFAE8 /* RUMScopeDependencies.swift in Sources */, D23F8E5829DDCD28001CFAE8 /* VitalMemoryReader.swift in Sources */, + 6194B9342BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, D23F8E5929DDCD28001CFAE8 /* WebViewEventReceiver.swift in Sources */, D253EE972B988CA90010B589 /* ViewCache.swift in Sources */, D23F8E5A29DDCD28001CFAE8 /* RUMResourceScope.swift in Sources */, @@ -8247,7 +8276,10 @@ D23F8E7229DDCD28001CFAE8 /* ErrorMessageReceiver.swift in Sources */, D23F8E7329DDCD28001CFAE8 /* SwiftUIActionModifier.swift in Sources */, D23F8E7429DDCD28001CFAE8 /* RUMCommandSubscriber.swift in Sources */, + 6194B92B2BB4116A00179430 /* RUMDataStore.swift in Sources */, + 6194B9312BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */, D23F8E7529DDCD28001CFAE8 /* RUMUserActionScope.swift in Sources */, + 6194B92E2BB43F9C00179430 /* FatalErrorContext.swift in Sources */, 6167E6D72B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */, 61C713A42A3B78F900FA735A /* RUMMonitorProtocol.swift in Sources */, 3C0D5DED2A54405A00446CF9 /* RUMViewEventsFilter.swift in Sources */, @@ -8256,6 +8288,7 @@ 61C713A62A3B78F900FA735A /* RUMMonitorProtocol+Internal.swift in Sources */, D23F8E7829DDCD28001CFAE8 /* LongTaskObserver.swift in Sources */, D23F8E7A29DDCD28001CFAE8 /* SessionReplayDependency.swift in Sources */, + 616F8C282BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */, D23F8E7B29DDCD28001CFAE8 /* RUMDeviceInfo.swift in Sources */, D23F8E7C29DDCD28001CFAE8 /* RUMOffViewEventsHandlingRule.swift in Sources */, D23F8E7D29DDCD28001CFAE8 /* RUMScope.swift in Sources */, @@ -8288,6 +8321,7 @@ D23F8EA029DDCD38001CFAE8 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, D23F8EA229DDCD38001CFAE8 /* RUMSessionScopeTests.swift in Sources */, D23F8EA329DDCD38001CFAE8 /* RUMUserActionScopeTests.swift in Sources */, + 615B0F8C2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */, 61C713B42A3C3A0B00FA735A /* RUMMonitorProtocol+InternalTests.swift in Sources */, D23F8EA529DDCD38001CFAE8 /* UIKitMocks.swift in Sources */, D23F8EA629DDCD38001CFAE8 /* RUMDeviceInfoTests.swift in Sources */, @@ -8347,6 +8381,7 @@ D257955B298ABB04008A1BE5 /* XCTestCase.swift in Sources */, D2579556298ABB04008A1BE5 /* FoundationMocks.swift in Sources */, D2579553298ABB04008A1BE5 /* DatadogContextMock.swift in Sources */, + 615B0F8E2BB33E0400E9ED6C /* DataStoreMock.swift in Sources */, D24C9C6929A7CE06002057CF /* DDErrorMocks.swift in Sources */, 6167E7142B837F0B00C3CA2D /* BacktraceReportingMocks.swift in Sources */, D2579558298ABB04008A1BE5 /* Encoding.swift in Sources */, @@ -8391,6 +8426,7 @@ D2579578298ABB83008A1BE5 /* XCTestCase.swift in Sources */, D2579579298ABB83008A1BE5 /* FoundationMocks.swift in Sources */, D257957A298ABB83008A1BE5 /* DatadogContextMock.swift in Sources */, + 615B0F8F2BB33E0400E9ED6C /* DataStoreMock.swift in Sources */, D24C9C6A29A7CE06002057CF /* DDErrorMocks.swift in Sources */, 6167E7152B837F0B00C3CA2D /* BacktraceReportingMocks.swift in Sources */, D257957B298ABB83008A1BE5 /* Encoding.swift in Sources */, @@ -8488,13 +8524,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6167E6D32B7F8B3300C3CA2D /* AppHangsObserver.swift in Sources */, + 6167E6D32B7F8B3300C3CA2D /* AppHangsMonitor.swift in Sources */, D29A9F8029DD85BB005C54A4 /* UIViewControllerHandler.swift in Sources */, D29A9F5929DD85BB005C54A4 /* RUMCommand.swift in Sources */, D29A9F8C29DD861C005C54A4 /* ValuePublisher.swift in Sources */, D29A9F7F29DD85BB005C54A4 /* RUMEventSanitizer.swift in Sources */, D29A9F5A29DD85BB005C54A4 /* RUMScopeDependencies.swift in Sources */, D29A9F5B29DD85BB005C54A4 /* VitalMemoryReader.swift in Sources */, + 6194B9332BB451DB00179430 /* FatalAppHangsHandler.swift in Sources */, D29A9F6229DD85BB005C54A4 /* WebViewEventReceiver.swift in Sources */, D253EE962B988CA90010B589 /* ViewCache.swift in Sources */, D29A9F8429DD85BB005C54A4 /* RUMResourceScope.swift in Sources */, @@ -8527,7 +8564,10 @@ D29A9F8829DD85BB005C54A4 /* ErrorMessageReceiver.swift in Sources */, D29A9F8729DD85BB005C54A4 /* SwiftUIActionModifier.swift in Sources */, D29A9F5D29DD85BB005C54A4 /* RUMCommandSubscriber.swift in Sources */, + 6194B92A2BB4116A00179430 /* RUMDataStore.swift in Sources */, + 6194B9302BB451C100179430 /* NonFatalAppHangsHandler.swift in Sources */, D29A9F6529DD85BB005C54A4 /* RUMUserActionScope.swift in Sources */, + 6194B92D2BB43F9C00179430 /* FatalErrorContext.swift in Sources */, 6167E6D62B7F8C3400C3CA2D /* AppHangsWatchdogThread.swift in Sources */, 61C713A32A3B78F900FA735A /* RUMMonitorProtocol.swift in Sources */, 3C0D5DEC2A54405A00446CF9 /* RUMViewEventsFilter.swift in Sources */, @@ -8536,6 +8576,7 @@ 61C713A52A3B78F900FA735A /* RUMMonitorProtocol+Internal.swift in Sources */, D29A9F5129DD85BB005C54A4 /* LongTaskObserver.swift in Sources */, D29A9F8629DD85BB005C54A4 /* SessionReplayDependency.swift in Sources */, + 616F8C272BB1CD990061EA53 /* ProcessIdentifier.swift in Sources */, D29A9F7129DD85BB005C54A4 /* RUMDeviceInfo.swift in Sources */, D29A9F5329DD85BB005C54A4 /* RUMOffViewEventsHandlingRule.swift in Sources */, D29A9F5429DD85BB005C54A4 /* RUMScope.swift in Sources */, @@ -8568,6 +8609,7 @@ D29A9FA629DDB483005C54A4 /* RUMOffViewEventsHandlingRuleTests.swift in Sources */, D29A9FBD29DDB483005C54A4 /* RUMSessionScopeTests.swift in Sources */, D29A9FAB29DDB483005C54A4 /* RUMUserActionScopeTests.swift in Sources */, + 615B0F8B2BB33C2800E9ED6C /* AppHangsMonitorTests.swift in Sources */, 61C713B32A3C3A0B00FA735A /* RUMMonitorProtocol+InternalTests.swift in Sources */, D29A9FE029DDC75A005C54A4 /* UIKitMocks.swift in Sources */, D29A9FA329DDB483005C54A4 /* RUMDeviceInfoTests.swift in Sources */, diff --git a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift index 99543500d3..d577de054f 100644 --- a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift +++ b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift @@ -51,8 +51,8 @@ class AppHangsMonitoringTests: XCTestCase { let appHangError = try XCTUnwrap(errors.first) let actualHangDuration = try XCTUnwrap(appHangError.freeze?.duration) - XCTAssertEqual(appHangError.error.message, AppHangsObserver.Constants.appHangErrorMessage) - XCTAssertEqual(appHangError.error.type, AppHangsObserver.Constants.appHangErrorType) + XCTAssertEqual(appHangError.error.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(appHangError.error.type, AppHangsMonitor.Constants.appHangErrorType) XCTAssertEqual(appHangError.error.category, .appHang) XCTAssertTrue(expectedHangDurationRangeNs.contains(actualHangDuration)) } @@ -74,8 +74,8 @@ class AppHangsMonitoringTests: XCTestCase { let appHangError = try XCTUnwrap(errors.first) let actualHangDuration = try XCTUnwrap(appHangError.freeze?.duration) - XCTAssertEqual(appHangError.error.message, AppHangsObserver.Constants.appHangErrorMessage) - XCTAssertEqual(appHangError.error.type, AppHangsObserver.Constants.appHangErrorType) + XCTAssertEqual(appHangError.error.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(appHangError.error.type, AppHangsMonitor.Constants.appHangErrorType) XCTAssertEqual(appHangError.error.category, .appHang) XCTAssertTrue(expectedHangDurationRangeNs.contains(actualHangDuration)) } @@ -104,8 +104,8 @@ class AppHangsMonitoringTests: XCTestCase { let appHangError = try XCTUnwrap(errors.first) let mainThreadStack = try XCTUnwrap(appHangError.error.stack) - XCTAssertEqual(appHangError.error.message, AppHangsObserver.Constants.appHangErrorMessage) - XCTAssertEqual(appHangError.error.type, AppHangsObserver.Constants.appHangErrorType) + XCTAssertEqual(appHangError.error.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(appHangError.error.type, AppHangsMonitor.Constants.appHangErrorType) XCTAssertTrue(mainThreadStack.contains(uiKitLibraryName), "Main thread stack should include UIKit symbols") XCTAssertEqual(appHangError.error.source, .source) XCTAssertNotNil(appHangError.error.threads, "Other threads should be available") @@ -128,9 +128,9 @@ class AppHangsMonitoringTests: XCTestCase { let errors = core.waitAndReturnEvents(ofFeature: RUMFeature.name, ofType: RUMErrorEvent.self) let appHangError = try XCTUnwrap(errors.first) - XCTAssertEqual(appHangError.error.message, AppHangsObserver.Constants.appHangErrorMessage) - XCTAssertEqual(appHangError.error.type, AppHangsObserver.Constants.appHangErrorType) - XCTAssertEqual(appHangError.error.stack, AppHangsObserver.Constants.appHangStackNotAvailableErrorMessage) + XCTAssertEqual(appHangError.error.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(appHangError.error.type, AppHangsMonitor.Constants.appHangErrorType) + XCTAssertEqual(appHangError.error.stack, AppHangsMonitor.Constants.appHangStackNotAvailableErrorMessage) XCTAssertEqual(appHangError.error.source, .source) XCTAssertNil(appHangError.error.threads, "Threads should be unavailable as CrashReporting was not enabled") XCTAssertNil(appHangError.error.binaryImages, "Binary Images should be unavailable as CrashReporting was not enabled") diff --git a/DatadogInternal/Sources/DataStore/DataStore.swift b/DatadogInternal/Sources/DataStore/DataStore.swift index 2cbe3b9ba6..4d9b170d9f 100644 --- a/DatadogInternal/Sources/DataStore/DataStore.swift +++ b/DatadogInternal/Sources/DataStore/DataStore.swift @@ -52,6 +52,9 @@ public protocol DataStore { /// - Parameters: /// - key: The unique identifier for the data. Must be a valid file name, as it will be persisted in files. /// - callback: A closure providing the result asynchronously on an internal queue. + /// + /// Note: The implementation must log errors to console and notify them through telemetry. Callers are not required + /// to implement logging of errors upon receiving `.error()` result. func value(forKey key: String, callback: @escaping (DataStoreValueResult) -> Void) /// Deletes the value associated with the specified key from the data store. diff --git a/DatadogRUM/Sources/Feature/RUMDataStore.swift b/DatadogRUM/Sources/Feature/RUMDataStore.swift new file mode 100644 index 0000000000..08ffd10bfc --- /dev/null +++ b/DatadogRUM/Sources/Feature/RUMDataStore.swift @@ -0,0 +1,76 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +internal extension FeatureScope { + /// Data store endpoint suited for RUM data. + var rumDataStore: RUMDataStore { + RUMDataStore(featureScope: self) + } + + func rumDataStoreContext(_ block: @escaping (DatadogContext, RUMDataStore) -> Void) { + dataStoreContext { context, dataStore in + block(context, rumDataStore) + } + } +} + +/// RUM interface for data store. +/// +/// It stores values in JSON format and implements convenience for type-safe key referencing and data serialization. +/// Serialization errors are logged to telemetry. +internal struct RUMDataStore { + internal enum Key: String { + /// References pending App Hang information. + /// If found during app start it is considered a fatal hang in previous process. + case fatalAppHangKey = "fatal-app-hang" + } + + /// Encodes values in RUM data store. + private static let encoder = JSONEncoder() + /// Decodes values in RUM data store. + private static let decoder = JSONDecoder() + + /// RUM feature scope. + let featureScope: FeatureScope + + func setValue(_ value: V, forKey key: Key, version: DataStoreKeyVersion = dataStoreDefaultKeyVersion) { + do { + let data = try RUMDataStore.encoder.encode(value) + featureScope.dataStore.setValue(data, forKey: key.rawValue, version: version) + } catch let error { + DD.logger.error("Failed to encode \(type(of: value)) in RUM Data Store") + featureScope.telemetry.error("Failed to encode \(type(of: value)) in RUM Data Store", error: error) + } + } + + func value(forKey key: Key, version: DataStoreKeyVersion = dataStoreDefaultKeyVersion, callback: @escaping (V?) -> Void) { + featureScope.dataStore.value(forKey: key.rawValue) { result in + guard let data = result.data(expectedVersion: version) else { + // One of following: + // - no value + // - value but in wrong version → skip + // - error in reading the value (already logged in telemetry by `store`) + callback(nil) + return + } + do { + let value = try RUMDataStore.decoder.decode(V.self, from: data) + callback(value) + } catch let error { + DD.logger.error("Failed to decode \(V.self) from RUM Data Store") + featureScope.telemetry.error("Failed to decode \(V.self) from RUM Data Store", error: error) + callback(nil) + } + } + } + + func removeValue(forKey key: Key) { + featureScope.dataStore.removeValue(forKey: key.rawValue) + } +} diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index f207260e88..161761dc34 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -78,6 +78,7 @@ internal final class RUMFeature: DatadogRemoteFeature { ) self.instrumentation = RUMInstrumentation( + featureScope: featureScope, uiKitRUMViewsPredicate: configuration.uiKitViewsPredicate, uiKitRUMActionsPredicate: configuration.uiKitActionsPredicate, longTaskThreshold: configuration.longTaskThreshold, @@ -85,7 +86,8 @@ internal final class RUMFeature: DatadogRemoteFeature { mainQueue: configuration.mainQueue, dateProvider: configuration.dateProvider, backtraceReporter: core.backtraceReporter, - telemetry: core.telemetry + fatalErrorContext: dependencies.fatalErrorContext, + processID: configuration.processID ) self.requestBuilder = RequestBuilder( customIntakeURL: configuration.customEndpoint, diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift index 1fc0caa85e..b008363d60 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift @@ -22,7 +22,22 @@ internal struct AppHang: Codable { } /// The date of hang start. + /// It is defined as device time, without considering NTP offset. let startDate: Date /// The result of generating backtrace for the hang. let backtraceResult: BacktraceGenerationResult } + +/// Persisted information on App Hang that may likely become fatal. +/// +/// It encodes all information necessary to report error on app restart. +internal struct FatalAppHang: Codable { + /// An identifier of the process that the hang was recorded in. + let processID: UUID + /// The actual hang that was recorded. + let hang: AppHang + /// Interval between device and server time. + let serverTimeOffset: TimeInterval + /// The last RUM view at the moment of hang's recording. + let lastRUMView: RUMViewEvent +} diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift new file mode 100644 index 0000000000..b19603b014 --- /dev/null +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift @@ -0,0 +1,99 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +internal final class AppHangsMonitor { + enum Constants { + /// The standardized `error.message` for RUM errors describing an app hang. + static let appHangErrorMessage = "App Hang" + /// The standardized `error.type` for RUM errors describing an app hang. + static let appHangErrorType = "AppHang" + /// The standardized `error.stack` when backtrace generation was not available. + static let appHangStackNotAvailableErrorMessage = "Stack trace was not generated because `DatadogCrashReporting` had not been enabled." + /// The standardized `error.stack` when backtrace generation failed due to an internal error. + static let appHangStackGenerationFailedErrorMessage = "Failed to generate stack trace. This is a known issue and we work on it." + } + + /// Watchdog thread that monitors the main queue for App Hangs. + private let watchdogThread: AppHangsObservingThread + /// Handles non-fatal App Hangs. + internal let nonFatalHangsHandler: NonFatalAppHangsHandler + /// Handles non-fatal App Hangs. + internal let fatalHangsHandler: FatalAppHangsHandler + + convenience init( + featureScope: FeatureScope, + appHangThreshold: TimeInterval, + observedQueue: DispatchQueue, + backtraceReporter: BacktraceReporting, + fatalErrorContext: FatalErrorContext, + dateProvider: DateProvider, + processID: UUID + ) { + self.init( + featureScope: featureScope, + watchdogThread: AppHangsWatchdogThread( + appHangThreshold: appHangThreshold, + queue: observedQueue, + dateProvider: dateProvider, + backtraceReporter: backtraceReporter, + telemetry: featureScope.telemetry + ), + fatalErrorContext: fatalErrorContext, + processID: processID + ) + } + + init( + featureScope: FeatureScope, + watchdogThread: AppHangsObservingThread, + fatalErrorContext: FatalErrorContext, + processID: UUID + ) { + self.watchdogThread = watchdogThread + self.nonFatalHangsHandler = NonFatalAppHangsHandler() + self.fatalHangsHandler = FatalAppHangsHandler( + featureScope: featureScope, + fatalErrorContext: fatalErrorContext, + processID: processID + ) + } + + func start() { + fatalHangsHandler.reportFatalAppHangIfFound() + watchdogThread.onHangStarted = { [weak self] hang in + self?.fatalHangsHandler.startHang(hang: hang) + } + watchdogThread.onHangCancelled = { [weak self] hang in + self?.fatalHangsHandler.cancelHang(hang: hang) + } + watchdogThread.onHangEnded = { [weak self] hang, duration in + self?.fatalHangsHandler.endHang(hang: hang) + self?.nonFatalHangsHandler.endHang(appHang: hang, duration: duration) + } + watchdogThread.start() + } + + func stop() { + watchdogThread.stop() + watchdogThread.onHangStarted = nil + watchdogThread.onHangCancelled = nil + watchdogThread.onHangEnded = nil + } +} + +extension AppHangsMonitor { + /// Awaits the processing of pending app hang. + /// + /// Note: This method is synchronous and will block the caller thread, in worst case up for `appHangThreshold`. + func flush() { + let semaphore = DispatchSemaphore(value: 0) + watchdogThread.onBeforeSleep = { semaphore.signal() } + semaphore.wait() + } +} diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsObserver.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsObserver.swift deleted file mode 100644 index 836ad4d1cf..0000000000 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsObserver.swift +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2019-Present Datadog, Inc. - */ - -import Foundation -import DatadogInternal - -internal class AppHangsObserver: RUMCommandPublisher { - enum Constants { - /// The standardized `error.message` for RUM errors describing an app hang. - static let appHangErrorMessage = "App Hang" - - /// The standardized `error.type` for RUM errors describing an app hang. - static let appHangErrorType = "AppHang" - - /// The standardized `error.stack` when backtrace generation was not available. - static let appHangStackNotAvailableErrorMessage = "Stack trace was not generated because `DatadogCrashReporting` had not been enabled." - - /// The standardized `error.stack` when backtrace generation failed due to an internal error. - static let appHangStackGenerationFailedErrorMessage = "Failed to generate stack trace. This is a known issue and we work on it." - } - - /// Watchdog thread that monitors the main queue for App Hangs. - private let watchdogThread: AppHangsWatchdogThread - /// Weak reference to RUM monitor for sending App Hang events. - private(set) weak var subscriber: RUMCommandSubscriber? - - init( - appHangThreshold: TimeInterval, - observedQueue: DispatchQueue, - backtraceReporter: BacktraceReporting, - dateProvider: DateProvider, - telemetry: Telemetry - ) { - watchdogThread = AppHangsWatchdogThread( - appHangThreshold: appHangThreshold, - queue: observedQueue, - dateProvider: dateProvider, - backtraceReporter: backtraceReporter, - telemetry: telemetry - ) - watchdogThread.onHangEnded = { [weak self] appHang, duration in - // called on watchdog thread - self?.report(nonFatal: appHang, duration: duration) - } - } - - func start() { - watchdogThread.start() - } - - func stop() { - watchdogThread.cancel() - } - - func publish(to subscriber: RUMCommandSubscriber) { - self.subscriber = subscriber - } - - private func report(nonFatal appHang: AppHang, duration: TimeInterval) { - let command = RUMAddCurrentViewAppHangCommand( - time: appHang.startDate, - attributes: [:], - message: Constants.appHangErrorMessage, - type: Constants.appHangErrorType, - stack: appHang.backtraceResult.stack, - threads: appHang.backtraceResult.threads, - binaryImages: appHang.backtraceResult.binaryImages, - isStackTraceTruncated: appHang.backtraceResult.wasTruncated, - hangDuration: duration - ) - - subscriber?.process(command: command) - } -} - -extension AppHangsObserver { - /// Awaits the processing of pending app hang. - /// - /// Note: This method is synchronous and will block the caller thread, in worst case up for `appHangThreshold`. - func flush() { - let semaphore = DispatchSemaphore(value: 0) - watchdogThread.onBeforeSleep = { semaphore.signal() } - semaphore.wait() - } -} - -internal extension AppHang.BacktraceGenerationResult { - var stack: String { - switch self { - case .succeeded(let backtrace): return backtrace.stack - case .failed: return AppHangsObserver.Constants.appHangStackGenerationFailedErrorMessage - case .notAvailable: return AppHangsObserver.Constants.appHangStackNotAvailableErrorMessage - } - } - - var threads: [DDThread]? { - switch self { - case .succeeded(let backtrace): return backtrace.threads - case .failed, .notAvailable: return nil - } - } - - var binaryImages: [BinaryImage]? { - switch self { - case .succeeded(let backtrace): return backtrace.binaryImages - case .failed, .notAvailable: return nil - } - } - - var wasTruncated: Bool? { - switch self { - case .succeeded(let backtrace): return backtrace.wasTruncated - case .failed, .notAvailable: return nil - } - } -} diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift index a4752e2679..f7603bf4af 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift @@ -7,7 +7,22 @@ import Foundation import DatadogInternal -internal final class AppHangsWatchdogThread: Thread { +internal protocol AppHangsObservingThread: AnyObject { + /// Starts the thread. + func start() + /// Stops the thread. + func stop() + /// Closure to be notified when App Hang starts. + var onHangStarted: ((AppHang) -> Void)? { set get } + /// Closure to be notified when App Hang gets cancelled due to possible false-positive. + var onHangCancelled: ((AppHang) -> Void)? { set get } + /// Closure to be notified when App Hang ends. It passes the hang and its duration. + var onHangEnded: ((AppHang, TimeInterval) -> Void)? { set get } + /// A block called after the thread finished its pass and will become idle. + var onBeforeSleep: (() -> Void)? { set get } +} + +internal final class AppHangsWatchdogThread: Thread, AppHangsObservingThread { enum Constants { /// The "idle" interval for sleeping the watchdog thread before scheduling the next task on the main queue, represented as a percentage of the `appHangThreshold`. /// @@ -107,6 +122,8 @@ internal final class AppHangsWatchdogThread: Thread { } } + func stop() { cancel() } + override func main() { let mainThreadTask = self.mainThreadTask diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/FatalAppHangsHandler.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/FatalAppHangsHandler.swift new file mode 100644 index 0000000000..9f9f8a5083 --- /dev/null +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/FatalAppHangsHandler.swift @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +internal final class FatalAppHangsHandler { + /// RUM feature scope. + private let featureScope: FeatureScope + /// RUM context for fatal App Hangs monitoring. + private let fatalErrorContext: FatalErrorContext + /// An ID of the current process. + private let processID: UUID + + init( + featureScope: FeatureScope, + fatalErrorContext: FatalErrorContext, + processID: UUID + ) { + self.featureScope = featureScope + self.fatalErrorContext = fatalErrorContext + self.processID = processID + } + + func startHang(hang: AppHang) { + guard let lastRUMView = fatalErrorContext.view else { + DD.logger.debug("App Hang is being detected, but won't be considered fatal as there is no active RUM view") + return // expected if there was no active view + } + + featureScope.rumDataStoreContext { [processID] context, dataStore in + let fatalHang = FatalAppHang( + processID: processID, + hang: hang, + serverTimeOffset: context.serverTimeOffset, + lastRUMView: lastRUMView + ) + dataStore.setValue(fatalHang, forKey: .fatalAppHangKey) + } + } + + func cancelHang(hang: AppHang) { + featureScope.rumDataStoreContext { _, dataStore in // on context queue to avoid race condition with `startHang(hang:)` + dataStore.removeValue(forKey: .fatalAppHangKey) + } + } + + func endHang(hang: AppHang) { + featureScope.rumDataStoreContext { _, dataStore in // on context queue to avoid race condition with `startHang(hang:)` + dataStore.removeValue(forKey: .fatalAppHangKey) + } + } + + func reportFatalAppHangIfFound() { + featureScope.rumDataStore.value(forKey: .fatalAppHangKey) { [weak self] (fatalHang: FatalAppHang?) in + guard let fatalHang = fatalHang else { + return // previous process didn't end up with a hang + } + guard fatalHang.processID != self?.processID else { + return // skip as possible false-positive + } + + DD.logger.debug("Loaded fatal App Hang") + + self?.send(fatalHang: fatalHang) + } + } + + private func send(fatalHang: FatalAppHang) { + // TODO: RUM-3461 + // Similar to how we send Crash report in `CrashReportReceiver`: + // - construct RUM error from `fatalHang.hang` information + // - update `error.count` in `fatalHang.lastRUMView` + } +} diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift new file mode 100644 index 0000000000..40ee0cdc9c --- /dev/null +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift @@ -0,0 +1,64 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +internal final class NonFatalAppHangsHandler: RUMCommandPublisher { + /// Weak reference to RUM monitor for sending App Hang events. + private(set) weak var subscriber: RUMCommandSubscriber? + + func publish(to subscriber: RUMCommandSubscriber) { + self.subscriber = subscriber + } + + func endHang(appHang: AppHang, duration: TimeInterval) { + let command = RUMAddCurrentViewAppHangCommand( + time: appHang.startDate, + attributes: [:], + message: AppHangsMonitor.Constants.appHangErrorMessage, + type: AppHangsMonitor.Constants.appHangErrorType, + stack: appHang.backtraceResult.stack, + threads: appHang.backtraceResult.threads, + binaryImages: appHang.backtraceResult.binaryImages, + isStackTraceTruncated: appHang.backtraceResult.wasTruncated, + hangDuration: duration + ) + + subscriber?.process(command: command) + } +} + +internal extension AppHang.BacktraceGenerationResult { + var stack: String { + switch self { + case .succeeded(let backtrace): return backtrace.stack + case .failed: return AppHangsMonitor.Constants.appHangStackGenerationFailedErrorMessage + case .notAvailable: return AppHangsMonitor.Constants.appHangStackNotAvailableErrorMessage + } + } + + var threads: [DDThread]? { + switch self { + case .succeeded(let backtrace): return backtrace.threads + case .failed, .notAvailable: return nil + } + } + + var binaryImages: [BinaryImage]? { + switch self { + case .succeeded(let backtrace): return backtrace.binaryImages + case .failed, .notAvailable: return nil + } + } + + var wasTruncated: Bool? { + switch self { + case .succeeded(let backtrace): return backtrace.wasTruncated + case .failed, .notAvailable: return nil + } + } +} diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/ProcessIdentifier.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/ProcessIdentifier.swift new file mode 100644 index 0000000000..5499831b5f --- /dev/null +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/ProcessIdentifier.swift @@ -0,0 +1,19 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation + +/// An identifier for the current application process. Being static variable, it is the same for all instances of RUM within the same +/// process but different for RUM instances after app restart. +/// +/// Use this identifier to distinguish data collected between different process instances and between SDK instances: +/// - Data collected in two processes will have different `processID`. +/// - Data collected in two SDK instances within the same process will share the same `processID`. +/// +/// Example use case in fatal App Hangs tracking: +/// - SDK started → RUM enabled → [hang occurs] → pending App Hang saved → SDK stopped → SDK started again → RUM enabled again → pending App Hang loaded +/// - When restarting RUM , the `processID` check ensures dropping pending hang from the previous instance, preventing false "fatal" hang detection. +internal let currentProcessID = UUID() diff --git a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift index af98bc1a3b..47e3cf334d 100644 --- a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift +++ b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift @@ -34,11 +34,12 @@ internal final class RUMInstrumentation: RUMCommandPublisher { let longTasks: LongTaskObserver? /// Instruments App Hangs. It is `nil` if hangs monitoring is not enabled. - let appHangs: AppHangsObserver? + let appHangs: AppHangsMonitor? // MARK: - Initialization init( + featureScope: FeatureScope, uiKitRUMViewsPredicate: UIKitRUMViewsPredicate?, uiKitRUMActionsPredicate: UIKitRUMActionsPredicate?, longTaskThreshold: TimeInterval?, @@ -46,7 +47,8 @@ internal final class RUMInstrumentation: RUMCommandPublisher { mainQueue: DispatchQueue, dateProvider: DateProvider, backtraceReporter: BacktraceReporting, - telemetry: Telemetry + fatalErrorContext: FatalErrorContext, + processID: UUID ) { // Always create views handler (we can't know if it will be used by SwiftUI instrumentation) // and only swizzle `UIViewController` if UIKit instrumentation is configured: @@ -59,7 +61,7 @@ internal final class RUMInstrumentation: RUMCommandPublisher { // Create long tasks and app hang observers only if configured: var longTasks: LongTaskObserver? = nil - var appHangs: AppHangsObserver? = nil + var appHangs: AppHangsMonitor? = nil do { if uiKitRUMViewsPredicate != nil { @@ -100,12 +102,14 @@ internal final class RUMInstrumentation: RUMCommandPublisher { DD.logger.warn("`RUM.Configuration.appHangThreshold` cannot be less than \(Constants.minAppHangThreshold)s. A value of \(Constants.minAppHangThreshold)s will be used.") } - appHangs = AppHangsObserver( + appHangs = AppHangsMonitor( + featureScope: featureScope, appHangThreshold: appHangThreshold, observedQueue: mainQueue, backtraceReporter: backtraceReporter, + fatalErrorContext: fatalErrorContext, dateProvider: dateProvider, - telemetry: telemetry + processID: processID ) } @@ -135,6 +139,6 @@ internal final class RUMInstrumentation: RUMCommandPublisher { viewsHandler.publish(to: subscriber) actionsHandler?.publish(to: subscriber) longTasks?.publish(to: subscriber) - appHangs?.publish(to: subscriber) + appHangs?.nonFatalHangsHandler.publish(to: subscriber) } } diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 7eea40eb45..2d8b5fe51a 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -274,6 +274,8 @@ extension RUM { internal var dateProvider: DateProvider = SystemDateProvider() /// The main queue, subject to App Hangs monitoring. internal var mainQueue: DispatchQueue = .main + /// Identifier of the current process, used to check if fatal App Hang originated in a previous process instance. + internal var processID: UUID = currentProcessID internal var debugSDK: Bool = ProcessInfo.processInfo.arguments.contains(LaunchArguments.Debug) internal var debugViews: Bool = ProcessInfo.processInfo.arguments.contains("DD_DEBUG_RUM") diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/FatalErrorContext.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/FatalErrorContext.swift new file mode 100644 index 0000000000..5af7f33ffe --- /dev/null +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/FatalErrorContext.swift @@ -0,0 +1,42 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +/// Manages RUM information necessary for building context for fatal errors such as Crashes or fatal App Hangs. +internal final class FatalErrorContext { + /// Message bus interface to send context updates to Crash Reporting. + private let messageBus: MessageSending + + init(messageBus: MessageSending) { + self.messageBus = messageBus + } + + /// The state of the current RUM session. + /// Can be `nil` if no session was yet started. Never gets `nil` after starting first session. + @ReadWriteLock + var sessionState: RUMSessionState? { + didSet { + if let sessionState = sessionState { + messageBus.send(message: .baggage(key: RUMBaggageKeys.sessionState, value: sessionState)) + } + } + } + + /// The active RUM view in current session. + /// Can be `nil` if no view is yet started. Will become `nil` if view was stopped without starting the new one. + @ReadWriteLock + var view: RUMViewEvent? { + didSet { + if let lastRUMView = view { + messageBus.send(message: .baggage(key: RUMBaggageKeys.viewEvent, value: lastRUMView)) + } else { + messageBus.send(message: .baggage(key: RUMBaggageKeys.viewReset, value: true)) + } + } + } +} diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift index 8fcfe7dcdd..a6693232db 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMScopeDependencies.swift @@ -42,16 +42,49 @@ internal struct RUMScopeDependencies { let vitalsReaders: VitalsReaders? let onSessionStart: RUM.SessionListener? let viewCache: ViewCache + /// The RUM context necessary for tracking fatal errors like Crashes or fatal App Hangs. + let fatalErrorContext: FatalErrorContext + /// Telemetry endpoint. + let telemetry: Telemetry + let sessionType: RUMSessionType - var telemetry: Telemetry { featureScope.telemetry } + init( + featureScope: FeatureScope, + rumApplicationID: String, + sessionSampler: Sampler, + trackBackgroundEvents: Bool, + trackFrustrations: Bool, + firstPartyHosts: FirstPartyHosts?, + eventBuilder: RUMEventBuilder, + rumUUIDGenerator: RUMUUIDGenerator, + ciTest: RUMCITest?, + syntheticsTest: RUMSyntheticsTest?, + vitalsReaders: VitalsReaders?, + onSessionStart: RUM.SessionListener?, + viewCache: ViewCache + ) { + self.featureScope = featureScope + self.rumApplicationID = rumApplicationID + self.sessionSampler = sessionSampler + self.trackBackgroundEvents = trackBackgroundEvents + self.trackFrustrations = trackFrustrations + self.firstPartyHosts = firstPartyHosts + self.eventBuilder = eventBuilder + self.rumUUIDGenerator = rumUUIDGenerator + self.ciTest = ciTest + self.syntheticsTest = syntheticsTest + self.vitalsReaders = vitalsReaders + self.onSessionStart = onSessionStart + self.viewCache = viewCache + self.fatalErrorContext = FatalErrorContext(messageBus: featureScope) + self.telemetry = featureScope.telemetry - var sessionType: RUMSessionType { if ciTest != nil { - return .ciTest + self.sessionType = .ciTest } else if syntheticsTest != nil { - return .synthetics + self.sessionType = .synthetics } else { - return .user + self.sessionType = .user } } } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift index 82a690315f..a3d9041e78 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMSessionScope.swift @@ -46,7 +46,7 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { /// Information about this session state, shared with `CrashContext`. private var state: RUMSessionState { didSet { - dependencies.featureScope.send(message: .baggage(key: RUMBaggageKeys.sessionState, value: state)) + dependencies.fatalErrorContext.sessionState = state } } @@ -117,8 +117,8 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { ) } - // Update `CrashContext` with recent RUM session state: - dependencies.featureScope.send(message: .baggage(key: RUMBaggageKeys.sessionState, value: state)) + // Update fatal error context with recent RUM session state: + dependencies.fatalErrorContext.sessionState = state // Notify Synthetics if needed if dependencies.syntheticsTest != nil && sessionUUID != .nullUUID { @@ -214,12 +214,12 @@ internal class RUMSessionScope: RUMScope, RUMContextProvider { viewScopes = viewScopes.scopes(byPropagating: command, context: context, writer: writer) if (isActive || deactivating) && !hasActiveView { - // If this session is active and there is no active view, update `CrashContext` accordingly, so eventual crash - // won't be associated to an inactive view and instead we will consider starting background view to track it. + // If this session is active and there is no active view, update fatal error context accordingly, so eventual + // error won't be associated to an inactive view and instead we will consider starting background view to track it. // We also want to send this as a session is being stopped. // It means that with Background Events Tracking disabled, eventual off-view crashes will be dropped // similar to how we drop other events. - dependencies.featureScope.send(message: .baggage(key: RUMBaggageKeys.viewReset, value: true)) + dependencies.fatalErrorContext.view = nil } return isActive || !viewScopes.isEmpty diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift index b61c221529..1a98a5e1d2 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMViewScope.swift @@ -540,14 +540,8 @@ internal class RUMViewScope: RUMScope, RUMContextProvider { if let event = dependencies.eventBuilder.build(from: viewEvent) { writer.write(value: event, metadata: event.metadata()) - // Update `CrashContext` with recent RUM view (no matter sampling - we want to always - // have recent information if process is interrupted by crash): - dependencies.featureScope.send( - message: .baggage( - key: RUMBaggageKeys.viewEvent, - value: event - ) - ) + // Update fatal error context with recent RUM view: + dependencies.fatalErrorContext.view = event } else { // if event was dropped by mapper version -= 1 } diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMOffViewEventsHandlingRule.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMOffViewEventsHandlingRule.swift index a7c46bc6e4..c494ea6d20 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMOffViewEventsHandlingRule.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/Utils/RUMOffViewEventsHandlingRule.swift @@ -7,7 +7,7 @@ import Foundation /// Lightweight representation of current RUM session state, used to compute `RUMOffViewEventsHandlingRule`. -/// It gets serialized into `CrashContext` for computing the rule upon app process restart after crash. +/// It gets serialized into fatal error context for computing the rule upon app process restart. internal struct RUMSessionState: Equatable, Codable { /// The session ID. Can be `.nullUUID` if the session was rejected by sampler. let sessionUUID: UUID diff --git a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift new file mode 100644 index 0000000000..74abedbdb0 --- /dev/null +++ b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift @@ -0,0 +1,180 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities +import DatadogInternal +@testable import DatadogRUM + +private class WatchdogThreadMock: AppHangsObservingThread { + var started: Bool? + + func start() { started = true } + func stop() { started = false } + + var onHangStarted: ((AppHang) -> Void)? + var onHangCancelled: ((AppHang) -> Void)? + var onHangEnded: ((AppHang, TimeInterval) -> Void)? + var onBeforeSleep: (() -> Void)? +} + +class AppHangsMonitorTests: XCTestCase { + private let featureScope = FeatureScopeMock() + private let watchdogThread = WatchdogThreadMock() + private let fatalErrorContext = FatalErrorContext(messageBus: NOPFeatureScope()) + private let currentProcessID = UUID() + private var dd: DDMock! // swiftlint:disable:this implicitly_unwrapped_optional + private var monitor: AppHangsMonitor! // swiftlint:disable:this implicitly_unwrapped_optional + + override func setUp() { + dd = DD.mockWith(logger: CoreLoggerMock()) + monitor = AppHangsMonitor( + featureScope: featureScope, + watchdogThread: watchdogThread, + fatalErrorContext: fatalErrorContext, + processID: currentProcessID + ) + } + + override func tearDown() { + monitor = nil + dd.reset() + } + + func testStartAndStop() throws { + // When, Then + monitor.start() + XCTAssertEqual(watchdogThread.started, true) + + // When, Then + monitor.stop() + XCTAssertEqual(watchdogThread.started, false) + } + + // MARK: - Non-Fatal App Hangs Monitoring + + func testWhenAppHangEnds_itSendsAppHangCommand() throws { + // Given + let subscriber = RUMCommandSubscriberMock() + monitor.nonFatalHangsHandler.publish(to: subscriber) + monitor.start() + defer { monitor.stop() } + + // When + let hang: AppHang = .mockRandom() + let duration: TimeInterval = .mockRandom(min: 1, max: 4) + watchdogThread.onHangEnded?(hang, duration) + + // Then + let command = try XCTUnwrap(subscriber.lastReceivedCommand as? RUMAddCurrentViewAppHangCommand) + XCTAssertEqual(command.time, hang.startDate) + XCTAssertEqual(command.hangDuration, duration) + XCTAssertEqual(command.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(command.type, AppHangsMonitor.Constants.appHangErrorType) + XCTAssertEqual(command.stack, hang.backtraceResult.stack) + DDAssertReflectionEqual(command.threads, hang.backtraceResult.threads) + DDAssertReflectionEqual(command.binaryImages, hang.backtraceResult.binaryImages) + XCTAssertEqual(command.isStackTraceTruncated, hang.backtraceResult.wasTruncated) + } + + // MARK: - Fatal App Hangs Monitoring + + func testGivenFatalErrorViewContextAvailable_whenAppHangStarts_itSavesPendingAppHangToDataStore() throws { + // Given + monitor.start() + defer { monitor.stop() } + fatalErrorContext.view = .mockRandom() + + // When + let hang: AppHang = .mockRandom() + watchdogThread.onHangStarted?(hang) + + // Then + XCTAssertNotNil(featureScope.dataStoreMock.value(forKey: RUMDataStore.Key.fatalAppHangKey.rawValue)) + XCTAssertTrue(dd.logger.recordedLogs.isEmpty, "It must log no issues") + } + + func testGivenFatalErrorViewContextNotAvailable_whenAppHangStarts_itLogsDebug() throws { + // Given + monitor.start() + defer { monitor.stop() } + fatalErrorContext.view = nil + + // When + let hang: AppHang = .mockRandom() + watchdogThread.onHangStarted?(hang) + + // Then + XCTAssertNil(featureScope.dataStoreMock.value(forKey: RUMDataStore.Key.fatalAppHangKey.rawValue)) + XCTAssertEqual( + dd.logger.debugLog?.message, + "App Hang is being detected, but won't be considered fatal as there is no active RUM view" + ) + } + + func testWhenAppHangGetsCancelled_itDeletesPendingAppHangInDataStore() throws { + // Given + monitor.start() + defer { monitor.stop() } + fatalErrorContext.view = .mockRandom() + + // When + let hang: AppHang = .mockRandom() + watchdogThread.onHangStarted?(hang) + watchdogThread.onHangCancelled?(hang) + + // Then + XCTAssertNil(featureScope.dataStoreMock.value(forKey: RUMDataStore.Key.fatalAppHangKey.rawValue)) + XCTAssertTrue(dd.logger.recordedLogs.isEmpty) + XCTAssertTrue(dd.logger.recordedLogs.isEmpty, "It must log no issues") + } + + func testWhenAppHangEnds_itDeletesPendingAppHangInDataStore() throws { + // Given + monitor.start() + defer { monitor.stop() } + fatalErrorContext.view = .mockRandom() + + // When + let hang: AppHang = .mockRandom() + let duration: TimeInterval = .mockAny() + watchdogThread.onHangStarted?(hang) + watchdogThread.onHangEnded?(hang, duration) + + // Then + XCTAssertNil(featureScope.dataStoreMock.value(forKey: RUMDataStore.Key.fatalAppHangKey.rawValue)) + XCTAssertTrue(dd.logger.recordedLogs.isEmpty, "It must log no issues") + } + + func testGivenPendingHangSavedInOneProcess_whenStartedInDiffferentProcess_itSendsFatalHang() throws { + let sessionState: RUMSessionState = .mockRandom() + let view: RUMViewEvent = .mockRandom() + let hang: AppHang = .mockRandom() + + // Given + monitor.start() + fatalErrorContext.sessionState = sessionState + fatalErrorContext.view = view + watchdogThread.onHangStarted?(hang) + monitor.stop() + + // When + let monitor = AppHangsMonitor( + featureScope: featureScope, + watchdogThread: watchdogThread, + fatalErrorContext: fatalErrorContext, + processID: UUID() // different process + ) + monitor.start() + defer { monitor.stop() } + + // Then + XCTAssertEqual(dd.logger.debugLog?.message, "Loaded fatal App Hang") + + // TODO: RUM-3461 + // Assert on collected RUM error and RUM view update, similar to how we test it for crash reports + } +} diff --git a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift index 628a1aeefd..258fb81a75 100644 --- a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift +++ b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift @@ -143,11 +143,11 @@ class AppHangsWatchdogThreadTests: XCTestCase { telemetry: TelemetryMock() ) watchdogThread.onHangStarted = { hang in - XCTAssertEqual(hang.backtraceResult.stack, AppHangsObserver.Constants.appHangStackNotAvailableErrorMessage) + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackNotAvailableErrorMessage) trackHangStart.fulfill() } watchdogThread.onHangEnded = { hang, _ in - XCTAssertEqual(hang.backtraceResult.stack, AppHangsObserver.Constants.appHangStackNotAvailableErrorMessage) + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackNotAvailableErrorMessage) trackHangEnd.fulfill() } watchdogThread.onHangCancelled = { _ in XCTFail("It should not cancel the hang") } @@ -180,11 +180,11 @@ class AppHangsWatchdogThreadTests: XCTestCase { telemetry: TelemetryMock() ) watchdogThread.onHangStarted = { hang in - XCTAssertEqual(hang.backtraceResult.stack, AppHangsObserver.Constants.appHangStackGenerationFailedErrorMessage) + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackGenerationFailedErrorMessage) trackHangStart.fulfill() } watchdogThread.onHangEnded = { hang, _ in - XCTAssertEqual(hang.backtraceResult.stack, AppHangsObserver.Constants.appHangStackGenerationFailedErrorMessage) + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackGenerationFailedErrorMessage) trackHangEnd.fulfill() } watchdogThread.onHangCancelled = { _ in XCTFail("It should not cancel the hang") } diff --git a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift index 73ed980d84..476587d873 100644 --- a/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift +++ b/DatadogRUM/Tests/Instrumentation/RUMInstrumentationTests.swift @@ -15,6 +15,7 @@ class RUMInstrumentationTests: XCTestCase { func testWhenOnlyUIKitViewsPredicateIsConfigured_itInstrumentsUIViewController() throws { // When let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), uiKitRUMViewsPredicate: UIKitRUMViewsPredicateMock(), uiKitRUMActionsPredicate: nil, longTaskThreshold: nil, @@ -22,7 +23,8 @@ class RUMInstrumentationTests: XCTestCase { mainQueue: .main, dateProvider: SystemDateProvider(), backtraceReporter: BacktraceReporterMock(), - telemetry: NOPTelemetry() + fatalErrorContext: .mockAny(), + processID: .mockAny() ) // Then @@ -38,6 +40,7 @@ class RUMInstrumentationTests: XCTestCase { func testWhenOnlyUIKitActionsPredicateIsConfigured_itInstrumentsUIApplication() throws { // When let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), uiKitRUMViewsPredicate: nil, uiKitRUMActionsPredicate: UIKitRUMActionsPredicateMock(), longTaskThreshold: nil, @@ -45,7 +48,8 @@ class RUMInstrumentationTests: XCTestCase { mainQueue: .main, dateProvider: SystemDateProvider(), backtraceReporter: BacktraceReporterMock(), - telemetry: NOPTelemetry() + fatalErrorContext: .mockAny(), + processID: .mockAny() ) // Then @@ -58,6 +62,7 @@ class RUMInstrumentationTests: XCTestCase { func testWhenOnlyLongTasksThresholdIsConfigured_itInstrumentsRunLoop() throws { // When let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), uiKitRUMViewsPredicate: nil, uiKitRUMActionsPredicate: nil, longTaskThreshold: 0.5, @@ -65,7 +70,8 @@ class RUMInstrumentationTests: XCTestCase { mainQueue: .main, dateProvider: SystemDateProvider(), backtraceReporter: BacktraceReporterMock(), - telemetry: NOPTelemetry() + fatalErrorContext: .mockAny(), + processID: .mockAny() ) // Then @@ -81,6 +87,7 @@ class RUMInstrumentationTests: XCTestCase { func testWhenLongTasksThresholdIsLessOrEqualZero_itDoesNotInstrumentsRunLoop() { // When let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), uiKitRUMViewsPredicate: nil, uiKitRUMActionsPredicate: nil, longTaskThreshold: .mockRandom(min: -100, max: 0), @@ -88,7 +95,8 @@ class RUMInstrumentationTests: XCTestCase { mainQueue: .main, dateProvider: SystemDateProvider(), backtraceReporter: BacktraceReporterMock(), - telemetry: NOPTelemetry() + fatalErrorContext: .mockAny(), + processID: .mockAny() ) // Then @@ -97,17 +105,61 @@ class RUMInstrumentationTests: XCTestCase { } } + func testWhenAppHangThresholdIsConfigured_itInstrumentsAppHangs() { + // When + let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), + uiKitRUMViewsPredicate: nil, + uiKitRUMActionsPredicate: nil, + longTaskThreshold: .mockRandom(min: -100, max: 0), + appHangThreshold: 2, + mainQueue: .main, + dateProvider: SystemDateProvider(), + backtraceReporter: BacktraceReporterMock(), + fatalErrorContext: .mockAny(), + processID: .mockAny() + ) + + // Then + withExtendedLifetime(instrumentation) { + XCTAssertNotNil(instrumentation.appHangs) + } + } + + func testWhenAppHangThresholdIsNotConfigured_itDoesNotInstrumentsAppHangs() { + // When + let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), + uiKitRUMViewsPredicate: nil, + uiKitRUMActionsPredicate: nil, + longTaskThreshold: .mockRandom(min: -100, max: 0), + appHangThreshold: nil, + mainQueue: .main, + dateProvider: SystemDateProvider(), + backtraceReporter: BacktraceReporterMock(), + fatalErrorContext: .mockAny(), + processID: .mockAny() + ) + + // Then + withExtendedLifetime(instrumentation) { + XCTAssertNil(instrumentation.appHangs) + } + } + func testGivenAllInstrumentationsConfigured_whenSubscribed_itSetsSubsciberInRespectiveHandlers() throws { // Given let instrumentation = RUMInstrumentation( + featureScope: NOPFeatureScope(), uiKitRUMViewsPredicate: UIKitRUMViewsPredicateMock(), uiKitRUMActionsPredicate: UIKitRUMActionsPredicateMock(), longTaskThreshold: 0.5, - appHangThreshold: .mockAny(), + appHangThreshold: 2, mainQueue: .main, dateProvider: SystemDateProvider(), backtraceReporter: BacktraceReporterMock(), - telemetry: NOPTelemetry() + fatalErrorContext: .mockAny(), + processID: .mockAny() ) let subscriber = RUMCommandSubscriberMock() @@ -119,6 +171,7 @@ class RUMInstrumentationTests: XCTestCase { XCTAssertIdentical(instrumentation.viewsHandler.subscriber, subscriber) XCTAssertIdentical((instrumentation.actionsHandler as? UIKitRUMUserActionsHandler)?.subscriber, subscriber) XCTAssertIdentical(instrumentation.longTasks?.subscriber, subscriber) + XCTAssertIdentical(instrumentation.appHangs?.nonFatalHangsHandler.subscriber, subscriber) } } } diff --git a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift index 5c637bef6e..36ca7c655d 100644 --- a/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift +++ b/DatadogRUM/Tests/Mocks/RUMFeatureMocks.swift @@ -739,6 +739,14 @@ func mockNoOpSessionListener() -> RUM.SessionListener { return { _, _ in } } +extension FatalErrorContext: AnyMockable { + public static func mockAny() -> FatalErrorContext { + return FatalErrorContext( + messageBus: NOPFeatureScope() + ) + } +} + extension RUMScopeDependencies { static func mockAny() -> RUMScopeDependencies { return mockWith() @@ -840,6 +848,35 @@ extension RUMSessionScope { } } +extension RUMSessionState: AnyMockable, RandomMockable { + public static func mockAny() -> RUMSessionState { + return .mockWith() + } + + public static func mockRandom() -> RUMSessionState { + return RUMSessionState( + sessionUUID: .mockRandom(), + isInitialSession: .mockRandom(), + hasTrackedAnyView: .mockRandom(), + didStartWithReplay: .mockRandom() + ) + } + + static func mockWith( + sessionUUID: UUID = .mockAny(), + isInitialSession: Bool = .mockAny(), + hasTrackedAnyView: Bool = .mockAny(), + didStartWithReplay: Bool? = .mockAny() + ) -> RUMSessionState { + return RUMSessionState( + sessionUUID: sessionUUID, + isInitialSession: isInitialSession, + hasTrackedAnyView: hasTrackedAnyView, + didStartWithReplay: didStartWithReplay + ) + } +} + private let mockWindow = UIWindow(frame: .zero) func createMockViewInWindow() -> UIViewController { @@ -1083,3 +1120,42 @@ extension Dictionary where Key == String, Value == FeatureBaggage { ] } } + +// MARK: - App Hangs Monitoring + +extension AppHang: AnyMockable, RandomMockable { + public static func mockAny() -> AppHang { + return .mockWith() + } + + public static func mockRandom() -> AppHang { + return AppHang( + startDate: .mockRandom(), + backtraceResult: .mockRandom() + ) + } + + static func mockWith( + startDate: Date = .mockAny(), + backtraceResult: BacktraceGenerationResult = .mockAny() + ) -> AppHang { + return AppHang( + startDate: startDate, + backtraceResult: backtraceResult + ) + } +} + +extension AppHang.BacktraceGenerationResult: AnyMockable, RandomMockable { + public static func mockAny() -> AppHang.BacktraceGenerationResult { + return .succeeded(.mockAny()) + } + + public static func mockRandom() -> AppHang.BacktraceGenerationResult { + return [ + .succeeded(.mockRandom()), + .failed, + .notAvailable + ].randomElement()! + } +} diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift index 2a461b5c4b..48d777a8ad 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMSessionScopeTests.swift @@ -349,7 +349,7 @@ class RUMSessionScopeTests: XCTestCase { parent: parent, dependencies: .mockWith( featureScope: featureScope, - sessionSampler: .mockRandom() // no matter if sampled or not + sessionSampler: Bool.random() ? .mockKeepAll() : .mockRejectAll() // no matter if sampled or not ), hasReplay: randomIsReplayBeingRecorded ) diff --git a/DatadogRUM/Tests/RUMTests.swift b/DatadogRUM/Tests/RUMTests.swift index 41930c0e38..7f5cbccb01 100644 --- a/DatadogRUM/Tests/RUMTests.swift +++ b/DatadogRUM/Tests/RUMTests.swift @@ -112,7 +112,7 @@ class RUMTests: XCTestCase { XCTAssertIdentical(monitor, rum.instrumentation.viewsHandler.subscriber) XCTAssertIdentical(monitor, (rum.instrumentation.actionsHandler as? UIKitRUMUserActionsHandler)?.subscriber) XCTAssertIdentical(monitor, rum.instrumentation.longTasks?.subscriber) - XCTAssertIdentical(monitor, rum.instrumentation.appHangs?.subscriber) + XCTAssertIdentical(monitor, rum.instrumentation.appHangs?.nonFatalHangsHandler.subscriber) } func testWhenEnabledWithNoInstrumentations() throws { @@ -437,7 +437,8 @@ class RUMTests: XCTestCase { waitForExpectations(timeout: 2.5) } - // MARK: RUM+Internal tests + // MARK: - RUM+Internal tests + func testWhenPassedNOPCore_lateEnableUrlSessionTrackingThrows() { // Given let core = NOPDatadogCore() diff --git a/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift b/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift index cc4c318292..6b578fb015 100644 --- a/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift +++ b/TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift @@ -58,7 +58,7 @@ public class FeatureScopeMock: FeatureScope { } /// Retrieve data written in Data Store. - public let dataStoreMock: DataStore = NOPDataStore() + public let dataStoreMock = DataStoreMock() /// Retrieve telemetries sent to Telemetry endpoint. public let telemetryMock = TelemetryMock() diff --git a/TestUtilities/Mocks/DataStoreMock.swift b/TestUtilities/Mocks/DataStoreMock.swift new file mode 100644 index 0000000000..212b9d51ea --- /dev/null +++ b/TestUtilities/Mocks/DataStoreMock.swift @@ -0,0 +1,35 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +public class DataStoreMock: DataStore { + @ReadWriteLock + public var storage: [String: DataStoreValueResult] + + init(storage: [String : DataStoreValueResult] = [:]) { + self.storage = storage + } + + public func setValue(_ value: Data, forKey key: String, version: DataStoreKeyVersion) { + storage[key] = .value(value, version) + } + + public func value(forKey key: String, callback: @escaping (DataStoreValueResult) -> Void) { + callback(storage[key] ?? .noValue) + } + + public func removeValue(forKey key: String) { + storage[key] = nil + } + + // MARK: - Side Effects Observation + + public func value(forKey key: String) -> DataStoreValueResult? { + return storage[key] + } +} diff --git a/TestUtilities/Mocks/TelemetryMocks.swift b/TestUtilities/Mocks/TelemetryMocks.swift index bb98112081..667134ab0f 100644 --- a/TestUtilities/Mocks/TelemetryMocks.swift +++ b/TestUtilities/Mocks/TelemetryMocks.swift @@ -9,7 +9,7 @@ import XCTest import DatadogInternal public class CoreLoggerMock: CoreLogger { - private let queue = DispatchQueue(label: "core-logger-mock") + @ReadWriteLock public private(set) var recordedLogs: [(level: CoreLoggerLevel, message: String, error: Error?)] = [] public init() { } @@ -18,11 +18,11 @@ public class CoreLoggerMock: CoreLogger { public func log(_ level: CoreLoggerLevel, message: @autoclosure () -> String, error: Error?) { let newLog = (level, message(), error) - queue.async { self.recordedLogs.append(newLog) } + recordedLogs.append(newLog) } public func reset() { - queue.async { self.recordedLogs = [] } + recordedLogs = [] } // MARK: - Matching @@ -30,11 +30,9 @@ public class CoreLoggerMock: CoreLogger { public typealias RecordedLog = (message: String, error: DDError?) private func recordedLogs(ofLevel level: CoreLoggerLevel) -> [RecordedLog] { - return queue.sync { - recordedLogs + return recordedLogs .filter({ $0.level == level }) .map { ($0.message, $0.error.map({ DDError(error: $0) })) } - } } public var debugLogs: [RecordedLog] { recordedLogs(ofLevel: .debug) }