Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 34 additions & 8 deletions DatadogRUM/Sources/RUMMonitor/Scopes/RUMApplicationScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment on lines +365 to +377
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: consider using @unknown default here instead of default so new LaunchReason cases added in the future trigger a compiler warning (see e.g. DatadogInternal/Sources/Context/AppState.swift:171).

Copilot uses AI. Check for mistakes.
}
}
}
197 changes: 197 additions & 0 deletions DatadogRUM/Tests/RUMMonitor/Scopes/RUMApplicationScopeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Comment on lines +499 to +506
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backgroundContext is built with DatadogContext.mockWith(...) without passing sdkInitDate, so it falls back to Date() (see TestUtilities/Sources/Mocks/DatadogInternal/DatadogContextMock.swift). Because session end metrics read context.sdkInitDate and context.launchInfo.processLaunchDate, this makes the test setup time-dependent and less deterministic. Consider passing a fixed sdkInitDate (e.g. currentTime) and (optionally) reusing the same launchInfo as the initial sdkContext so the context represents a single process lifecycle consistently.

Copilot uses AI. Check for mistakes.
_ = 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())
}
}