From 49ae9931f5bf226eb0fe5f4adb7dd5591179ea7d Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 27 Jul 2026 03:08:59 -0700 Subject: [PATCH 1/4] feat(rum): report app launch (TTID) on Android The native RumAppStartupDetector registers its Activity lifecycle callbacks when the RUM feature initializes. Flutter initializes the SDK from Dart main(), by which time the first Activity has already been created, so the detector never sees a launch and none is ever reported. Report it from the plugin instead, on the launch frame: - DatadogSdkPlugin is now ActivityAware and stamps the UI creation time from onAttachedToActivity. Engine attach is not used: a pre-warmed or headless engine attaches with no UI, which would place the launch start far too early. An engine that never gets an Activity reports no UI creation time, and the SDK falls back to timing from process start. - Dart subscribes a one-shot timings callback and passes how long ago the launch frame finished rasterizing, so neither the callback scheduling nor the method channel round trip is counted as launch time. Measured at ~61ms on a release build, which is no longer charged to the launch. - If the first frame is already on screen when RUM is enabled, no launch is reported at all: the next frame is not the launch frame, and a static page may never render one. - Registration tolerates a missing binding, since enable() is reachable from plain Dart contexts where touching SchedulerBinding.instance throws. Requires cloud.flashcat:dd-sdk-android-rum 0.5.0 for the notifyAppLaunch entry point. Verified end to end against a local RUM intake: startup_type=cold_start, app_launch_metric=ttid, durations 0.6s-2.1s, landing in Doris. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/build.gradle | 2 +- .../com/datadoghq/flutter/DatadogRumPlugin.kt | 39 +++++++++ .../com/datadoghq/flutter/DatadogSdkPlugin.kt | 21 ++++- .../lib/src/rum/ddrum.dart | 82 ++++++++++++++++++- .../lib/src/rum/ddrum_method_channel.dart | 7 ++ .../lib/src/rum/ddrum_noop_platform.dart | 5 ++ .../lib/src/rum/ddrum_platform_interface.dart | 2 + .../lib/src/rum/web/ddrum_web.dart | 5 ++ 8 files changed, 160 insertions(+), 3 deletions(-) diff --git a/packages/datadog_flutter_plugin/android/build.gradle b/packages/datadog_flutter_plugin/android/build.gradle index 6ec26b04..2674b87c 100644 --- a/packages/datadog_flutter_plugin/android/build.gradle +++ b/packages/datadog_flutter_plugin/android/build.gradle @@ -10,7 +10,7 @@ version "1.0-SNAPSHOT" buildscript { ext.kotlin_version = "2.1.0" - ext.datadog_version = "0.4.1" + ext.datadog_version = "0.5.0" repositories { google() 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 761fe00a..50501450 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 @@ -29,6 +29,7 @@ import com.datadog.android.rum.metric.networksettled.TimeBasedInitialResourceIde import com.datadog.android.rum.tracking.ViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import io.flutter.embedding.engine.plugins.FlutterPlugin +import java.util.concurrent.atomic.AtomicLong import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.Result @@ -40,6 +41,7 @@ import kotlin.time.Duration.Companion.seconds class DatadogRumPlugin : MethodChannel.MethodCallHandler { companion object { const val PARAM_AT = "at" + const val PARAM_FRAME_AGE_NS = "frameAgeNs" const val PARAM_DURATION = "duration" const val PARAM_KEY = "key" const val PARAM_KEYS = "keys" @@ -68,9 +70,28 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { // Static instance of the event mapper internal val eventMapper: DatadogRumEventMapper = DatadogRumEventMapper() + // System.nanoTime() at the moment the first Activity was attached, which is the + // stand-in for first-Activity-onCreate that the native RumAppStartupDetector uses to + // tell a cold launch from a warm one. Written exactly once, by whichever engine gets + // there first: a second engine attaching later must not overwrite the real moment the + // UI came up. compareAndSet rather than a plain @Volatile check-then-set, because two + // engines can attach concurrently. + private val uiCreateTime = AtomicLong(0L) + + internal val uiCreateTimeNs: Long + get() = uiCreateTime.get() + + /** + * Records when the app's UI was created, if it has not been recorded already. + */ + internal fun markUiCreated() { + uiCreateTime.compareAndSet(0L, System.nanoTime()) + } + // For testing purposes only internal fun resetConfig() { previousConfiguration = null + uiCreateTime.set(0L) } @JvmStatic @@ -131,6 +152,7 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { "removeViewAttributes" -> removeViewAttributes(call, result) "reportLongTask" -> reportLongTask(call, result) "updatePerformanceMetrics" -> updatePerformanceMetrics(call, result) + "notifyAppLaunch" -> notifyAppLaunch(call, result) "addFeatureFlagEvaluation" -> addFeatureFlagEvaluation(call, result) "startFeatureOperation" -> startFeatureOperation(call, result) "succeedFeatureOperation" -> succeedFeatureOperation(call, result) @@ -493,6 +515,23 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { } } + // Report the Android app launch (TTID) to RUM. The native RumAppStartupDetector never + // fires for Flutter (SDK inits from Dart main, after the Activity's first draw), so we hand + // the SDK the moment our UI was created and let it do the measuring. Deliberately not + // computed here: the SDK derives process start from a value that already has the buggy + // Process.getStartElapsedRealtime() readings filtered out, and classifies cold vs warm with + // the same heuristic the native detector uses. iOS is a no-op here (its native SDK measures + // app launch on its own). + // + // frameAgeNs is how long before this call the launch frame actually finished rasterizing. + // Without it the Dart callback scheduling and this method channel round trip would be + // counted as launch time; Dart sends 0 when it cannot measure that reliably. + private fun notifyAppLaunch(call: MethodCall, result: Result) { + val frameAgeNs = call.argument(PARAM_FRAME_AGE_NS)?.toLong() ?: 0L + rum?._getInternal()?.notifyAppLaunch(uiCreateTimeNs, frameAgeNs) + result.success(null) + } + private fun addFeatureFlagEvaluation(call: MethodCall, result: Result) { val name = call.argument(PARAM_NAME) val value = call.argument(PARAM_VALUE) diff --git a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogSdkPlugin.kt b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogSdkPlugin.kt index b2d6e0d4..a374e5e2 100644 --- a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogSdkPlugin.kt +++ b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogSdkPlugin.kt @@ -19,6 +19,8 @@ import com.datadog.android.ndk.NdkCrashReports import com.datadog.android.privacy.TrackingConsent import com.datadog.android.rum.GlobalRumMonitor import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -29,7 +31,7 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @Suppress("LargeClass") -class DatadogSdkPlugin : FlutterPlugin, MethodCallHandler { +class DatadogSdkPlugin : FlutterPlugin, ActivityAware, MethodCallHandler { companion object { const val CONTRACT_VIOLATION = "DatadogSdk:ContractViolation" const val INVALID_OPERATION = "DatadogSdk:InvalidOperation" @@ -359,6 +361,23 @@ class DatadogSdkPlugin : FlutterPlugin, MethodCallHandler { logsPlugin.detachFromEngine() rumPlugin.detachFromEngine() } + + // The Activity is what the native app-launch detector times from, so anchor our own + // measurement to it as well. Engine attach is deliberately not used: a pre-warmed or + // headless engine attaches without any UI, which would put the launch start far too + // early. An engine that never gets an Activity therefore reports no UI creation time + // at all, and the SDK falls back to timing from process start. + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + DatadogRumPlugin.markUiCreated() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + // A configuration change recreates the Activity long after launch - not a launch. + } + + override fun onDetachedFromActivityForConfigChanges() = Unit + + override fun onDetachedFromActivity() = Unit } internal fun parseTrackingConsent(trackingConsent: String): TrackingConsent { diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart index 9dab1d68..6647340e 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart @@ -2,12 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-2021 Datadog, Inc. +import 'dart:developer' show Timeline; import 'dart:io'; -import 'dart:ui' show PlatformDispatcher; +import 'dart:ui' show FramePhase, PlatformDispatcher; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; // Dart 3.9 moved made it so meta is no longer needed for `@internal`, but we // still need it for versions below 3.9. // ignore: unnecessary_import @@ -222,6 +224,12 @@ class DatadogRum { )?.addTimingsCallback(_timingsCallback); } + // Report app launch (TTID) once, on the launch frame. iOS is a no-op (its native + // SDK measures app launch itself); Android's native detector can't, because Flutter + // initializes the SDK from Dart main, after the first Activity's onCreate has + // already gone by. + _registerAppLaunchCallback(); + core.updateConfigurationInfo( LateConfigurationProperty.trackFlutterPerformance, reportFlutterPerformance, @@ -758,6 +766,78 @@ class DatadogRum { return rate > 0 ? rate : 60.0; } + bool _appLaunchReported = false; + + // A frame reported as more than this old is not plausibly the frame we just rendered - + // treat the reading as unusable rather than shifting the launch time by it. + static const _maxPlausibleFrameAgeUs = 10 * 1000 * 1000; + + /// Subscribes to the launch frame, if there is still one to observe. + /// + /// Tolerates a missing binding on purpose: [enable] is reachable from plain Dart + /// contexts - unit tests, background isolates - where no binding has been initialized + /// and touching `instance` throws instead of returning null. + void _registerAppLaunchCallback() { + final scheduler = _schedulerBindingOrNull(); + if (scheduler == null) return; + + // addTimingsCallback only delivers frames rendered after it is registered. If the + // first frame is already on screen, the next frame handed to us is not the launch + // frame - reporting it would overstate the launch, and a static page may never + // render another frame at all. Report nothing rather than something wrong. + if (_firstFrameAlreadyRasterized()) { + _appLaunchReported = true; + return; + } + + scheduler.addTimingsCallback(_appLaunchTimingsCallback); + } + + SchedulerBinding? _schedulerBindingOrNull() { + try { + return ambiguate(SchedulerBinding.instance); + } catch (_) { + return null; + } + } + + bool _firstFrameAlreadyRasterized() { + try { + return ambiguate(WidgetsBinding.instance)?.firstFrameRasterized ?? false; + } catch (_) { + return false; + } + } + + void _appLaunchTimingsCallback(List timings) { + if (_appLaunchReported || timings.isEmpty) return; + _appLaunchReported = true; + _schedulerBindingOrNull() + ?.removeTimingsCallback(_appLaunchTimingsCallback); + + wrap('rum.notifyAppLaunch', logger, null, () { + return _platform.notifyAppLaunch(_frameAgeNs(timings.first)); + }); + } + + /// How long ago [timing] finished rasterizing, in nanoseconds. + /// + /// The native side subtracts this from its own clock reading so that neither the + /// callback scheduling nor the method channel round trip is counted as launch time. + /// [Timeline.now] shares its clock with [FrameTiming], which is what makes the + /// subtraction meaningful; an implausible result falls back to 0, which simply + /// reproduces measuring at arrival. + int _frameAgeNs(FrameTiming timing) { + try { + final ageUs = + Timeline.now - timing.timestampInMicroseconds(FramePhase.rasterFinish); + if (ageUs <= 0 || ageUs >= _maxPlausibleFrameAgeUs) return 0; + return ageUs * 1000; + } catch (_) { + return 0; + } + } + void _timingsCallback(List timings) { if (timings.isNotEmpty) { var buildTimes = []; 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 e50df759..723f13a9 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 @@ -416,6 +416,13 @@ class DdRumMethodChannel extends DdRumPlatform { }); } + @override + Future notifyAppLaunch(int frameAgeNs) { + return methodChannel.invokeMethod('notifyAppLaunch', { + 'frameAgeNs': frameAgeNs, + }); + } + void _onSessionChanged(MethodCall call) { if (call.arguments case final Map arguments?) { final sessionId = arguments['sessionId']; 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 be31edaa..c6f10e26 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 @@ -193,6 +193,11 @@ class DdNoOpRumPlatform extends DdRumPlatform { return Future.value(); } + @override + Future notifyAppLaunch(int frameAgeNs) { + return Future.value(); + } + @override Future failFeatureOperation( 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 5122910c..375e95e9 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 @@ -145,4 +145,6 @@ abstract class DdRumPlatform extends PlatformInterface { List rasterTimes, [ List frameTimes = const [], ]); + + Future notifyAppLaunch(int frameAgeNs); } 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 9537ec90..345c1919 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 @@ -434,6 +434,11 @@ class DdRumWeb extends DdRumPlatform { // NOOP - Not supported by the Browser SDK } + @override + Future notifyAppLaunch(int frameAgeNs) async { + // NOOP - Browser SDK measures its own load timings + } + JSNumber _toRelativeTime(DateTime time) { return _webPlugin?.getEventRelativeTime(time) ?? time.microsecondsSinceEpoch.toJS; From 5523a69d00951f6e77bc1431fe4f58e39b7b6e57 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 27 Jul 2026 04:10:47 -0700 Subject: [PATCH 2/4] docs(changelog): note app launch reporting in 0.1.2 Co-Authored-By: Claude Opus 5 (1M context) --- packages/datadog_flutter_plugin/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/datadog_flutter_plugin/CHANGELOG.md b/packages/datadog_flutter_plugin/CHANGELOG.md index 78e20d0a..4d0462d6 100644 --- a/packages/datadog_flutter_plugin/CHANGELOG.md +++ b/packages/datadog_flutter_plugin/CHANGELOG.md @@ -9,6 +9,17 @@ external refresh-rate hook. iOS already measured this natively and is unchanged. +* Report app launch (TTID) for Flutter on Android. The native app-startup + detector registers its Activity lifecycle callbacks when the RUM feature + initializes, and Flutter initializes the SDK from Dart `main()` — by then the + first Activity already exists, so no launch was ever reported. The launch is + now reported from the plugin on the launch frame, timed from the Activity + attach and excluding the callback scheduling and method channel round trip. + Nothing is reported when RUM is enabled after the first frame is already on + screen, since that frame is no longer observable. iOS measures app launch + natively and is unchanged. **Requires `cloud.flashcat:dd-sdk-android-rum` + 0.5.0.** + ## 0.1.1 * Fix the SDK version reported in events: `ddPackageVersion` still carried the From e622f21473721420bad0083656fbda6c849bd2fe Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 28 Jul 2026 01:27:23 -0700 Subject: [PATCH 3/4] fix(rum): ask for an app launch on every Android launch, and keep refresh rate on attach Three problems with the first cut, all of them cases where a launch or a vital silently went missing. Gating the app-launch request on Flutter-owned initialization lost the launch entirely on hosts that attach to a natively initialized SDK. Initializing the native SDK before Flutter attaches does not mean it initialized early enough for the native detector to observe the first Activity, so on a host that initializes late neither side reported anything. The request is now unconditional on Android and the native SDK arbitrates through notifyAppLaunchIfAbsent, which is the only side that knows whether its own detector saw the launch. The plugin's own de-duplication is gone with it - one latch, in one place. attachToExisting also stopped reporting a refresh rate: it passed a null vital frequency, so the callback was never registered even with reportFlutterPerformance on. That was a regression against the behaviour before build and refresh-rate timings were split apart. DatadogAttachConfiguration now carries vitalUpdateFrequency, defaulting to average. Finally, performance and refresh-rate reporting each registered their own timings callback and each sent its own platform message. They stay independently configurable but now share one subscription and one message per frame batch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/datadog_flutter_plugin/CHANGELOG.md | 15 +- .../com/datadoghq/flutter/DatadogRumPlugin.kt | 18 ++- .../datadoghq/flutter/DatadogRumPluginTest.kt | 83 ++++++++++- .../lib/src/datadog_configuration.dart | 12 ++ .../lib/src/rum/ddrum.dart | 132 +++++++++++------- .../lib/src/rum/ddrum_app_launch.dart | 29 ++++ .../lib/src/rum/ddrum_method_channel.dart | 16 +-- .../lib/src/rum/ddrum_noop_platform.dart | 10 +- .../lib/src/rum/ddrum_performance.dart | 28 ++++ .../lib/src/rum/ddrum_platform_interface.dart | 10 +- .../lib/src/rum/web/ddrum_web.dart | 10 +- .../test/rum/ddrum_app_launch_test.dart | 66 +++++++++ .../test/rum/ddrum_method_channel_test.dart | 19 ++- .../test/rum/ddrum_performance_test.dart | 67 +++++++++ 14 files changed, 431 insertions(+), 84 deletions(-) create mode 100644 packages/datadog_flutter_plugin/lib/src/rum/ddrum_app_launch.dart create mode 100644 packages/datadog_flutter_plugin/lib/src/rum/ddrum_performance.dart create mode 100644 packages/datadog_flutter_plugin/test/rum/ddrum_app_launch_test.dart create mode 100644 packages/datadog_flutter_plugin/test/rum/ddrum_performance_test.dart diff --git a/packages/datadog_flutter_plugin/CHANGELOG.md b/packages/datadog_flutter_plugin/CHANGELOG.md index 4d0462d6..14d70c8a 100644 --- a/packages/datadog_flutter_plugin/CHANGELOG.md +++ b/packages/datadog_flutter_plugin/CHANGELOG.md @@ -15,10 +15,17 @@ first Activity already exists, so no launch was ever reported. The launch is now reported from the plugin on the launch frame, timed from the Activity attach and excluding the callback scheduling and method channel round trip. - Nothing is reported when RUM is enabled after the first frame is already on - screen, since that frame is no longer observable. iOS measures app launch - natively and is unchanged. **Requires `cloud.flashcat:dd-sdk-android-rum` - 0.5.0.** + This applies to `attachToExisting` too: initializing the native SDK before + Flutter attaches does not mean it initialized early enough for the detector to + see the launch, so the plugin always asks and the native SDK reports only when + its own detector did not. Nothing is reported when RUM is enabled after the + first frame is already on screen, since that frame is no longer observable. + iOS measures app launch natively and is unchanged. **Requires + `cloud.flashcat:dd-sdk-android-rum` 0.5.0.** + +* Add `vitalUpdateFrequency` to `DatadogAttachConfiguration`, so an app that + attaches to a natively initialized SDK can still report a Flutter refresh rate + on Android. Defaults to `VitalsFrequency.average`. ## 0.1.1 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 50501450..390edbb4 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 @@ -29,12 +29,12 @@ import com.datadog.android.rum.metric.networksettled.TimeBasedInitialResourceIde import com.datadog.android.rum.tracking.ViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import io.flutter.embedding.engine.plugins.FlutterPlugin -import java.util.concurrent.atomic.AtomicLong import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.Result import java.lang.ClassCastException import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalRumApi::class) @@ -60,6 +60,7 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { const val PARAM_TYPE = "type" const val PARAM_BUILD_TIMES = "buildTimes" const val PARAM_RASTER_TIMES = "rasterTimes" + const val PARAM_FRAME_TIMES = "frameTimes" const val PARAM_OVERWRITE = "overwrite" const val PARAM_OPERATION_KEY = "operationKey" const val PARAM_FAILURE_REASON = "failureReason" @@ -490,14 +491,17 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { private fun updatePerformanceMetrics(call: MethodCall, result: Result) { val buildTimes = call.argument>(PARAM_BUILD_TIMES) val rasterTimes = call.argument>(PARAM_RASTER_TIMES) - if (buildTimes != null && rasterTimes != null) { - buildTimes.forEach { + val frameTimes = call.argument>(PARAM_FRAME_TIMES) + val hasFlutterMetrics = buildTimes != null && rasterTimes != null + val hasIncompleteFlutterMetrics = (buildTimes == null) != (rasterTimes == null) + if (!hasIncompleteFlutterMetrics && (hasFlutterMetrics || frameTimes != null)) { + buildTimes?.forEach { rum?._getInternal()?.updatePerformanceMetric( RumPerformanceMetric.FLUTTER_BUILD_TIME, it ) } - rasterTimes.forEach { + rasterTimes?.forEach { rum?._getInternal()?.updatePerformanceMetric( RumPerformanceMetric.FLUTTER_RASTER_TIME, it @@ -506,7 +510,7 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { // Flutter renders to its own surface, so the native JankStats monitor never // observes these frames. Push per-frame intervals into the external refresh-rate // hook so the view still gets a refresh_rate vital (iOS measures this natively). - call.argument>("frameTimes")?.forEach { + frameTimes?.forEach { rum?._getInternal()?.updateExternalRefreshRate(it) } result.success(null) @@ -528,7 +532,9 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { // counted as launch time; Dart sends 0 when it cannot measure that reliably. private fun notifyAppLaunch(call: MethodCall, result: Result) { val frameAgeNs = call.argument(PARAM_FRAME_AGE_NS)?.toLong() ?: 0L - rum?._getInternal()?.notifyAppLaunch(uiCreateTimeNs, frameAgeNs) + // Requested unconditionally: only the native SDK can tell whether its own detector saw + // this launch, and it also de-duplicates several engines asking for the same one. + rum?._getInternal()?.notifyAppLaunchIfAbsent(uiCreateTimeNs, frameAgeNs) result.success(null) } 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 a1758c64..5da9e266 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 @@ -921,6 +921,29 @@ class DatadogRumPluginTest { verify { mockResult.success(null) } } + @Test + fun `M call internal updatePerformanceMetrics W only frame times are provided`( + forge: Forge, + ) { + // GIVEN + val frameTimes = forge.aList { forge.aDouble() } + val call = MethodCall( + "updatePerformanceMetrics", + mapOf("frameTimes" to frameTimes) + ) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + // WHEN + plugin.onMethodCall(call, mockResult) + + // THEN + frameTimes.forEach { + verify { monitorProxy.mockInternalProxy.updateExternalRefreshRate(it) } + } + verify { mockResult.success(null) } + } + @Test fun `M call internal setInternalViewAttribute W setInternalViewAtttribute is called`( forge: Forge, @@ -957,6 +980,61 @@ class DatadogRumPluginTest { verify { mockResult.success(null) } } + @Test + fun `M forward every app launch request W app launch is reported`( + @LongForgery frameAgeNs: Long, + ) { + // GIVEN - de-duplication belongs to the native SDK, which is the only side that knows + // whether its own startup detector already reported this launch. The plugin forwards + // unconditionally so that a host whose native SDK initialized too late to observe the + // first Activity still gets a launch reported. + val call = MethodCall( + "notifyAppLaunch", + mapOf("frameAgeNs" to frameAgeNs) + ) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + // WHEN + plugin.onMethodCall(call, mockResult) + plugin.onMethodCall(call, mockResult) + + // THEN + verify(exactly = 2) { + monitorProxy.mockInternalProxy.notifyAppLaunchIfAbsent( + DatadogRumPlugin.uiCreateTimeNs, + frameAgeNs + ) + } + verify(exactly = 2) { mockResult.success(null) } + } + + @Test + fun `M pass the recorded UI creation time W app launch is reported`( + @LongForgery frameAgeNs: Long, + ) { + // GIVEN + DatadogRumPlugin.resetConfig() + DatadogRumPlugin.markUiCreated() + val uiCreateTimeNs = DatadogRumPlugin.uiCreateTimeNs + val call = MethodCall( + "notifyAppLaunch", + mapOf("frameAgeNs" to frameAgeNs) + ) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + // WHEN - a second engine attaching later must not move the launch start + DatadogRumPlugin.markUiCreated() + plugin.onMethodCall(call, mockResult) + + // THEN + assertThat(DatadogRumPlugin.uiCreateTimeNs).isEqualTo(uiCreateTimeNs) + verify(exactly = 1) { + monitorProxy.mockInternalProxy.notifyAppLaunchIfAbsent(uiCreateTimeNs, frameAgeNs) + } + } + private val contracts = listOf( Contract("startView", mapOf( "key" to ContractParameter.Type(SupportedContractType.STRING), @@ -1036,6 +1114,9 @@ class DatadogRumPluginTest { "buildTimes" to ContractParameter.Type(SupportedContractType.LIST), "rasterTimes" to ContractParameter.Type(SupportedContractType.LIST), )), + Contract("updatePerformanceMetrics", mapOf( + "frameTimes" to ContractParameter.Type(SupportedContractType.LIST), + )), Contract("addFeatureFlagEvaluation", mapOf( "name" to ContractParameter.Type(SupportedContractType.STRING), "value" to ContractParameter.Type(SupportedContractType.ANY), @@ -1066,4 +1147,4 @@ class DatadogRumPluginTest { ) { testContracts(contracts, forge, plugin) } -} \ No newline at end of file +} diff --git a/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart b/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart index 12c13ce0..b870d956 100644 --- a/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart +++ b/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart @@ -413,11 +413,23 @@ class DatadogAttachConfiguration { /// is initialized. final List additionalPlugins = []; + /// The frequency at which Flutter-measured vitals are sampled. + /// + /// Only used on Android, where the native SDK measures frame rate with + /// JankStats bound to the host Activity window and therefore never observes + /// Flutter's render surface. iOS measures refresh rate natively and ignores + /// this. + /// + /// Defaults to [VitalsFrequency.average]. Set to [VitalsFrequency.never] to + /// stop reporting a Flutter refresh rate. + final VitalsFrequency? vitalUpdateFrequency; + DatadogAttachConfiguration({ this.detectLongTasks = true, this.longTaskThreshold = 0.1, this.traceSampleRate = 100.0, this.reportFlutterPerformance = false, + this.vitalUpdateFrequency = VitalsFrequency.average, List? firstPartyHosts, this.firstPartyHostsWithTracingHeaders = const {}, this.traceContextInjection = TraceContextInjection.sampled, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart index 6647340e..2c25a302 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart @@ -17,6 +17,8 @@ import 'package:meta/meta.dart'; import '../../flashcat_flutter_plugin.dart'; import '../../datadog_internal.dart'; +import 'ddrum_app_launch.dart'; +import 'ddrum_performance.dart'; import 'ddrum_platform_interface.dart'; import 'inv_metric_provider.dart'; import 'rum_long_task_observer.dart'; @@ -168,6 +170,7 @@ class DatadogRum { detectLongTasks: config.detectLongTasks, longTaskThreshold: config.longTaskThreshold, reportFlutterPerformance: config.reportFlutterPerformance, + vitalUpdateFrequency: config.vitalUpdateFrequency, ); } @@ -183,6 +186,7 @@ class DatadogRum { detectLongTasks: false, longTaskThreshold: 0.0, reportFlutterPerformance: false, + vitalUpdateFrequency: null, ); } @@ -196,6 +200,7 @@ class DatadogRum { detectLongTasks: configuration.detectLongTasks, longTaskThreshold: configuration.longTaskThreshold, reportFlutterPerformance: configuration.reportFlutterPerformance, + vitalUpdateFrequency: configuration.vitalUpdateFrequency, ); } @@ -204,6 +209,7 @@ class DatadogRum { required bool detectLongTasks, required double longTaskThreshold, required bool reportFlutterPerformance, + required VitalsFrequency? vitalUpdateFrequency, }) { final isBackgroundIsolate = kIsWeb ? false : ServicesBinding.rootIsolateToken == null; @@ -218,17 +224,30 @@ class DatadogRum { ); _longTaskObserver!.init(); } - if (reportFlutterPerformance) { + // Both signals come off the same frame timings, so they share one + // subscription and one platform call per batch even though they stay + // independently configurable. + _reportFlutterPerformance = reportFlutterPerformance; + _sampleRefreshRate = shouldSampleRefreshRate( + isWeb: kIsWeb, + isAndroid: !kIsWeb && Platform.isAndroid, + vitalUpdateFrequency: vitalUpdateFrequency, + ); + if (_reportFlutterPerformance || _sampleRefreshRate) { ambiguate( SchedulerBinding.instance, - )?.addTimingsCallback(_timingsCallback); + )?.addTimingsCallback(_performanceTimingsCallback); } - // Report app launch (TTID) once, on the launch frame. iOS is a no-op (its native - // SDK measures app launch itself); Android's native detector can't, because Flutter - // initializes the SDK from Dart main, after the first Activity's onCreate has - // already gone by. - _registerAppLaunchCallback(); + // Report app launch (TTID) once, on the launch frame. Android's native + // detector cannot observe Flutter-owned initialization because Dart main + // initializes the SDK after the first Activity's onCreate. + if (shouldRegisterAppLaunchCallback( + isWeb: kIsWeb, + isAndroid: !kIsWeb && Platform.isAndroid, + )) { + _registerAppLaunchCallback(); + } core.updateConfigurationInfo( LateConfigurationProperty.trackFlutterPerformance, @@ -768,10 +787,6 @@ class DatadogRum { bool _appLaunchReported = false; - // A frame reported as more than this old is not plausibly the frame we just rendered - - // treat the reading as unusable rather than shifting the launch time by it. - static const _maxPlausibleFrameAgeUs = 10 * 1000 * 1000; - /// Subscribes to the launch frame, if there is still one to observe. /// /// Tolerates a missing binding on purpose: [enable] is reachable from plain Dart @@ -812,8 +827,7 @@ class DatadogRum { void _appLaunchTimingsCallback(List timings) { if (_appLaunchReported || timings.isEmpty) return; _appLaunchReported = true; - _schedulerBindingOrNull() - ?.removeTimingsCallback(_appLaunchTimingsCallback); + _schedulerBindingOrNull()?.removeTimingsCallback(_appLaunchTimingsCallback); wrap('rum.notifyAppLaunch', logger, null, () { return _platform.notifyAppLaunch(_frameAgeNs(timings.first)); @@ -829,47 +843,69 @@ class DatadogRum { /// reproduces measuring at arrival. int _frameAgeNs(FrameTiming timing) { try { - final ageUs = - Timeline.now - timing.timestampInMicroseconds(FramePhase.rasterFinish); - if (ageUs <= 0 || ageUs >= _maxPlausibleFrameAgeUs) return 0; - return ageUs * 1000; + final rasterFinishUs = + timing.timestampInMicroseconds(FramePhase.rasterFinish); + return frameAgeNsFromTimestamps( + nowUs: Timeline.now, + rasterFinishUs: rasterFinishUs, + ); } catch (_) { return 0; } } - void _timingsCallback(List timings) { - if (timings.isNotEmpty) { - var buildTimes = []; - var rasterTimes = []; - var frameTimes = []; - final refreshRate = _displayRefreshRate; - for (final timing in timings) { - final build = - timing.buildDuration.inMicroseconds / Duration.microsecondsPerSecond; - buildTimes.add(build); - rasterTimes.add( - timing.rasterDuration.inMicroseconds / Duration.microsecondsPerSecond, - ); - // Mirror the native FPSVitalListener: use the UI-thread frame duration - // (buildDuration ≈ frameDurationUiNanos), cap the instantaneous rate at the - // display rate, then normalize to a 60fps baseline. The native external hook - // stores Hz = 1/frameTime unchanged, so we pass frameTime = 1/normalizedRate. - final rawRate = build > 0 ? 1.0 / build : refreshRate; - final capped = rawRate < refreshRate ? rawRate : refreshRate; - final normalized = capped * 60.0 / refreshRate; - if (normalized > 0) { - frameTimes.add(1.0 / normalized); - } - } + bool _reportFlutterPerformance = false; + bool _sampleRefreshRate = false; + + void _performanceTimingsCallback(List timings) { + if (timings.isEmpty) return; + + List? buildTimes; + List? rasterTimes; + if (_reportFlutterPerformance) { + buildTimes = timings + .map( + (timing) => + timing.buildDuration.inMicroseconds / + Duration.microsecondsPerSecond, + ) + .toList(); + rasterTimes = timings + .map( + (timing) => + timing.rasterDuration.inMicroseconds / + Duration.microsecondsPerSecond, + ) + .toList(); + } - wrap('rum.updatePerformanceMetrics', logger, null, () { - return _platform.updatePerformanceMetrics( - buildTimes, - rasterTimes, - frameTimes, - ); - }); + List? frameTimes; + if (_sampleRefreshRate) { + final refreshRate = _displayRefreshRate; + final sampled = timings + .map( + // Mirror the native FPSVitalListener: use the UI-thread frame + // duration, cap the instantaneous rate at the display rate, and + // normalize it to a 60 fps baseline. + (timing) => frameIntervalForRefreshRate( + timing.buildDuration.inMicroseconds / + Duration.microsecondsPerSecond, + refreshRate, + ), + ) + .nonNulls + .toList(); + if (sampled.isNotEmpty) frameTimes = sampled; } + + if (buildTimes == null && frameTimes == null) return; + + wrap('rum.updatePerformanceMetrics', logger, null, () { + return _platform.updatePerformanceMetrics( + buildTimes: buildTimes, + rasterTimes: rasterTimes, + frameTimes: frameTimes, + ); + }); } } diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_app_launch.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_app_launch.dart new file mode 100644 index 00000000..a5947d2d --- /dev/null +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_app_launch.dart @@ -0,0 +1,29 @@ +// 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-2022 Datadog, Inc. + +const _maxPlausibleFrameAgeUs = 10 * Duration.microsecondsPerSecond; + +/// Whether this platform should ask the native SDK to report an app launch. +/// +/// Deliberately not conditioned on who initialized the native SDK. Initializing +/// it before Flutter attaches does not mean it initialized early enough for the +/// native detector to observe the first Activity, so "not Flutter-owned" is not +/// a safe proxy for "the native detector has it covered" - assuming it was lost +/// the launch entirely on such hosts. The native SDK arbitrates instead: it +/// reports only when its own detector did not. +bool shouldRegisterAppLaunchCallback({ + required bool isWeb, + required bool isAndroid, +}) { + return !isWeb && isAndroid; +} + +int frameAgeNsFromTimestamps({ + required int nowUs, + required int rasterFinishUs, +}) { + final ageUs = nowUs - rasterFinishUs; + if (ageUs <= 0 || ageUs >= _maxPlausibleFrameAgeUs) return 0; + return ageUs * 1000; +} 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 723f13a9..c8656716 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 @@ -404,15 +404,15 @@ class DdRumMethodChannel extends DdRumPlatform { } @override - Future updatePerformanceMetrics( - List buildTimes, - List rasterTimes, [ - List frameTimes = const [], - ]) { + Future updatePerformanceMetrics({ + List? buildTimes, + List? rasterTimes, + List? frameTimes, + }) { return methodChannel.invokeMethod('updatePerformanceMetrics', { - 'buildTimes': buildTimes, - 'rasterTimes': rasterTimes, - 'frameTimes': frameTimes, + if (buildTimes != null) 'buildTimes': buildTimes, + if (rasterTimes != null) 'rasterTimes': rasterTimes, + if (frameTimes != null) 'frameTimes': frameTimes, }); } 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 c6f10e26..2808267d 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 @@ -185,11 +185,11 @@ class DdNoOpRumPlatform extends DdRumPlatform { } @override - Future updatePerformanceMetrics( - List buildTimes, - List rasterTimes, [ - List frameTimes = const [], - ]) { + Future updatePerformanceMetrics({ + List? buildTimes, + List? rasterTimes, + List? frameTimes, + }) { return Future.value(); } diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_performance.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_performance.dart new file mode 100644 index 00000000..86ba2c76 --- /dev/null +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_performance.dart @@ -0,0 +1,28 @@ +// 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-2022 Datadog, Inc. + +import 'rum_configuration.dart'; + +bool shouldSampleRefreshRate({ + required bool isWeb, + required bool isAndroid, + required VitalsFrequency? vitalUpdateFrequency, +}) { + return !isWeb && isAndroid && vitalUpdateFrequency != null; +} + +double? frameIntervalForRefreshRate( + double buildDurationSeconds, + double displayRefreshRate, +) { + if (buildDurationSeconds <= 0) return null; + + final rawRate = 1.0 / buildDurationSeconds; + final cappedRate = + rawRate < displayRefreshRate ? rawRate : displayRefreshRate; + final normalizedRate = cappedRate * 60.0 / displayRefreshRate; + if (normalizedRate <= 1.0) return null; + + return 1.0 / normalizedRate; +} 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 375e95e9..f3d2b08e 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 @@ -140,11 +140,11 @@ abstract class DdRumPlatform extends PlatformInterface { ); Future reportLongTask(DateTime at, int durationMs); - Future updatePerformanceMetrics( - List buildTimes, - List rasterTimes, [ - List frameTimes = const [], - ]); + Future updatePerformanceMetrics({ + List? buildTimes, + List? rasterTimes, + List? frameTimes, + }); Future notifyAppLaunch(int frameAgeNs); } 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 345c1919..663e7120 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 @@ -426,11 +426,11 @@ class DdRumWeb extends DdRumPlatform { } @override - Future updatePerformanceMetrics( - List buildTimes, - List rasterTimes, [ - List frameTimes = const [], - ]) async { + Future updatePerformanceMetrics({ + List? buildTimes, + List? rasterTimes, + List? frameTimes, + }) async { // NOOP - Not supported by the Browser SDK } diff --git a/packages/datadog_flutter_plugin/test/rum/ddrum_app_launch_test.dart b/packages/datadog_flutter_plugin/test/rum/ddrum_app_launch_test.dart new file mode 100644 index 00000000..61ce8f08 --- /dev/null +++ b/packages/datadog_flutter_plugin/test/rum/ddrum_app_launch_test.dart @@ -0,0 +1,66 @@ +// 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-2022 Datadog, Inc. + +import 'package:flashcat_flutter_plugin/src/rum/ddrum_app_launch.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('asks for an app launch on every Android platform target', () { + // Deliberately not conditioned on who initialized the native SDK: the host + // cannot tell whether the native detector observed the first Activity, so it + // always asks and the native SDK declines when its detector already reported. + expect( + shouldRegisterAppLaunchCallback(isWeb: false, isAndroid: true), + isTrue, + ); + expect( + shouldRegisterAppLaunchCallback(isWeb: false, isAndroid: false), + isFalse, + ); + expect( + shouldRegisterAppLaunchCallback(isWeb: true, isAndroid: true), + isFalse, + ); + }); + + group('frameAgeNsFromTimestamps', () { + test('converts a plausible positive age to nanoseconds', () { + expect( + frameAgeNsFromTimestamps( + nowUs: 2 * Duration.microsecondsPerSecond, + rasterFinishUs: Duration.microsecondsPerSecond, + ), + Duration.microsecondsPerSecond * 1000, + ); + }); + + test('falls back to zero for non-positive ages', () { + expect( + frameAgeNsFromTimestamps(nowUs: 100, rasterFinishUs: 100), + 0, + ); + expect( + frameAgeNsFromTimestamps(nowUs: 100, rasterFinishUs: 101), + 0, + ); + }); + + test('falls back to zero for ages of ten seconds or more', () { + expect( + frameAgeNsFromTimestamps( + nowUs: 10 * Duration.microsecondsPerSecond, + rasterFinishUs: 0, + ), + 0, + ); + expect( + frameAgeNsFromTimestamps( + nowUs: 11 * Duration.microsecondsPerSecond, + rasterFinishUs: 0, + ), + 0, + ); + }); + }); +} 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 db273a35..49662ce4 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 @@ -612,7 +612,10 @@ void main() { }); test('updatePerformanceMetrics calls to platform', () async { - await ddRumPlatform.updatePerformanceMetrics([0.2, 0.3], [0.11, 0.25]); + await ddRumPlatform.updatePerformanceMetrics( + buildTimes: [0.2, 0.3], + rasterTimes: [0.11, 0.25], + ); expect(log, [ isMethodCall( @@ -620,7 +623,19 @@ void main() { arguments: { 'buildTimes': [0.2, 0.3], 'rasterTimes': [0.11, 0.25], - 'frameTimes': [], + }, + ), + ]); + }); + + test('updatePerformanceMetrics can send only frame times', () async { + await ddRumPlatform.updatePerformanceMetrics(frameTimes: [0.01, 0.02]); + + expect(log, [ + isMethodCall( + 'updatePerformanceMetrics', + arguments: { + 'frameTimes': [0.01, 0.02], }, ), ]); diff --git a/packages/datadog_flutter_plugin/test/rum/ddrum_performance_test.dart b/packages/datadog_flutter_plugin/test/rum/ddrum_performance_test.dart new file mode 100644 index 00000000..50ac36de --- /dev/null +++ b/packages/datadog_flutter_plugin/test/rum/ddrum_performance_test.dart @@ -0,0 +1,67 @@ +// 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-2022 Datadog, Inc. + +import 'package:flashcat_flutter_plugin/src/rum/ddrum_performance.dart'; +import 'package:flashcat_flutter_plugin/src/rum/rum_configuration.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('shouldSampleRefreshRate', () { + test('samples on Android when vitals use the default frequency', () { + expect( + shouldSampleRefreshRate( + isWeb: false, + isAndroid: true, + vitalUpdateFrequency: VitalsFrequency.average, + ), + isTrue, + ); + }); + + test('does not sample when vitals are disabled', () { + expect( + shouldSampleRefreshRate( + isWeb: false, + isAndroid: true, + vitalUpdateFrequency: null, + ), + isFalse, + ); + }); + + test('does not sample outside Android', () { + expect( + shouldSampleRefreshRate( + isWeb: false, + isAndroid: false, + vitalUpdateFrequency: VitalsFrequency.average, + ), + isFalse, + ); + expect( + shouldSampleRefreshRate( + isWeb: true, + isAndroid: true, + vitalUpdateFrequency: VitalsFrequency.average, + ), + isFalse, + ); + }); + }); + + group('frameIntervalForRefreshRate', () { + test('normalizes a valid frame duration to a 60 Hz baseline', () { + expect(frameIntervalForRefreshRate(0.01, 120.0), closeTo(1 / 50, 1e-9)); + }); + + test('drops normalized rates at or below 1 Hz', () { + expect(frameIntervalForRefreshRate(1.0, 60.0), isNull); + expect(frameIntervalForRefreshRate(2.0, 60.0), isNull); + }); + + test('drops zero-duration frames', () { + expect(frameIntervalForRefreshRate(0.0, 120.0), isNull); + }); + }); +} From dfb4eeb6f8fef56a5202257eae95951894bdd707 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 28 Jul 2026 02:47:32 -0700 Subject: [PATCH 4/4] docs(rum): drop a reference to a VitalsFrequency member that does not exist VitalsFrequency has frequent, average and rare - there is no never - so the dartdoc link was broken and the advice unfollowable. Assigning null is what actually disables the collection, matching how DatadogRumConfiguration documents the same field. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/datadog_configuration.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart b/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart index b870d956..be191be0 100644 --- a/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart +++ b/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart @@ -420,8 +420,9 @@ class DatadogAttachConfiguration { /// Flutter's render surface. iOS measures refresh rate natively and ignores /// this. /// - /// Defaults to [VitalsFrequency.average]. Set to [VitalsFrequency.never] to - /// stop reporting a Flutter refresh rate. + /// Assign to `null` to stop reporting a Flutter refresh rate. + /// + /// Defaults to [VitalsFrequency.average]. final VitalsFrequency? vitalUpdateFrequency; DatadogAttachConfiguration({