diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 21eeb7b28c..1f845ce1b2 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -431,6 +431,8 @@ 9E58E8E324615EDA008E5063 /* JSONEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */; }; 9E68FB55244707FD0013A8AA /* ObjcExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 9E68FB53244707FD0013A8AA /* ObjcExceptionHandler.m */; }; 9E68FB56244707FD0013A8AA /* ObjcExceptionHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 9E68FB54244707FD0013A8AA /* ObjcExceptionHandler.h */; settings = {ATTRIBUTES = (Private, ); }; }; + 9E989A4225F640D100235FC3 /* AppStateListenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E989A4125F640D100235FC3 /* AppStateListenerTests.swift */; }; + 9ED6A6B425F2901800CB2E29 /* AppStateListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED6A6B325F2901800CB2E29 /* AppStateListener.swift */; }; 9EEA4871258B76A100EBDA9D /* Global+objc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEA4870258B76A100EBDA9D /* Global+objc.swift */; }; 9EF963E82537556300235F98 /* DDURLSessionDelegateAsSuperclassTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF963E72537556300235F98 /* DDURLSessionDelegateAsSuperclassTests.swift */; }; 9EFD112C24B32D29003A1A2B /* FirstPartyURLsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EFD112B24B32D29003A1A2B /* FirstPartyURLsFilter.swift */; }; @@ -966,7 +968,9 @@ 9E58E8E224615EDA008E5063 /* JSONEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEncoderTests.swift; sourceTree = ""; }; 9E68FB53244707FD0013A8AA /* ObjcExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjcExceptionHandler.m; sourceTree = ""; }; 9E68FB54244707FD0013A8AA /* ObjcExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ObjcExceptionHandler.h; sourceTree = ""; }; + 9E989A4125F640D100235FC3 /* AppStateListenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateListenerTests.swift; sourceTree = ""; }; 9E9EB37624468CE90002C80B /* Datadog.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = Datadog.modulemap; sourceTree = ""; }; + 9ED6A6B325F2901800CB2E29 /* AppStateListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateListener.swift; sourceTree = ""; }; 9EEA4870258B76A100EBDA9D /* Global+objc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Global+objc.swift"; sourceTree = ""; }; 9EF49F1624476FBD004F2CA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9EF49F17244770AD004F2CA0 /* DatadogIntegrationTests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DatadogIntegrationTests.xcconfig; sourceTree = ""; }; @@ -1212,6 +1216,7 @@ 61133BA32423979B00786299 /* MobileDevice.swift */, 61133BA42423979B00786299 /* NetworkConnectionInfoProvider.swift */, 61133BA52423979B00786299 /* BatteryStatusProvider.swift */, + 9ED6A6B325F2901800CB2E29 /* AppStateListener.swift */, ); path = System; sourceTree = ""; @@ -2309,6 +2314,7 @@ 613F23E2252B05D7006CD2D7 /* URLFiltering */, 61B03874252724AB00518F3C /* URLSessionInterceptorTests.swift */, 613F23E3252B062F006CD2D7 /* TaskInterceptionTests.swift */, + 9E989A4125F640D100235FC3 /* AppStateListenerTests.swift */, ); path = Interception; sourceTree = ""; @@ -3285,6 +3291,7 @@ 61C3638524361E9200C4D4E6 /* Globals.swift in Sources */, E1D202EA24C065CF00D1AF3A /* ActiveSpansPool.swift in Sources */, 61940C7C25668EC600A20043 /* URLSessionInterceptionHandler.swift in Sources */, + 9ED6A6B425F2901800CB2E29 /* AppStateListener.swift in Sources */, 61F3CDA3251118FB00C816E5 /* UIKitRUMViewsHandler.swift in Sources */, 61C5A88824509A0C00DA608C /* Warnings.swift in Sources */, 619E16E92578E73E00B2516B /* DataMigrator.swift in Sources */, @@ -3524,6 +3531,7 @@ 9EF963E82537556300235F98 /* DDURLSessionDelegateAsSuperclassTests.swift in Sources */, 61133C552423990D00786299 /* BatteryStatusProviderTests.swift in Sources */, 61F3CDAB25121FB500C816E5 /* UIViewControllerSwizzlerTests.swift in Sources */, + 9E989A4225F640D100235FC3 /* AppStateListenerTests.swift in Sources */, 617B954024BF4DB300E6F443 /* RUMApplicationScopeTests.swift in Sources */, 61F2724925C943C500D54BF8 /* CrashReporterTests.swift in Sources */, 6172472725D673D7007085B3 /* CrashContextTests.swift in Sources */, diff --git a/Sources/Datadog/Core/System/AppStateListener.swift b/Sources/Datadog/Core/System/AppStateListener.swift new file mode 100644 index 0000000000..1c5134538d --- /dev/null +++ b/Sources/Datadog/Core/System/AppStateListener.swift @@ -0,0 +1,138 @@ +/* + * 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-2020 Datadog, Inc. + */ + +import Foundation +import class UIKit.UIApplication + +/// A data structure to represent recorded app states in a given period of time +internal struct AppStateHistory: Equatable { + /// Snapshot of the app state at `date` + struct Snapshot: Equatable { + let isActive: Bool + let date: Date + } + + var initialState: Snapshot + var changes = [Snapshot]() + var finalDate: Date + var finalState: Snapshot { + return Snapshot( + isActive: (changes.last ?? initialState).isActive, + date: finalDate + ) + } + + /// Limits or extrapolates app state history to the given range + /// This is useful when you record between 0...3t but you are concerned of t...2t only + /// - Parameter range: if outside of `initialState` and `finalState`, it extrapolates; otherwise it limits + /// - Returns: a history instance spanning the given range + func take(between range: ClosedRange) -> AppStateHistory { + var taken = self + // move initial state to lowerBound + taken.initialState = Snapshot( + isActive: isActive(at: range.lowerBound), + date: range.lowerBound + ) + // move final state to upperBound + taken.finalDate = range.upperBound + // filter changes outside of the range + taken.changes = taken.changes.filter { range.contains($0.date) } + return taken + } + + var foregroundDuration: TimeInterval { + var duration: TimeInterval = 0.0 + var lastActiveStartDate: Date? + let allEvents = [initialState] + changes + [finalState] + for event in allEvents { + if let startDate = lastActiveStartDate { + duration += event.date.timeIntervalSince(startDate) + } + if event.isActive { + lastActiveStartDate = event.date + } else { + lastActiveStartDate = nil + } + } + return duration + } + + var didRunInBackground: Bool { + return !initialState.isActive || !finalState.isActive + } + + private func isActive(at date: Date) -> Bool { + if date <= initialState.date { + // we assume there was no change before initial state + return initialState.isActive + } else if finalState.date <= date { + // and no change after final state + return finalState.isActive + } + var active = initialState + for change in changes { + if date < change.date { + break + } + active = change + } + return active.isActive + } +} + +internal protocol AppStateListening: class { + var history: AppStateHistory { get } +} + +internal class AppStateListener: AppStateListening { + typealias Snapshot = AppStateHistory.Snapshot + + private let dateProvider: DateProvider + private let publisher: ValuePublisher + + var history: AppStateHistory { + var current = publisher.currentValue + current.finalDate = dateProvider.currentDate() + return current + } + + private static var isAppActive: Bool { + return UIApplication.managedShared?.applicationState == .active + } + + init(dateProvider: DateProvider) { + self.dateProvider = dateProvider + let currentState = Snapshot( + isActive: AppStateListener.isAppActive, + date: dateProvider.currentDate() + ) + self.publisher = ValuePublisher( + initialValue: AppStateHistory( + initialState: currentState, + finalDate: currentState.date + ) + ) + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + nc.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + } + + @objc + private func appWillResignActive() { + let now = dateProvider.currentDate() + var value = publisher.currentValue + value.changes.append(Snapshot(isActive: false, date: now)) + publisher.publishAsync(value) + } + @objc + private func appDidBecomeActive() { + let now = dateProvider.currentDate() + var value = publisher.currentValue + value.changes.append(Snapshot(isActive: true, date: now)) + publisher.publishAsync(value) + } +} diff --git a/Sources/Datadog/Datadog.swift b/Sources/Datadog/Datadog.swift index 7eccf814e8..eaa1654e31 100644 --- a/Sources/Datadog/Datadog.swift +++ b/Sources/Datadog/Datadog.swift @@ -245,7 +245,8 @@ public class Datadog { if let urlSessionAutoInstrumentationConfiguration = configuration.urlSessionAutoInstrumentation { urlSessionAutoInstrumentation = URLSessionAutoInstrumentation( configuration: urlSessionAutoInstrumentationConfiguration, - dateProvider: dateProvider + dateProvider: dateProvider, + appStateListener: AppStateListener(dateProvider: dateProvider) ) } diff --git a/Sources/Datadog/Tracer.swift b/Sources/Datadog/Tracer.swift index 216ffe67fe..dc1e5cdcaf 100644 --- a/Sources/Datadog/Tracer.swift +++ b/Sources/Datadog/Tracer.swift @@ -15,6 +15,12 @@ public struct DDTags { /// /// Expects `String` value set for a tag. public static let resource = "resource.name" + /// Internal tag. `Integer` value. Measures elapsed time at app's foreground state in nanoseconds. + /// (duration - foregroundDuration) gives you the elapsed time while the app wasn't active (probably at background) + internal static let foregroundDuration = "foreground_duration" + /// Internal tag. `Bool` value. + /// `true` if span was started or ended while the app was not active, `false` otherwise. + internal static let isBackground = "is_background" /// Those keys used to encode information received from the user through `OpenTracingLogFields`, `OpenTracingTagKeys` or custom fields. /// Supported by Datadog platform. diff --git a/Sources/Datadog/Tracing/AutoInstrumentation/URLSessionTracingHandler.swift b/Sources/Datadog/Tracing/AutoInstrumentation/URLSessionTracingHandler.swift index 730f7b6bbb..6909039600 100644 --- a/Sources/Datadog/Tracing/AutoInstrumentation/URLSessionTracingHandler.swift +++ b/Sources/Datadog/Tracing/AutoInstrumentation/URLSessionTracingHandler.swift @@ -7,6 +7,13 @@ import Foundation internal class URLSessionTracingHandler: URLSessionInterceptionHandler { + /// Listening to app state changes and use it to report `foreground_duration` + let appStateListener: AppStateListening + + init(appStateListener: AppStateListening) { + self.appStateListener = appStateListener + } + // MARK: - URLSessionInterceptionHandler func notify_taskInterceptionStarted(interception: TaskInterception) { @@ -68,6 +75,11 @@ internal class URLSessionTracingHandler: URLSessionInterceptionHandler { } } } + let appStateHistory = appStateListener.history.take( + between: resourceMetrics.fetch.start...resourceMetrics.fetch.end + ) + span.setTag(key: DDTags.foregroundDuration, value: appStateHistory.foregroundDuration.toNanoseconds) + span.setTag(key: DDTags.isBackground, value: appStateHistory.didRunInBackground) span.finish(at: resourceMetrics.fetch.end) } diff --git a/Sources/Datadog/URLSessionAutoInstrumentation/Interception/TaskInterception.swift b/Sources/Datadog/URLSessionAutoInstrumentation/Interception/TaskInterception.swift index ced70fa122..a7f6f7798d 100644 --- a/Sources/Datadog/URLSessionAutoInstrumentation/Interception/TaskInterception.swift +++ b/Sources/Datadog/URLSessionAutoInstrumentation/Interception/TaskInterception.swift @@ -22,7 +22,10 @@ internal class TaskInterception { /// or when the task was created through `URLSession.dataTask(with:url)` on some iOS13+. private(set) var spanContext: DDSpanContext? - init(request: URLRequest, isFirstParty: Bool) { + init( + request: URLRequest, + isFirstParty: Bool + ) { self.identifier = UUID() self.request = request self.isFirstPartyRequest = isFirstParty diff --git a/Sources/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptor.swift b/Sources/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptor.swift index 3cb66b5077..1867fa64aa 100644 --- a/Sources/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptor.swift +++ b/Sources/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptor.swift @@ -37,22 +37,24 @@ public class URLSessionInterceptor: URLSessionInterceptorType { convenience init( configuration: FeaturesConfiguration.URLSessionAutoInstrumentation, - dateProvider: DateProvider + dateProvider: DateProvider, + appStateListener: AppStateListening ) { let handler: URLSessionInterceptionHandler if configuration.instrumentRUM { handler = URLSessionRUMResourcesHandler(dateProvider: dateProvider) } else { - handler = URLSessionTracingHandler() + handler = URLSessionTracingHandler(appStateListener: appStateListener) } - self.init(configuration: configuration, handler: handler) + self.init(configuration: configuration, handler: handler, appStateListener: appStateListener) } init( configuration: FeaturesConfiguration.URLSessionAutoInstrumentation, - handler: URLSessionInterceptionHandler + handler: URLSessionInterceptionHandler, + appStateListener: AppStateListening ) { self.defaultFirstPartyURLsFilter = FirstPartyURLsFilter(hosts: configuration.userDefinedFirstPartyHosts) self.internalURLsFilter = InternalURLsFilter(urls: configuration.sdkInternalURLs) diff --git a/Sources/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentation.swift b/Sources/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentation.swift index b4fc0af63f..dbb44d7e45 100644 --- a/Sources/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentation.swift +++ b/Sources/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentation.swift @@ -15,10 +15,15 @@ internal class URLSessionAutoInstrumentation { init?( configuration: FeaturesConfiguration.URLSessionAutoInstrumentation, - dateProvider: DateProvider + dateProvider: DateProvider, + appStateListener: AppStateListening ) { do { - self.interceptor = URLSessionInterceptor(configuration: configuration, dateProvider: dateProvider) + self.interceptor = URLSessionInterceptor( + configuration: configuration, + dateProvider: dateProvider, + appStateListener: appStateListener + ) self.swizzler = try URLSessionSwizzler() } catch { consolePrint( diff --git a/Tests/DatadogTests/Datadog/Mocks/CoreMocks.swift b/Tests/DatadogTests/Datadog/Mocks/CoreMocks.swift index e353f4dfee..786d47ef2b 100644 --- a/Tests/DatadogTests/Datadog/Mocks/CoreMocks.swift +++ b/Tests/DatadogTests/Datadog/Mocks/CoreMocks.swift @@ -829,6 +829,12 @@ class CarrierInfoProviderMock: CarrierInfoProviderType, WrappedCarrierInfoProvid } } +extension AppStateListener { + static func mockAny() -> AppStateListener { + return AppStateListener(dateProvider: SystemDateProvider()) + } +} + extension EncodableValue { static func mockAny() -> EncodableValue { return EncodableValue(String.mockAny()) diff --git a/Tests/DatadogTests/Datadog/Tracing/Autoinstrumentation/URLSessionTracingHandlerTests.swift b/Tests/DatadogTests/Datadog/Tracing/Autoinstrumentation/URLSessionTracingHandlerTests.swift index 99f6ead98c..01d6e13cf2 100644 --- a/Tests/DatadogTests/Datadog/Tracing/Autoinstrumentation/URLSessionTracingHandlerTests.swift +++ b/Tests/DatadogTests/Datadog/Tracing/Autoinstrumentation/URLSessionTracingHandlerTests.swift @@ -7,10 +7,17 @@ import XCTest @testable import Datadog +private class MockAppStateListener: AppStateListening { + let history = AppStateHistory( + initialState: .init(isActive: true, date: .mockDecember15th2019At10AMUTC()), + finalDate: .mockDecember15th2019At10AMUTC() + 10 + ) +} + class URLSessionTracingHandlerTests: XCTestCase { private let spanOutput = SpanOutputMock() private let logOutput = LogOutputMock() - private let handler = URLSessionTracingHandler() + private let handler = URLSessionTracingHandler(appStateListener: MockAppStateListener()) override func setUp() { Global.sharedTracer = Tracer.mockWith( @@ -95,7 +102,7 @@ class URLSessionTracingHandlerTests: XCTestCase { XCTAssertEqual(span.tags[OTTags.httpUrl]?.encodable.value as? String, request.url!.absoluteString) XCTAssertEqual(span.tags[OTTags.httpMethod]?.encodable.value as? String, "POST") XCTAssertEqual(span.tags[OTTags.httpStatusCode]?.encodable.value as? Int, 200) - XCTAssertEqual(span.tags.count, 3) + XCTAssertEqual(span.tags.count, 5) let log = logOutput.recordedLog XCTAssertNil(log) @@ -138,7 +145,7 @@ class URLSessionTracingHandlerTests: XCTestCase { "Error Domain=domain Code=123 \"network error\" UserInfo={NSLocalizedDescription=network error}" ) XCTAssertEqual(span.tags[DDTags.errorMessage]?.encodable.value as? String, "network error") - XCTAssertEqual(span.tags.count, 5) + XCTAssertEqual(span.tags.count, 7) let log = try XCTUnwrap(logOutput.recordedLog, "It should send error log") XCTAssertEqual(log.status, .error) @@ -201,7 +208,7 @@ class URLSessionTracingHandlerTests: XCTestCase { span.tags[DDTags.errorStack]?.encodable.value as? String, "Error Domain=HTTPURLResponse Code=404 \"404 not found\" UserInfo={NSLocalizedDescription=404 not found}" ) - XCTAssertEqual(span.tags.count, 6) + XCTAssertEqual(span.tags.count, 8) let log = try XCTUnwrap(logOutput.recordedLog, "It should send error log") XCTAssertEqual(log.status, .error) @@ -269,4 +276,26 @@ class URLSessionTracingHandlerTests: XCTestCase { XCTAssertNil(spanOutput.recordedSpan) XCTAssertNil(logOutput.recordedLog) } + + func testGivenAnyInterception_itAddsAppStateInformationToSpan() throws { + // Given + let interception = TaskInterception(request: .mockAny(), isFirstParty: true) + interception.register(completion: .mockAny()) + interception.register( + metrics: .mockWith( + fetch: .init( + start: .mockDecember15th2019At10AMUTC(), + end: .mockDecember15th2019At10AMUTC(addingTimeInterval: 10) + ) + ) + ) + + // When + handler.notify_taskInterceptionCompleted(interception: interception) + + // Then + let recordedSpan = try XCTUnwrap(spanOutput.recordedSpan) + XCTAssertEqual(recordedSpan.tags[DDTags.foregroundDuration]?.encodable.value as? UInt64, 10_000_000_000) + XCTAssertEqual(recordedSpan.tags[DDTags.isBackground]?.encodable.value as? Bool, false) + } } diff --git a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/DDURLSessionDelegateTests.swift b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/DDURLSessionDelegateTests.swift index 3dbad82f95..305b437a49 100644 --- a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/DDURLSessionDelegateTests.swift +++ b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/DDURLSessionDelegateTests.swift @@ -194,7 +194,8 @@ class DDURLSessionDelegateTests: XCTestCase { // given URLSessionAutoInstrumentation.instance = URLSessionAutoInstrumentation( configuration: .mockAny(), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) defer { URLSessionAutoInstrumentation.instance = nil } @@ -212,7 +213,8 @@ class DDURLSessionDelegateTests: XCTestCase { // given URLSessionAutoInstrumentation.instance = URLSessionAutoInstrumentation( configuration: .mockAny(), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) defer { URLSessionAutoInstrumentation.instance = nil } diff --git a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/AppStateListenerTests.swift b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/AppStateListenerTests.swift new file mode 100644 index 0000000000..574b7fa0e7 --- /dev/null +++ b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/AppStateListenerTests.swift @@ -0,0 +1,195 @@ +/* + * 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-2020 Datadog, Inc. + */ + +import XCTest +@testable import Datadog + +class AppStateHistoryTests: XCTestCase { + func testForegroundDurationWithoutChanges() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [], + finalDate: startDate + 1.0 + ) + + XCTAssertEqual(history.foregroundDuration, 1.0) + } + + func testForegroundDuration() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [ + .init(isActive: false, date: startDate + 1.0), + .init(isActive: true, date: startDate + 2.0), + .init(isActive: false, date: startDate + 3.0), + .init(isActive: true, date: startDate + 4.0) + ], + finalDate: startDate + 5.0 + ) + + XCTAssertEqual(history.foregroundDuration, 3.0) + } + + func testForegroundDurationWithMissingChange() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [ + .init(isActive: false, date: startDate + 1.0), + .init(isActive: false, date: startDate + 3.0) + ], + finalDate: startDate + 5.0 + ) + + XCTAssertEqual(history.foregroundDuration, 1.0) + } + + func testExtrapolation() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [], + finalDate: startDate + 5.0 + ) + let extrapolatedHistory = history.take( + between: (startDate - 5.0)...(startDate + 15.0) + ) + + let expectedHistory = AppStateHistory( + initialState: .init(isActive: true, date: startDate - 5.0), + changes: [], + finalDate: startDate + 15.0 + ) + XCTAssertEqual(extrapolatedHistory, expectedHistory) + } + + func testLimiting() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [], + finalDate: startDate + 20.0 + ) + let limitedHistory = history.take( + between: (startDate + 5.0)...(startDate + 10.0) + ) + + let expectedHistory = AppStateHistory( + initialState: .init(isActive: true, date: startDate + 5.0), + changes: [], + finalDate: startDate + 10.0 + ) + XCTAssertEqual(limitedHistory, expectedHistory) + } + + func testLimitingWithChanges() { + let startDate = Date(timeIntervalSinceReferenceDate: 0.0) + let firstChanges = (0...100).map { _ in + AppStateHistory.Snapshot( + isActive: false, + date: startDate + TimeInterval.random(in: 1...1_000) + ) + } + let lastChanges = (0...100).map { _ in + AppStateHistory.Snapshot( + isActive: true, + date: startDate + TimeInterval.random(in: 2_000...3_000) + ) + } + var allChanges = (firstChanges + lastChanges) + allChanges.append(.init(isActive: true, date: startDate + 1_200)) + allChanges.append(.init(isActive: false, date: startDate + 1_500)) + allChanges.sort { $0.date < $1.date } + let history = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: allChanges, + finalDate: startDate + 4_000 + ) + + let limitedHistory = history.take( + between: (startDate + 1_250)...(startDate + 1_750) + ) + + let expectedHistory = AppStateHistory( + initialState: .init(isActive: true, date: startDate + 1_250), + changes: [.init(isActive: false, date: startDate + 1_500)], + finalDate: startDate + 1_750 + ) + XCTAssertEqual(limitedHistory, expectedHistory) + } +} + +class AppStateListenerTests: XCTestCase { + func testWhenAppResignActiveAndBecomeActive_thenAppStateHistoryIsRecorded() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let listener = AppStateListener( + dateProvider: RelativeDateProvider(startingFrom: startDate, advancingBySeconds: 1.0) + ) + + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + let expected = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [ + .init(isActive: false, date: startDate + 1.0), + .init(isActive: true, date: startDate + 2.0) + ], + finalDate: startDate + 3.0 + ) + XCTAssertEqual(listener.history, expected) + } + + func testWhenAppBecomeActiveAndResignActive_thenAppStateHistoryIsRecorded() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let listener = AppStateListener( + dateProvider: RelativeDateProvider(startingFrom: startDate, advancingBySeconds: 1.0) + ) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.post(name: UIApplication.willResignActiveNotification, object: nil) + + let expected = AppStateHistory( + initialState: .init(isActive: true, date: startDate), + changes: [ + .init(isActive: true, date: startDate + 1.0), + .init(isActive: false, date: startDate + 2.0) + ], + finalDate: startDate + 3.0 + ) + XCTAssertEqual(listener.history, expected) + } + + func testWhenAppStateHistoryIsRetrieved_thenFinalDateOfHistoryChanges() { + let startDate = Date.mockDecember15th2019At10AMUTC() + let listener = AppStateListener( + dateProvider: RelativeDateProvider(startingFrom: startDate, advancingBySeconds: 1.0) + ) + let history1 = listener.history + let history2 = listener.history + + XCTAssertEqual(history2.finalState.date.timeIntervalSince(history1.finalState.date), 1.0) + } + + func testWhenAppStateListenerIsCalledFromDifferentThreads_thenItWorks() { + let listener = AppStateListener(dateProvider: SystemDateProvider()) + DispatchQueue.concurrentPerform(iterations: 10_000) { iteration in + // write + if iteration < 1_000 { + let nc = NotificationCenter.default + nc.post( + name: (Bool.random() ? + UIApplication.willResignActiveNotification : + UIApplication.didBecomeActiveNotification), + object: nil + ) + } + // read + XCTAssertFalse(listener.history.changes.isEmpty) + } + } +} diff --git a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptorTests.swift b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptorTests.swift index c1e6858ead..fd1dd28541 100644 --- a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptorTests.swift +++ b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/Interception/URLSessionInterceptorTests.swift @@ -38,19 +38,22 @@ class URLSessionInterceptorTests: XCTestCase { // MARK: - Initialization - func testGivenOnlyTracingInstrumentationEnabled_whenInitializing_itRegistersTracingHandler() { + func testGivenOnlyTracingInstrumentationEnabled_whenInitializing_itRegistersTracingHandler() throws { // Given let instrumentTracing = true let instrumentRUM = false // When + let appStateListener = AppStateListener.mockAny() let interceptor = URLSessionInterceptor( configuration: .mockWith(instrumentTracing: instrumentTracing, instrumentRUM: instrumentRUM), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: appStateListener ) // Then - XCTAssertTrue(interceptor.handler is URLSessionTracingHandler) + let tracingHandler = try XCTUnwrap(interceptor.handler as? URLSessionTracingHandler) + XCTAssert(tracingHandler.appStateListener === appStateListener) XCTAssertTrue( interceptor.injectTracingHeadersToFirstPartyRequests, "Tracing headers should be injected when only Tracing instrumentation is enabled." @@ -69,7 +72,8 @@ class URLSessionInterceptorTests: XCTestCase { // When let interceptor = URLSessionInterceptor( configuration: .mockWith(instrumentTracing: instrumentTracing, instrumentRUM: instrumentRUM), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) // Then @@ -92,7 +96,8 @@ class URLSessionInterceptorTests: XCTestCase { // When let interceptor = URLSessionInterceptor( configuration: .mockWith(instrumentTracing: instrumentTracing, instrumentRUM: instrumentRUM), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) // Then @@ -126,7 +131,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: true, rumInstrumentationEnabled: true), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) Global.sharedTracer = Tracer.mockAny() defer { Global.sharedTracer = DDNoopGlobals.tracer } @@ -176,7 +182,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: true, rumInstrumentationEnabled: false), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) Global.sharedTracer = Tracer.mockAny() defer { Global.sharedTracer = DDNoopGlobals.tracer } @@ -205,7 +212,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: false, rumInstrumentationEnabled: true), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) Global.sharedTracer = Tracer.mockAny() defer { Global.sharedTracer = DDNoopGlobals.tracer } @@ -225,7 +233,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: true, rumInstrumentationEnabled: .random()), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) XCTAssertTrue(Global.sharedTracer is DDNoopTracer) @@ -260,7 +269,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: true, rumInstrumentationEnabled: .random()), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) Global.sharedTracer = Tracer.mockAny() defer { Global.sharedTracer = DDNoopGlobals.tracer } @@ -359,7 +369,8 @@ class URLSessionInterceptorTests: XCTestCase { // Given let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: false, rumInstrumentationEnabled: true), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) let interceptedFirstPartyRequest = interceptor.modify(request: firstPartyRequest) @@ -421,7 +432,8 @@ class URLSessionInterceptorTests: XCTestCase { func testRandomlyCallingDifferentAPIsConcurrentlyDoesNotCrash() { let interceptor = URLSessionInterceptor( configuration: mockConfiguration(tracingInstrumentationEnabled: true, rumInstrumentationEnabled: true), - handler: handler + handler: handler, + appStateListener: AppStateListener.mockAny() ) let requests = [firstPartyRequest, thirdPartyRequest, internalRequest] diff --git a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentationTests.swift b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentationTests.swift index e609a8b171..d21a2e26e8 100644 --- a/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentationTests.swift +++ b/Tests/DatadogTests/Datadog/URLSessionAutoInstrumentation/URLSessionAutoInstrumentationTests.swift @@ -24,7 +24,8 @@ class URLSessionAutoInstrumentationTests: XCTestCase { // When URLSessionAutoInstrumentation.instance = URLSessionAutoInstrumentation( configuration: .mockAny(), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) defer { URLSessionAutoInstrumentation.instance?.swizzler.unswizzle() @@ -42,7 +43,8 @@ class URLSessionAutoInstrumentationTests: XCTestCase { URLSessionAutoInstrumentation.instance = URLSessionAutoInstrumentation( configuration: .mockAny(), - dateProvider: SystemDateProvider() + dateProvider: SystemDateProvider(), + appStateListener: AppStateListener.mockAny() ) defer { URLSessionAutoInstrumentation.instance?.swizzler.unswizzle()