Skip to content

Commit a9d015c

Browse files
committed
RUM-11470: Report Time to full display
1 parent 528a940 commit a9d015c

19 files changed

+1072
-322
lines changed

DatadogCore/Tests/Objc/DDRUMMonitorTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ class DDRUMMonitorTests: XCTestCase {
323323
objcRUMMonitor.stopView(viewController: mockView, attributes: ["event-attribute2": "foo2"])
324324
objcRUMMonitor.startView(key: "view2", name: "SecondView", attributes: ["event-attribute1": "bar1"])
325325
objcRUMMonitor.addViewLoadingTime(overwrite: true)
326+
objcRUMMonitor.reportAppFullyDisplayed()
326327
objcRUMMonitor.stopView(key: "view2", attributes: ["event-attribute2": "bar2"])
327328

328329
let rumEventMatchers = try core.waitAndReturnRUMEventMatchers()

DatadogCore/Tests/Objc/ObjcAPITests/DDRUMMonitor+apiTests.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ - (void)testDDRUMMonitorAPI {
5959
DDRUMMonitor *monitor = [DDRUMMonitor shared];
6060
[monitor currentSessionIDWithCompletion:^(NSString * _Nullable sessionID) {}];
6161
[monitor stopSession];
62+
[monitor reportAppFullyDisplayed];
6263

6364
[monitor addViewAttributeForKey:@"key" value: @"value"];
6465
[monitor addViewAttributes:@{@"string": @"value", @"integer": @1, @"boolean": @true}];

DatadogRUM/Sources/RUM+objc.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,10 @@ public class objc_RUMMonitor: NSObject {
522522
swiftRUMMonitor.stopSession()
523523
}
524524

525+
public func reportAppFullyDisplayed() {
526+
swiftRUMMonitor.reportAppFullyDisplayed()
527+
}
528+
525529
public func addViewAttribute(forKey key: String, value: Any) {
526530
swiftRUMMonitor.addViewAttribute(forKey: key, value: AnyEncodable(value))
527531
}

DatadogRUM/Sources/RUMMonitor/Monitor.swift

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,8 @@ extension Monitor: RUMMonitorProtocol {
241241
process(command: RUMStopSessionCommand(time: dateProvider.now))
242242
}
243243

244-
// MARK: - custom timings
245-
246-
func addTiming(name: String) {
247-
process(
248-
command: RUMAddViewTimingCommand(
249-
time: dateProvider.now,
250-
globalAttributes: self.attributes,
251-
attributes: [:],
252-
timingName: name
253-
)
254-
)
244+
func reportAppFullyDisplayed() {
245+
process(command: RUMTimeToFullDisplayCommand(time: dateProvider.now))
255246
}
256247

257248
// MARK: - errors
@@ -664,6 +655,17 @@ extension Monitor: RUMMonitorViewProtocol {
664655
)
665656
}
666657

658+
func addTiming(name: String) {
659+
process(
660+
command: RUMAddViewTimingCommand(
661+
time: dateProvider.now,
662+
globalAttributes: self.attributes,
663+
attributes: [:],
664+
timingName: name
665+
)
666+
)
667+
}
668+
667669
func addViewLoadingTime(overwrite: Bool) {
668670
process(
669671
command: RUMAddViewLoadingTime(

DatadogRUM/Sources/RUMMonitor/RUMCommand.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,19 @@ internal struct RUMTimeToInitialDisplayCommand: RUMCommand {
115115
let missedEventType: SessionEndedMetric.MissedEventType? = nil
116116
}
117117

118+
internal struct RUMTimeToFullDisplayCommand: RUMCommand {
119+
var time: Date
120+
var globalAttributes: [AttributeKey: AttributeValue] = [:]
121+
var attributes: [AttributeKey: AttributeValue] = [:]
122+
var canStartApplicationLaunchView = false
123+
var canStartBackgroundView = false
124+
let shouldRestartLastViewAfterSessionExpiration = false
125+
let shouldRestartLastViewAfterSessionStop = false
126+
let canStartBackgroundViewAfterSessionStop = false
127+
let isUserInteraction = false
128+
let missedEventType: SessionEndedMetric.MissedEventType? = nil
129+
}
130+
118131
// MARK: - RUM View related commands
119132

120133
internal struct RUMAddViewAttributesCommand: RUMCommand {

DatadogRUM/Sources/RUMMonitor/Scopes/RUMAppLaunchManager.swift

Lines changed: 181 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,22 @@
77
import DatadogInternal
88
import Foundation
99

10-
internal class RUMAppLaunchVitalScope {
10+
internal class RUMAppLaunchManager {
1111
internal enum Constants {
1212
// Maximum time for an erroneous TTID (Time to Initial Display)
1313
static let maxTTIDDuration: TimeInterval = 60 // 1 minute
14+
// Maximum time for an erroneous ttfd
15+
static let maxTTFDDuration: TimeInterval = 90 // 90 seconds
1416
}
1517
// MARK: - Properties
1618

1719
private unowned let parent: RUMContextProvider
1820
private let dependencies: RUMScopeDependencies
1921

2022
private var timeToInitialDisplay: Double?
23+
private var timeToFullDisplay: Double?
24+
private var viewLoadingTime: Double?
25+
private var startupType: RUMVitalEvent.Vital.AppLaunchProperties.StartupType?
2126

2227
private lazy var startupTypeHandler = StartupTypeHandler(appStateManager: dependencies.appStateManager)
2328

@@ -35,84 +40,79 @@ internal class RUMAppLaunchVitalScope {
3540
switch command {
3641
case let command as RUMTimeToInitialDisplayCommand:
3742
try writeTTIDVitalEvent(from: command, context: context, writer: writer)
43+
case let command as RUMTimeToFullDisplayCommand:
44+
try writeTTFDVitalEvent(from: command, context: context, writer: writer)
45+
case let command as RUMAddViewLoadingTime:
46+
try registerViewLoadingTime(from: command, context: context)
47+
case let command as RUMStopViewCommand:
48+
if viewLoadingTime != nil,
49+
timeToFullDisplay == nil {
50+
try writeTTFDVitalEvent(from: command, context: context, writer: writer)
51+
}
3852
default: break
3953
}
4054
} catch {
4155
dependencies.telemetry.error("RUMAppLaunchManager failed to write the ttid vital event.", error: error)
4256
}
4357
}
58+
}
4459

45-
// MARK: - Private Methods
60+
// MARK: - TTID
4661

62+
private extension RUMAppLaunchManager {
4763
private func writeTTIDVitalEvent(from command: RUMTimeToInitialDisplayCommand, context: DatadogContext, writer: Writer) throws {
48-
guard
49-
shouldProcess(command: command, context: context),
50-
let timeToInitialDisplay = timeToInitialDisplay(from: command, context: context)
64+
guard shouldProcess(command: command, context: context),
65+
let ttid = time(from: command, context: context)
5166
else {
5267
return
5368
}
5469

55-
self.timeToInitialDisplay = timeToInitialDisplay
56-
57-
let attributes = command.globalAttributes
58-
.merging(command.attributes) { $1 }
70+
self.timeToInitialDisplay = ttid
5971

6072
dependencies.appStateManager.currentAppStateInfo { [weak self] currentAppStateInfo in
6173
guard let self else {
6274
return
6375
}
6476

65-
let appLaunchMetric: RUMVitalEvent.Vital.AppLaunchProperties.AppLaunchMetric = .ttid
77+
let attributes = command.globalAttributes
78+
.merging(command.attributes) { $1 }
79+
6680
let startupType = self.startupTypeHandler.startupType(currentAppState: currentAppStateInfo)
67-
let vital = RUMVitalEvent.Vital.appLaunchProperties(
68-
value: RUMVitalEvent.Vital.AppLaunchProperties(
69-
appLaunchMetric: .ttid,
70-
duration: Double(timeToInitialDisplay.toInt64Nanoseconds),
71-
id: dependencies.rumUUIDGenerator.generateUnique().toRUMDataFormat,
72-
isPrewarmed: context.launchInfo.launchReason == .prewarming,
73-
name: appLaunchMetric.name,
74-
startupType: startupType
75-
)
76-
)
81+
self.startupType = startupType
7782

78-
let vitalEvent = RUMVitalEvent(
79-
dd: .init(),
80-
account: .init(context: context),
81-
application: .init(id: parent.context.rumApplicationID),
82-
buildId: context.buildId,
83-
buildVersion: context.buildNumber,
84-
ciTest: dependencies.ciTest,
85-
connectivity: .init(context: context),
86-
context: RUMEventAttributes(contextInfo: attributes),
87-
date: context.launchInfo.processLaunchDate.timeIntervalSince1970.toInt64Milliseconds,
88-
ddtags: context.ddTags,
89-
device: context.normalizedDevice(),
90-
os: context.os,
91-
service: context.service,
92-
session: .init(
93-
hasReplay: context.hasReplay,
94-
id: parent.context.sessionID.toRUMDataFormat,
95-
type: dependencies.sessionType
96-
),
97-
source: .init(rawValue: context.source) ?? .ios,
98-
synthetics: dependencies.syntheticsTest,
99-
usr: .init(context: context),
100-
version: context.version,
101-
vital: vital
83+
self.writeVitalEvent(
84+
duration: Double(ttid.toInt64Nanoseconds),
85+
appLaunchMetric: .ttid,
86+
startupType: startupType,
87+
attributes: attributes,
88+
context: context,
89+
writer: writer
10290
)
10391

104-
writer.write(value: vitalEvent)
92+
// The TTFD is always written after the TTID. If it exists already, means it was not written before.
93+
if let timeToFullDisplay {
94+
let ttfd = max(ttid, timeToFullDisplay)
95+
self.writeVitalEvent(
96+
duration: Double(ttfd.toInt64Nanoseconds),
97+
appLaunchMetric: .ttfd,
98+
startupType: startupType,
99+
attributes: attributes,
100+
context: context,
101+
writer: writer
102+
)
103+
}
105104
}
106105
}
107106

108-
private func shouldProcess(command: RUMTimeToInitialDisplayCommand, context: DatadogContext) -> Bool {
107+
func shouldProcess(command: RUMTimeToInitialDisplayCommand, context: DatadogContext) -> Bool {
109108
// Ignore command if the time to initial display was already written
110109
guard self.timeToInitialDisplay == nil else {
111110
return false
112111
}
113112

114-
// Ignore command if the time to initial display is too big
115-
guard command.time.timeIntervalSince(context.launchInfo.processLaunchDate) < Constants.maxTTIDDuration else {
113+
// Ignore command if the time since the SDK load is too big
114+
guard let runtimeLoadDate = context.launchInfo.launchPhaseDates[.runtimeLoad],
115+
command.time.timeIntervalSince(runtimeLoadDate) < Constants.maxTTIDDuration else {
116116
return false
117117
}
118118

@@ -124,7 +124,7 @@ internal class RUMAppLaunchVitalScope {
124124
return true
125125
}
126126

127-
private func timeToInitialDisplay(from command: RUMTimeToInitialDisplayCommand, context: DatadogContext) -> TimeInterval? {
127+
func time(from command: RUMCommand, context: DatadogContext) -> TimeInterval? {
128128
switch context.launchInfo.launchReason {
129129
case .userLaunch:
130130
return command.time.timeIntervalSince(context.launchInfo.processLaunchDate)
@@ -138,6 +138,138 @@ internal class RUMAppLaunchVitalScope {
138138
return nil
139139
}
140140
}
141+
142+
func writeVitalEvent(
143+
duration: TimeInterval,
144+
appLaunchMetric: RUMVitalEvent.Vital.AppLaunchProperties.AppLaunchMetric,
145+
startupType: RUMVitalEvent.Vital.AppLaunchProperties.StartupType,
146+
attributes: [AttributeKey: AttributeValue],
147+
context: DatadogContext,
148+
writer: Writer
149+
) {
150+
let vital = RUMVitalEvent.Vital.appLaunchProperties(
151+
value: RUMVitalEvent.Vital.AppLaunchProperties(
152+
appLaunchMetric: appLaunchMetric,
153+
duration: duration,
154+
id: dependencies.rumUUIDGenerator.generateUnique().toRUMDataFormat,
155+
isPrewarmed: context.launchInfo.launchReason == .prewarming,
156+
name: appLaunchMetric.name,
157+
startupType: startupType
158+
)
159+
)
160+
161+
let vitalEvent = RUMVitalEvent(
162+
dd: .init(),
163+
account: .init(context: context),
164+
application: .init(id: parent.context.rumApplicationID),
165+
buildId: context.buildId,
166+
buildVersion: context.buildNumber,
167+
ciTest: dependencies.ciTest,
168+
connectivity: .init(context: context),
169+
context: RUMEventAttributes(contextInfo: attributes),
170+
date: context.launchInfo.processLaunchDate.timeIntervalSince1970.toInt64Milliseconds,
171+
ddtags: context.ddTags,
172+
device: context.normalizedDevice(),
173+
os: context.os,
174+
service: context.service,
175+
session: .init(
176+
hasReplay: context.hasReplay,
177+
id: parent.context.sessionID.toRUMDataFormat,
178+
type: dependencies.sessionType
179+
),
180+
source: .init(rawValue: context.source) ?? .ios,
181+
synthetics: dependencies.syntheticsTest,
182+
usr: .init(context: context),
183+
version: context.version,
184+
vital: vital
185+
)
186+
187+
writer.write(value: vitalEvent)
188+
}
189+
}
190+
191+
// MARK: - TTFD
192+
193+
private extension RUMAppLaunchManager {
194+
func writeTTFDVitalEvent(from command: RUMTimeToFullDisplayCommand, context: DatadogContext, writer: Writer) throws {
195+
guard shouldProcess(command: command, context: context),
196+
let ttfd = time(from: command, context: context) else { return }
197+
198+
self.timeToFullDisplay = ttfd
199+
200+
if let timeToFullDisplay, let timeToInitialDisplay, let startupType {
201+
let attributes = command.globalAttributes
202+
.merging(command.attributes) { $1 }
203+
let ttfd = max(timeToInitialDisplay, timeToFullDisplay)
204+
205+
self.writeVitalEvent(
206+
duration: Double(ttfd.toInt64Nanoseconds),
207+
appLaunchMetric: .ttfd,
208+
startupType: startupType,
209+
attributes: attributes,
210+
context: context,
211+
writer: writer
212+
)
213+
}
214+
}
215+
216+
func shouldProcess(command: RUMTimeToFullDisplayCommand, context: DatadogContext) -> Bool {
217+
// Ignore command if the time to full display was already written
218+
guard self.timeToFullDisplay == nil else {
219+
return false
220+
}
221+
222+
// Ignore command if the time since the SDK load is too big
223+
guard let runtimeLoadDate = context.launchInfo.launchPhaseDates[.runtimeLoad],
224+
command.time.timeIntervalSince(runtimeLoadDate) < Constants.maxTTFDDuration else {
225+
return false
226+
}
227+
228+
return true
229+
}
230+
}
231+
232+
// MARK: - View loading time
233+
234+
private extension RUMAppLaunchManager {
235+
func registerViewLoadingTime(from command: RUMAddViewLoadingTime, context: DatadogContext) throws {
236+
guard shouldProcess(command: command, context: context),
237+
let viewLoadingTime = time(from: command, context: context) else { return }
238+
239+
self.viewLoadingTime = viewLoadingTime
240+
}
241+
242+
func writeTTFDVitalEvent(from command: RUMStopViewCommand, context: DatadogContext, writer: Writer) throws {
243+
if let viewLoadingTime, let timeToInitialDisplay, let startupType {
244+
let attributes = command.globalAttributes
245+
.merging(command.attributes) { $1 }
246+
let ttfd = max(timeToInitialDisplay, viewLoadingTime)
247+
248+
self.writeVitalEvent(
249+
duration: Double(ttfd.toInt64Nanoseconds),
250+
appLaunchMetric: .ttfd,
251+
startupType: startupType,
252+
attributes: attributes,
253+
context: context,
254+
writer: writer
255+
)
256+
}
257+
}
258+
259+
func shouldProcess(command: RUMAddViewLoadingTime, context: DatadogContext) -> Bool {
260+
// Ignore command if the time to full display was already written
261+
guard self.timeToFullDisplay == nil else {
262+
return false
263+
}
264+
265+
// Ignore command if the time since the SDK load is too big
266+
guard let runtimeLoadDate = context.launchInfo.launchPhaseDates[.runtimeLoad],
267+
command.time.timeIntervalSince(runtimeLoadDate) < Constants.maxTTFDDuration else {
268+
return false
269+
}
270+
271+
return true
272+
}
141273
}
142274

143275
private extension RUMVitalEvent.Vital.AppLaunchProperties.AppLaunchMetric {

0 commit comments

Comments
 (0)