diff --git a/packages/datadog_flutter_plugin/CHANGELOG.md b/packages/datadog_flutter_plugin/CHANGELOG.md index 78e20d0a..14d70c8a 100644 --- a/packages/datadog_flutter_plugin/CHANGELOG.md +++ b/packages/datadog_flutter_plugin/CHANGELOG.md @@ -9,6 +9,24 @@ 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. + 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 * Fix the SDK version reported in events: `ddPackageVersion` still carried the 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..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 @@ -34,12 +34,14 @@ 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) 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" @@ -58,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" @@ -68,9 +71,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 +153,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) @@ -468,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 @@ -484,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) @@ -493,6 +519,25 @@ 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 + // 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) + } + 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/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..be191be0 100644 --- a/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart +++ b/packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart @@ -413,11 +413,24 @@ 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. + /// + /// Assign to `null` to stop reporting a Flutter refresh rate. + /// + /// Defaults to [VitalsFrequency.average]. + 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 9dab1d68..2c25a302 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 @@ -15,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'; @@ -166,6 +170,7 @@ class DatadogRum { detectLongTasks: config.detectLongTasks, longTaskThreshold: config.longTaskThreshold, reportFlutterPerformance: config.reportFlutterPerformance, + vitalUpdateFrequency: config.vitalUpdateFrequency, ); } @@ -181,6 +186,7 @@ class DatadogRum { detectLongTasks: false, longTaskThreshold: 0.0, reportFlutterPerformance: false, + vitalUpdateFrequency: null, ); } @@ -194,6 +200,7 @@ class DatadogRum { detectLongTasks: configuration.detectLongTasks, longTaskThreshold: configuration.longTaskThreshold, reportFlutterPerformance: configuration.reportFlutterPerformance, + vitalUpdateFrequency: configuration.vitalUpdateFrequency, ); } @@ -202,6 +209,7 @@ class DatadogRum { required bool detectLongTasks, required double longTaskThreshold, required bool reportFlutterPerformance, + required VitalsFrequency? vitalUpdateFrequency, }) { final isBackgroundIsolate = kIsWeb ? false : ServicesBinding.rootIsolateToken == null; @@ -216,10 +224,29 @@ 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. 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( @@ -758,38 +785,127 @@ class DatadogRum { return rate > 0 ? rate : 60.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 _appLaunchReported = false; - wrap('rum.updatePerformanceMetrics', logger, null, () { - return _platform.updatePerformanceMetrics( - buildTimes, - rasterTimes, - frameTimes, - ); - }); + /// 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 rasterFinishUs = + timing.timestampInMicroseconds(FramePhase.rasterFinish); + return frameAgeNsFromTimestamps( + nowUs: Timeline.now, + rasterFinishUs: rasterFinishUs, + ); + } catch (_) { + return 0; + } + } + + 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(); + } + + 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 e50df759..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,22 @@ 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, + }); + } + + @override + Future notifyAppLaunch(int frameAgeNs) { + return methodChannel.invokeMethod('notifyAppLaunch', { + 'frameAgeNs': frameAgeNs, }); } 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..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,16 @@ 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(); + } + + @override + Future notifyAppLaunch(int frameAgeNs) { 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 5122910c..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,9 +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 9537ec90..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,14 +426,19 @@ 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 } + @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; 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); + }); + }); +}