Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-6501 feat: RUM View Loading Time metrics #2139

Merged
merged 24 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e179f52
RUM-7102 Update RUM schema
ncreated Nov 18, 2024
c38e7b4
RUM-7102 Implement TTNS metric for RUM
ncreated Nov 25, 2024
3ab544b
RUM-7102 Integrate TTNS metric into RUM
ncreated Nov 28, 2024
62f355b
RUM-7102 Add integration tests for TTNS metric
ncreated Nov 29, 2024
63dc7f8
RUM-7102 Refine & update CHANGELOG.md
ncreated Nov 29, 2024
ab55dec
RUM-7102 CR feedback
ncreated Dec 10, 2024
fd4e916
RUM-7102 Exclude dropped resources and errors from TTNS computation
ncreated Dec 10, 2024
14c6904
Merge pull request #2125 from DataDog/ncreated/RUM-7102/vl-ttns-basics
ncreated Dec 10, 2024
7088539
Merge branch 'develop' into feature/view-loading-times
ncreated Dec 18, 2024
8b2a25a
RUM-7102 Implement ITNV metric for RUM
ncreated Dec 16, 2024
d57950f
RUM-7107 Update CHANGELOG
ncreated Dec 18, 2024
790e154
RUM-7107 Cleanup
ncreated Dec 19, 2024
d26e770
RUM-7107 CR feedback
ncreated Dec 30, 2024
bf43041
Merge pull request #2153 from DataDog/feature/RUM-7107/vl-itnv-basics
ncreated Dec 30, 2024
6172e70
RUM-7103 Implement strategy for classifying resources in TTNS
ncreated Dec 20, 2024
8cc7658
RUM-7105 Add public API for customizing TTNS resource predicate
ncreated Dec 20, 2024
f5c6c8e
RUM-7103 Add integration test for custom TTNS predicate
ncreated Dec 20, 2024
2f13c4c
Merge pull request #2160 from DataDog/ncreated/RUM-7103/ttns-time-str…
ncreated Dec 31, 2024
4363cfa
RUM-7823 Implement strategy for classifying actions in ITNV
ncreated Dec 27, 2024
1d57406
RUM-7823 Add public API for customizing ITNV action predicate
ncreated Dec 31, 2024
f015c86
RUM-7823 CR feedback
ncreated Jan 2, 2025
95648a4
Merge pull request #2165 from DataDog/ncreated/RUM-7823/itnv-custom-s…
ncreated Jan 3, 2025
d79dcfa
Merge branch 'develop' into feature/view-loading-times
ncreated Jan 14, 2025
074bc4b
RUM-6501 Update api-surface
ncreated Jan 14, 2025
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
Prev Previous commit
Next Next commit
RUM-7103 Implement strategy for classifying resources in TTNS
  • Loading branch information
