diff --git a/packages/datadog_common_test/lib/src/decoders/rum_decoder.dart b/packages/datadog_common_test/lib/src/decoders/rum_decoder.dart index 80a255497..f22c601c4 100644 --- a/packages/datadog_common_test/lib/src/decoders/rum_decoder.dart +++ b/packages/datadog_common_test/lib/src/decoders/rum_decoder.dart @@ -82,9 +82,14 @@ class RumSessionDecoder { visit.longTaskEvents.add(longTaskEvent); break; case 'vital': - final operationStepEvent = - RumVitalOperationStepEventDecoder(e.rumEvent); - visit.vitalStepEvents.add(operationStepEvent); + if (RumVitalAppLaunchEventDecoder.isAppLaunchVital(e.rumEvent)) { + final appLaunchEvent = RumVitalAppLaunchEventDecoder(e.rumEvent); + visit.appLaunchVitalEvents.add(appLaunchEvent); + } else { + final operationStepEvent = + RumVitalOperationStepEventDecoder(e.rumEvent); + visit.vitalStepEvents.add(operationStepEvent); + } break; } } @@ -109,6 +114,7 @@ class RumViewVisit { final List errorEvents = []; final List longTaskEvents = []; final List vitalStepEvents = []; + final List appLaunchVitalEvents = []; RumViewVisit(this.id, this.name, this.path); } @@ -361,3 +367,24 @@ class RumVitalOperationStepEventDecoder extends RumEventDecoder { rumEvent['vital']['failure_reason'] as String?; String get stepType => rumEvent['vital']['step_type'] as String; } + +/// Decodes app launch vitals, which carry the app launch metrics (`ttid` and +/// `ttfd`) rather than the operation step properties. +class RumVitalAppLaunchEventDecoder extends RumEventDecoder { + RumVitalAppLaunchEventDecoder(super.rumEvent); + + static bool isAppLaunchVital(Map rumEvent) { + final vital = rumEvent['vital']; + return vital is Map && vital['type'] == 'app_launch'; + } + + RumViewInfoDecoder get view => RumViewInfoDecoder(rumEvent['view']); + + String get appLaunchMetric => + rumEvent['vital']['app_launch_metric'] as String; + + /// Duration of the app launch metric, in nanoseconds. + int get duration => (rumEvent['vital']['duration'] as num).toInt(); + + String? get startupType => rumEvent['vital']['startup_type'] as String?; +} diff --git a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt index fdb8e10c7..e6dcf058e 100644 --- a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt +++ b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt @@ -118,6 +118,7 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { "stopView" -> stopView(call, result) "addTiming" -> addTiming(call, result) "addViewLoadingTime" -> addViewLoadingTime(call, result) + "reportAppFullyDisplayed" -> reportAppFullyDisplayed(call, result) "startResource" -> startResource(call, result) "stopResource" -> stopResource(call, result) "stopResourceWithError" -> stopResourceWithError(call, result) @@ -291,6 +292,11 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { } } + private fun reportAppFullyDisplayed(call: MethodCall, result: Result) { + rum?.reportAppFullyDisplayed() + result.success(null) + } + private fun startResource(call: MethodCall, result: Result) { val key = call.argument(PARAM_KEY) val url = call.argument(PARAM_URL) diff --git a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt index c1739057b..f883a0846 100644 --- a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt +++ b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt @@ -593,6 +593,21 @@ class DatadogRumPluginTest { verify { mockResult.success(null) } } + @Test + fun `M call monitor reportAppFullyDisplayed W reportAppFullyDisplayed is called`() { + // GIVEN + val call = MethodCall("reportAppFullyDisplayed", mapOf()) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + // WHEN + plugin.onMethodCall(call, mockResult) + + // THEN + verify { monitorProxy.mockMonitor.reportAppFullyDisplayed() } + verify { mockResult.success(null) } + } + @Test fun `M call monitor startResource W startResource is called`( @StringForgery resourceKey: String, @@ -1066,7 +1081,8 @@ class DatadogRumPluginTest { "failureReason" to ContractParameter.Type(SupportedContractType.STRING), "attributes" to ContractParameter.Type(SupportedContractType.MAP) )), - Contract("stopSession", mapOf()) + Contract("stopSession", mapOf()), + Contract("reportAppFullyDisplayed", mapOf()) ) @Test diff --git a/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift b/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift index d65cd018e..8ed272067 100644 --- a/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift +++ b/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift @@ -196,7 +196,8 @@ class DatadogRumPluginTests: XCTestCase { "failureReason": .string, "attributes": .map ]), - Contract(methodName: "stopSession", requiredParameters: [:]) + Contract(methodName: "stopSession", requiredParameters: [:]), + Contract(methodName: "reportAppFullyDisplayed", requiredParameters: [:]) ] func testRumPlugin_ContractViolationsThrowErrors() { @@ -382,6 +383,18 @@ class DatadogRumPluginTests: XCTestCase { XCTAssertEqual(resultStatus, .called(value: nil)) } + func testReportAppFullyDisplayed_CallsRumMonitor() { + let call = FlutterMethodCall(methodName: "reportAppFullyDisplayed", arguments: [:] as [String: Any?]) + + var resultStatus = ResultStatus.notCalled + plugin.handle(call) { result in + resultStatus = .called(value: result) + } + + XCTAssertEqual(mock.callLog, [ .reportAppFullyDisplayed ]) + XCTAssertEqual(resultStatus, .called(value: nil)) + } + func testStartResource_CallsRumMonitor() { let call = FlutterMethodCall(methodName: "startResource", arguments: [ "key": "resource_key", diff --git a/packages/datadog_flutter_plugin/integration_test_app/integration_test/rum_manual_test.dart b/packages/datadog_flutter_plugin/integration_test_app/integration_test/rum_manual_test.dart index 6ab1c9ef4..83731af06 100644 --- a/packages/datadog_flutter_plugin/integration_test_app/integration_test/rum_manual_test.dart +++ b/packages/datadog_flutter_plugin/integration_test_app/integration_test/rum_manual_test.dart @@ -187,6 +187,33 @@ void main() { expect(view1.vitalStepEvents[2].vitalOperationKey, isNull); expect(view1.vitalStepEvents[2].vitalFailureReason, 'error'); + // `reportAppFullyDisplayed` reports TTFD as an app launch vital rather than + // as a property of the view it was called from, so look for it across the + // whole session. + // + // This is only checked on iOS. The Browser SDK has no equivalent API, and + // the Android SDK only sends TTFD once it has computed TTID for the startup + // scenario, which does not happen in this app -- it sends no app launch + // vitals at all, so there is nothing to assert on there yet. + if (!kIsWeb && Platform.isIOS) { + final ttfdVitals = rumLog + .where((e) => + e.eventType == 'vital' && + RumVitalAppLaunchEventDecoder.isAppLaunchVital(e.rumEvent)) + .map((e) => RumVitalAppLaunchEventDecoder(e.rumEvent)) + .where((e) => e.appLaunchMetric == 'ttfd') + .toList(); + + // Only the first call to `reportAppFullyDisplayed` is reported. + expect(ttfdVitals.length, 1); + // TTFD is measured from the launch of the app, so it should be at least + // as long as the fake loading the scenario performs before reporting it. + expect(ttfdVitals[0].duration, + greaterThanOrEqualTo(const Duration(milliseconds: 50).inNanoseconds)); + expect(ttfdVitals[0].duration, + lessThan(const Duration(seconds: 60).inNanoseconds)); + } + // Verify user in all events, except for the first view event for (final viewEvent in view1.viewEvents.sublist(1)) { verifyUser(viewEvent); diff --git a/packages/datadog_flutter_plugin/integration_test_app/lib/integration_scenarios/rum_manual_instrumentation_scenario.dart b/packages/datadog_flutter_plugin/integration_test_app/lib/integration_scenarios/rum_manual_instrumentation_scenario.dart index cfef8ed5d..9c1b3589d 100644 --- a/packages/datadog_flutter_plugin/integration_test_app/lib/integration_scenarios/rum_manual_instrumentation_scenario.dart +++ b/packages/datadog_flutter_plugin/integration_test_app/lib/integration_scenarios/rum_manual_instrumentation_scenario.dart @@ -105,6 +105,7 @@ class _RumManualInstrumentationScenarioState await Future.delayed(const Duration(milliseconds: 50)); DatadogSdk.instance.rum?.addTiming('content-ready'); DatadogSdk.instance.rum?.addViewLoadingTime(); + DatadogSdk.instance.rum?.reportAppFullyDisplayed(); DatadogSdk.instance.setUserInfo( id: 'fake-id', diff --git a/packages/datadog_flutter_plugin/ios/datadog_flutter_plugin/Sources/datadog_flutter_plugin/DatadogRumPlugin.swift b/packages/datadog_flutter_plugin/ios/datadog_flutter_plugin/Sources/datadog_flutter_plugin/DatadogRumPlugin.swift index 82c72fc65..30bf56a00 100644 --- a/packages/datadog_flutter_plugin/ios/datadog_flutter_plugin/Sources/datadog_flutter_plugin/DatadogRumPlugin.swift +++ b/packages/datadog_flutter_plugin/ios/datadog_flutter_plugin/Sources/datadog_flutter_plugin/DatadogRumPlugin.swift @@ -194,6 +194,9 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin { FlutterError.missingParameter(methodName: call.method) ) } + case "reportAppFullyDisplayed": + rum?.reportAppFullyDisplayed() + result(nil) case "startResource": if let key = arguments["key"] as? String, let methodString = arguments["httpMethod"] as? String, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart index 1c058bb04..45e41f638 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart @@ -271,6 +271,20 @@ class DatadogRum { }); } + /// Marks the moment when the UI of the app is considered fully displayed. + /// The duration between the launch of the app and this call is reported as + /// the time to full display (TTFD) of the app launch. + /// + /// Only the first call to this method has any effect for a given RUM + /// session. + /// + /// *Note*: This API is experimental and may change in the future. + void reportAppFullyDisplayed() { + wrap('rum.reportAppFullyDisplayed', logger, null, () { + return _platform.reportAppFullyDisplayed(); + }); + } + /// Notifies that the Exception or Error [error] occurred in currently /// presented View, with an origin of [source]. You can optionally set /// additional [attributes] for this error, an [errorType] and a diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart index d300d619c..a5eff2522 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart @@ -202,6 +202,14 @@ class DdRumMethodChannel extends DdRumPlatform { }); } + @override + Future reportAppFullyDisplayed() { + return methodChannel.invokeMethod( + 'reportAppFullyDisplayed', + {}, + ); + } + @override Future addErrorInfo( DateTime timestamp, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart index 5b87bfced..84e54744a 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart @@ -57,6 +57,11 @@ class DdNoOpRumPlatform extends DdRumPlatform { return Future.value(); } + @override + Future reportAppFullyDisplayed() { + return Future.value(); + } + @override Future addAction( DateTime timestamp, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart index 5bb1467d8..88e59e6c5 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart @@ -41,6 +41,7 @@ abstract class DdRumPlatform extends PlatformInterface { ); Future addTiming(DateTime timestamp, String name); Future addViewLoadingTime(bool overwrite); + Future reportAppFullyDisplayed(); Future startResource( DateTime timestamp, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart b/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart index b38a4ff80..c2ce8462c 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart @@ -210,6 +210,11 @@ class DdRumWeb extends DdRumPlatform { // NOOP - Not supported by the Browser SDK } + @override + Future reportAppFullyDisplayed() async { + // NOOP - Not supported by the Browser SDK + } + @override Future addAction( DateTime timestamp, diff --git a/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart b/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart index a453d72c7..d01124cf1 100644 --- a/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart +++ b/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart @@ -150,6 +150,14 @@ void main() { ]); }); + test('reportAppFullyDisplayed calls to platform', () async { + await ddRumPlatform.reportAppFullyDisplayed(); + + expect(log, [ + isMethodCall('reportAppFullyDisplayed', arguments: {}), + ]); + }); + test('startResource calls to platform', () async { final timestamp = randomTimestamp(); await ddRumPlatform.startResource(