diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 9e0421772e..79960f4779 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -69,6 +69,10 @@ interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry fun getHeatmapIdentifier(Long, String): HeatmapIdentifier? companion object fun create(): HeatmapIdentifierRegistry +interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider + val heatmapIdentifierRegistry: HeatmapIdentifierRegistry +fun heatmapViewKey(android.view.View): Long +fun android.view.View.isValidTapTarget(): Boolean class com.datadog.android.internal.lifecycle.ProcessLifecycleMonitor : android.app.Application.ActivityLifecycleCallbacks constructor(Callback) val activitiesResumedCounter: java.util.concurrent.atomic.AtomicInteger diff --git a/dd-sdk-android-internal/api/dd-sdk-android-internal.api b/dd-sdk-android-internal/api/dd-sdk-android-internal.api index cf8dfdc2e6..fb9890dfbb 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -150,12 +150,24 @@ public final class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegist public final fun create ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry; } +public abstract interface class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider { + public abstract fun getHeatmapIdentifierRegistry ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry; +} + +public final class com/datadog/android/internal/heatmaps/HeatmapViewKeyKt { + public static final fun heatmapViewKey (Landroid/view/View;)J +} + public final class com/datadog/android/internal/heatmaps/NoOpHeatmapIdentifierRegistry : com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry { public fun ()V public fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier; public fun setHeatmapIdentifiers (Ljava/util/Map;Ljava/lang/String;)V } +public final class com/datadog/android/internal/heatmaps/TapTargetUtilsKt { + public static final fun isValidTapTarget (Landroid/view/View;)Z +} + public final class com/datadog/android/internal/lifecycle/ProcessLifecycleMonitor : android/app/Application$ActivityLifecycleCallbacks { public fun (Lcom/datadog/android/internal/lifecycle/ProcessLifecycleMonitor$Callback;)V public final fun getActivitiesResumedCounter ()Ljava/util/concurrent/atomic/AtomicInteger; diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry.kt index b7af149640..169f1ddcb6 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry.kt @@ -9,7 +9,12 @@ package com.datadog.android.internal.heatmaps import com.datadog.tools.annotation.NoOpImplementation /** - * Stores and retrieves [HeatmapIdentifier]s keyed by view identity. + * Stores and retrieves [HeatmapIdentifier]s keyed by composite view identity key + * (see [heatmapViewKey]). + * + * Implementations must be thread-safe: the write side (Session Replay) may call + * [setHeatmapIdentifiers] from a background traversal thread while the read side (RUM) calls + * [getHeatmapIdentifier] from the main thread. */ @NoOpImplementation(publicNoOpImplementation = true) interface HeatmapIdentifierRegistry { @@ -17,22 +22,24 @@ interface HeatmapIdentifierRegistry { /** * Replaces the current snapshot with [identifiers], scoped to [screenName]. * - * @param identifiers a map of [android.view.View.getId] values to their [HeatmapIdentifier]s, - * computed during the most recent Session Replay view tree traversal. + * @param identifiers a map of view identity keys (see [heatmapViewKey]) to their + * [HeatmapIdentifier]s. Keys must be computed with [heatmapViewKey] at the time the + * snapshot is captured so they match the keys produced at lookup time. * @param screenName the RUM view URL active when the snapshot was computed. Used to * guard against stale reads after screen navigation. */ fun setHeatmapIdentifiers(identifiers: Map, screenName: String) /** - * Returns the [HeatmapIdentifier] for the view with the given [viewId], or null if the - * view is unknown or if [currentScreenName] does not match the screen that produced the + * Returns the [HeatmapIdentifier] for the view with the given [heatmapViewKey], or null if + * the view is unknown or if [currentScreenName] does not match the screen that produced the * current snapshot (indicating the snapshot is stale). * - * @param viewId the Android view ID of the tapped view. + * @param heatmapViewKey the composite identity key of the tapped view, as returned by [heatmapViewKey]. + * Must match the key used when populating [setHeatmapIdentifiers]. * @param currentScreenName the RUM view URL active at the time of the tap. */ - fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier? + fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier? companion object { diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider.kt new file mode 100644 index 0000000000..a59b4f43fc --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider.kt @@ -0,0 +1,16 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.heatmaps + +/** + * Implemented by SDK features that own a [HeatmapIdentifierRegistry], allowing peer features + * to obtain a typed reference via [com.datadog.android.api.feature.FeatureScope.unwrap]. + */ +interface HeatmapIdentifierRegistryProvider { + /** The [HeatmapIdentifierRegistry] owned by this feature. */ + val heatmapIdentifierRegistry: HeatmapIdentifierRegistry +} diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierStore.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierStore.kt index a98a599669..3ad2eea189 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierStore.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierStore.kt @@ -29,6 +29,10 @@ internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry { private var snapshotScreenName: String? = null private val identifiers: MutableMap = mutableMapOf() + // Contract: both `screenName` (write side) and `currentScreenName` (read side) must be the + // raw `rumContext.viewUrl` string without any normalization (no trailing slashes, no encoding). + // A mismatch — e.g. from different formatting — silently returns null on every lookup. + override fun setHeatmapIdentifiers(identifiers: Map, screenName: String) { lock.write { this.snapshotScreenName = screenName @@ -37,9 +41,9 @@ internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry { } } - override fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier? { + override fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier? { return lock.read { - if (snapshotScreenName == currentScreenName) identifiers[viewId] else null + if (snapshotScreenName == currentScreenName) identifiers[heatmapViewKey] else null } } } diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapViewKey.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapViewKey.kt new file mode 100644 index 0000000000..714d1e0401 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapViewKey.kt @@ -0,0 +1,30 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.heatmaps + +import android.view.View + +// Polynomial coefficient — matches Java's standard hashCode convention. +internal const val HEATMAP_VIEW_KEY_COEFFICIENT = 31L + +/** + * Returns the key used to store and look up a view's [HeatmapIdentifier] in a + * [HeatmapIdentifierRegistry]. + * + * The key combines the identity hash of the view with the identity hash of its direct parent + * using a polynomial combination with coefficient 31 (the same coefficient used by Java's + * standard `hashCode` convention). This makes the combinator non-commutative: swapping the + * view and parent hashes produces a different key. + * + * Note: the returned value is an opaque 64-bit quantity and may be negative, since + * [System.identityHashCode] returns a signed [Int] that is sign-extended on widening to [Long]. + * Callers must not assume the key is non-negative. + */ +fun heatmapViewKey(view: View): Long { + val parentHash = view.parent?.let { System.identityHashCode(it) } ?: 0 + return HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(view).toLong() + parentHash.toLong() +} diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/TapTargetUtils.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/TapTargetUtils.kt new file mode 100644 index 0000000000..20f0991b8a --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/TapTargetUtils.kt @@ -0,0 +1,21 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.heatmaps + +import android.view.View + +/** + * Single canonical tap-target predicate shared by the RUM gesture layer and Session Replay, + * so both subsystems stay in sync automatically. + * + * Note: [View.isEnabled] is intentionally not checked. Disabled views retain [View.isClickable] + * = true on Android but never receive touch events, so the RUM gesture layer never fires for + * them. Excluding `isEnabled` here keeps the predicate identical to RUM's existing behaviour. + */ +fun View.isValidTapTarget(): Boolean { + return isClickable && visibility == View.VISIBLE +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/HeatmapViewKeyTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/HeatmapViewKeyTest.kt new file mode 100644 index 0000000000..974b8adce1 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/HeatmapViewKeyTest.kt @@ -0,0 +1,94 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.heatmaps + +import android.view.View +import android.view.ViewParent +import com.datadog.android.internal.forge.Configurator +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class HeatmapViewKeyTest { + + @Test + fun `M include parent hash W heatmapViewKey() {view has parent}`() { + // Given + val fakeView: View = mock() + val fakeParent: ViewParent = mock() + whenever(fakeView.parent).thenReturn(fakeParent) + + // When + val key = heatmapViewKey(fakeView) + + // Then + val expected = HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(fakeView).toLong() + + System.identityHashCode(fakeParent).toLong() + assertThat(key).isEqualTo(expected) + } + + @Test + fun `M use zero for parent hash W heatmapViewKey() {view has no parent}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.parent).thenReturn(null) + + // When + val key = heatmapViewKey(fakeView) + + // Then + assertThat(key).isEqualTo(HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(fakeView).toLong()) + } + + @Test + fun `M return distinct keys W heatmapViewKey() {two views with same parent}`() { + // Given + val fakeParent: ViewParent = mock() + val fakeView1: View = mock() + val fakeView2: View = mock() + whenever(fakeView1.parent).thenReturn(fakeParent) + whenever(fakeView2.parent).thenReturn(fakeParent) + + // When / Then + assertThat(heatmapViewKey(fakeView1)).isNotEqualTo(heatmapViewKey(fakeView2)) + } + + @Test + fun `M return distinct keys W heatmapViewKey() {view and parent hashes swapped}`() { + // Given — verifies the combinator is non-commutative: + // key(view=A, parent=B) must not equal key(view=B, parent=A). + // We wire fakeViewA's parent to fakeParentB and fakeViewB's parent to fakeParentA, + // so the two keys use the same pair of identity hashes but in opposite roles. + // + // Note: the two keys are equal if and only if 31*H(viewA) + H(parentB) == 31*H(viewB) + H(parentA), + // i.e. when the identity hashes satisfy a specific linear equation. This holds with + // probability ~1/2^32 per run, which is considered acceptable. + val fakeViewA: View = mock() + val fakeViewB: View = mock() + val fakeParentA: ViewParent = mock() + val fakeParentB: ViewParent = mock() + whenever(fakeViewA.parent).thenReturn(fakeParentB) + whenever(fakeViewB.parent).thenReturn(fakeParentA) + + // When / Then + assertThat(heatmapViewKey(fakeViewA)).isNotEqualTo(heatmapViewKey(fakeViewB)) + } +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/TapTargetUtilsTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/TapTargetUtilsTest.kt new file mode 100644 index 0000000000..afd37a2d46 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/TapTargetUtilsTest.kt @@ -0,0 +1,86 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.heatmaps + +import android.view.View +import com.datadog.android.internal.forge.Configurator +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class TapTargetUtilsTest { + + @Test + fun `M return true W isValidTapTarget() {clickable and VISIBLE}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.isClickable).thenReturn(true) + whenever(fakeView.visibility).thenReturn(View.VISIBLE) + + // When / Then + assertThat(fakeView.isValidTapTarget()).isTrue() + } + + @Test + fun `M return false W isValidTapTarget() {clickable and INVISIBLE}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.isClickable).thenReturn(true) + whenever(fakeView.visibility).thenReturn(View.INVISIBLE) + + // When / Then + assertThat(fakeView.isValidTapTarget()).isFalse() + } + + @Test + fun `M return false W isValidTapTarget() {clickable and GONE}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.isClickable).thenReturn(true) + whenever(fakeView.visibility).thenReturn(View.GONE) + + // When / Then + assertThat(fakeView.isValidTapTarget()).isFalse() + } + + @Test + fun `M return false W isValidTapTarget() {not clickable and VISIBLE}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.isClickable).thenReturn(false) + whenever(fakeView.visibility).thenReturn(View.VISIBLE) + + // When / Then + assertThat(fakeView.isValidTapTarget()).isFalse() + } + + @Test + fun `M return true W isValidTapTarget() {disabled but clickable and VISIBLE}`() { + // Given + val fakeView: View = mock() + whenever(fakeView.isEnabled).thenReturn(false) + whenever(fakeView.isClickable).thenReturn(true) + whenever(fakeView.visibility).thenReturn(View.VISIBLE) + + // When / Then + assertThat(fakeView.isValidTapTarget()).isTrue() + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index af7ad5dc96..c8e6467287 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -171,7 +171,8 @@ object Rum { rumAppStartupTelemetryReporter = rumAppStartupTelemetryReporter ) }, - insightsCollector = rumFeature.insightsCollector + insightsCollector = rumFeature.insightsCollector, + heatmapIdentifierRegistry = rumFeature.heatmapIdentifierRegistry ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index b0447744b0..835c54bbd8 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -34,6 +34,8 @@ import com.datadog.android.event.EventMapper import com.datadog.android.event.MapperSerializer import com.datadog.android.event.NoOpEventMapper import com.datadog.android.internal.flags.RumFlagEvaluationMessage +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.thread.isMainThread @@ -140,7 +142,7 @@ internal class RumFeature( }, private val buildSdkVersionProvider: BuildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT, private val handler: Handler = Handler(Looper.getMainLooper()) -) : StorageBackedFeature, FeatureEventReceiver { +) : StorageBackedFeature, FeatureEventReceiver, HeatmapIdentifierRegistryProvider { internal var dataWriter: DataWriter = NoOpDataWriter() internal val initialized = AtomicBoolean(false) @@ -178,6 +180,7 @@ internal class RumFeature( internal var displayInfoProvider: InfoProvider = NoOpDisplayInfoProvider() internal val rumContextUpdateReceivers = mutableSetOf() internal var insightsCollector: InsightsCollector = NoOpInsightsCollector() + override val heatmapIdentifierRegistry: HeatmapIdentifierRegistry = HeatmapIdentifierRegistry.create() private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) } internal var rumAppStartupDetector: RumAppStartupDetector? = null @@ -891,7 +894,7 @@ internal class RumFeature( providers, interactionPredicate, composeActionsTrackingStrategy = composeActionTrackingStrategy, - internalLogger + internalLogger = internalLogger ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/HeatmapActionData.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/HeatmapActionData.kt new file mode 100644 index 0000000000..b8fd60cfbc --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/HeatmapActionData.kt @@ -0,0 +1,15 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.domain.scope + +internal data class HeatmapActionData( + val viewKey: Long, + val positionX: Long, + val positionY: Long, + val targetWidth: Long?, + val targetHeight: Long? +) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt index e06f49982b..74186129ac 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver @@ -28,7 +29,7 @@ import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.math.max -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") internal class RumActionScope( override val parentScope: RumScope, private val sdkCore: InternalSdkCore, @@ -44,7 +45,9 @@ internal class RumActionScope( private val trackFrustrations: Boolean, internal val sampleRate: Float, private val rumSessionTypeOverride: RumSessionType?, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + internal val heatmapData: HeatmapActionData? = null, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope { private val inactivityThresholdNs = TimeUnit.MILLISECONDS.toNanos(inactivityThresholdMs) @@ -71,7 +74,7 @@ internal class RumActionScope( private var sent = false internal var stopped = false - // endregion + // region RumScope @WorkerThread override fun handleEvent( @@ -276,7 +279,6 @@ internal class RumActionScope( datadogContext, rumContext.viewId.orEmpty() ) - insightsCollector.onAction() ActionEvent( date = eventTimestamp, @@ -354,7 +356,8 @@ internal class RumActionScope( session = ActionEvent.DdSession( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), - configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate) + configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), + action = heatmapData?.let { resolveHeatmapAction(it, rumContext.viewUrl.orEmpty()) } ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, @@ -379,6 +382,20 @@ internal class RumActionScope( sent = true } + private fun resolveHeatmapAction(data: HeatmapActionData, viewUrl: String): ActionEvent.DdAction? { + val permanentId = heatmapIdentifierRegistry + ?.getHeatmapIdentifier(data.viewKey, viewUrl) + ?.rawValue ?: return null + return ActionEvent.DdAction( + position = ActionEvent.Position(x = data.positionX, y = data.positionY), + target = ActionEvent.DdActionTarget( + permanentId = permanentId, + width = data.targetWidth, + height = data.targetHeight + ) + ) + } + // endregion companion object { @@ -395,7 +412,8 @@ internal class RumActionScope( trackFrustrations: Boolean, sampleRate: Float, rumSessionTypeOverride: RumSessionType?, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ): RumScope { return RumActionScope( parentScope = parentScope, @@ -410,7 +428,9 @@ internal class RumActionScope( trackFrustrations = trackFrustrations, sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapData = event.heatmapData, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index da42899374..28d431aeaf 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.sampling.Sampler +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumSessionListener @@ -55,7 +56,8 @@ internal class RumApplicationScope( private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -85,7 +87,8 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) ) @@ -207,7 +210,8 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt index 77154f3383..ef3764d25d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt @@ -41,8 +41,9 @@ internal sealed class RumRawEvent { val type: RumActionType, val name: String, val waitForStop: Boolean, - val attributes: Map, - override val eventTime: Time = Time() + val heatmapData: HeatmapActionData? = null, + override val eventTime: Time = Time(), + val attributes: Map = emptyMap() ) : RumRawEvent() internal data class StopAction( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 8c8221ddf9..71ed9cd640 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.sampling.Sampler +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.profiling.ProfilerStopEvent import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType @@ -60,7 +61,8 @@ internal class RumSessionScope( private val sessionMaxDurationNanos: Long = DEFAULT_SESSION_MAX_DURATION_NS, rumSessionTypeOverride: RumSessionType?, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope { internal var sessionId = RumContext.NULL_UUID @@ -97,7 +99,8 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) internal val activeView: RumViewScope? diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index f7832a0c79..1303b715b7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.metrics.MethodCallSamplingRate +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.RumSessionType @@ -60,7 +61,8 @@ internal class RumViewManagerScope( private val accessibilitySnapshotManager: AccessibilitySnapshotManager, private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope { private val interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver = @@ -295,7 +297,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) applicationDisplayed = true childrenScopes.add(viewScope) @@ -378,7 +381,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } @@ -421,7 +425,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index a08c7ee194..25068ba559 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.internal.attributes.LocalAttribute import com.datadog.android.internal.attributes.ViewScopeInstrumentationType +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumActionType @@ -89,7 +90,8 @@ internal open class RumViewScope( private val accessibilitySnapshotManager: AccessibilitySnapshotManager, private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope { internal val url = key.url.replace('.', '/') @@ -472,7 +474,8 @@ internal open class RumViewScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } @@ -608,7 +611,8 @@ internal open class RumViewScope( trackFrustrations = trackFrustrations, sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) pendingActionCount++ customActionScope.handleEvent(RumRawEvent.SendCustomActionNow(), datadogContext, writeScope, writer) @@ -632,7 +636,8 @@ internal open class RumViewScope( trackFrustrations = trackFrustrations, sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) pendingActionCount++ } @@ -1676,7 +1681,8 @@ internal open class RumViewScope( accessibilitySnapshotManager: AccessibilitySnapshotManager, batteryInfoProvider: InfoProvider, displayInfoProvider: InfoProvider, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ): RumViewScope { val networkSettledMetricResolver = NetworkSettledMetricResolver( networkSettledResourceIdentifier, @@ -1713,7 +1719,8 @@ internal open class RumViewScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AndroidActionTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AndroidActionTrackingStrategy.kt index 05595e88cc..1c297af480 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AndroidActionTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AndroidActionTrackingStrategy.kt @@ -12,6 +12,7 @@ import android.widget.AbsListView import android.widget.ScrollView import androidx.core.view.ScrollingView import com.datadog.android.api.SdkCore +import com.datadog.android.internal.heatmaps.isValidTapTarget import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.ViewTarget import java.lang.ref.WeakReference @@ -34,7 +35,7 @@ internal class AndroidActionTrackingStrategy : ActionTrackingStrategy { } override fun findTargetForTap(view: View, x: Float, y: Float): ViewTarget? { - return if (hitTest(view, x, y, coordinatesContainer) && isValidTapTarget(view)) { + return if (hitTest(view, x, y, coordinatesContainer) && view.isValidTapTarget()) { ViewTarget(viewRef = WeakReference(view)) } else { null @@ -49,13 +50,6 @@ internal class AndroidActionTrackingStrategy : ActionTrackingStrategy { } } - private fun isValidTapTarget(view: View): Boolean { - return view.isClickable && view.isVisible - } - - private val View.isVisible: Boolean - get() = visibility == View.VISIBLE - private fun hitTest( view: View, x: Float, @@ -73,7 +67,7 @@ internal class AndroidActionTrackingStrategy : ActionTrackingStrategy { } private fun isValidScrollableTarget(view: View): Boolean { - return view.isVisible && isScrollableView(view) + return view.visibility == View.VISIBLE && isScrollableView(view) } @Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt index 8b4fecb2fd..6a9d5b6315 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt @@ -10,6 +10,7 @@ import android.content.Context import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.ViewAttributesProvider @@ -103,7 +104,7 @@ internal class DatadogGesturesTracker( return GesturesDetectorWrapper( context, GesturesListener( - sdkCore = sdkCore, + sdkCore = sdkCore as FeatureSdkCore, windowReference = WeakReference(window), attributesProviders = targetAttributesProviders, interactionPredicate = interactionPredicate, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt index 34dc2e2a1f..767b9a6614 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt @@ -13,10 +13,13 @@ import android.view.ViewGroup import android.view.Window import androidx.core.view.isVisible import com.datadog.android.api.InternalLogger -import com.datadog.android.api.SdkCore +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.internal.heatmaps.heatmapViewKey import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData +import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate @@ -27,10 +30,11 @@ import java.lang.ref.Reference import java.lang.ref.WeakReference import java.util.LinkedList import kotlin.math.abs +import kotlin.math.roundToLong @Suppress("TooManyFunctions") internal class GesturesListener( - private val sdkCore: SdkCore, + private val sdkCore: FeatureSdkCore, private val windowReference: WeakReference, private val attributesProviders: Array = emptyArray(), private val interactionPredicate: InteractionPredicate = NoOpInteractionPredicate(), @@ -46,6 +50,8 @@ internal class GesturesListener( private var onTouchDownXPos = 0f private var onTouchDownYPos = 0f + private val tapLocationBuffer = IntArray(2) + // region GesturesListener init { @@ -226,7 +232,7 @@ internal class GesturesListener( onUpEvent.y ) downTarget?.takeIf { it == upTarget }?.let { target -> - sendTapEventWithTarget(target) + sendTapEventWithTarget(target, onUpEvent.x, onUpEvent.y) } } } @@ -257,29 +263,44 @@ internal class GesturesListener( private fun handleTapUp(decorView: View?, e: MotionEvent) { if (decorView != null) { findTarget(decorView, e.x, e.y)?.let { target -> - sendTapEventWithTarget(target) + sendTapEventWithTarget(target, e.x, e.y) } } } - private fun sendTapEventWithTarget(target: ViewTarget) { + private fun sendTapEventWithTarget(target: ViewTarget, touchX: Float, touchY: Float) { val attributes = mutableMapOf() + var heatmapData: HeatmapActionData? = null target.viewRef.get()?.let { view -> - val targetId: String = contextRef.get().resourceIdName(view.id) - attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() - attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId - attributesProviders.forEach { - it.extractAttributes(view, attributes) + addViewAttributes(view, attributes) + + if (view.isAttachedToWindow) { + @Suppress("UnsafeThirdPartyFunctionCall") // tapLocationBuffer is non-null with exactly 2 elements + view.getLocationInWindow(tapLocationBuffer) + // Read density fresh per tap: it can change at runtime on foldables or when + // the user adjusts display size, so caching it as a field would be incorrect. + val density = view.resources.displayMetrics.density + + heatmapData = HeatmapActionData( + viewKey = heatmapViewKey(view), + positionX = ((touchX - tapLocationBuffer[0]) / density).roundToLong(), + positionY = ((touchY - tapLocationBuffer[1]) / density).roundToLong(), + targetWidth = (view.width / density).roundToLong(), + targetHeight = (view.height / density).roundToLong() + ) } } target.node?.let { attributes.putAll(it.customAttributes) } - GlobalRumMonitor.get(sdkCore).addAction( + val rumMonitor = GlobalRumMonitor.get(sdkCore) + val targetName = resolveViewTargetName(interactionPredicate, target) + (rumMonitor as? AdvancedRumMonitor)?.addActionWithHeatmap( RumActionType.TAP, - resolveViewTargetName(interactionPredicate, target), + targetName, + heatmapData, attributes - ) + ) ?: rumMonitor.addAction(RumActionType.TAP, targetName, attributes) } private fun resolveAttributes( @@ -288,12 +309,7 @@ internal class GesturesListener( ): MutableMap { val attributes = mutableMapOf() scrollTarget.viewRef.get()?.let { view -> - val targetId: String = contextRef.get().resourceIdName(view.id) - attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() - attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId - attributesProviders.forEach { - it.extractAttributes(view, attributes) - } + addViewAttributes(view, attributes) } scrollTarget.node?.let { attributes.putAll(it.customAttributes) @@ -305,6 +321,15 @@ internal class GesturesListener( return attributes } + private fun addViewAttributes(view: View, attributes: MutableMap) { + val targetId: String = contextRef.get().resourceIdName(view.id) + attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() + attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId + attributesProviders.forEach { + it.extractAttributes(view, attributes) + } + } + private fun resolveGestureDirection(endEvent: MotionEvent): String { val diffX = endEvent.x - onTouchDownXPos val diffY = endEvent.y - onTouchDownYPos diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt index d28a9c68c3..fb94e9920b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt @@ -9,10 +9,12 @@ package com.datadog.android.rum.internal.monitor import android.app.Activity import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumMonitor import com.datadog.android.rum.RumPerformanceMetric import com.datadog.android.rum.internal.debug.RumDebugListener +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.tools.annotation.NoOpImplementation @@ -28,6 +30,13 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor { fun start() + fun addActionWithHeatmap( + type: RumActionType, + name: String, + heatmapData: HeatmapActionData?, + attributes: Map + ) + fun sendWebViewEvent() fun addLongTask(durationNs: Long, target: String) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index dd1916c8dd..ad1d4cbc44 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -27,6 +27,7 @@ import com.datadog.android.core.internal.utils.getSafe import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.core.sampling.Sampler +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.AddOperationStepVital.ActionType import com.datadog.android.internal.thread.NamedCallable @@ -54,6 +55,7 @@ import com.datadog.android.rum.internal.domain.asTime import com.datadog.android.rum.internal.domain.battery.BatteryInfo import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.domain.event.ResourceTiming +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData import com.datadog.android.rum.internal.domain.scope.RumApplicationScope import com.datadog.android.rum.internal.domain.scope.RumRawEvent import com.datadog.android.rum.internal.domain.scope.RumScopeKey @@ -105,7 +107,8 @@ internal class DatadogRumMonitor( batteryInfoProvider: InfoProvider, displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -128,7 +131,8 @@ internal class DatadogRumMonitor( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) internal var debugListener: RumDebugListener? = null @@ -200,14 +204,45 @@ internal class DatadogRumMonitor( override fun addAction(type: RumActionType, name: String, attributes: Map) { val eventTime = getEventTime(attributes) handleEvent( - RumRawEvent.StartAction(type, name, false, attributes.toMap(), eventTime) + RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = false, + attributes = attributes.toMap(), + eventTime = eventTime + ) + ) + } + + override fun addActionWithHeatmap( + type: RumActionType, + name: String, + heatmapData: HeatmapActionData?, + attributes: Map + ) { + val eventTime = getEventTime(attributes) + handleEvent( + RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = false, + heatmapData = heatmapData, + eventTime = eventTime, + attributes = attributes.toMap() + ) ) } override fun startAction(type: RumActionType, name: String, attributes: Map) { val eventTime = getEventTime(attributes) handleEvent( - RumRawEvent.StartAction(type, name, true, attributes.toMap(), eventTime) + RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = true, + attributes = attributes.toMap(), + eventTime = eventTime + ) ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt index 035b3bac8e..93ab7dc5b9 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt @@ -559,6 +559,56 @@ internal class ActionEventAssert(actual: ActionEvent) : return this } + fun hasPermanentId(expected: String?): ActionEventAssert { + assertThat(actual.dd.action?.target?.permanentId) + .overridingErrorMessage( + "Expected event data to have dd.action.target.permanent_id $expected " + + "but was ${actual.dd.action?.target?.permanentId}" + ) + .isEqualTo(expected) + return this + } + + fun hasTargetWidth(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.target?.width) + .overridingErrorMessage( + "Expected event data to have dd.action.target.width $expected " + + "but was ${actual.dd.action?.target?.width}" + ) + .isEqualTo(expected) + return this + } + + fun hasTargetHeight(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.target?.height) + .overridingErrorMessage( + "Expected event data to have dd.action.target.height $expected " + + "but was ${actual.dd.action?.target?.height}" + ) + .isEqualTo(expected) + return this + } + + fun hasPositionX(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.position?.x) + .overridingErrorMessage( + "Expected event data to have dd.action.position.x $expected " + + "but was ${actual.dd.action?.position?.x}" + ) + .isEqualTo(expected) + return this + } + + fun hasPositionY(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.position?.y) + .overridingErrorMessage( + "Expected event data to have dd.action.position.y $expected " + + "but was ${actual.dd.action?.position?.y}" + ) + .isEqualTo(expected) + return this + } + companion object { internal const val TIMESTAMP_THRESHOLD_MS = 50L internal fun assertThat(actual: ActionEvent): ActionEventAssert = diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt index 1f9a80bec4..82dc5e5440 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt @@ -857,6 +857,40 @@ internal class RumFeatureTest { assertThat(GlobalRumMonitor.get(mockSdkCore)).isInstanceOf(NoOpAdvancedRumMonitor::class.java) } + @Test + fun `M keep heatmap identifier registry instance W onStop()`() { + // Session Replay holds a direct reference to the registry obtained via unwrap(). + // Resetting the registry on stop would leave SR writing to an orphaned instance. + + // Given + testedFeature.onInitialize(appContext.mockInstance) + val registryBeforeStop = testedFeature.heatmapIdentifierRegistry + + // When + testedFeature.onStop() + + // Then + assertThat(testedFeature.heatmapIdentifierRegistry) + .isSameAs(registryBeforeStop) + } + + @Test + fun `M keep same heatmap identifier registry instance W onStop() + onInitialize()`() { + // SR holds a direct reference from the first init. Re-initializing must reuse the + // same instance so SR's cached reference stays valid. + + // Given + testedFeature.onInitialize(appContext.mockInstance) + val registryAfterFirstInit = testedFeature.heatmapIdentifierRegistry + + // When + testedFeature.onStop() + testedFeature.onInitialize(appContext.mockInstance) + + // Then + assertThat(testedFeature.heatmapIdentifierRegistry).isSameAs(registryAfterFirstInit) + } + @ParameterizedTest @EnumSource(VitalsUpdateFrequency::class, names = ["NEVER"], mode = EnumSource.Mode.EXCLUDE) fun `M initialize vital executor W initialize { frequency != NEVER }()`( diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt index b788c3b442..ff523741c2 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt @@ -13,6 +13,8 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType +import com.datadog.android.internal.heatmaps.HeatmapIdentifier +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumResourceKind @@ -3041,11 +3043,11 @@ internal class RumActionScopeTest { val result = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) fakeEvent = RumRawEvent.StartAction( - RumActionType.TAP, - name, - false, - emptyMap(), - timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) + type = RumActionType.TAP, + name = name, + waitForStop = false, + attributes = emptyMap(), + eventTime = timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) ) val result2 = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) @@ -3160,6 +3162,203 @@ internal class RumActionScopeTest { ) } + @Test + fun `M populate heatmap fields W sendAction() {viewIdentity present}`( + @StringForgery fakeViewIdentity: String, + @LongForgery fakeViewKey: Long, + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // Given + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + whenever(mockHeatmapRegistry.getHeatmapIdentifier(fakeViewKey, fakeParentContext.viewUrl.orEmpty())) + .thenReturn(HeatmapIdentifier(fakeViewIdentity)) + + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = fakeAttributes, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector, + heatmapData = HeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = fakeWidth, + targetHeight = fakeHeight + ), + heatmapIdentifierRegistry = mockHeatmapRegistry + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .hasPermanentId(fakeViewIdentity) + .hasTargetWidth(fakeWidth) + .hasTargetHeight(fakeHeight) + .hasPositionX(fakePosX) + .hasPositionY(fakePosY) + } + } + + @Test + fun `M omit dd action W sendAction() {registry returns null for viewKey}`( + @LongForgery fakeViewKey: Long, + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // Given + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + whenever(mockHeatmapRegistry.getHeatmapIdentifier(any(), any())).thenReturn(null) + + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = fakeAttributes, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector, + heatmapData = HeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = fakeWidth, + targetHeight = fakeHeight + ), + heatmapIdentifierRegistry = mockHeatmapRegistry + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.action).isNull() + } + } + + @Test + fun `M omit dd action W sendAction() {registry is null}`( + @LongForgery fakeViewKey: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // Given — heatmapData is set but no registry is provided + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = fakeAttributes, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector, + heatmapData = HeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = null, + targetHeight = null + ), + heatmapIdentifierRegistry = null + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.action).isNull() + } + } + + @Test + fun `M omit dd action W sendAction() {no heatmap attributes}`() { + // Given + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = fakeAttributes, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.action).isNull() + } + } + // region Internal private fun mockEvent(timeOffset: Long = 0L): RumRawEvent { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt index 68fec3f199..faa28c7f2e 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt @@ -42,13 +42,18 @@ internal fun Forge.stopViewEvent(): RumRawEvent.StopView { ) } -internal fun Forge.startActionEvent(continuous: Boolean? = null, eventTime: Time = Time()): RumRawEvent.StartAction { +internal fun Forge.startActionEvent( + continuous: Boolean? = null, + eventTime: Time = Time(), + heatmapData: HeatmapActionData? = null +): RumRawEvent.StartAction { return RumRawEvent.StartAction( type = aValueFrom(RumActionType::class.java), name = anAlphabeticalString(), waitForStop = continuous ?: aBool(), - attributes = exhaustiveAttributes(), - eventTime = eventTime + heatmapData = heatmapData, + eventTime = eventTime, + attributes = exhaustiveAttributes() ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 54c2ad2a9d..33cac8fdd4 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -18,6 +18,7 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumActionType @@ -2864,7 +2865,12 @@ internal class RumViewScopeTest { val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) // When - val fakeStartActionEvent = RumRawEvent.StartAction(type, name, waitForStop, attributes) + val fakeStartActionEvent = RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = waitForStop, + attributes = attributes + ) val result = testedScope.handleEvent( fakeStartActionEvent, fakeDatadogContext, @@ -2886,6 +2892,33 @@ internal class RumViewScopeTest { assertThat(actionScope.sampleRate).isCloseTo(fakeSampleRate, Assertions.offset(0.001f)) } + @Test + fun `M pass heatmapData through W handleEvent(StartAction) {heatmapData set}`( + @Forgery type: RumActionType, + @StringForgery name: String, + @BoolForgery waitForStop: Boolean, + @Forgery fakeHeatmapData: HeatmapActionData, + forge: Forge + ) { + // Given + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + testedScope = newRumViewScope(heatmapIdentifierRegistry = mockHeatmapRegistry) + val fakeStartActionEvent = RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = waitForStop, + heatmapData = fakeHeatmapData, + attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) + ) + + // When + testedScope.handleEvent(fakeStartActionEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) + + // Then + val actionScope = testedScope.activeActionScope as RumActionScope + assertThat(actionScope.heatmapData).isEqualTo(fakeHeatmapData) + } + @ParameterizedTest @EnumSource(RumActionType::class, names = ["CUSTOM"], mode = EnumSource.Mode.EXCLUDE) fun `M do nothing + log warning W handleEvent(StartAction+!CUSTOM)+active child ActionScope`( @@ -2897,7 +2930,12 @@ internal class RumViewScopeTest { // Given val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) testedScope.activeActionScope = mockChildScope - fakeEvent = RumRawEvent.StartAction(actionType, name, waitForStop, attributes) + fakeEvent = RumRawEvent.StartAction( + type = actionType, + name = name, + waitForStop = waitForStop, + attributes = attributes + ) whenever( mockChildScope.handleEvent( fakeEvent, @@ -2936,7 +2974,12 @@ internal class RumViewScopeTest { val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) testedScope.activeActionScope = mockChildScope fakeEvent = - RumRawEvent.StartAction(RumActionType.CUSTOM, name, waitForStop = true, attributes) + RumRawEvent.StartAction( + type = RumActionType.CUSTOM, + name = name, + waitForStop = true, + attributes = attributes + ) whenever( mockChildScope.handleEvent( fakeEvent, @@ -2974,7 +3017,12 @@ internal class RumViewScopeTest { // Given val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) testedScope.activeActionScope = mockChildScope - fakeEvent = RumRawEvent.StartAction(RumActionType.CUSTOM, name, false, attributes) + fakeEvent = RumRawEvent.StartAction( + type = RumActionType.CUSTOM, + name = name, + waitForStop = false, + attributes = attributes + ) whenever( mockChildScope.handleEvent( fakeEvent, @@ -3050,7 +3098,12 @@ internal class RumViewScopeTest { // Given val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) testedScope.activeActionScope = mockChildScope - fakeEvent = RumRawEvent.StartAction(RumActionType.CUSTOM, name, false, attributes) + fakeEvent = RumRawEvent.StartAction( + type = RumActionType.CUSTOM, + name = name, + waitForStop = false, + attributes = attributes + ) whenever( mockChildScope.handleEvent( fakeEvent, @@ -3131,7 +3184,12 @@ internal class RumViewScopeTest { // Given val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) testedScope.stopped = true - fakeEvent = RumRawEvent.StartAction(type, name, waitForStop, attributes) + fakeEvent = RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = waitForStop, + attributes = attributes + ) // When val result = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) @@ -3203,7 +3261,12 @@ internal class RumViewScopeTest { // Given testedScope.activeActionScope = null testedScope.pendingActionCount = 0 - fakeEvent = RumRawEvent.StartAction(type, name, waitForStop, emptyMap()) + fakeEvent = RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = waitForStop, + attributes = emptyMap() + ) val result = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) @@ -9426,7 +9489,8 @@ internal class RumViewScopeTest { mockInteractionToNextViewMetricResolver, networkSettledMetricResolver: NetworkSettledMetricResolver = mockNetworkSettledMetricResolver, viewEndedMetricDispatcher: ViewMetricDispatcher = mockViewEndedMetricDispatcher, - slowFramesMetricListener: SlowFramesListener = mockSlowFramesListener + slowFramesMetricListener: SlowFramesListener = mockSlowFramesListener, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) = RumViewScope( parentScope = parentScope, sdkCore = sdkCore, @@ -9451,7 +9515,8 @@ internal class RumViewScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) data class RumRawEventData(val event: RumRawEvent, val viewKey: RumScopeKey) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt index d23c139ea3..2833f99321 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.rum.internal.instrumentation.gestures import android.app.Application import android.content.res.Resources +import android.util.DisplayMetrics import android.util.Log import android.view.MotionEvent import android.view.View @@ -56,6 +57,8 @@ internal abstract class AbstractGesturesListenerTest { @Mock lateinit var mockResources: Resources + val fakeDisplayMetrics = DisplayMetrics() + @Mock lateinit var mockWindow: Window @@ -67,6 +70,8 @@ internal abstract class AbstractGesturesListenerTest { @BeforeEach open fun `set up`() { Datadog.setVerbosity(Log.VERBOSE) + fakeDisplayMetrics.density = 1f + whenever(mockResources.displayMetrics).thenReturn(fakeDisplayMetrics) whenever(mockAppContext.resources).thenReturn(mockResources) } @@ -116,6 +121,8 @@ internal abstract class AbstractGesturesListenerTest { whenever(it.id).thenReturn(id) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) + whenever(it.isAttachedToWindow).thenReturn(true) + whenever(it.resources).thenReturn(mockResources) whenever(it.getLocationInWindow(any())).doAnswer { val array = it.arguments[0] as IntArray diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt index 42a8f77ed3..2a9746cb1a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt @@ -17,6 +17,7 @@ import androidx.core.view.ScrollingView import com.datadog.android.api.InternalLogger import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes +import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.Node @@ -26,6 +27,7 @@ import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions @@ -36,6 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -416,8 +420,17 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() testedListener.onUp(endUpEvent) // Then - verify(rumMonitor.mockInstance) - .addAction(RumActionType.TAP, "", expectedStartAttributes) + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + anyOrNull(), + attributesCaptor.capture() + ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(expectedStartAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME]) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedStartAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID]) verifyNoMoreInteractions(rumMonitor.mockInstance) } @@ -1205,6 +1218,60 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() verifyNoMoreInteractions(rumMonitor.mockInstance) } + @Test + fun `M send scroll actions W onScroll() { registry returns null }`( + forge: Forge + ) { + // Given + val startDownEvent: MotionEvent = forge.getForgery() + val scrollEvent: MotionEvent = forge.getForgery() + val endUpEvent: MotionEvent = forge.getForgery() + val expectedDirection = GesturesListener.SCROLL_DIRECTION_DOWN + stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) + + val scrollingTarget: ScrollableView = mockView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(scrollingTarget) + } + val expectedResourceName = forge.anAlphabeticalString() + mockResourcesForTarget(scrollingTarget, expectedResourceName) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger + ) + + // When + testedListener.onDown(startDownEvent) + testedListener.onScroll(startDownEvent, scrollEvent, forge.aFloat(), forge.aFloat()) + testedListener.onUp(endUpEvent) + + // Then — scroll uses the public startAction API, not addActionWithHeatmap. + verify(rumMonitor.mockInstance).startAction( + eq(RumActionType.SCROLL), + any(), + any() + ) + verify(rumMonitor.mockInstance).stopAction( + eq(RumActionType.SCROLL), + any(), + any() + ) + verifyNoMoreInteractions(rumMonitor.mockInstance) + } + // endregion // region internal diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt index b360aacd36..79d7ba8e20 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt @@ -14,10 +14,15 @@ import android.view.ViewGroup import android.view.Window import androidx.compose.ui.platform.ComposeView import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.heatmaps.heatmapViewKey import com.datadog.android.internal.utils.toHexString +import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes +import com.datadog.android.rum.RumMonitor +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData import com.datadog.android.rum.internal.instrumentation.gestures.GesturesListenerScrollSwipeTest.ScrollableListView +import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.Node @@ -35,14 +40,18 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.argThat +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.lang.ref.WeakReference +import kotlin.math.roundToLong @Extensions( ExtendWith(MockitoExtension::class), @@ -380,9 +389,10 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { // Then verifyNoInteractions(mockInternalLogger) - verify(rumMonitor.mockInstance).addAction( + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(targetName), + isNull(), eq(emptyMap()) ) } @@ -567,11 +577,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + anyOrNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME]) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID]) } @Test @@ -631,11 +647,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + anyOrNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME]) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID]) } @Test @@ -683,11 +705,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - fakeCustomTargetName, - expectedAttributes + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(fakeCustomTargetName), + anyOrNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME]) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID]) } @Test @@ -734,11 +762,147 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + anyOrNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME]) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + } + + @Test + fun `M calculate correct relative position W tap { position relative to target }`( + forge: Forge + ) { + // Given + val fakeDensity = forge.aFloat(min = 1f, max = 4f) + fakeDisplayMetrics.density = fakeDensity + + val targetX = forge.anInt(min = 0, max = 1000) + val targetY = forge.anInt(min = 0, max = 1000) + val touchOffsetX = forge.anInt(min = 1, max = 500) + val touchOffsetY = forge.anInt(min = 1, max = 500) + val touchX = (targetX + touchOffsetX).toFloat() + val touchY = (targetY + touchOffsetY).toFloat() + + val mockEvent: MotionEvent = mock { + whenever(it.x).thenReturn(touchX) + whenever(it.y).thenReturn(touchY) + } + + val targetId = forge.anInt() + val validTarget: View = mockView( + id = targetId, + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + whenever(validTarget.getLocationInWindow(any())).doAnswer { invocation -> + val array = invocation.arguments[0] as IntArray + array[0] = targetX + array[1] = targetY + null + } + val fakeTargetWidth = touchOffsetX + 1 + val fakeTargetHeight = touchOffsetY + 1 + whenever(validTarget.width).thenReturn(fakeTargetWidth) + whenever(validTarget.height).thenReturn(fakeTargetHeight) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + val expectedResourceName = forge.anAlphabeticalString() + mockResourcesForTarget(validTarget, expectedResourceName) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + val expectedXInTarget = ((touchX - targetX) / fakeDensity).roundToLong() + val expectedYInTarget = ((touchY - targetY) / fakeDensity).roundToLong() + val expectedTargetWidth = (fakeTargetWidth / fakeDensity).roundToLong() + val expectedTargetHeight = (fakeTargetHeight / fakeDensity).roundToLong() + val heatmapCaptor = argumentCaptor() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + heatmapCaptor.capture(), + any() + ) + val capturedHeatmap = heatmapCaptor.firstValue + assertThat(capturedHeatmap.viewKey).isEqualTo(heatmapViewKey(validTarget)) + assertThat(capturedHeatmap.positionX).isEqualTo(expectedXInTarget) + assertThat(capturedHeatmap.positionY).isEqualTo(expectedYInTarget) + assertThat(capturedHeatmap.targetWidth).isEqualTo(expectedTargetWidth) + assertThat(capturedHeatmap.targetHeight).isEqualTo(expectedTargetHeight) + } + + @Test + fun `M not include heatmap attributes W tap { view not attached to window }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val targetId = forge.anInt() + val validTarget: View = mockView( + id = targetId, + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + whenever(validTarget.isAttachedToWindow).thenReturn(false) + + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + val expectedResourceName = forge.anAlphabeticalString() + mockResourcesForTarget(validTarget, expectedResourceName) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + isNull(), + attributesCaptor.capture() + ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(validTarget.javaClass.canonicalName) } @Test @@ -860,11 +1024,52 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - fakeCustomTargetName, - fakeAttributes + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(fakeCustomTargetName), + isNull(), + eq(fakeAttributes) + ) + } + + @Test + fun `M fall back to addAction W onTap() { rumMonitor is not AdvancedRumMonitor }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val validTarget: View = mockView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + // Replace the global monitor with a plain RumMonitor (not AdvancedRumMonitor). + GlobalRumMonitor.clear() + val plainMonitor: RumMonitor = mock() + GlobalRumMonitor.registerIfAbsent(plainMonitor, rumMonitor.mockSdkCore) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then — graceful degradation: tap is still tracked, just without heatmap data. + verify(plainMonitor).addAction(eq(RumActionType.TAP), any(), any()) } // region Internal @@ -874,15 +1079,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { expectedTargetName: String, expectedResourceName: String ) { - verify(rumMonitor.mockInstance).addAction( + val attributesCaptor = argumentCaptor>() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(expectedTargetName), - argThat { - val targetClassName = target.javaClass.canonicalName - this[RumAttributes.ACTION_TARGET_CLASS_NAME] == targetClassName && - this[RumAttributes.ACTION_TARGET_RESOURCE_ID] == expectedResourceName - } + anyOrNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(target.javaClass.canonicalName) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_RESOURCE_ID]) + .isEqualTo(expectedResourceName) } // endregion diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt index 26cfd84023..c083729b8a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt @@ -41,6 +41,7 @@ import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapsh import com.datadog.android.rum.internal.domain.battery.BatteryInfo import com.datadog.android.rum.internal.domain.display.DisplayInfo import com.datadog.android.rum.internal.domain.event.ResourceTiming +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData import com.datadog.android.rum.internal.domain.scope.RumApplicationScope import com.datadog.android.rum.internal.domain.scope.RumRawEvent import com.datadog.android.rum.internal.domain.scope.RumScopeKey @@ -509,6 +510,41 @@ internal class DatadogRumMonitorTest { assertThat(event.waitForStop).isFalse assertThat(event.attributes).containsAllEntriesOf(fakeAttributes) assertThat(event.eventTime.timestamp).isEqualTo(eventTimeMs) + assertThat(event.heatmapData).isNull() + } + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M enqueue StartAction with heatmapData set W addActionWithHeatmap() {heatmapData non-null}`( + @Forgery type: RumActionType, + @StringForgery name: String, + @Forgery fakeHeatmapData: HeatmapActionData + ) { + // When + testedMonitor.addActionWithHeatmap( + type = type, + name = name, + heatmapData = fakeHeatmapData, + attributes = fakeAttributes + ) + + // Then + argumentCaptor { + verify(mockApplicationScope).handleEvent( + capture(), + same(fakeDatadogContext), + same(mockEventWriteScope), + same(mockWriter) + ) + + val event = firstValue as RumRawEvent.StartAction + assertThat(event.type).isEqualTo(type) + assertThat(event.name).isEqualTo(name) + assertThat(event.waitForStop).isFalse + assertThat(event.attributes).containsAllEntriesOf(fakeAttributes) + assertThat(event.eventTime.timestamp).isEqualTo(eventTimeMs) + assertThat(event.heatmapData).isEqualTo(fakeHeatmapData) } verifyNoMoreInteractions(mockWriter) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt index 16fa387995..dc00005eaf 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt @@ -54,6 +54,7 @@ internal class Configurator : BaseConfigurator() { forge.addFactory(SlowFramesConfigurationForgeryFactory()) forge.addFactory(DisplayInfoForgeryFactory()) forge.addFactory(BatteryInfoForgeryFactory()) + forge.addFactory(HeatmapActionDataForgeryFactory()) // Telemetry schema models forge.addFactory(TelemetryDebugEventForgeryFactory()) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/HeatmapActionDataForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/HeatmapActionDataForgeryFactory.kt new file mode 100644 index 0000000000..04904dc4ac --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/HeatmapActionDataForgeryFactory.kt @@ -0,0 +1,24 @@ +/* + * 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 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.utils.forge + +import com.datadog.android.rum.internal.domain.scope.HeatmapActionData +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class HeatmapActionDataForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): HeatmapActionData { + return HeatmapActionData( + viewKey = forge.aLong(), + positionX = forge.aLong(), + positionY = forge.aLong(), + targetWidth = forge.aNullable { aPositiveLong() }, + targetHeight = forge.aNullable { aPositiveLong() } + ) + } +}