ncreated committed Dec 30, 2024
commit 6172e70d894e2b6c55a7681055b7b6834f03f69b
6 changes: 6 additions & 0 deletions Datadog/Datadog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@
618C0FC02B482F6800266B38 /* SpanWriteContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618C0FBF2B482F6800266B38 /* SpanWriteContextTests.swift */; };
618C0FC12B482F6800266B38 /* SpanWriteContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618C0FBF2B482F6800266B38 /* SpanWriteContextTests.swift */; };
618C365F248E85B400520CDE /* DateFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618C365E248E85B400520CDE /* DateFormattingTests.swift */; };
618F2B032D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F2B022D146BB300A647C4 /* NetworkSettledResourcePredicate.swift */; };
618F2B042D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F2B022D146BB300A647C4 /* NetworkSettledResourcePredicate.swift */; };
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 */; };
Expand Down Expand Up @@ -2604,6 +2606,7 @@
618DCFDE24C75FD300589570 /* RUMScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMScopeTests.swift; sourceTree = "<group>"; };
618E13A92524B8700098C6B0 /* HTTPHeadersReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPHeadersReader.swift; sourceTree = "<group>"; };
618E13B02524B8F80098C6B0 /* TracingHTTPHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingHTTPHeaders.swift; sourceTree = "<group>"; };
618F2B022D146BB300A647C4 /* NetworkSettledResourcePredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettledResourcePredicate.swift; sourceTree = "<group>"; };
618F9840265BC486009959F8 /* E2EInstrumentationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = E2EInstrumentationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
618F9842265BC486009959F8 /* E2EInstrumentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EInstrumentationTests.swift; sourceTree = "<group>"; };
618F9844265BC486009959F8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4151,6 +4154,7 @@
isa = PBXGroup;
children = (
6105C4F52CEBA7A100C4C5EE /* TTNSMetric.swift */,
618F2B022D146BB300A647C4 /* NetworkSettledResourcePredicate.swift */,
6105C5082CFA222400C4C5EE /* ITNVMetric.swift */,
);
path = RUMMetrics;
Expand Down Expand Up @@ -9016,6 +9020,7 @@
D23F8E8529DDCD28001CFAE8 /* SwiftUIExtensions.swift in Sources */,
3CFF4F952C09E63C006F191D /* WatchdogTerminationChecker.swift in Sources */,
D23F8E8629DDCD28001CFAE8 /* RUMDataModelsMapping.swift in Sources */,
618F2B042D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */,
D23F8E8729DDCD28001CFAE8 /* RUMInstrumentation.swift in Sources */,
D23F8E8829DDCD28001CFAE8 /* VitalCPUReader.swift in Sources */,
D23F8E8929DDCD28001CFAE8 /* RUMOperatingSystemInfo.swift in Sources */,
Expand Down Expand Up @@ -9358,6 +9363,7 @@
D29A9F8E29DD8665005C54A4 /* SwiftUIExtensions.swift in Sources */,
3CFF4F942C09E63C006F191D /* WatchdogTerminationChecker.swift in Sources */,
D29A9F7829DD85BB005C54A4 /* RUMDataModelsMapping.swift in Sources */,
618F2B032D146BB300A647C4 /* NetworkSettledResourcePredicate.swift in Sources */,
D29A9F6F29DD85BB005C54A4 /* RUMInstrumentation.swift in Sources */,
D29A9F7A29DD85BB005C54A4 /* VitalCPUReader.swift in Sources */,
D29A9F6729DD85BB005C54A4 /* RUMOperatingSystemInfo.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class ViewLoadingMetricsTests: XCTestCase {
monitor.startView(key: "view", name: "ViewName")
monitor.startResource(resourceKey: "resource1", url: .mockRandom())
monitor.startResource(resourceKey: "resource2", url: .mockRandom())
rumTime.now.addTimeInterval(TTNSMetric.Constants.initialResourceThreshold * 0.99) // Wait no more than threshold, so next resource is still counted
rumTime.now.addTimeInterval(TimeBasedTTNSResourcePredicate.defaultThreshold * 0.99) // Wait no more than threshold, so next resource is still counted
monitor.startResource(resourceKey: "resource3", url: .mockRandom())

// When (end resources during the same view)
Expand Down Expand Up @@ -78,7 +78,7 @@ class ViewLoadingMetricsTests: XCTestCase {
monitor.startResource(resourceKey: "resource2", url: .mockRandom())

// When (start non-initial resource after threshold)
rumTime.now.addTimeInterval(TTNSMetric.Constants.initialResourceThreshold * 1.01) // Wait more than threshold, so next resource is not counted
rumTime.now.addTimeInterval(TimeBasedTTNSResourcePredicate.defaultThreshold * 1.01) // Wait more than threshold, so next resource is not counted
monitor.startResource(resourceKey: "resource3", url: .mockRandom())

// When (end resources during the same view)
Expand Down
6 changes: 3 additions & 3 deletions DatadogCore/Tests/Datadog/Mocks/RUMFeatureMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ extension RUMScopeDependencies {
fatalErrorContext: FatalErrorContextNotifying = FatalErrorContextNotifierMock(),
sessionEndedMetric: SessionEndedMetricController = SessionEndedMetricController(telemetry: NOPTelemetry(), sampleRate: 0),
watchdogTermination: WatchdogTerminationMonitor? = nil,
networkSettledMetricFactory: @escaping (Date) -> TTNSMetricTracking = { TTNSMetric(viewStartDate: $0) }
networkSettledMetricFactory: @escaping (Date, String) -> TTNSMetricTracking = { TTNSMetric(viewName: $1, viewStartDate: $0) }
) -> RUMScopeDependencies {
return RUMScopeDependencies(
featureScope: featureScope,
Expand Down Expand Up @@ -775,7 +775,7 @@ extension RUMScopeDependencies {
fatalErrorContext: FatalErrorContextNotifying? = nil,
sessionEndedMetric: SessionEndedMetricController? = nil,
watchdogTermination: WatchdogTerminationMonitor? = nil,
networkSettledMetricFactory: ((Date) -> TTNSMetricTracking)? = nil
networkSettledMetricFactory: ((Date, String) -> TTNSMetricTracking)? = nil
) -> RUMScopeDependencies {
return RUMScopeDependencies(
featureScope: self.featureScope,
Expand Down Expand Up @@ -914,7 +914,7 @@ extension RUMResourceScope {
isFirstPartyResource: Bool? = nil,
resourceKindBasedOnRequest: RUMResourceType? = nil,
spanContext: RUMSpanContext? = .mockAny(),
networkSettledMetric: TTNSMetricTracking = TTNSMetric(viewStartDate: .mockAny()),
networkSettledMetric: TTNSMetricTracking = TTNSMetric(viewName: .mockAny(), viewStartDate: .mockAny()),
onResourceEvent: @escaping (Bool) -> Void = { _ in },
onErrorEvent: @escaping (Bool) -> Void = { _ in }
) -> RUMResourceScope {
Expand Down
8 changes: 7 additions & 1 deletion DatadogRUM/Sources/Feature/RUMFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ internal final class RUMFeature: DatadogRemoteFeature {
fatalErrorContext: FatalErrorContextNotifier(messageBus: featureScope),
sessionEndedMetric: sessionEndedMetric,
watchdogTermination: watchdogTermination,
networkSettledMetricFactory: { viewStartDate in TTNSMetric(viewStartDate: viewStartDate) }
networkSettledMetricFactory: { viewStartDate, viewName in
TTNSMetric(
viewName: viewName,
viewStartDate: viewStartDate,
resourcePredicate: TimeBasedTTNSResourcePredicate() // TODO: RUM-7103 read predicatefrom configuration
)
}
)

self.monitor = Monitor(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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

/// A struct representing the parameters of a resource that may be considered for the Time-to-Network-Settled (TTNS) metric.
public struct TTNSResourceParams {
/// The URL of the resource.
public let url: String

/// The time elapsed from when the view started to when the resource started.
public let timeSinceViewStart: TimeInterval

/// The name of the view in which the resource is tracked.
public let viewName: String
}

/// A protocol for classifying network resources for the Time-to-Network-Settled (TTNS) metric.
/// Implement this protocol to customize the logic for determining which resources are included in the TTNS calculation.
///
/// **Note:**
/// - The `isInitialResource` method will be called on a secondary thread.
/// - The implementation must not assume any threading behavior and should avoid blocking the thread.
/// - The method should always return the same result for the same input parameters to ensure consistency in TTNS calculation.
public protocol NetworkSettledResourcePredicate {
/// Determines if the provided resource should be included in the TTNS metric calculation.
///
/// - Parameter resource: The parameters of the resource.
/// - Returns: `true` if the resource qualifies for TTNS metric calculation, `false` otherwise.
func isInitialResource(resource: TTNSResourceParams) -> Bool
}

/// A predicate implementation for classifying Time-to-Network-Settled (TTNS) resources based on a time threshold.
/// It will calculate TTNS using all resources that start within the specified threshold after the view starts.
public struct TimeBasedTTNSResourcePredicate: NetworkSettledResourcePredicate {
/// The default value of the threshold.
public static let defaultThreshold: TimeInterval = 0.1

/// The time threshold (in seconds) used to classify a resource.
let threshold: TimeInterval

/// Initializes a new predicate with a specified time threshold.
///
/// - Parameter threshold: The time threshold (in seconds) used to classify resources. The default value is 0.1 seconds.
public init(threshold: TimeInterval = TimeBasedTTNSResourcePredicate.defaultThreshold) {
self.threshold = threshold
}

/// Determines if the provided resource should be included in the TTNS metric calculation.
/// A resource is included if it starts within the specified threshold from the view start time.
///
/// - Parameter resource: The parameters of the resource.
/// - Returns: `true` if the resource qualifies for TTNS metric calculation, `false` otherwise.
public func isInitialResource(resource: TTNSResourceParams) -> Bool {
return resource.timeSinceViewStart <= threshold
}
}
75 changes: 49 additions & 26 deletions DatadogRUM/Sources/RUMMetrics/TTNSMetric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ internal protocol TTNSMetricTracking {
/// - Parameters:
/// - startDate: The start time of the resource (device time, no NTP offset).
/// - resourceID: The unique identifier for the resource.
func trackResourceStart(at startDate: Date, resourceID: RUMUUID)
/// - resourceURL: The URL of this resource.
func trackResourceStart(at startDate: Date, resourceID: RUMUUID, resourceURL: String)

/// Tracks the completion of a resource identified by its `resourceID`.
///
Expand All @@ -37,51 +38,73 @@ internal protocol TTNSMetricTracking {
/// - Parameters:
/// - time: The current time (device time, no NTP offset).
/// - appStateHistory: The history of app state transitions.
/// - Returns: The value for TTNS metric.
/// - Returns: The value for the TTNS metric, or `nil` if the metric cannot be calculated.
func value(at time: Date, appStateHistory: AppStateHistory) -> TimeInterval?
}

/// A metric (**Time-to-Network-Settled**) that measures the time from when the current view becomes visible until all initial resources are loaded.
///
/// "Initial resources" are defined as resources starting within 100ms of the view becoming visible.
/// A metric (**Time-to-Network-Settled**, or TTNS) that measures the time from when the view becomes visible until all initial resources are loaded.
/// "Initial resources" are now classified using a customizable predicate.
internal final class TTNSMetric: TTNSMetricTracking {
enum Constants {
/// Only resources starting within this interval of the view becoming visible are considered "initial resources".
static let initialResourceThreshold: TimeInterval = 0.1
}
/// The name of the view this metric is tracked for.
private let viewName: String

/// The time when the view tracking this metric becomes visible (device time, no NTP offset).
private let viewStartDate: Date

/// The predicate used to classify resources as "initial" for TTNS.
private let resourcePredicate: NetworkSettledResourcePredicate

/// Indicates whether the view is active (`true`) or stopped (`false`).
private var isViewActive = true

/// A dictionary mapping resource IDs to their start times. Only tracks initial resources.
/// A dictionary mapping resource IDs to their start times. Tracks resources classified as "initial."
private var pendingResourcesStartDates: [RUMUUID: Date] = [:]

/// The time when the last of the initial resources completes.
private var latestResourceEndDate: Date?

/// Initializes a new TTNSMetric instance for a view.
/// Stores the last computed value for the TTNS metric.
/// This is used to return the same value for subsequent calls to `value(at:appStateHistory:)`
/// while some resources are still pending.
private var lastReturnedValue: TimeInterval?

/// Initializes a new TTNSMetric instance for a view with a customizable predicate.
///
/// - Parameter viewStartDate: The time when the view becomes visible (device time, no NTP offset).
init(viewStartDate: Date) {
/// - Parameters:
/// - viewName: The name of the view this metric is tracked for.
/// - viewStartDate: The time when the view becomes visible (device time, no NTP offset).
/// - resourcePredicate: A predicate used to classify resources as "initial" for TTNS.
init(
viewName: String,
viewStartDate: Date,
resourcePredicate: NetworkSettledResourcePredicate
) {
self.viewName = viewName
self.viewStartDate = viewStartDate
self.resourcePredicate = resourcePredicate
}

/// Tracks the start time of a resource identified by its `resourceID`.
/// Only resources starting within the initial threshold are tracked.
/// Only resources classified as "initial" by the predicate are tracked.
///
/// - Parameters:
/// - startDate: The start time of the resource (device time, no NTP offset).
/// - resourceID: The unique identifier for the resource.
func trackResourceStart(at startDate: Date, resourceID: RUMUUID) {
/// - resourceURL: The URL of this resource.
func trackResourceStart(at startDate: Date, resourceID: RUMUUID, resourceURL: String) {
guard isViewActive else {
return // View was stopped, do not track the resource
}
guard startDate >= viewStartDate else {
return // Sanity check to ensure resource is being tracked after view start
}

let isInitialResource = startDate.timeIntervalSince(viewStartDate) <= Constants.initialResourceThreshold && startDate >= viewStartDate
if isInitialResource {
let resourceParams = TTNSResourceParams(
url: resourceURL,
timeSinceViewStart: startDate.timeIntervalSince(viewStartDate),
viewName: viewName
)
if resourcePredicate.isInitialResource(resource: resourceParams) {
pendingResourcesStartDates[resourceID] = startDate
}
}
Expand All @@ -100,7 +123,7 @@ internal final class TTNSMetric: TTNSMetricTracking {
let duration = resourceDuration ?? endDate.timeIntervalSince(startDate)

guard duration >= 0 else {
return // sanity check
return // Sanity check to avoid negative durations
}

let resourceEndDate = startDate.addingTimeInterval(duration)
Expand All @@ -123,29 +146,27 @@ internal final class TTNSMetric: TTNSMetricTracking {
}

/// Returns the value for the TTNS metric.
/// - The value is only available after all initial resources have completed loading and no earlier than 100ms after view start.
/// - The value is not tracked if the view was stopped before all initial resources completed loading.
/// - The value is only available after all initial resources have completed loading.
/// - The value is not updated after view is stopped.
/// - The value is only tracked if the app was in "active" state during view loading.
///
/// - Parameters:
/// - time: The current time (device time, no NTP offset).
/// - appStateHistory: The history of app state transitions.
/// - Returns: The value for TTNS metric.
func value(at time: Date, appStateHistory: AppStateHistory) -> TimeInterval? {
guard time > viewStartDate.addingTimeInterval(Constants.initialResourceThreshold) else {
return nil // No value before 100ms after view start
}
guard pendingResourcesStartDates.isEmpty else {
return nil // No value until all initial resources are completed
return lastReturnedValue // No new value until all pending resources are completed
}
guard let latestResourceEndDate = latestResourceEndDate else {
return nil // Tracked no resource
return nil // No resources were tracked
}

let ttnsValue = latestResourceEndDate.timeIntervalSince(viewStartDate)
let viewLoadedDate = viewStartDate.addingTimeInterval(ttnsValue)

guard viewLoadedDate >= viewStartDate else { // sanity check
guard viewLoadedDate >= viewStartDate else { // Sanity check to ensure valid time
lastReturnedValue = nil
return nil
}

Expand All @@ -154,9 +175,11 @@ internal final class TTNSMetric: TTNSMetricTracking {
let trackedInForeground = !(viewLoadingAppStates.snapshots.contains { $0.state != .active })

guard trackedInForeground else {
lastReturnedValue = nil
return nil // The app was not always "active" during view loading
}

lastReturnedValue = ttnsValue
return ttnsValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ internal class RUMResourceScope: RUMScope {
self.onErrorEvent = onErrorEvent

// Track this resource in view's TTNS metric:
networkSettledMetric.trackResourceStart(at: startTime, resourceID: resourceUUID)
networkSettledMetric.trackResourceStart(at: startTime, resourceID: resourceUUID, resourceURL: url)
}

// MARK: - RUMScope
Expand Down
Loading