From e63b7f4a4901b37e094fdbd070265086a6448092 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Mon, 18 May 2026 10:40:16 +0300 Subject: [PATCH 1/4] PANA-5681: Integrate heatmaps into RUM --- dd-sdk-android-internal/api/apiSurface | 2 + .../api/dd-sdk-android-internal.api | 4 + .../heatmaps/HeatmapIdentifierRegistry.kt | 10 +- .../HeatmapIdentifierRegistryProvider.kt | 16 + .../heatmaps/HeatmapIdentifierStore.kt | 4 +- .../android/rum/internal/RumFeature.kt | 20 +- .../domain/scope/HeatmapActionData.kt | 15 + .../internal/domain/scope/RumActionScope.kt | 26 +- .../rum/internal/domain/scope/RumRawEvent.kt | 3 +- .../internal/domain/scope/RumSessionScope.kt | 2 +- .../gestures/DatadogGesturesTracker.kt | 7 +- .../gestures/GesturesListener.kt | 79 +++-- .../internal/monitor/AdvancedRumMonitor.kt | 9 + .../rum/internal/monitor/DatadogRumMonitor.kt | 36 +- .../android/rum/assertj/ActionEventAssert.kt | 50 +++ .../android/rum/internal/RumFeatureTest.kt | 34 ++ .../domain/scope/RumActionScopeTest.kt | 92 ++++- .../gestures/AbstractGesturesListenerTest.kt | 5 + .../gestures/DatadogGesturesTrackerTest.kt | 16 +- .../GesturesListenerScrollSwipeTest.kt | 129 +++++-- .../gestures/GesturesListenerTapTest.kt | 318 +++++++++++++++--- .../internal/monitor/DatadogRumMonitorTest.kt | 31 ++ .../android/rum/utils/forge/Configurator.kt | 1 + .../forge/HeatmapActionDataForgeryFactory.kt | 24 ++ 24 files changed, 824 insertions(+), 109 deletions(-) create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider.kt create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/HeatmapActionData.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/HeatmapActionDataForgeryFactory.kt diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 9e0421772e..505034398a 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -69,6 +69,8 @@ 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 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..66c47cc41f 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -150,6 +150,10 @@ 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/NoOpHeatmapIdentifierRegistry : com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry { public fun ()V public fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier; 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..fbef25d881 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 @@ -17,7 +17,7 @@ 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, + * @param identifiers a map of [System.identityHashCode] values to their [HeatmapIdentifier]s, * computed during the most recent Session Replay view tree traversal. * @param screenName the RUM view URL active when the snapshot was computed. Used to * guard against stale reads after screen navigation. @@ -25,14 +25,14 @@ interface HeatmapIdentifierRegistry { 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 [viewIdentityHash], 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 viewIdentityHash the identity hash of the tapped view (`System.identityHashCode(view).toLong()`). * @param currentScreenName the RUM view URL active at the time of the tap. */ - fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier? + fun getHeatmapIdentifier(viewIdentityHash: 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..ea44faa05b 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 @@ -37,9 +37,9 @@ internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry { } } - override fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier? { + override fun getHeatmapIdentifier(viewIdentityHash: Long, currentScreenName: String): HeatmapIdentifier? { return lock.read { - if (snapshotScreenName == currentScreenName) identifiers[viewId] else null + if (snapshotScreenName == currentScreenName) identifiers[viewIdentityHash] else null } } } 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..790a69b967 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 by lazy { HeatmapIdentifierRegistry.create() } private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) } internal var rumAppStartupDetector: RumAppStartupDetector? = null @@ -238,7 +241,8 @@ internal class RumFeature( configuration.interactionPredicate, composeActionTrackingStrategy = configuration.composeActionTrackingStrategy, buildSdkVersionProvider, - sdkCore.internalLogger + sdkCore.internalLogger, + heatmapIdentifierRegistry ) } else { NoOpUserActionTrackingStrategy() @@ -863,14 +867,16 @@ internal class RumFeature( interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, buildSdkVersionProvider: BuildSdkVersionProvider, - internalLogger: InternalLogger + internalLogger: InternalLogger, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry ): UserActionTrackingStrategy { val gesturesTracker = provideGestureTracker( customProviders = touchTargetExtraAttributesProviders, interactionPredicate = interactionPredicate, composeActionTrackingStrategy = composeActionTrackingStrategy, - internalLogger = internalLogger + internalLogger = internalLogger, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) return if (buildSdkVersionProvider.isAtLeastQ) { UserActionTrackingStrategyApi29(gesturesTracker) @@ -883,7 +889,8 @@ internal class RumFeature( customProviders: Array, interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, - internalLogger: InternalLogger + internalLogger: InternalLogger, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry ): DatadogGesturesTracker { val defaultProviders = arrayOf(JetpackViewAttributesProvider()) val providers = customProviders + defaultProviders @@ -891,7 +898,8 @@ internal class RumFeature( providers, interactionPredicate, composeActionsTrackingStrategy = composeActionTrackingStrategy, - internalLogger + internalLogger = internalLogger, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) } 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..df600988ab --- /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 targetIdentity: String, + 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..c03cb3d1ee 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 @@ -28,7 +28,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 +44,8 @@ internal class RumActionScope( private val trackFrustrations: Boolean, internal val sampleRate: Float, private val rumSessionTypeOverride: RumSessionType?, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val heatmapData: HeatmapActionData? = null ) : RumScope { private val inactivityThresholdNs = TimeUnit.MILLISECONDS.toNanos(inactivityThresholdMs) @@ -71,7 +72,7 @@ internal class RumActionScope( private var sent = false internal var stopped = false - // endregion + // region RumScope @WorkerThread override fun handleEvent( @@ -276,6 +277,7 @@ internal class RumActionScope( datadogContext, rumContext.viewId.orEmpty() ) + val ddAction = buildDdAction(heatmapData) insightsCollector.onAction() ActionEvent( @@ -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 = ddAction ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, @@ -379,6 +382,18 @@ internal class RumActionScope( sent = true } + private fun buildDdAction(heatmapData: HeatmapActionData?): ActionEvent.DdAction? { + heatmapData ?: return null + return ActionEvent.DdAction( + position = ActionEvent.Position(x = heatmapData.positionX, y = heatmapData.positionY), + target = ActionEvent.DdActionTarget( + permanentId = heatmapData.targetIdentity, + width = heatmapData.targetWidth, + height = heatmapData.targetHeight + ) + ) + } + // endregion companion object { @@ -410,7 +425,8 @@ internal class RumActionScope( trackFrustrations = trackFrustrations, sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapData = event.heatmapData ) } } 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..fc2c62efe9 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 @@ -42,7 +42,8 @@ internal sealed class RumRawEvent { val name: String, val waitForStop: Boolean, val attributes: Map, - override val eventTime: Time = Time() + override val eventTime: Time = Time(), + val heatmapData: HeatmapActionData? = null ) : 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..383df8ce08 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 @@ -97,7 +97,7 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector ) internal val activeView: RumViewScope? 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..270c226b7e 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.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.ViewAttributesProvider @@ -19,7 +20,8 @@ internal class DatadogGesturesTracker( internal val targetAttributesProviders: Array, internal val interactionPredicate: InteractionPredicate, private val composeActionsTrackingStrategy: ActionTrackingStrategy, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry ) : GesturesTracker { // region GesturesTracker @@ -109,7 +111,8 @@ internal class DatadogGesturesTracker( interactionPredicate = interactionPredicate, contextRef = WeakReference(context), composeActionTrackingStrategy = composeActionsTrackingStrategy, - internalLogger = internalLogger + internalLogger = internalLogger, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) ) } 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..dbdd0a8c6e 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 @@ -14,9 +14,16 @@ 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.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry +import com.datadog.android.internal.heatmaps.NoOpHeatmapIdentifierRegistry 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.RumContext +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 @@ -37,7 +44,8 @@ internal class GesturesListener( private val contextRef: Reference, private val internalLogger: InternalLogger, private val composeActionTrackingStrategy: ActionTrackingStrategy = NoOpActionTrackingStrategy(), - private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy() + private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy(), + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry = NoOpHeatmapIdentifierRegistry() ) : GestureListenerCompat() { private var scrollEventType: RumActionType? = null @@ -226,7 +234,7 @@ internal class GesturesListener( onUpEvent.y ) downTarget?.takeIf { it == upTarget }?.let { target -> - sendTapEventWithTarget(target) + sendTapEventWithTarget(target, onUpEvent.x, onUpEvent.y) } } } @@ -257,29 +265,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) { + resolveHeatmapIdentifier(view)?.let { identity -> + val locationInWindow = IntArray(2) + @Suppress("UnsafeThirdPartyFunctionCall") // locationInWindow is non-null with exactly 2 elements + view.getLocationInWindow(locationInWindow) + + heatmapData = HeatmapActionData( + targetIdentity = identity, + positionX = (touchX - locationInWindow[0]).toLong(), + positionY = (touchY - locationInWindow[1]).toLong(), + targetWidth = view.width.toLong(), + targetHeight = view.height.toLong() + ) + } } } 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), - attributes - ) + targetName, + attributes, + heatmapData + ) ?: rumMonitor.addAction(RumActionType.TAP, targetName, attributes) } private fun resolveAttributes( @@ -288,12 +311,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 +323,29 @@ 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 resolveHeatmapIdentifier(view: View): String? { + val screenName = currentScreenName() ?: return null + + return heatmapIdentifierRegistry + .getHeatmapIdentifier(System.identityHashCode(view).toLong(), screenName) + ?.rawValue + } + + private fun currentScreenName(): String? { + val featureSdkCore = sdkCore as? FeatureSdkCore ?: return null + return featureSdkCore + .getFeatureContext(Feature.RUM_FEATURE_NAME)[RumContext.VIEW_URL] as? String + } + 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..1e7abbc362 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, + attributes: Map, + heatmapData: HeatmapActionData? + ) + 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..e1f6dbce42 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 @@ -54,6 +54,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 @@ -200,14 +201,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, + attributes: Map, + heatmapData: HeatmapActionData? + ) { + val eventTime = getEventTime(attributes) + handleEvent( + RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = false, + attributes = attributes.toMap(), + eventTime = eventTime, + heatmapData = heatmapData + ) ) } 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..e71a3df9b8 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 @@ -3045,7 +3045,7 @@ internal class RumActionScopeTest { name, false, emptyMap(), - timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) + eventTime = timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) ) val result2 = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) @@ -3160,6 +3160,96 @@ internal class RumActionScopeTest { ) } + @Test + fun `M populate heatmap fields W sendAction() {viewIdentity present}`( + @StringForgery fakeViewIdentity: String, + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // 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, + heatmapData = HeatmapActionData( + targetIdentity = fakeViewIdentity, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = fakeWidth, + targetHeight = fakeHeight + ) + ) + + // 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() {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/instrumentation/gestures/AbstractGesturesListenerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt index d23c139ea3..248e607939 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 @@ -14,6 +14,7 @@ import android.view.View import android.view.Window import com.datadog.android.Datadog import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.rum.utils.forge.Configurator import com.datadog.tools.unit.annotations.TestConfigurationsProvider @@ -62,6 +63,9 @@ internal abstract class AbstractGesturesListenerTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockHeatmapIdentifierRegistry: HeatmapIdentifierRegistry + // region Tests @BeforeEach @@ -116,6 +120,7 @@ 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.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/DatadogGesturesTrackerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt index 008823d453..d05c77dc0a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt @@ -11,6 +11,7 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate @@ -65,6 +66,9 @@ internal class DatadogGesturesTrackerTest : ObjectTest() @Mock lateinit var mockSdkCore: FeatureSdkCore + @Mock + lateinit var mockHeatmapIdentifierRegistry: HeatmapIdentifierRegistry + @BeforeEach fun `set up`() { testedTracker = @@ -72,7 +76,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() emptyArray(), mockInteractionPredicate, mockActionTrackingStrategy, - mockInternalLogger + mockInternalLogger, + mockHeatmapIdentifierRegistry ) whenever(mockActivity.window).thenReturn(mockWindow) whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger @@ -83,7 +88,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() forge.aList { StubViewAttributesProvider(anAlphabeticalString()) }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger + mockInternalLogger, + mockHeatmapIdentifierRegistry ) } @@ -98,7 +104,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger + mockInternalLogger, + mockHeatmapIdentifierRegistry ) } @@ -113,7 +120,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), StubInteractionPredicate(), mockActionTrackingStrategy, - mockInternalLogger + mockInternalLogger, + mockHeatmapIdentifierRegistry ) } 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..15faa77594 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 @@ -36,9 +37,12 @@ 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.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -108,7 +112,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -179,7 +184,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -249,7 +255,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -332,7 +339,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -405,7 +413,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -416,8 +425,18 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() testedListener.onUp(endUpEvent) // Then - verify(rumMonitor.mockInstance) - .addAction(RumActionType.TAP, "", expectedStartAttributes) + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedStartAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedStartAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + classMatches && resourceMatches + }, + anyOrNull() + ) verifyNoMoreInteractions(rumMonitor.mockInstance) } @@ -452,7 +471,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -505,7 +525,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -568,7 +589,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -642,7 +664,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -720,7 +743,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -780,7 +804,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -843,7 +868,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -906,7 +932,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -983,7 +1010,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -1043,7 +1071,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) testedListener.onUp(startDownEvent) testedListener.onDown(endUpEvent) @@ -1088,7 +1117,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -1165,7 +1195,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) whenever( @@ -1205,6 +1236,66 @@ 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, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + ) + + // 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() + ) + verify(rumMonitor.mockInstance as AdvancedRumMonitor, never()).addActionWithHeatmap( + any(), + any(), + any(), + anyOrNull() + ) + } + // 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..dc432f1136 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,16 @@ import android.view.ViewGroup import android.view.Window import androidx.compose.ui.platform.ComposeView import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.Feature +import com.datadog.android.internal.heatmaps.HeatmapIdentifier 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.RumContext 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,8 +41,11 @@ 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.anyOrNull import org.mockito.kotlin.argThat +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 @@ -60,7 +69,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -130,7 +140,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -170,7 +181,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -215,7 +227,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -259,7 +272,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -296,7 +310,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -324,7 +339,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -372,7 +388,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -380,10 +397,11 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { // Then verifyNoInteractions(mockInternalLogger) - verify(rumMonitor.mockInstance).addAction( + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(targetName), - eq(emptyMap()) + eq(emptyMap()), + isNull() ) } @@ -404,7 +422,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) val expectedResourceName = forge.anAlphabeticalString() mockResourcesForTarget(mockDecorView, expectedResourceName) @@ -446,7 +465,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -486,7 +506,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -508,7 +529,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(null), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -561,16 +583,24 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + classMatches && resourceMatches + }, + anyOrNull() ) } @@ -625,16 +655,24 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + classMatches && resourceMatches + }, + anyOrNull() ) } @@ -676,17 +714,25 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - fakeCustomTargetName, - expectedAttributes + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(fakeCustomTargetName), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + classMatches && resourceMatches + }, + anyOrNull() ) } @@ -727,17 +773,160 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When testedListener.onSingleTapUp(mockEvent) // Then - verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + classMatches && resourceMatches + }, + anyOrNull() + ) + } + + @Test + fun `M calculate correct relative position W tap { position relative to target }`( + forge: Forge + ) { + // Given + 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 + ) + // Override the default mock-view location with our forged values. + whenever(validTarget.getLocationInWindow(any())).doAnswer { invocation -> + val array = invocation.arguments[0] as IntArray + array[0] = targetX + array[1] = targetY + null + } + // Ensure the view dimensions are large enough for the hit test to pass. + whenever(validTarget.width).thenReturn(touchOffsetX + 1) + whenever(validTarget.height).thenReturn(touchOffsetY + 1) + 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) + val fakeScreenName = forge.anAlphabeticalString() + val fakeViewIdentity = forge.anAlphabeticalString() + whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) + .thenReturn(mapOf(RumContext.VIEW_URL to fakeScreenName)) + whenever( + mockHeatmapIdentifierRegistry.getHeatmapIdentifier( + System.identityHashCode(validTarget).toLong(), + fakeScreenName + ) + ).thenReturn(HeatmapIdentifier(fakeViewIdentity)) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + val expectedXInTarget = (touchX - targetX).toLong() + val expectedYInTarget = (touchY - targetY).toLong() + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + any(), + argThat { heatmapData -> + heatmapData != null && + heatmapData.targetIdentity == fakeViewIdentity && + heatmapData.positionX == expectedXInTarget && + heatmapData.positionY == expectedYInTarget + } + ) + } + + @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 + ) + // Mock view as not attached to window + 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, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == validTarget.javaClass.canonicalName + }, + isNull() ) } @@ -776,7 +965,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) // When @@ -837,7 +1027,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) whenever( @@ -860,11 +1051,53 @@ 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), + eq(fakeAttributes), + isNull() + ) + } + + @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, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry ) + + // 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,14 +1107,15 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { expectedTargetName: String, expectedResourceName: String ) { - verify(rumMonitor.mockInstance).addAction( + 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() ) } 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..e37365f0d2 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,36 @@ 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, name, fakeAttributes, fakeHeatmapData) + + // 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..05075ef0cc --- /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( + targetIdentity = forge.anAlphabeticalString(), + positionX = forge.aLong(), + positionY = forge.aLong(), + targetWidth = forge.aNullable { aPositiveLong() }, + targetHeight = forge.aNullable { aPositiveLong() } + ) + } +} From fa8baf802150fea75fa87b1bcf6b496a3f1d22c1 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Thu, 28 May 2026 14:57:13 +0300 Subject: [PATCH 2/4] PANA-5681: Address review comments --- dd-sdk-android-internal/api/apiSurface | 2 + .../api/dd-sdk-android-internal.api | 8 + .../heatmaps/HeatmapIdentifierRegistry.kt | 19 +- .../heatmaps/HeatmapIdentifierStore.kt | 4 +- .../internal/heatmaps/HeatmapViewKey.kt | 30 +++ .../internal/heatmaps/TapTargetUtils.kt | 21 ++ .../internal/heatmaps/HeatmapViewKeyTest.kt | 94 +++++++ .../internal/heatmaps/TapTargetUtilsTest.kt | 86 +++++++ .../android/rum/internal/RumFeature.kt | 2 +- .../internal/domain/scope/RumActionScope.kt | 25 +- .../rum/internal/domain/scope/RumRawEvent.kt | 4 +- .../gestures/AndroidActionTrackingStrategy.kt | 12 +- .../gestures/DatadogGesturesTracker.kt | 3 +- .../gestures/GesturesListener.kt | 33 +-- .../internal/monitor/AdvancedRumMonitor.kt | 4 +- .../rum/internal/monitor/DatadogRumMonitor.kt | 8 +- .../domain/scope/RumActionScopeTest.kt | 8 +- .../internal/domain/scope/RumViewScopeTest.kt | 49 +++- .../gestures/AbstractGesturesListenerTest.kt | 6 + .../GesturesListenerScrollSwipeTest.kt | 26 +- .../gestures/GesturesListenerTapTest.kt | 239 +++++++++++++----- .../internal/monitor/DatadogRumMonitorTest.kt | 7 +- 22 files changed, 543 insertions(+), 147 deletions(-) create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapViewKey.kt create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/TapTargetUtils.kt create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/HeatmapViewKeyTest.kt create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/TapTargetUtilsTest.kt diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 505034398a..79960f4779 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -71,6 +71,8 @@ interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry 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 66c47cc41f..fb9890dfbb 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -154,12 +154,20 @@ public abstract interface class com/datadog/android/internal/heatmaps/HeatmapIde 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 fbef25d881..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 [System.identityHashCode] 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 [viewIdentityHash], or null if + * 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 viewIdentityHash the identity hash of the tapped view (`System.identityHashCode(view).toLong()`). + * @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(viewIdentityHash: 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/HeatmapIdentifierStore.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierStore.kt index ea44faa05b..4af741603f 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 @@ -37,9 +37,9 @@ internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry { } } - override fun getHeatmapIdentifier(viewIdentityHash: Long, currentScreenName: String): HeatmapIdentifier? { + override fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier? { return lock.read { - if (snapshotScreenName == currentScreenName) identifiers[viewIdentityHash] 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..12d737caaa --- /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 assertion relies on the four mock objects having distinct identity hashes. + // Two mocks could theoretically collide (probability < 1/2^32 per run), which would make + // the assertion trivially true but for the wrong reason. This 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/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 790a69b967..c2a1dd0640 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 @@ -180,7 +180,7 @@ internal class RumFeature( internal var displayInfoProvider: InfoProvider = NoOpDisplayInfoProvider() internal val rumContextUpdateReceivers = mutableSetOf() internal var insightsCollector: InsightsCollector = NoOpInsightsCollector() - override val heatmapIdentifierRegistry: HeatmapIdentifierRegistry by lazy { HeatmapIdentifierRegistry.create() } + override val heatmapIdentifierRegistry: HeatmapIdentifierRegistry = HeatmapIdentifierRegistry.create() private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) } internal var rumAppStartupDetector: RumAppStartupDetector? = null 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 c03cb3d1ee..6363fa35b7 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 @@ -277,8 +277,6 @@ internal class RumActionScope( datadogContext, rumContext.viewId.orEmpty() ) - val ddAction = buildDdAction(heatmapData) - insightsCollector.onAction() ActionEvent( date = eventTimestamp, @@ -357,7 +355,16 @@ internal class RumActionScope( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), - action = ddAction + action = heatmapData?.let { + ActionEvent.DdAction( + position = ActionEvent.Position(x = it.positionX, y = it.positionY), + target = ActionEvent.DdActionTarget( + permanentId = it.targetIdentity, + width = it.targetWidth, + height = it.targetHeight + ) + ) + } ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, @@ -382,18 +389,6 @@ internal class RumActionScope( sent = true } - private fun buildDdAction(heatmapData: HeatmapActionData?): ActionEvent.DdAction? { - heatmapData ?: return null - return ActionEvent.DdAction( - position = ActionEvent.Position(x = heatmapData.positionX, y = heatmapData.positionY), - target = ActionEvent.DdActionTarget( - permanentId = heatmapData.targetIdentity, - width = heatmapData.targetWidth, - height = heatmapData.targetHeight - ) - ) - } - // endregion companion object { 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 fc2c62efe9..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,9 +41,9 @@ internal sealed class RumRawEvent { val type: RumActionType, val name: String, val waitForStop: Boolean, - val attributes: Map, + val heatmapData: HeatmapActionData? = null, override val eventTime: Time = Time(), - val heatmapData: HeatmapActionData? = null + 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/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 270c226b7e..e6c2bab323 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.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate @@ -105,7 +106,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 dbdd0a8c6e..e18b9a7910 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,11 +13,11 @@ 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.Feature import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.heatmaps.NoOpHeatmapIdentifierRegistry +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 @@ -34,10 +34,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(), @@ -54,6 +55,8 @@ internal class GesturesListener( private var onTouchDownXPos = 0f private var onTouchDownYPos = 0f + private val tapLocationBuffer = IntArray(2) + // region GesturesListener init { @@ -278,16 +281,18 @@ internal class GesturesListener( if (view.isAttachedToWindow) { resolveHeatmapIdentifier(view)?.let { identity -> - val locationInWindow = IntArray(2) - @Suppress("UnsafeThirdPartyFunctionCall") // locationInWindow is non-null with exactly 2 elements - view.getLocationInWindow(locationInWindow) + @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( targetIdentity = identity, - positionX = (touchX - locationInWindow[0]).toLong(), - positionY = (touchY - locationInWindow[1]).toLong(), - targetWidth = view.width.toLong(), - targetHeight = view.height.toLong() + positionX = ((touchX - tapLocationBuffer[0]) / density).roundToLong(), + positionY = ((touchY - tapLocationBuffer[1]) / density).roundToLong(), + targetWidth = (view.width / density).roundToLong(), + targetHeight = (view.height / density).roundToLong() ) } } @@ -300,8 +305,8 @@ internal class GesturesListener( (rumMonitor as? AdvancedRumMonitor)?.addActionWithHeatmap( RumActionType.TAP, targetName, - attributes, - heatmapData + heatmapData, + attributes ) ?: rumMonitor.addAction(RumActionType.TAP, targetName, attributes) } @@ -336,14 +341,12 @@ internal class GesturesListener( val screenName = currentScreenName() ?: return null return heatmapIdentifierRegistry - .getHeatmapIdentifier(System.identityHashCode(view).toLong(), screenName) + .getHeatmapIdentifier(heatmapViewKey(view), screenName) ?.rawValue } private fun currentScreenName(): String? { - val featureSdkCore = sdkCore as? FeatureSdkCore ?: return null - return featureSdkCore - .getFeatureContext(Feature.RUM_FEATURE_NAME)[RumContext.VIEW_URL] as? String + return sdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)[RumContext.VIEW_URL] as? String } private fun resolveGestureDirection(endEvent: MotionEvent): String { 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 1e7abbc362..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 @@ -33,8 +33,8 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor { fun addActionWithHeatmap( type: RumActionType, name: String, - attributes: Map, - heatmapData: HeatmapActionData? + heatmapData: HeatmapActionData?, + attributes: Map ) fun sendWebViewEvent() 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 e1f6dbce42..2553f464c8 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 @@ -214,8 +214,8 @@ internal class DatadogRumMonitor( override fun addActionWithHeatmap( type: RumActionType, name: String, - attributes: Map, - heatmapData: HeatmapActionData? + heatmapData: HeatmapActionData?, + attributes: Map ) { val eventTime = getEventTime(attributes) handleEvent( @@ -223,9 +223,9 @@ internal class DatadogRumMonitor( type = type, name = name, waitForStop = false, - attributes = attributes.toMap(), + heatmapData = heatmapData, eventTime = eventTime, - heatmapData = heatmapData + attributes = attributes.toMap() ) ) } 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 e71a3df9b8..d1a141849f 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 @@ -3041,10 +3041,10 @@ internal class RumActionScopeTest { val result = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) fakeEvent = RumRawEvent.StartAction( - RumActionType.TAP, - name, - false, - emptyMap(), + type = RumActionType.TAP, + name = name, + waitForStop = false, + attributes = emptyMap(), eventTime = timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) ) val result2 = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) 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..1899a25857 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 @@ -2864,7 +2864,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, @@ -2897,7 +2902,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 +2946,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 +2989,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 +3070,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 +3156,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 +3233,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) 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 248e607939..cedd7985cd 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 @@ -57,6 +58,8 @@ internal abstract class AbstractGesturesListenerTest { @Mock lateinit var mockResources: Resources + val fakeDisplayMetrics = DisplayMetrics() + @Mock lateinit var mockWindow: Window @@ -71,6 +74,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) } @@ -121,6 +126,7 @@ internal abstract class AbstractGesturesListenerTest { 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 15faa77594..b327431e51 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 @@ -27,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 @@ -38,11 +39,10 @@ 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.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -425,18 +425,17 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() testedListener.onUp(endUpEvent) // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), - argThat { attributes -> - val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == - expectedStartAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] - val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == - expectedStartAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] - classMatches && resourceMatches - }, - anyOrNull() + 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) } @@ -1288,12 +1287,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() any(), any() ) - verify(rumMonitor.mockInstance as AdvancedRumMonitor, never()).addActionWithHeatmap( - any(), - any(), - any(), - anyOrNull() - ) + verifyNoMoreInteractions(rumMonitor.mockInstance) } // endregion 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 dc432f1136..51f8c57c2f 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 @@ -11,17 +11,20 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.ViewParent import android.view.Window import androidx.compose.ui.platform.ComposeView import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.internal.heatmaps.HeatmapIdentifier +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.RumContext +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 @@ -42,7 +45,7 @@ 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.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.isNull @@ -52,6 +55,7 @@ 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), @@ -400,8 +404,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(targetName), - eq(emptyMap()), - isNull() + isNull(), + eq(emptyMap()) ) } @@ -590,18 +594,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), - argThat { attributes -> - val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == - expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] - val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == - expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] - classMatches && resourceMatches - }, - anyOrNull() + 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 @@ -662,18 +665,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), - argThat { attributes -> - val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == - expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] - val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == - expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] - classMatches && resourceMatches - }, - anyOrNull() + 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 @@ -722,18 +724,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(fakeCustomTargetName), - argThat { attributes -> - val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == - expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] - val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == - expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] - classMatches && resourceMatches - }, - anyOrNull() + 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 @@ -781,18 +782,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), - argThat { attributes -> - val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == - expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] - val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == - expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] - classMatches && resourceMatches - }, - anyOrNull() + 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 @@ -800,6 +800,9 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { 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) @@ -820,16 +823,16 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { forge = forge, clickable = true ) - // Override the default mock-view location with our forged values. whenever(validTarget.getLocationInWindow(any())).doAnswer { invocation -> val array = invocation.arguments[0] as IntArray array[0] = targetX array[1] = targetY null } - // Ensure the view dimensions are large enough for the hit test to pass. - whenever(validTarget.width).thenReturn(touchOffsetX + 1) - whenever(validTarget.height).thenReturn(touchOffsetY + 1) + 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, @@ -843,11 +846,13 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { mockResourcesForTarget(validTarget, expectedResourceName) val fakeScreenName = forge.anAlphabeticalString() val fakeViewIdentity = forge.anAlphabeticalString() + val fakeParent: ViewParent = mock() + whenever(validTarget.parent).thenReturn(fakeParent) whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) .thenReturn(mapOf(RumContext.VIEW_URL to fakeScreenName)) whenever( mockHeatmapIdentifierRegistry.getHeatmapIdentifier( - System.identityHashCode(validTarget).toLong(), + heatmapViewKey(validTarget), fakeScreenName ) ).thenReturn(HeatmapIdentifier(fakeViewIdentity)) @@ -864,19 +869,23 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { testedListener.onSingleTapUp(mockEvent) // Then - val expectedXInTarget = (touchX - targetX).toLong() - val expectedYInTarget = (touchY - targetY).toLong() + 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(""), - any(), - argThat { heatmapData -> - heatmapData != null && - heatmapData.targetIdentity == fakeViewIdentity && - heatmapData.positionX == expectedXInTarget && - heatmapData.positionY == expectedYInTarget - } + heatmapCaptor.capture(), + any() ) + val capturedHeatmap = heatmapCaptor.firstValue + assertThat(capturedHeatmap.targetIdentity).isEqualTo(fakeViewIdentity) + assertThat(capturedHeatmap.positionX).isEqualTo(expectedXInTarget) + assertThat(capturedHeatmap.positionY).isEqualTo(expectedYInTarget) + assertThat(capturedHeatmap.targetWidth).isEqualTo(expectedTargetWidth) + assertThat(capturedHeatmap.targetHeight).isEqualTo(expectedTargetHeight) } @Test @@ -893,7 +902,6 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { forge = forge, clickable = true ) - // Mock view as not attached to window whenever(validTarget.isAttachedToWindow).thenReturn(false) mockDecorView = mockDecorView( @@ -920,14 +928,120 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { 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 + fun `M not include heatmap attributes W tap { screen name not available }`( + 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 + ) + 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) + whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) + .thenReturn(emptyMap()) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + ) + + // 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 + fun `M not include heatmap attributes W tap { view not in heatmap registry }`( + 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 + ) + 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) + val fakeScreenName = forge.anAlphabeticalString() + whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) + .thenReturn(mapOf(RumContext.VIEW_URL to fakeScreenName)) + whenever(mockHeatmapIdentifierRegistry.getHeatmapIdentifier(any(), any())) + .thenReturn(null) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + val attributesCaptor = argumentCaptor>() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), - argThat { attributes -> - attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == validTarget.javaClass.canonicalName - }, - isNull() + isNull(), + attributesCaptor.capture() ) + assertThat(attributesCaptor.firstValue[RumAttributes.ACTION_TARGET_CLASS_NAME]) + .isEqualTo(validTarget.javaClass.canonicalName) } @Test @@ -1054,8 +1168,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(fakeCustomTargetName), - eq(fakeAttributes), - isNull() + isNull(), + eq(fakeAttributes) ) } @@ -1107,16 +1221,17 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { expectedTargetName: String, expectedResourceName: String ) { + 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() + 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 e37365f0d2..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 @@ -522,7 +522,12 @@ internal class DatadogRumMonitorTest { @Forgery fakeHeatmapData: HeatmapActionData ) { // When - testedMonitor.addActionWithHeatmap(type, name, fakeAttributes, fakeHeatmapData) + testedMonitor.addActionWithHeatmap( + type = type, + name = name, + heatmapData = fakeHeatmapData, + attributes = fakeAttributes + ) // Then argumentCaptor { From 97d13353320ec3f5f9dff8b30442a173ea427d48 Mon Sep 17 00:00:00 2001 From: jonathanmos <48201295+jonathanmos@users.noreply.github.com> Date: Sun, 31 May 2026 14:10:17 +0300 Subject: [PATCH 3/4] PANA-5681: Defer heatmap identifier resolution to RumActionScope --- .../android/rum/internal/RumFeature.kt | 15 +- .../domain/scope/HeatmapActionData.kt | 2 +- .../internal/domain/scope/RumActionScope.kt | 25 ++- .../gestures/DatadogGesturesTracker.kt | 7 +- .../gestures/GesturesListener.kt | 47 ++--- .../domain/scope/RumActionScopeTest.kt | 74 ++++++- .../gestures/AbstractGesturesListenerTest.kt | 4 - .../gestures/DatadogGesturesTrackerTest.kt | 16 +- .../GesturesListenerScrollSwipeTest.kt | 54 ++--- .../gestures/GesturesListenerTapTest.kt | 186 +++--------------- .../forge/HeatmapActionDataForgeryFactory.kt | 2 +- 11 files changed, 157 insertions(+), 275 deletions(-) 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 c2a1dd0640..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 @@ -241,8 +241,7 @@ internal class RumFeature( configuration.interactionPredicate, composeActionTrackingStrategy = configuration.composeActionTrackingStrategy, buildSdkVersionProvider, - sdkCore.internalLogger, - heatmapIdentifierRegistry + sdkCore.internalLogger ) } else { NoOpUserActionTrackingStrategy() @@ -867,16 +866,14 @@ internal class RumFeature( interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, buildSdkVersionProvider: BuildSdkVersionProvider, - internalLogger: InternalLogger, - heatmapIdentifierRegistry: HeatmapIdentifierRegistry + internalLogger: InternalLogger ): UserActionTrackingStrategy { val gesturesTracker = provideGestureTracker( customProviders = touchTargetExtraAttributesProviders, interactionPredicate = interactionPredicate, composeActionTrackingStrategy = composeActionTrackingStrategy, - internalLogger = internalLogger, - heatmapIdentifierRegistry = heatmapIdentifierRegistry + internalLogger = internalLogger ) return if (buildSdkVersionProvider.isAtLeastQ) { UserActionTrackingStrategyApi29(gesturesTracker) @@ -889,8 +886,7 @@ internal class RumFeature( customProviders: Array, interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, - internalLogger: InternalLogger, - heatmapIdentifierRegistry: HeatmapIdentifierRegistry + internalLogger: InternalLogger ): DatadogGesturesTracker { val defaultProviders = arrayOf(JetpackViewAttributesProvider()) val providers = customProviders + defaultProviders @@ -898,8 +894,7 @@ internal class RumFeature( providers, interactionPredicate, composeActionsTrackingStrategy = composeActionTrackingStrategy, - internalLogger = internalLogger, - heatmapIdentifierRegistry = heatmapIdentifierRegistry + 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 index df600988ab..b8fd60cfbc 100644 --- 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 @@ -7,7 +7,7 @@ package com.datadog.android.rum.internal.domain.scope internal data class HeatmapActionData( - val targetIdentity: String, + val viewKey: Long, val positionX: Long, val positionY: Long, val targetWidth: 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 6363fa35b7..8759760235 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 @@ -9,11 +9,13 @@ package com.datadog.android.rum.internal.domain.scope import androidx.annotation.WorkerThread import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.EventWriteScope +import com.datadog.android.api.feature.Feature import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver +import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector @@ -355,15 +357,22 @@ internal class RumActionScope( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), - action = heatmapData?.let { - ActionEvent.DdAction( - position = ActionEvent.Position(x = it.positionX, y = it.positionY), - target = ActionEvent.DdActionTarget( - permanentId = it.targetIdentity, - width = it.targetWidth, - height = it.targetHeight + action = heatmapData?.let { data -> + val identity = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) + ?.unwrap() + ?.heatmapIdentifierRegistry + ?.getHeatmapIdentifier(data.viewKey, rumContext.viewUrl.orEmpty()) + ?.rawValue + identity?.let { permanentId -> + ActionEvent.DdAction( + position = ActionEvent.Position(x = data.positionX, y = data.positionY), + target = ActionEvent.DdActionTarget( + permanentId = permanentId, + width = data.targetWidth, + height = data.targetHeight + ) ) - ) + } } ), connectivity = networkInfo.toActionConnectivity(), 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 e6c2bab323..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 @@ -11,7 +11,6 @@ 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.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.ViewAttributesProvider @@ -21,8 +20,7 @@ internal class DatadogGesturesTracker( internal val targetAttributesProviders: Array, internal val interactionPredicate: InteractionPredicate, private val composeActionsTrackingStrategy: ActionTrackingStrategy, - private val internalLogger: InternalLogger, - private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry + private val internalLogger: InternalLogger ) : GesturesTracker { // region GesturesTracker @@ -112,8 +110,7 @@ internal class DatadogGesturesTracker( interactionPredicate = interactionPredicate, contextRef = WeakReference(context), composeActionTrackingStrategy = composeActionsTrackingStrategy, - internalLogger = internalLogger, - heatmapIdentifierRegistry = heatmapIdentifierRegistry + internalLogger = internalLogger ) ) } 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 e18b9a7910..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,15 +13,11 @@ import android.view.ViewGroup import android.view.Window import androidx.core.view.isVisible import com.datadog.android.api.InternalLogger -import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.NoOpHeatmapIdentifierRegistry 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.RumContext 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 @@ -45,8 +41,7 @@ internal class GesturesListener( private val contextRef: Reference, private val internalLogger: InternalLogger, private val composeActionTrackingStrategy: ActionTrackingStrategy = NoOpActionTrackingStrategy(), - private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy(), - private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry = NoOpHeatmapIdentifierRegistry() + private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy() ) : GestureListenerCompat() { private var scrollEventType: RumActionType? = null @@ -280,21 +275,19 @@ internal class GesturesListener( addViewAttributes(view, attributes) if (view.isAttachedToWindow) { - resolveHeatmapIdentifier(view)?.let { identity -> - @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( - targetIdentity = identity, - positionX = ((touchX - tapLocationBuffer[0]) / density).roundToLong(), - positionY = ((touchY - tapLocationBuffer[1]) / density).roundToLong(), - targetWidth = (view.width / density).roundToLong(), - targetHeight = (view.height / density).roundToLong() - ) - } + @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 { @@ -337,18 +330,6 @@ internal class GesturesListener( } } - private fun resolveHeatmapIdentifier(view: View): String? { - val screenName = currentScreenName() ?: return null - - return heatmapIdentifierRegistry - .getHeatmapIdentifier(heatmapViewKey(view), screenName) - ?.rawValue - } - - private fun currentScreenName(): String? { - return sdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)[RumContext.VIEW_URL] as? String - } - 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/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 d1a141849f..f72ed27562 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 @@ -10,9 +10,13 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.feature.EventWriteScope +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureScope 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 @@ -20,6 +24,7 @@ import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.assertj.ActionEventAssert.Companion.assertThat import com.datadog.android.rum.internal.FeaturesContextResolver +import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector @@ -3163,12 +3168,22 @@ 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 mockRumFeatureScope: FeatureScope = mock() + val mockRumFeature: RumFeature = mock() + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + whenever(rumMonitor.mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope + whenever(mockRumFeatureScope.unwrap()) doReturn mockRumFeature + whenever(mockRumFeature.heatmapIdentifierRegistry) doReturn mockHeatmapRegistry + whenever(mockHeatmapRegistry.getHeatmapIdentifier(fakeViewKey, fakeParentContext.viewUrl.orEmpty())) + .thenReturn(HeatmapIdentifier(fakeViewIdentity)) + testedScope = RumActionScope( parentScope = mockParentScope, sdkCore = rumMonitor.mockSdkCore, @@ -3186,7 +3201,7 @@ internal class RumActionScopeTest { rumSessionTypeOverride = fakeRumSessionType, insightsCollector = mockInsightsCollector, heatmapData = HeatmapActionData( - targetIdentity = fakeViewIdentity, + viewKey = fakeViewKey, positionX = fakePosX, positionY = fakePosY, targetWidth = fakeWidth, @@ -3214,6 +3229,63 @@ internal class RumActionScopeTest { } } + @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 mockRumFeatureScope: FeatureScope = mock() + val mockRumFeature: RumFeature = mock() + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + whenever(rumMonitor.mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope + whenever(mockRumFeatureScope.unwrap()) doReturn mockRumFeature + whenever(mockRumFeature.heatmapIdentifierRegistry) doReturn mockHeatmapRegistry + 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 + ) + ) + + // 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 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 cedd7985cd..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 @@ -15,7 +15,6 @@ import android.view.View import android.view.Window import com.datadog.android.Datadog import com.datadog.android.api.InternalLogger -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.rum.utils.forge.Configurator import com.datadog.tools.unit.annotations.TestConfigurationsProvider @@ -66,9 +65,6 @@ internal abstract class AbstractGesturesListenerTest { @Mock lateinit var mockInternalLogger: InternalLogger - @Mock - lateinit var mockHeatmapIdentifierRegistry: HeatmapIdentifierRegistry - // region Tests @BeforeEach diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt index d05c77dc0a..008823d453 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt @@ -11,7 +11,6 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate @@ -66,9 +65,6 @@ internal class DatadogGesturesTrackerTest : ObjectTest() @Mock lateinit var mockSdkCore: FeatureSdkCore - @Mock - lateinit var mockHeatmapIdentifierRegistry: HeatmapIdentifierRegistry - @BeforeEach fun `set up`() { testedTracker = @@ -76,8 +72,7 @@ internal class DatadogGesturesTrackerTest : ObjectTest() emptyArray(), mockInteractionPredicate, mockActionTrackingStrategy, - mockInternalLogger, - mockHeatmapIdentifierRegistry + mockInternalLogger ) whenever(mockActivity.window).thenReturn(mockWindow) whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger @@ -88,8 +83,7 @@ internal class DatadogGesturesTrackerTest : ObjectTest() forge.aList { StubViewAttributesProvider(anAlphabeticalString()) }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger, - mockHeatmapIdentifierRegistry + mockInternalLogger ) } @@ -104,8 +98,7 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger, - mockHeatmapIdentifierRegistry + mockInternalLogger ) } @@ -120,8 +113,7 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), StubInteractionPredicate(), mockActionTrackingStrategy, - mockInternalLogger, - mockHeatmapIdentifierRegistry + mockInternalLogger ) } 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 b327431e51..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 @@ -112,8 +112,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -184,8 +183,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -255,8 +253,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -339,8 +336,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -413,8 +409,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -470,8 +465,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -524,8 +518,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -588,8 +581,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + composeActionTrackingStrategy = mockComposeActionTrackingStrategy ) // When @@ -663,8 +655,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -742,8 +733,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -803,8 +793,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -867,8 +856,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -931,8 +919,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -1009,8 +996,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -1070,8 +1056,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) testedListener.onUp(startDownEvent) testedListener.onDown(endUpEvent) @@ -1116,8 +1101,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -1194,8 +1178,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + composeActionTrackingStrategy = mockComposeActionTrackingStrategy ) stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) whenever( @@ -1267,8 +1250,7 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When 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 51f8c57c2f..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 @@ -11,19 +11,15 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.view.ViewParent import android.view.Window import androidx.compose.ui.platform.ComposeView import com.datadog.android.api.InternalLogger -import com.datadog.android.api.feature.Feature -import com.datadog.android.internal.heatmaps.HeatmapIdentifier 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.RumContext 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 @@ -73,8 +69,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -144,8 +139,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -185,8 +179,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -231,8 +224,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -276,8 +268,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -314,8 +305,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -343,8 +333,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -392,8 +381,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + composeActionTrackingStrategy = mockComposeActionTrackingStrategy ) // When @@ -426,8 +414,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) val expectedResourceName = forge.anAlphabeticalString() mockResourcesForTarget(mockDecorView, expectedResourceName) @@ -469,8 +456,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -510,8 +496,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -533,8 +518,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(null), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -587,8 +571,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When testedListener.onSingleTapUp(mockEvent) @@ -658,8 +641,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When testedListener.onSingleTapUp(mockEvent) @@ -716,8 +698,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -774,8 +755,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -844,25 +824,12 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { } val expectedResourceName = forge.anAlphabeticalString() mockResourcesForTarget(validTarget, expectedResourceName) - val fakeScreenName = forge.anAlphabeticalString() - val fakeViewIdentity = forge.anAlphabeticalString() - val fakeParent: ViewParent = mock() - whenever(validTarget.parent).thenReturn(fakeParent) - whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) - .thenReturn(mapOf(RumContext.VIEW_URL to fakeScreenName)) - whenever( - mockHeatmapIdentifierRegistry.getHeatmapIdentifier( - heatmapViewKey(validTarget), - fakeScreenName - ) - ).thenReturn(HeatmapIdentifier(fakeViewIdentity)) testedListener = GesturesListener( rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -881,7 +848,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { any() ) val capturedHeatmap = heatmapCaptor.firstValue - assertThat(capturedHeatmap.targetIdentity).isEqualTo(fakeViewIdentity) + assertThat(capturedHeatmap.viewKey).isEqualTo(heatmapViewKey(validTarget)) assertThat(capturedHeatmap.positionX).isEqualTo(expectedXInTarget) assertThat(capturedHeatmap.positionY).isEqualTo(expectedYInTarget) assertThat(capturedHeatmap.targetWidth).isEqualTo(expectedTargetWidth) @@ -920,113 +887,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry - ) - - // 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 - fun `M not include heatmap attributes W tap { screen name not available }`( - 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 - ) - 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) - whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) - .thenReturn(emptyMap()) - - testedListener = GesturesListener( - rumMonitor.mockSdkCore, - WeakReference(mockWindow), - contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry - ) - - // 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 - fun `M not include heatmap attributes W tap { view not in heatmap registry }`( - 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 - ) - 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) - val fakeScreenName = forge.anAlphabeticalString() - whenever(rumMonitor.mockSdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME)) - .thenReturn(mapOf(RumContext.VIEW_URL to fakeScreenName)) - whenever(mockHeatmapIdentifierRegistry.getHeatmapIdentifier(any(), any())) - .thenReturn(null) - - testedListener = GesturesListener( - rumMonitor.mockSdkCore, - WeakReference(mockWindow), - contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -1079,8 +940,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When @@ -1141,8 +1001,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + composeActionTrackingStrategy = mockComposeActionTrackingStrategy ) whenever( @@ -1203,8 +1062,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger, - heatmapIdentifierRegistry = mockHeatmapIdentifierRegistry + internalLogger = mockInternalLogger ) // When 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 index 05075ef0cc..04904dc4ac 100644 --- 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 @@ -14,7 +14,7 @@ internal class HeatmapActionDataForgeryFactory : ForgeryFactory Date: Sun, 31 May 2026 19:13:43 +0300 Subject: [PATCH 4/4] PANA-5681: Pass HeatmapIdentifierRegistry through RUM scope chain --- .../heatmaps/HeatmapIdentifierStore.kt | 4 ++ .../internal/heatmaps/HeatmapViewKeyTest.kt | 6 +- .../kotlin/com/datadog/android/rum/Rum.kt | 3 +- .../internal/domain/scope/RumActionScope.kt | 44 ++++++------ .../domain/scope/RumApplicationScope.kt | 10 ++- .../internal/domain/scope/RumSessionScope.kt | 7 +- .../domain/scope/RumViewManagerScope.kt | 13 ++-- .../rum/internal/domain/scope/RumViewScope.kt | 19 ++++-- .../rum/internal/monitor/DatadogRumMonitor.kt | 7 +- .../domain/scope/RumActionScopeTest.kt | 67 ++++++++++++++----- .../internal/domain/scope/RumRawEventExt.kt | 11 ++- .../internal/domain/scope/RumViewScopeTest.kt | 34 +++++++++- 12 files changed, 162 insertions(+), 63 deletions(-) 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 4af741603f..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 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 index 12d737caaa..974b8adce1 100644 --- 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 @@ -78,9 +78,9 @@ internal class HeatmapViewKeyTest { // 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 assertion relies on the four mock objects having distinct identity hashes. - // Two mocks could theoretically collide (probability < 1/2^32 per run), which would make - // the assertion trivially true but for the wrong reason. This is considered acceptable. + // 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() 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/domain/scope/RumActionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt index 8759760235..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 @@ -9,13 +9,12 @@ package com.datadog.android.rum.internal.domain.scope import androidx.annotation.WorkerThread import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.EventWriteScope -import com.datadog.android.api.feature.Feature 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 -import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector @@ -47,7 +46,8 @@ internal class RumActionScope( internal val sampleRate: Float, private val rumSessionTypeOverride: RumSessionType?, private val insightsCollector: InsightsCollector, - private val heatmapData: HeatmapActionData? = null + internal val heatmapData: HeatmapActionData? = null, + private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ) : RumScope { private val inactivityThresholdNs = TimeUnit.MILLISECONDS.toNanos(inactivityThresholdMs) @@ -357,23 +357,7 @@ internal class RumActionScope( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), - action = heatmapData?.let { data -> - val identity = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) - ?.unwrap() - ?.heatmapIdentifierRegistry - ?.getHeatmapIdentifier(data.viewKey, rumContext.viewUrl.orEmpty()) - ?.rawValue - identity?.let { permanentId -> - ActionEvent.DdAction( - position = ActionEvent.Position(x = data.positionX, y = data.positionY), - target = ActionEvent.DdActionTarget( - permanentId = permanentId, - width = data.targetWidth, - height = data.targetHeight - ) - ) - } - } + action = heatmapData?.let { resolveHeatmapAction(it, rumContext.viewUrl.orEmpty()) } ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, @@ -398,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 { @@ -414,7 +412,8 @@ internal class RumActionScope( trackFrustrations: Boolean, sampleRate: Float, rumSessionTypeOverride: RumSessionType?, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null ): RumScope { return RumActionScope( parentScope = parentScope, @@ -430,7 +429,8 @@ internal class RumActionScope( sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, insightsCollector = insightsCollector, - heatmapData = event.heatmapData + 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/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 383df8ce08..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 = 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/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index 2553f464c8..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 @@ -106,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( @@ -129,7 +131,8 @@ internal class DatadogRumMonitor( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + heatmapIdentifierRegistry = heatmapIdentifierRegistry ) internal var debugListener: RumDebugListener? = null 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 f72ed27562..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 @@ -10,8 +10,6 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.feature.EventWriteScope -import com.datadog.android.api.feature.Feature -import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType @@ -24,7 +22,6 @@ import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.assertj.ActionEventAssert.Companion.assertThat import com.datadog.android.rum.internal.FeaturesContextResolver -import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector @@ -3175,12 +3172,7 @@ internal class RumActionScopeTest { @LongForgery fakePosY: Long ) { // Given - val mockRumFeatureScope: FeatureScope = mock() - val mockRumFeature: RumFeature = mock() val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() - whenever(rumMonitor.mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope - whenever(mockRumFeatureScope.unwrap()) doReturn mockRumFeature - whenever(mockRumFeature.heatmapIdentifierRegistry) doReturn mockHeatmapRegistry whenever(mockHeatmapRegistry.getHeatmapIdentifier(fakeViewKey, fakeParentContext.viewUrl.orEmpty())) .thenReturn(HeatmapIdentifier(fakeViewIdentity)) @@ -3206,7 +3198,8 @@ internal class RumActionScopeTest { positionY = fakePosY, targetWidth = fakeWidth, targetHeight = fakeHeight - ) + ), + heatmapIdentifierRegistry = mockHeatmapRegistry ) // When @@ -3238,12 +3231,7 @@ internal class RumActionScopeTest { @LongForgery fakePosY: Long ) { // Given - val mockRumFeatureScope: FeatureScope = mock() - val mockRumFeature: RumFeature = mock() val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() - whenever(rumMonitor.mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope - whenever(mockRumFeatureScope.unwrap()) doReturn mockRumFeature - whenever(mockRumFeature.heatmapIdentifierRegistry) doReturn mockHeatmapRegistry whenever(mockHeatmapRegistry.getHeatmapIdentifier(any(), any())).thenReturn(null) testedScope = RumActionScope( @@ -3268,7 +3256,56 @@ internal class RumActionScopeTest { 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 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 1899a25857..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 @@ -2891,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`( @@ -9461,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, @@ -9486,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)