Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/datadog_flutter_plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/datadog_flutter_plugin/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -468,14 +491,17 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler {
private fun updatePerformanceMetrics(call: MethodCall, result: Result) {
val buildTimes = call.argument<List<Double>>(PARAM_BUILD_TIMES)
val rasterTimes = call.argument<List<Double>>(PARAM_RASTER_TIMES)
if (buildTimes != null && rasterTimes != null) {
buildTimes.forEach {
val frameTimes = call.argument<List<Double>>(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
Expand All @@ -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<List<Double>>("frameTimes")?.forEach {
frameTimes?.forEach {
rum?._getInternal()?.updateExternalRefreshRate(it)
}
result.success(null)
Expand All @@ -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<Number>(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<String>(PARAM_NAME)
val value = call.argument<Any>(PARAM_VALUE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MethodChannel.Result>()
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,
Expand Down Expand Up @@ -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<MethodChannel.Result>()
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<MethodChannel.Result>()
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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1066,4 +1147,4 @@ class DatadogRumPluginTest {
) {
testContracts(contracts, forge, plugin)
}
}
}
13 changes: 13 additions & 0 deletions packages/datadog_flutter_plugin/lib/src/datadog_configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,24 @@ class DatadogAttachConfiguration {
/// is initialized.
final List<DatadogPluginConfiguration> 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<String>? firstPartyHosts,
this.firstPartyHostsWithTracingHeaders = const {},
this.traceContextInjection = TraceContextInjection.sampled,
Expand Down
Loading