diff --git a/Datadog/IntegrationUnitTests/RUM/RUMSessionStopTests.swift b/Datadog/IntegrationUnitTests/RUM/RUMSessionStopTests.swift index 04263a9af9..5f20a7eb51 100644 --- a/Datadog/IntegrationUnitTests/RUM/RUMSessionStopTests.swift +++ b/Datadog/IntegrationUnitTests/RUM/RUMSessionStopTests.swift @@ -762,7 +762,7 @@ class RUMSessionStopTests: RUMSessionTestsBase { XCTAssertNil(session2.timeToInitialDisplay) DDAssertEqual(session2.sessionStartDate, processLaunchDate + timeToSDKInit + dt1 + dt2 + dt3 + dt4, accuracy: accuracy) DDAssertEqual(session2.duration, dt5, accuracy: accuracy) - XCTAssertEqual(session2.sessionPrecondition, .explicitStop) + XCTAssertEqual(session2.sessionPrecondition, given == given1 ? .backgroundLaunch : .prewarm) XCTAssertEqual(session2.views.count, 1) XCTAssertEqual(session2.views[0].name, backgroundViewName) DDAssertEqual(session2.views[0].duration, dt5, accuracy: accuracy) diff --git a/Datadog/IntegrationUnitTests/RUM/RUMSessionTimeOutTests.swift b/Datadog/IntegrationUnitTests/RUM/RUMSessionTimeOutTests.swift index d2499a0c68..bf0806942c 100644 --- a/Datadog/IntegrationUnitTests/RUM/RUMSessionTimeOutTests.swift +++ b/Datadog/IntegrationUnitTests/RUM/RUMSessionTimeOutTests.swift @@ -750,7 +750,7 @@ class RUMSessionTimeOutTests: RUMSessionTestsBase { XCTAssertNil(session2.timeToInitialDisplay) DDAssertEqual(session2.sessionStartDate, processLaunchDate + timeToSDKInit + dt1 + dt2 + sessionTimeoutDuration + dt3, accuracy: accuracy) DDAssertEqual(session2.duration, dt4, accuracy: accuracy) - XCTAssertEqual(session2.sessionPrecondition, .inactivityTimeout) + XCTAssertEqual(session2.sessionPrecondition, given == given1 ? .backgroundLaunch : .prewarm) XCTAssertEqual(session2.views.count, 1) XCTAssertEqual(session2.views[0].name, backgroundViewName) DDAssertEqual(session2.views[0].duration, dt4, accuracy: accuracy) diff --git a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift index f11ee8f1a3..9254f6d1a4 100644 --- a/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift +++ b/DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift @@ -217,10 +217,11 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { if context.applicationStateHistory.currentState == .background { switch context.launchInfo.launchReason { - case .userLaunch: startPrecondition = .userAppLaunch // UISceneDelegate-based apps always start in background - case .backgroundLaunch: startPrecondition = .backgroundLaunch - case .prewarming: startPrecondition = .prewarm - default: + case .userLaunch: + startPrecondition = .userAppLaunch // UISceneDelegate-based apps always start in background + case .backgroundLaunch, .prewarming: + startPrecondition = preconditionForNewBackgroundSession(context: context) + @unknown default: dependencies.telemetry.error("Creating initial session in background with unexpected launch reason: \(context.launchInfo.launchReason)") } } else { @@ -246,12 +247,16 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { private func refresh(expiredSession: RUMSessionScope, on command: RUMCommand, context: DatadogContext, writer: Writer) -> RUMSessionScope { var startPrecondition: RUMSessionPrecondition? = nil - if lastSessionEndReason == .timeOut { + // If the app is in background, use the background-aware precondition; otherwise fall through to the end-reason logic. + if context.applicationStateHistory.currentState == .background, + let backgroundPrecondition = preconditionForNewBackgroundSession(context: context) { + startPrecondition = backgroundPrecondition + } else if lastSessionEndReason == .timeOut { startPrecondition = .inactivityTimeout } else if lastSessionEndReason == .maxDuration { startPrecondition = .maxDuration } else { - dependencies.telemetry.error("Failed to determine session precondition for REFRESHED session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown"))") + dependencies.telemetry.error("Failed to determine session precondition for REFRESHED session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown")") } let refreshingInForeground = context.applicationStateHistory.currentState == .active @@ -278,14 +283,18 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { private func startNewSession(on command: RUMCommand, context: DatadogContext, writer: Writer) { var startPrecondition: RUMSessionPrecondition? = nil - if lastSessionEndReason == .stopAPI { + // If the app is in background, use the background-aware precondition; otherwise fall through to the end-reason logic. + if context.applicationStateHistory.currentState == .background, + let backgroundPrecondition = preconditionForNewBackgroundSession(context: context) { + startPrecondition = backgroundPrecondition + } else if lastSessionEndReason == .stopAPI { startPrecondition = .explicitStop } else if lastSessionEndReason == .timeOut { startPrecondition = .inactivityTimeout } else if lastSessionEndReason == .maxDuration { startPrecondition = .maxDuration } else { - dependencies.telemetry.error("Failed to determine session precondition for NEW session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown"))") + dependencies.telemetry.error("Failed to determine session precondition for NEW session with end reason: \(lastSessionEndReason?.rawValue ?? "unknown")") } if didCreateInitialSessionCount > 0 { // Sanity check @@ -351,4 +360,21 @@ internal class RUMApplicationScope: RUMScope, RUMContextProvider { writer: writer ) } + + private func preconditionForNewBackgroundSession(context: DatadogContext) -> RUMSessionPrecondition? { + switch context.launchInfo.launchReason { + case .backgroundLaunch: + return .backgroundLaunch + case .prewarming: + return .prewarm + case .userLaunch: + // Normal: a user-launched process went to background. Caller uses end-reason-based precondition. + return nil + @unknown default: + dependencies.telemetry.error( + "Starting session in background with unexpected launch reason: \(context.launchInfo.launchReason)" + ) + return nil + } + } } diff --git a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift index 6e05647e5a..6178e47da4 100644 --- a/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift +++ b/DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift @@ -484,4 +484,201 @@ class RUMApplicationScopeTests: XCTestCase { // Then XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .explicitStop) } + + func testGivenInactiveSession_whenNewOneIsStartedInBackground_itSetsBackgroundLaunchPrecondition() { + // Given + var currentTime: Date = .mockDecember15th2019At10AMUTC() + let sdkContext: DatadogContext = .mockWith(sdkInitDate: currentTime) + let scope = createRUMApplicationScope( + dependencies: .mockWith(samplingRate: 100), + sdkContext: sdkContext + ) + + // When + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .backgroundLaunch, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime, isUserInteraction: true), + context: backgroundContext, + writer: writer + ) + + // Then + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .backgroundLaunch) + } + + func testGivenExpiredSession_whenNewOneIsStartedInBackground_itSetsBackgroundLaunchPrecondition() { + // Given + let initialTime: Date = .mockDecember15th2019At10AMUTC() + var currentTime: Date = initialTime + let sdkContext: DatadogContext = .mockWith(sdkInitDate: currentTime) + let scope = createRUMApplicationScope( + dependencies: .mockWith(samplingRate: 100), + sdkContext: sdkContext + ) + + // Keep session active without exceeding maxDuration — stop one step before it would expire + while currentTime.addingTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) < initialTime.addingTimeInterval(RUMSessionScope.Constants.sessionMaxDuration) { + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) + _ = scope.process( + command: RUMCommandMock(time: currentTime, isUserInteraction: true), + context: sdkContext, + writer: writer + ) + } + + // When - advance past maxDuration without triggering inactivity timeout, then send in background + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration - 1) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .backgroundLaunch, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime, isUserInteraction: true), + context: backgroundContext, + writer: writer + ) + + // Then + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .backgroundLaunch) + } + + func testGivenStoppedSession_whenNewOneIsStartedInBackground_itSetsBackgroundLaunchPrecondition() { + // Given + var currentTime: Date = .mockDecember15th2019At10AMUTC() + let sdkContext: DatadogContext = .mockWith(sdkInitDate: currentTime) + let scope = createRUMApplicationScope( + dependencies: .mockWith(samplingRate: 100), + sdkContext: sdkContext + ) + + currentTime.addTimeInterval(1) + _ = scope.process(command: RUMStopSessionCommand(time: currentTime), context: sdkContext, writer: writer) + + // When + currentTime.addTimeInterval(1) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .backgroundLaunch, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMAddUserActionCommand.mockWith(time: currentTime), + context: backgroundContext, + writer: writer + ) + + // Then + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .backgroundLaunch) + } + + func testGivenInactiveSession_whenNewOneIsStartedInBackgroundWithPrewarming_itSetsPrewarmPrecondition() { + // Given + var currentTime: Date = .mockDecember15th2019At10AMUTC() + let sdkContext: DatadogContext = .mockWith(sdkInitDate: currentTime) + let scope = createRUMApplicationScope( + dependencies: .mockWith(samplingRate: 100), + sdkContext: sdkContext + ) + + // When + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .prewarming, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime, isUserInteraction: true), + context: backgroundContext, + writer: writer + ) + + // Then + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .prewarm) + } + + func testGivenStoppedSession_whenNewOneIsStartedInBackgroundWithPrewarming_itSetsPrewarmPrecondition() { + // Given + var currentTime: Date = .mockDecember15th2019At10AMUTC() + let sdkContext: DatadogContext = .mockWith(sdkInitDate: currentTime) + let scope = createRUMApplicationScope( + dependencies: .mockWith(samplingRate: 100), + sdkContext: sdkContext + ) + + currentTime.addTimeInterval(1) + _ = scope.process(command: RUMStopSessionCommand(time: currentTime), context: sdkContext, writer: writer) + + // When + currentTime.addTimeInterval(1) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .prewarming, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMAddUserActionCommand.mockWith(time: currentTime), + context: backgroundContext, + writer: writer + ) + + // Then + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .prewarm) + } + + func testGivenUserLaunchedApp_whenSessionTimesOutInBackground_itSetsInactivityTimeoutPrecondition() { + // Given - app launched by user, session becomes inactive + var currentTime: Date = .mockDecember15th2019At10AMUTC() + let sdkContext: DatadogContext = .mockWith( + sdkInitDate: currentTime, + launchInfo: .mockWith(launchReason: .userLaunch) + ) + let featureScope = FeatureScopeMock() + let scope = createRUMApplicationScope( + dependencies: .mockWith(featureScope: featureScope, samplingRate: 100), + sdkContext: sdkContext + ) + + // When - session times out while app is in background + currentTime.addTimeInterval(RUMSessionScope.Constants.sessionTimeoutDuration) + let backgroundContext: DatadogContext = .mockWith( + sdkInitDate: .mockDecember15th2019At10AMUTC(), + launchInfo: .mockWith( + launchReason: .userLaunch, + processLaunchDate: .mockDecember15th2019At10AMUTC() + ), + applicationStateHistory: .mockAppInBackground(since: currentTime) + ) + _ = scope.process( + command: RUMCommandMock(time: currentTime, isUserInteraction: true), + context: backgroundContext, + writer: writer + ) + + // Then - end-reason-based precondition is used, not backgroundLaunch + XCTAssertEqual(scope.activeSession?.context.sessionPrecondition, .inactivityTimeout) + // And no error telemetry is fired for .userLaunch in background (it is a valid scenario) + XCTAssertNil(featureScope.telemetryMock.messages.firstError()) + } }