diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 79960f4779..6b6cd4330b 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -1,3 +1,8 @@ +data class com.datadog.android.heatmaps.CrossPlatformHeatmapActionData + constructor(List, String, Long, Long, Long?, Long?) +interface com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider + val heatmapIdentifierRegistry: com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry +fun heatmapViewKey(android.view.View): Long interface com.datadog.android.internal.attributes.LocalAttribute enum Key constructor(String) @@ -69,10 +74,6 @@ interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry fun getHeatmapIdentifier(Long, String): HeatmapIdentifier? companion object fun create(): HeatmapIdentifierRegistry -interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider - val heatmapIdentifierRegistry: HeatmapIdentifierRegistry -fun heatmapViewKey(android.view.View): Long -fun android.view.View.isValidTapTarget(): Boolean class com.datadog.android.internal.lifecycle.ProcessLifecycleMonitor : android.app.Application.ActivityLifecycleCallbacks constructor(Callback) val activitiesResumedCounter: java.util.concurrent.atomic.AtomicInteger @@ -283,6 +284,7 @@ fun StringBuilder.appendIfNotEmpty(String) fun StringBuilder.appendIfNotEmpty(Char) fun String.toBase64(): String fun String.fromBase64(): String? +fun android.view.View.isValidTapTarget(): Boolean fun Thread.safeGetThreadId(): Long fun Thread.State.asString(): String fun Array.loggableStackTrace(): String 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 fb9890dfbb..e6e3b3bfb6 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -1,3 +1,32 @@ +public final class com/datadog/android/heatmaps/CrossPlatformHeatmapActionData { + public fun (Ljava/util/List;Ljava/lang/String;JJLjava/lang/Long;Ljava/lang/Long;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()J + public final fun component4 ()J + public final fun component5 ()Ljava/lang/Long; + public final fun component6 ()Ljava/lang/Long; + public final fun copy (Ljava/util/List;Ljava/lang/String;JJLjava/lang/Long;Ljava/lang/Long;)Lcom/datadog/android/heatmaps/CrossPlatformHeatmapActionData; + public static synthetic fun copy$default (Lcom/datadog/android/heatmaps/CrossPlatformHeatmapActionData;Ljava/util/List;Ljava/lang/String;JJLjava/lang/Long;Ljava/lang/Long;ILjava/lang/Object;)Lcom/datadog/android/heatmaps/CrossPlatformHeatmapActionData; + public fun equals (Ljava/lang/Object;)Z + public final fun getElementPath ()Ljava/util/List; + public final fun getPositionX ()J + public final fun getPositionY ()J + public final fun getTargetHeight ()Ljava/lang/Long; + public final fun getTargetWidth ()Ljava/lang/Long; + public final fun getViewUrl ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/datadog/android/heatmaps/HeatmapIdentifierRegistryProvider { + public abstract fun getHeatmapIdentifierRegistry ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry; +} + +public final class com/datadog/android/heatmaps/HeatmapViewKeyKt { + public static final fun heatmapViewKey (Landroid/view/View;)J +} + public abstract interface class com/datadog/android/internal/attributes/LocalAttribute { } @@ -150,24 +179,12 @@ public final class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegist public final fun create ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry; } -public abstract interface class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider { - public abstract fun getHeatmapIdentifierRegistry ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry; -} - -public final class com/datadog/android/internal/heatmaps/HeatmapViewKeyKt { - public static final fun heatmapViewKey (Landroid/view/View;)J -} - public final class com/datadog/android/internal/heatmaps/NoOpHeatmapIdentifierRegistry : com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry { public fun ()V public fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier; public fun setHeatmapIdentifiers (Ljava/util/Map;Ljava/lang/String;)V } -public final class com/datadog/android/internal/heatmaps/TapTargetUtilsKt { - public static final fun isValidTapTarget (Landroid/view/View;)Z -} - public final class com/datadog/android/internal/lifecycle/ProcessLifecycleMonitor : android/app/Application$ActivityLifecycleCallbacks { public fun (Lcom/datadog/android/internal/lifecycle/ProcessLifecycleMonitor$Callback;)V public final fun getActivitiesResumedCounter ()Ljava/util/concurrent/atomic/AtomicInteger; @@ -653,6 +670,10 @@ public final class com/datadog/android/internal/utils/StringExtKt { public static final fun toBase64 (Ljava/lang/String;)Ljava/lang/String; } +public final class com/datadog/android/internal/utils/TapTargetUtilsKt { + public static final fun isValidTapTarget (Landroid/view/View;)Z +} + public final class com/datadog/android/internal/utils/ThreadExtKt { public static final fun asString (Ljava/lang/Thread$State;)Ljava/lang/String; public static final fun loggableStackTrace ([Ljava/lang/StackTraceElement;)Ljava/lang/String; diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/heatmaps/CrossPlatformHeatmapActionData.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/heatmaps/CrossPlatformHeatmapActionData.kt new file mode 100644 index 0000000000..96e0ba3f8d --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/heatmaps/CrossPlatformHeatmapActionData.kt @@ -0,0 +1,29 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.heatmaps + +/** + * Heatmap data for a RUM action, used by cross-platform SDKs to correlate a tap action with + * a specific UI element. + * + * @property elementPath the path segments from the root component down to the tapped element + * (e.g. `["root", "container", "submitButton"]`), using the same naming convention as the + * cross-platform Session Replay layer so that the resulting identifier matches the wireframe. + * @property viewUrl the RUM view URL returned by `_RumInternalProxy.getCurrentViewUrl()` at tap time. + * @property positionX the x-coordinate of the tap relative to the target element, in dp. + * @property positionY the y-coordinate of the tap relative to the target element, in dp. + * @property targetWidth the width of the tapped element, in dp, or null if unavailable. + * @property targetHeight the height of the tapped element, in dp, or null if unavailable. + */ +data class CrossPlatformHeatmapActionData( + val elementPath: List, + val viewUrl: String, + val positionX: Long, + val positionY: Long, + val targetWidth: Long?, + val targetHeight: Long? +) 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/heatmaps/HeatmapIdentifierRegistryProvider.kt similarity index 58% rename from dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/heatmaps/HeatmapIdentifierRegistryProvider.kt index a59b4f43fc..cd3959f7bd 100644 --- 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/heatmaps/HeatmapIdentifierRegistryProvider.kt @@ -4,13 +4,19 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.internal.heatmaps +package com.datadog.android.heatmaps + +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry /** * 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. */ + /** + * The registry that maps view identity keys to their stable [HeatmapIdentifier]s for this + * feature's current screen. Session Replay writes identifiers into this registry during + * each traversal; the RUM layer reads from it when a tap action is sent. + */ val heatmapIdentifierRegistry: HeatmapIdentifierRegistry } 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/heatmaps/HeatmapViewKey.kt similarity index 96% rename from dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapViewKey.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/heatmaps/HeatmapViewKey.kt index 714d1e0401..44aa5241b2 100644 --- 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/heatmaps/HeatmapViewKey.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.internal.heatmaps +package com.datadog.android.heatmaps import android.view.View diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifier.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifier.kt index 7e8059ffaa..d460b0d313 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifier.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/HeatmapIdentifier.kt @@ -7,6 +7,7 @@ package com.datadog.android.internal.heatmaps import com.datadog.android.internal.utils.toHexString +import java.io.UnsupportedEncodingException import java.net.URLEncoder import java.security.MessageDigest import java.security.NoSuchAlgorithmException @@ -29,6 +30,11 @@ data class HeatmapIdentifier(val rawValue: String) { * Creates a [HeatmapIdentifier] for the given UI element by hashing its canonical path, * or null if hashing fails. * + * All string parameters must be **raw, unencoded** values — this function applies + * percent-encoding internally. Cross-platform SDKs must pass raw strings (e.g. the + * literal screen name returned by `getCurrentViewUrl()`, not a pre-encoded version) + * so that the identifier is always produced by the same Android encoding logic. + * * @param elementPath the path segments from the root view down to this element, * where each segment identifies a view in the hierarchy (e.g. its resource entry name). * @param screenName the current RUM view URL, used to scope identifiers to a screen. @@ -37,7 +43,6 @@ data class HeatmapIdentifier(val rawValue: String) { * @param appPackageName the application package name (e.g. `com.example.app`), * used to globally namespace identifiers across apps. * @param onHashingFailure invoked with the caught exception if hashing fails. - * Callers should forward this to telemetry. */ fun create( elementPath: List, @@ -45,8 +50,18 @@ data class HeatmapIdentifier(val rawValue: String) { appPackageName: String, onHashingFailure: (Throwable) -> Unit = {} ): HeatmapIdentifier? { - val path = canonicalPath(elementPath, screenName, appPackageName) - return sha256Hex(path, onHashingFailure)?.let { HeatmapIdentifier(it) } + return try { + val path = canonicalPath(elementPath, screenName, appPackageName) + HeatmapIdentifier(sha256Hex(path)) + } catch (e: NoSuchAlgorithmException) { + // Unreachable on Android: SHA-256 is always available. + onHashingFailure(e) + null + } catch (e: UnsupportedEncodingException) { + // Thrown by URLEncoder.encode if UTF-8 is unavailable (unreachable on Android). + onHashingFailure(e) + null + } } private fun canonicalPath( @@ -63,21 +78,15 @@ data class HeatmapIdentifier(val rawValue: String) { } } - @Suppress("UnsafeThirdPartyFunctionCall") // UTF-8 is always available on Android; cannot throw + @Suppress("UnsafeThirdPartyFunctionCall") // UnsupportedEncodingException caught in create() private fun escape(input: String): String = URLEncoder.encode(input, "UTF-8") - private fun sha256Hex(input: String, onHashingFailure: (Throwable) -> Unit): String? { - return try { - val digest = MessageDigest.getInstance("SHA-256") - // digest(ByteArray) does not throw: the input is non-null (toByteArray always - // returns a non-null array) and DigestException is only declared on the - // offset/length overload, not this one. - @Suppress("UnsafeThirdPartyFunctionCall") - digest.digest(input.toByteArray(Charsets.UTF_8)).toHexString() - } catch (e: NoSuchAlgorithmException) { - onHashingFailure(e) - null - } + @Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is always available on Android + private fun sha256Hex(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + // digest(ByteArray): input is non-null, DigestException not thrown on this overload. + @Suppress("UnsafeThirdPartyFunctionCall") + return digest.digest(input.toByteArray(Charsets.UTF_8)).toHexString() } } } 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/utils/TapTargetUtils.kt similarity index 94% rename from dd-sdk-android-internal/src/main/java/com/datadog/android/internal/heatmaps/TapTargetUtils.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/TapTargetUtils.kt index 20f0991b8a..559b409838 100644 --- 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/utils/TapTargetUtils.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.internal.heatmaps +package com.datadog.android.internal.utils import android.view.View 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/heatmaps/HeatmapViewKeyTest.kt similarity index 98% rename from dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/HeatmapViewKeyTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/android/heatmaps/HeatmapViewKeyTest.kt index 974b8adce1..c6f4f85c76 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/heatmaps/HeatmapViewKeyTest.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.internal.heatmaps +package com.datadog.android.heatmaps import android.view.View import android.view.ViewParent 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/utils/TapTargetUtilsTest.kt similarity index 98% rename from dd-sdk-android-internal/src/test/java/com/datadog/android/internal/heatmaps/TapTargetUtilsTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/android/internal/utils/TapTargetUtilsTest.kt index afd37a2d46..20c79b2124 100644 --- 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/utils/TapTargetUtilsTest.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.internal.heatmaps +package com.datadog.android.internal.utils import android.view.View import com.datadog.android.internal.forge.Configurator diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index a6e090cb4e..178e71c94c 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -172,6 +172,8 @@ enum com.datadog.android.rum.RumSessionType - SYNTHETICS - USER class com.datadog.android.rum._RumInternalProxy + fun addActionWithHeatmap(RumActionType, String, com.datadog.android.heatmaps.CrossPlatformHeatmapActionData, Map) + fun getCurrentViewUrl(): String? fun addLongTask(Long, String) fun updatePerformanceMetric(RumPerformanceMetric, Double) fun updateExternalRefreshRate(Double) diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index f991d80b43..a556a50761 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -272,8 +272,10 @@ public final class com/datadog/android/rum/RumSessionType : java/lang/Enum { public final class com/datadog/android/rum/_RumInternalProxy { public static final field Companion Lcom/datadog/android/rum/_RumInternalProxy$Companion; + public final fun addActionWithHeatmap (Lcom/datadog/android/rum/RumActionType;Ljava/lang/String;Lcom/datadog/android/heatmaps/CrossPlatformHeatmapActionData;Ljava/util/Map;)V public final fun addLongTask (JLjava/lang/String;)V public final fun enableJankStatsTracking (Landroid/app/Activity;)V + public final fun getCurrentViewUrl ()Ljava/lang/String; public final fun setInternalViewAttribute (Ljava/lang/String;Ljava/lang/Object;)V public final fun setSyntheticsAttribute (Ljava/lang/String;Ljava/lang/String;)V public final fun setSyntheticsAttributeFromIntent (Landroid/content/Intent;)V 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 c8e6467287..b155c5aec5 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 @@ -137,6 +137,7 @@ object Rum { return DatadogRumMonitor( applicationId = rumFeature.applicationId, sdkCore = sdkCore, + appPackageName = rumFeature.appContext.packageName, sessionEndedMetricDispatcher = sessionEndedMetricDispatcher, sessionSampler = sessionSampler, writer = rumFeature.dataWriter, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt index b91638cc20..e80a48ffad 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt @@ -10,6 +10,7 @@ import android.app.Activity import android.content.Intent import com.datadog.android.api.InternalLogger import com.datadog.android.event.EventMapper +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.NetworkInstrumentation.LibraryType import com.datadog.android.lint.InternalApi import com.datadog.android.rum.RumConfiguration.Builder @@ -42,6 +43,17 @@ import com.datadog.android.telemetry.model.TelemetryConfigurationEvent class _RumInternalProxy internal constructor(private val rumMonitor: AdvancedRumMonitor) { @Volatile private var handledSyntheticsAttribute = false + fun addActionWithHeatmap( + type: RumActionType, + name: String, + crossPlatformHeatmapActionData: CrossPlatformHeatmapActionData, + attributes: Map + ) { + rumMonitor.addActionWithHeatmapAttributes(type, name, crossPlatformHeatmapActionData, attributes) + } + + fun getCurrentViewUrl(): String? = rumMonitor.getCurrentViewUrl() + fun addLongTask(durationNs: Long, target: String) { rumMonitor.addLongTask(durationNs, target) } 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 835c54bbd8..659f0f4856 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 @@ -33,9 +33,9 @@ import com.datadog.android.core.internal.utils.scheduleSafe import com.datadog.android.event.EventMapper import com.datadog.android.event.MapperSerializer import com.datadog.android.event.NoOpEventMapper +import com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider 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 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 74186129ac..f8df7c7fb7 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 @@ -17,6 +17,7 @@ import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.heatmaps.HeatmapActionResolver import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.monitor.StorageEvent import com.datadog.android.rum.internal.toAction @@ -46,8 +47,7 @@ internal class RumActionScope( internal val sampleRate: Float, private val rumSessionTypeOverride: RumSessionType?, private val insightsCollector: InsightsCollector, - internal val heatmapData: HeatmapActionData? = null, - private val heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = null + internal val heatmapResolver: HeatmapActionResolver? = null ) : RumScope { private val inactivityThresholdNs = TimeUnit.MILLISECONDS.toNanos(inactivityThresholdMs) @@ -357,7 +357,7 @@ internal class RumActionScope( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), - action = heatmapData?.let { resolveHeatmapAction(it, rumContext.viewUrl.orEmpty()) } + action = heatmapResolver?.resolve(rumContext.viewUrl.orEmpty()) ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, @@ -382,20 +382,6 @@ 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 { @@ -429,8 +415,19 @@ internal class RumActionScope( sampleRate = sampleRate, rumSessionTypeOverride = rumSessionTypeOverride, insightsCollector = insightsCollector, - heatmapData = event.heatmapData, - heatmapIdentifierRegistry = heatmapIdentifierRegistry + heatmapResolver = when { + event.crossPlatformHeatmapActionData != null && event.appPackageName != null -> + HeatmapActionResolver.CrossPlatform( + data = event.crossPlatformHeatmapActionData, + appPackageName = event.appPackageName, + logger = sdkCore.internalLogger + ) + event.nativeHeatmapActionData != null -> HeatmapActionResolver.Native( + data = event.nativeHeatmapActionData, + registry = heatmapIdentifierRegistry + ) + else -> null + } ) } } 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 ef3764d25d..4f2dbd9169 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 @@ -7,6 +7,7 @@ package com.datadog.android.rum.internal.domain.scope import com.datadog.android.core.feature.event.ThreadDump +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumErrorSource @@ -16,6 +17,7 @@ import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.internal.RumErrorSourceType import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.domain.event.ResourceTiming +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.android.rum.model.ActionEvent @@ -41,7 +43,9 @@ internal sealed class RumRawEvent { val type: RumActionType, val name: String, val waitForStop: Boolean, - val heatmapData: HeatmapActionData? = null, + val nativeHeatmapActionData: NativeHeatmapActionData? = null, + val crossPlatformHeatmapActionData: CrossPlatformHeatmapActionData? = null, + val appPackageName: String? = null, override val eventTime: Time = Time(), val attributes: Map = emptyMap() ) : RumRawEvent() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/heatmaps/HeatmapActionResolver.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/heatmaps/HeatmapActionResolver.kt new file mode 100644 index 0000000000..87d5be1c9e --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/heatmaps/HeatmapActionResolver.kt @@ -0,0 +1,97 @@ +/* + * 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.heatmaps + +import com.datadog.android.api.InternalLogger +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData +import com.datadog.android.internal.heatmaps.HeatmapIdentifier +import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry +import com.datadog.android.rum.model.ActionEvent +import java.util.Locale + +internal sealed class HeatmapActionResolver { + + abstract fun resolve(viewUrl: String): ActionEvent.DdAction? + + internal class Native( + private val data: NativeHeatmapActionData, + private val registry: HeatmapIdentifierRegistry? + ) : HeatmapActionResolver() { + override fun resolve(viewUrl: String): ActionEvent.DdAction? { + val permanentId = registry + ?.getHeatmapIdentifier(data.viewKey, viewUrl) + ?.rawValue ?: return null + return buildDdAction(permanentId, data.positionX, data.positionY, data.targetWidth, data.targetHeight) + } + } + + internal class CrossPlatform( + private val data: CrossPlatformHeatmapActionData, + private val appPackageName: String, + private val logger: InternalLogger + ) : HeatmapActionResolver() { + @Suppress("ReturnCount") + override fun resolve(viewUrl: String): ActionEvent.DdAction? { + if (data.viewUrl != viewUrl) { + logger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { HEATMAP_VIEW_URL_MISMATCH.format(Locale.US, data.viewUrl, viewUrl) } + ) + return null + } + if (data.elementPath.isEmpty()) { + logger.log(InternalLogger.Level.ERROR, InternalLogger.Target.USER, { HEATMAP_EMPTY_ELEMENT_PATH }) + return null + } + val identifier = HeatmapIdentifier.create( + elementPath = data.elementPath, + screenName = viewUrl, + appPackageName = appPackageName, + onHashingFailure = { throwable -> + logger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { HEATMAP_IDENTIFIER_HASH_FAILURE }, + throwable + ) + } + ) ?: return null + return buildDdAction( + identifier.rawValue, + data.positionX, + data.positionY, + data.targetWidth, + data.targetHeight + ) + } + } + + companion object { + internal const val HEATMAP_VIEW_URL_MISMATCH = + "Heatmap view URL mismatch (recorded \"%s\", current \"%s\") — heatmap data dropped." + internal const val HEATMAP_EMPTY_ELEMENT_PATH = + "CrossPlatformHeatmapActionData.elementPath is empty — heatmap data dropped." + internal const val HEATMAP_IDENTIFIER_HASH_FAILURE = + "Failed to compute heatmap identifier — heatmap data dropped." + + internal fun buildDdAction( + permanentId: String, + positionX: Long, + positionY: Long, + targetWidth: Long?, + targetHeight: Long? + ): ActionEvent.DdAction = ActionEvent.DdAction( + position = ActionEvent.Position(x = positionX, y = positionY), + target = ActionEvent.DdActionTarget( + permanentId = permanentId, + width = targetWidth, + height = targetHeight + ) + ) + } +} 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/heatmaps/NativeHeatmapActionData.kt similarity index 80% rename from features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/HeatmapActionData.kt rename to features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/heatmaps/NativeHeatmapActionData.kt index b8fd60cfbc..4666902625 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/heatmaps/NativeHeatmapActionData.kt @@ -4,9 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.domain.scope +package com.datadog.android.rum.internal.heatmaps -internal data class HeatmapActionData( +internal data class NativeHeatmapActionData( val viewKey: Long, val positionX: Long, val positionY: Long, 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 1c297af480..7a7936af42 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,7 +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.internal.utils.isValidTapTarget import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.ViewTarget import java.lang.ref.WeakReference 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 767b9a6614..05ed0c075d 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,11 +14,11 @@ import android.view.Window import androidx.core.view.isVisible import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore -import com.datadog.android.internal.heatmaps.heatmapViewKey +import com.datadog.android.heatmaps.heatmapViewKey import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes -import com.datadog.android.rum.internal.domain.scope.HeatmapActionData +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.tracking.ActionTrackingStrategy @@ -270,7 +270,7 @@ internal class GesturesListener( private fun sendTapEventWithTarget(target: ViewTarget, touchX: Float, touchY: Float) { val attributes = mutableMapOf() - var heatmapData: HeatmapActionData? = null + var nativeHeatmapActionData: NativeHeatmapActionData? = null target.viewRef.get()?.let { view -> addViewAttributes(view, attributes) @@ -281,7 +281,7 @@ internal class GesturesListener( // the user adjusts display size, so caching it as a field would be incorrect. val density = view.resources.displayMetrics.density - heatmapData = HeatmapActionData( + nativeHeatmapActionData = NativeHeatmapActionData( viewKey = heatmapViewKey(view), positionX = ((touchX - tapLocationBuffer[0]) / density).roundToLong(), positionY = ((touchY - tapLocationBuffer[1]) / density).roundToLong(), @@ -298,7 +298,7 @@ internal class GesturesListener( (rumMonitor as? AdvancedRumMonitor)?.addActionWithHeatmap( RumActionType.TAP, targetName, - heatmapData, + nativeHeatmapActionData, attributes ) ?: rumMonitor.addAction(RumActionType.TAP, targetName, attributes) } 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 fb94e9920b..4a6e74c425 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 @@ -8,13 +8,14 @@ package com.datadog.android.rum.internal.monitor import android.app.Activity import com.datadog.android.core.feature.event.ThreadDump +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData 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.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.tools.annotation.NoOpImplementation @@ -33,10 +34,19 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor { fun addActionWithHeatmap( type: RumActionType, name: String, - heatmapData: HeatmapActionData?, + nativeHeatmapActionData: NativeHeatmapActionData?, attributes: Map ) + fun addActionWithHeatmapAttributes( + type: RumActionType, + name: String, + crossPlatformHeatmapActionData: CrossPlatformHeatmapActionData, + attributes: Map + ) + + fun getCurrentViewUrl(): String? + 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 aa90c83f28..a0f4a3c664 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.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.AddOperationStepVital.ActionType @@ -55,11 +56,11 @@ 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 import com.datadog.android.rum.internal.domain.scope.RumSessionScope +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener @@ -86,6 +87,7 @@ import com.datadog.android.rum.featureoperations.FailureReason as DeprecatedFail internal class DatadogRumMonitor( applicationId: String, private val sdkCore: InternalSdkCore, + internal val appPackageName: String, internal val sessionSampler: Sampler, internal val backgroundTrackingEnabled: Boolean, internal val trackFrustrations: Boolean, @@ -111,6 +113,8 @@ internal class DatadogRumMonitor( heatmapIdentifierRegistry: HeatmapIdentifierRegistry? ) : RumMonitor, AdvancedRumMonitor { + @Volatile private var cachedViewUrl: String? = null + internal var rootScope = RumApplicationScope( applicationId = applicationId, sdkCore = sdkCore, @@ -217,7 +221,26 @@ internal class DatadogRumMonitor( override fun addActionWithHeatmap( type: RumActionType, name: String, - heatmapData: HeatmapActionData?, + nativeHeatmapActionData: NativeHeatmapActionData?, + attributes: Map + ) { + val eventTime = getEventTime(attributes) + handleEvent( + RumRawEvent.StartAction( + type = type, + name = name, + waitForStop = false, + nativeHeatmapActionData = nativeHeatmapActionData, + eventTime = eventTime, + attributes = attributes.toMap() + ) + ) + } + + override fun addActionWithHeatmapAttributes( + type: RumActionType, + name: String, + crossPlatformHeatmapActionData: CrossPlatformHeatmapActionData, attributes: Map ) { val eventTime = getEventTime(attributes) @@ -226,7 +249,8 @@ internal class DatadogRumMonitor( type = type, name = name, waitForStop = false, - heatmapData = heatmapData, + crossPlatformHeatmapActionData = crossPlatformHeatmapActionData, + appPackageName = appPackageName, eventTime = eventTime, attributes = attributes.toMap() ) @@ -922,7 +946,9 @@ internal class DatadogRumMonitor( synchronized(rootScope) { handleEventWithMethodCallPerf(event, datadogContext, writeScope) notifyDebugListenerWithState() - currentRumContext() + val context = currentRumContext() + updateCachedViewUrl(context) + context } } ) @@ -976,6 +1002,13 @@ internal class DatadogRumMonitor( } } + override fun getCurrentViewUrl(): String? = cachedViewUrl + + private fun updateCachedViewUrl(context: RumContext?) { + val newUrl = context?.viewUrl + if (cachedViewUrl != newUrl) cachedViewUrl = newUrl + } + private fun currentRumContext(): RumContext? { val activeSession = rootScope.activeSession ?: return null val context = activeSession.activeView?.getRumContext() diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumInternalProxyTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumInternalProxyTest.kt index 9e68a6c601..fd533f2bed 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumInternalProxyTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumInternalProxyTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum import android.app.Activity +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.NetworkInstrumentation.LibraryType import com.datadog.android.rum.configuration.RumNetworkInstrumentationConfiguration import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor @@ -25,7 +26,9 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mockito.mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @Extensions( @@ -36,6 +39,42 @@ import org.mockito.quality.Strictness @ForgeConfiguration(Configurator::class) internal class RumInternalProxyTest { + @Test + fun `M proxy getCurrentViewUrl to RumMonitor W getCurrentViewUrl()`( + forge: Forge + ) { + // Given + val fakeViewUrl = forge.aNullable { anAlphabeticalString() } + val mockRumMonitor = mock(AdvancedRumMonitor::class.java) + whenever(mockRumMonitor.getCurrentViewUrl()) doReturn fakeViewUrl + val proxy = _RumInternalProxy(mockRumMonitor) + + // When + val result = proxy.getCurrentViewUrl() + + // Then + assertThat(result).isEqualTo(fakeViewUrl) + } + + @Test + fun `M proxy addActionWithHeatmap to RumMonitor W addActionWithHeatmap()`( + forge: Forge, + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData, + @StringForgery fakeName: String + ) { + // Given + val fakeType = forge.aValueFrom(RumActionType::class.java) + val fakeAttributes = forge.aMap { anAlphabeticalString() to anAlphabeticalString() } + val mockRumMonitor = mock(AdvancedRumMonitor::class.java) + val proxy = _RumInternalProxy(mockRumMonitor) + + // When + proxy.addActionWithHeatmap(fakeType, fakeName, fakeHeatmapAttributes, fakeAttributes) + + // Then + verify(mockRumMonitor).addActionWithHeatmapAttributes(fakeType, fakeName, fakeHeatmapAttributes, fakeAttributes) + } + @Test fun `M proxy addLongTask to RumMonitor W addLongTask()`( @LongForgery time: Long, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumTest.kt index 64716e87c3..e58dcd292d 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumTest.kt @@ -74,25 +74,23 @@ internal class RumTest { @StringForgery fakePackageName: String, @Forgery fakeRumConfiguration: RumConfiguration ) { + val mockApplication = mock { + whenever(it.packageName) doReturn fakePackageName + whenever(it.resources) doReturn mock() + whenever(it.contentResolver) doReturn mock() + whenever(it.resources.configuration) doReturn mock() + } + whenever(mockApplication.applicationContext) doReturn mockApplication + whenever(mockSdkCore.registerFeature(any())) doAnswer { + it.getArgument(0).onInitialize(appContext = mockApplication) + } + // When Rum.enable(fakeRumConfiguration, mockSdkCore) // Then argumentCaptor { verify(mockSdkCore).registerFeature(capture()) - - val mockApplication = mock { - whenever(it.packageName) doReturn fakePackageName - whenever(it.resources) doReturn mock() - whenever(it.contentResolver) doReturn mock() - whenever(it.resources.configuration) doReturn mock() - } - - whenever(mockApplication.applicationContext) doReturn mockApplication - - lastValue.onInitialize( - appContext = mockApplication - ) assertThat(lastValue.sampleRate) .isEqualTo(fakeRumConfiguration.featureConfiguration.sampleRate) assertThat(lastValue.telemetrySampleRate) @@ -182,6 +180,7 @@ internal class RumTest { .isSameAs(fakeRumConfiguration.featureConfiguration.initialResourceIdentifier) assertThat(rumApplicationScope.lastInteractionIdentifier) .isSameAs(fakeRumConfiguration.featureConfiguration.lastInteractionIdentifier) + assertThat(monitor.appPackageName).isEqualTo(fakePackageName) } @Test 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 ff523741c2..3517e0bc01 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.heatmaps.HeatmapIdentifier import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry import com.datadog.android.rum.RumActionType @@ -24,6 +25,8 @@ import com.datadog.android.rum.assertj.ActionEventAssert.Companion.assertThat import com.datadog.android.rum.internal.FeaturesContextResolver import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.heatmaps.HeatmapActionResolver +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.monitor.StorageEvent @@ -59,6 +62,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.isA +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -3192,14 +3196,16 @@ internal class RumActionScopeTest { sampleRate = fakeSampleRate, rumSessionTypeOverride = fakeRumSessionType, insightsCollector = mockInsightsCollector, - heatmapData = HeatmapActionData( - viewKey = fakeViewKey, - positionX = fakePosX, - positionY = fakePosY, - targetWidth = fakeWidth, - targetHeight = fakeHeight - ), - heatmapIdentifierRegistry = mockHeatmapRegistry + heatmapResolver = HeatmapActionResolver.Native( + data = NativeHeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = fakeWidth, + targetHeight = fakeHeight + ), + registry = mockHeatmapRegistry + ) ) // When @@ -3250,14 +3256,16 @@ internal class RumActionScopeTest { sampleRate = fakeSampleRate, rumSessionTypeOverride = fakeRumSessionType, insightsCollector = mockInsightsCollector, - heatmapData = HeatmapActionData( - viewKey = fakeViewKey, - positionX = fakePosX, - positionY = fakePosY, - targetWidth = fakeWidth, - targetHeight = fakeHeight - ), - heatmapIdentifierRegistry = mockHeatmapRegistry + heatmapResolver = HeatmapActionResolver.Native( + data = NativeHeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = fakeWidth, + targetHeight = fakeHeight + ), + registry = mockHeatmapRegistry + ) ) // When @@ -3298,14 +3306,16 @@ internal class RumActionScopeTest { sampleRate = fakeSampleRate, rumSessionTypeOverride = fakeRumSessionType, insightsCollector = mockInsightsCollector, - heatmapData = HeatmapActionData( - viewKey = fakeViewKey, - positionX = fakePosX, - positionY = fakePosY, - targetWidth = null, - targetHeight = null - ), - heatmapIdentifierRegistry = null + heatmapResolver = HeatmapActionResolver.Native( + data = NativeHeatmapActionData( + viewKey = fakeViewKey, + positionX = fakePosX, + positionY = fakePosY, + targetWidth = null, + targetHeight = null + ), + registry = null + ) ) // When @@ -3359,6 +3369,236 @@ internal class RumActionScopeTest { } } + @Test + fun `M populate heatmap fields W sendAction() {crossPlatformHeatmapActionData set}`( + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData, + @StringForgery fakeAppPackageName: String + ) { + val matchingHeatmapAttributes = fakeHeatmapAttributes.copy( + viewUrl = fakeParentContext.viewUrl.orEmpty() + ) + val expectedPermanentId = HeatmapIdentifier.create( + elementPath = matchingHeatmapAttributes.elementPath, + screenName = fakeParentContext.viewUrl.orEmpty(), + appPackageName = fakeAppPackageName + )?.rawValue + 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, + heatmapResolver = HeatmapActionResolver.CrossPlatform( + data = matchingHeatmapAttributes, + appPackageName = fakeAppPackageName, + logger = rumMonitor.mockSdkCore.internalLogger + ) + ) + + // 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(checkNotNull(expectedPermanentId)) + .hasPositionX(fakeHeatmapAttributes.positionX) + .hasPositionY(fakeHeatmapAttributes.positionY) + .hasTargetWidth(fakeHeatmapAttributes.targetWidth) + .hasTargetHeight(fakeHeatmapAttributes.targetHeight) + } + } + + @Test + fun `M prefer crossPlatformHeatmapActionData over nativeHeatmapActionData W sendAction() {both set}`( + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData, + @StringForgery fakeAppPackageName: String + ) { + // StartAction.init enforces mutual exclusivity, so RumActionScope is constructed directly here. + val mockHeatmapRegistry: HeatmapIdentifierRegistry = mock() + whenever(mockHeatmapRegistry.getHeatmapIdentifier(any(), any())) + .thenReturn(HeatmapIdentifier("native-id")) + val matchingHeatmapAttributes = fakeHeatmapAttributes.copy( + viewUrl = fakeParentContext.viewUrl.orEmpty() + ) + val expectedPermanentId = HeatmapIdentifier.create( + elementPath = matchingHeatmapAttributes.elementPath, + screenName = fakeParentContext.viewUrl.orEmpty(), + appPackageName = fakeAppPackageName + )?.rawValue + + 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, + heatmapResolver = HeatmapActionResolver.CrossPlatform( + data = matchingHeatmapAttributes, + appPackageName = fakeAppPackageName, + logger = rumMonitor.mockSdkCore.internalLogger + ) + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + verifyNoInteractions(mockHeatmapRegistry) + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue).hasPermanentId(checkNotNull(expectedPermanentId)) + } + } + + @Test + fun `M drop heatmap data and warn W sendAction() {heatmapAttributes viewUrl mismatches current view}`( + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData, + @StringForgery fakeAppPackageName: String, + @StringForgery fakeDifferentViewUrl: String + ) { + // Given — simulate a race: the action was recorded for a different view than the one + // that is active when sendAction() fires (e.g. navigation happened on the async hop + // between the cross-platform tap and Android's RUM event queue processing). + val staleViewUrl = if (fakeDifferentViewUrl != fakeParentContext.viewUrl.orEmpty()) { + fakeDifferentViewUrl + } else { + "$fakeDifferentViewUrl#other" + } + val staleHeatmapAttributes = fakeHeatmapAttributes.copy(viewUrl = staleViewUrl) + + 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, + heatmapResolver = HeatmapActionResolver.CrossPlatform( + data = staleHeatmapAttributes, + appPackageName = fakeAppPackageName, + logger = rumMonitor.mockSdkCore.internalLogger + ) + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then — heatmap fields are absent; a developer warning was logged + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue.dd.action).isNull() + } + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.USER), + any(), + isNull(), + eq(false), + isNull() + ) + } + + @Test + fun `M drop heatmap data and log user error W sendAction() {empty elementPath}`( + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData, + @StringForgery fakeAppPackageName: String + ) { + // Given — elementPath is empty; every tap on the same screen would collide to the same hash + val emptyPathAttributes = fakeHeatmapAttributes.copy( + elementPath = emptyList(), + viewUrl = fakeParentContext.viewUrl.orEmpty() + ) + 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, + heatmapResolver = HeatmapActionResolver.CrossPlatform( + data = emptyPathAttributes, + appPackageName = fakeAppPackageName, + logger = rumMonitor.mockSdkCore.internalLogger + ) + ) + + // 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() + } + verify(mockInternalLogger).log( + eq(InternalLogger.Level.ERROR), + eq(InternalLogger.Target.USER), + any(), + isNull(), + eq(false), + isNull() + ) + } + // region Internal private fun mockEvent(timeOffset: Long = 0L): RumRawEvent { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt index faa28c7f2e..c009bf2f9a 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 @@ -11,6 +11,7 @@ import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumResourceKind import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.internal.domain.Time +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.model.ActionEvent import com.datadog.android.rum.operations.FailureReason import com.datadog.tools.unit.forge.aThrowable @@ -45,13 +46,13 @@ internal fun Forge.stopViewEvent(): RumRawEvent.StopView { internal fun Forge.startActionEvent( continuous: Boolean? = null, eventTime: Time = Time(), - heatmapData: HeatmapActionData? = null + nativeHeatmapActionData: NativeHeatmapActionData? = null ): RumRawEvent.StartAction { return RumRawEvent.StartAction( type = aValueFrom(RumActionType::class.java), name = anAlphabeticalString(), waitForStop = continuous ?: aBool(), - heatmapData = heatmapData, + nativeHeatmapActionData = nativeHeatmapActionData, 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 33cac8fdd4..c5af1ec3f2 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 @@ -44,6 +44,8 @@ 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.state.SlowFrameRecord import com.datadog.android.rum.internal.domain.state.ViewUIPerformanceReport +import com.datadog.android.rum.internal.heatmaps.HeatmapActionResolver +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.NoValueReason import com.datadog.android.rum.internal.metric.SessionMetricDispatcher @@ -2897,7 +2899,7 @@ internal class RumViewScopeTest { @Forgery type: RumActionType, @StringForgery name: String, @BoolForgery waitForStop: Boolean, - @Forgery fakeHeatmapData: HeatmapActionData, + @Forgery fakeHeatmapData: NativeHeatmapActionData, forge: Forge ) { // Given @@ -2907,7 +2909,7 @@ internal class RumViewScopeTest { type = type, name = name, waitForStop = waitForStop, - heatmapData = fakeHeatmapData, + nativeHeatmapActionData = fakeHeatmapData, attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) ) @@ -2916,7 +2918,7 @@ internal class RumViewScopeTest { // Then val actionScope = testedScope.activeActionScope as RumActionScope - assertThat(actionScope.heatmapData).isEqualTo(fakeHeatmapData) + assertThat(actionScope.heatmapResolver).isInstanceOf(HeatmapActionResolver.Native::class.java) } @ParameterizedTest 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 79d7ba8e20..2e73d922cc 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,13 +14,13 @@ import android.view.ViewGroup import android.view.Window import androidx.compose.ui.platform.ComposeView import com.datadog.android.api.InternalLogger -import com.datadog.android.internal.heatmaps.heatmapViewKey +import com.datadog.android.heatmaps.heatmapViewKey import com.datadog.android.internal.utils.toHexString import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumMonitor -import com.datadog.android.rum.internal.domain.scope.HeatmapActionData +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData 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 @@ -840,7 +840,7 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { val expectedYInTarget = ((touchY - targetY) / fakeDensity).roundToLong() val expectedTargetWidth = (fakeTargetWidth / fakeDensity).roundToLong() val expectedTargetHeight = (fakeTargetHeight / fakeDensity).roundToLong() - val heatmapCaptor = argumentCaptor() + val heatmapCaptor = argumentCaptor() verify(rumMonitor.mockInstance as AdvancedRumMonitor).addActionWithHeatmap( eq(RumActionType.TAP), eq(""), 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 d5e81e9e95..98f3c8c5e4 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 @@ -21,6 +21,7 @@ import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.sampling.DeterministicSampler import com.datadog.android.core.sampling.Sampler +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.ExperimentalRumApi @@ -41,7 +42,6 @@ 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 @@ -49,6 +49,7 @@ import com.datadog.android.rum.internal.domain.scope.RumSessionScope import com.datadog.android.rum.internal.domain.scope.RumViewManagerScope import com.datadog.android.rum.internal.domain.scope.RumViewScope import com.datadog.android.rum.internal.domain.state.ViewUIPerformanceReport +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import com.datadog.android.rum.internal.instrumentation.insights.InsightsCollector import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.metric.slowframes.SlowFramesListener @@ -201,6 +202,9 @@ internal class DatadogRumMonitorTest { @StringForgery(regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") lateinit var fakeApplicationId: String + @StringForgery + lateinit var fakeApplicationPackageName: String + lateinit var fakeAttributes: Map @FloatForgery(min = 0f, max = 100f) @@ -302,6 +306,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) testedMonitor.rootScope = mockApplicationScope @@ -335,6 +340,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) @@ -411,6 +417,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) testedMonitor.start() @@ -455,6 +462,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) testedMonitor.start() @@ -514,7 +522,7 @@ internal class DatadogRumMonitorTest { assertThat(event.waitForStop).isFalse assertThat(event.attributes).containsAllEntriesOf(fakeAttributes) assertThat(event.eventTime.timestamp).isEqualTo(eventTimeMs) - assertThat(event.heatmapData).isNull() + assertThat(event.nativeHeatmapActionData).isNull() } verifyNoMoreInteractions(mockWriter) } @@ -523,13 +531,13 @@ internal class DatadogRumMonitorTest { fun `M enqueue StartAction with heatmapData set W addActionWithHeatmap() {heatmapData non-null}`( @Forgery type: RumActionType, @StringForgery name: String, - @Forgery fakeHeatmapData: HeatmapActionData + @Forgery fakeHeatmapData: NativeHeatmapActionData ) { // When testedMonitor.addActionWithHeatmap( type = type, name = name, - heatmapData = fakeHeatmapData, + nativeHeatmapActionData = fakeHeatmapData, attributes = fakeAttributes ) @@ -548,11 +556,154 @@ internal class DatadogRumMonitorTest { assertThat(event.waitForStop).isFalse assertThat(event.attributes).containsAllEntriesOf(fakeAttributes) assertThat(event.eventTime.timestamp).isEqualTo(eventTimeMs) - assertThat(event.heatmapData).isEqualTo(fakeHeatmapData) + assertThat(event.nativeHeatmapActionData).isEqualTo(fakeHeatmapData) + } + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M enqueue StartAction with crossPlatformHeatmapActionData set W addActionWithHeatmapAttributes()`( + @Forgery type: RumActionType, + @StringForgery name: String, + @Forgery fakeHeatmapAttributes: CrossPlatformHeatmapActionData + ) { + // When + testedMonitor.addActionWithHeatmapAttributes( + type = type, + name = name, + crossPlatformHeatmapActionData = fakeHeatmapAttributes, + attributes = fakeAttributes + ) + + // Then + argumentCaptor { + verify(mockApplicationScope).handleEvent( + capture(), + same(fakeDatadogContext), + same(mockEventWriteScope), + same(mockWriter) + ) + + val event = firstValue as RumRawEvent.StartAction + assertThat(event.type).isEqualTo(type) + assertThat(event.name).isEqualTo(name) + assertThat(event.waitForStop).isFalse + assertThat(event.attributes).containsAllEntriesOf(fakeAttributes) + assertThat(event.nativeHeatmapActionData).isNull() + assertThat(event.crossPlatformHeatmapActionData).isEqualTo(fakeHeatmapAttributes) + assertThat(event.appPackageName).isEqualTo(fakeApplicationPackageName) } verifyNoMoreInteractions(mockWriter) } + @Test + fun `M return current view URL W getCurrentViewUrl()`( + @Forgery fakeRumContext: RumContext, + @Forgery type: RumActionType, + @StringForgery name: String + ) { + // Given + val fakeContextWithSession = fakeRumContext.copy( + sessionId = java.util.UUID.randomUUID().toString(), + sessionState = RumSessionScope.State.TRACKED + ) + val mockSessionScope = mock() + val mockViewScope = mock() + whenever(mockApplicationScope.activeSession) doReturn mockSessionScope + whenever(mockSessionScope.activeView) doReturn mockViewScope + whenever(mockViewScope.getRumContext()) doReturn fakeContextWithSession + testedMonitor.startAction(type, name, fakeAttributes) + + // When + val result = testedMonitor.getCurrentViewUrl() + + // Then + assertThat(result).isEqualTo(fakeContextWithSession.viewUrl) + } + + @Test + fun `M return null W getCurrentViewUrl() { no active session }`( + @Forgery fakeRumContext: RumContext, + @Forgery type: RumActionType, + @StringForgery name: String + ) { + // Given + val primeContext = fakeRumContext.copy( + sessionId = java.util.UUID.randomUUID().toString(), + sessionState = RumSessionScope.State.TRACKED + ) + val mockSessionScope = mock() + val mockViewScope = mock() + whenever(mockApplicationScope.activeSession) doReturn mockSessionScope + whenever(mockSessionScope.activeView) doReturn mockViewScope + whenever(mockViewScope.getRumContext()) doReturn primeContext + testedMonitor.startAction(type, name, fakeAttributes) + whenever(mockApplicationScope.activeSession) doReturn null + testedMonitor.startAction(type, name, fakeAttributes) + + // When + val result = testedMonitor.getCurrentViewUrl() + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W getCurrentViewUrl() { session exists but has NULL_UUID — not yet sampled in }`( + @Forgery fakeRumContext: RumContext, + @Forgery type: RumActionType, + @StringForgery name: String + ) { + // Given + val primeContext = fakeRumContext.copy( + sessionId = java.util.UUID.randomUUID().toString(), + sessionState = RumSessionScope.State.TRACKED + ) + val mockSessionScope = mock() + val mockViewScope = mock() + whenever(mockApplicationScope.activeSession) doReturn mockSessionScope + whenever(mockSessionScope.activeView) doReturn mockViewScope + whenever(mockViewScope.getRumContext()) doReturn primeContext + testedMonitor.startAction(type, name, fakeAttributes) + whenever(mockSessionScope.activeView) doReturn null + whenever(mockSessionScope.getRumContext()) doReturn RumContext(sessionId = RumContext.NULL_UUID) + testedMonitor.startAction(type, name, fakeAttributes) + + // When + val result = testedMonitor.getCurrentViewUrl() + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W getCurrentViewUrl() { view stopped with no new view started }`( + @Forgery fakeRumContext: RumContext, + @Forgery type: RumActionType, + @StringForgery name: String + ) { + // Given + val primeContext = fakeRumContext.copy( + sessionId = java.util.UUID.randomUUID().toString(), + sessionState = RumSessionScope.State.TRACKED + ) + val mockSessionScope = mock() + val mockViewScope = mock() + whenever(mockApplicationScope.activeSession) doReturn mockSessionScope + whenever(mockSessionScope.activeView) doReturn mockViewScope + whenever(mockViewScope.getRumContext()) doReturn primeContext + testedMonitor.startAction(type, name, fakeAttributes) + whenever(mockSessionScope.activeView) doReturn null + whenever(mockSessionScope.getRumContext()) doReturn primeContext.copy(viewUrl = null) + testedMonitor.startAction(type, name, fakeAttributes) + + // When + val result = testedMonitor.getCurrentViewUrl() + + // Then + assertThat(result).isNull() + } + @Test fun `M delegate event to rootScope W startAction()`( @Forgery type: RumActionType, @@ -2074,6 +2225,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) @@ -2115,6 +2267,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) @@ -2157,6 +2310,7 @@ internal class DatadogRumMonitorTest { rumSessionTypeOverride = null, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) whenever(mockExecutorService.isShutdown).thenReturn(true) @@ -2394,6 +2548,7 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), insightsCollector = mockInsightsCollector, + appPackageName = fakeApplicationPackageName, heatmapIdentifierRegistry = null ) testedMonitor.startView(key, name, attributes) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/config/GlobalRumMonitorTestConfiguration.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/config/GlobalRumMonitorTestConfiguration.kt index 21fe41d39f..0ab4d91dc1 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/config/GlobalRumMonitorTestConfiguration.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/config/GlobalRumMonitorTestConfiguration.kt @@ -7,7 +7,9 @@ package com.datadog.android.rum.utils.config import com.datadog.android.core.InternalSdkCore +import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData import com.datadog.android.rum.GlobalRumMonitor +import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumMonitor import com.datadog.android.rum._RumInternalProxy import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor @@ -18,9 +20,14 @@ import org.mockito.kotlin.whenever @Suppress("TestFunctionName") internal abstract class InternalAdvancedRumMonitor : AdvancedRumMonitor { - override fun _getInternal(): _RumInternalProxy? { - return null - } + override fun _getInternal(): _RumInternalProxy? = null + override fun addActionWithHeatmapAttributes( + type: RumActionType, + name: String, + crossPlatformHeatmapActionData: CrossPlatformHeatmapActionData, + attributes: Map + ) = Unit + override fun getCurrentViewUrl(): String? = null } internal class GlobalRumMonitorTestConfiguration : 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 dc00005eaf..e90e7113ba 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,7 +54,8 @@ internal class Configurator : BaseConfigurator() { forge.addFactory(SlowFramesConfigurationForgeryFactory()) forge.addFactory(DisplayInfoForgeryFactory()) forge.addFactory(BatteryInfoForgeryFactory()) - forge.addFactory(HeatmapActionDataForgeryFactory()) + forge.addFactory(NativeHeatmapActionDataForgeryFactory()) + forge.addFactory(CrossPlatformHeatmapActionDataForgeryFactory()) // Telemetry schema models forge.addFactory(TelemetryDebugEventForgeryFactory()) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/CrossPlatformHeatmapActionDataForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/CrossPlatformHeatmapActionDataForgeryFactory.kt new file mode 100644 index 0000000000..88efa0c289 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/CrossPlatformHeatmapActionDataForgeryFactory.kt @@ -0,0 +1,25 @@ +/* + * 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.heatmaps.CrossPlatformHeatmapActionData +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class CrossPlatformHeatmapActionDataForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): CrossPlatformHeatmapActionData { + return CrossPlatformHeatmapActionData( + elementPath = forge.aList(size = forge.anInt(min = 1, max = 7)) { anAlphabeticalString() }, + viewUrl = forge.aString(), + positionX = forge.aPositiveLong(), + positionY = forge.aPositiveLong(), + targetWidth = forge.aNullable { aPositiveLong() }, + targetHeight = forge.aNullable { aPositiveLong() } + ) + } +} 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/NativeHeatmapActionDataForgeryFactory.kt similarity index 69% rename from features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/HeatmapActionDataForgeryFactory.kt rename to features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/NativeHeatmapActionDataForgeryFactory.kt index 04904dc4ac..d46972307d 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/NativeHeatmapActionDataForgeryFactory.kt @@ -6,14 +6,14 @@ package com.datadog.android.rum.utils.forge -import com.datadog.android.rum.internal.domain.scope.HeatmapActionData +import com.datadog.android.rum.internal.heatmaps.NativeHeatmapActionData import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.ForgeryFactory -internal class HeatmapActionDataForgeryFactory : ForgeryFactory { +internal class NativeHeatmapActionDataForgeryFactory : ForgeryFactory { - override fun getForgery(forge: Forge): HeatmapActionData { - return HeatmapActionData( + override fun getForgery(forge: Forge): NativeHeatmapActionData { + return NativeHeatmapActionData( viewKey = forge.aLong(), positionX = forge.aLong(), positionY = forge.aLong(), diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistry.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistry.kt index 8514350f55..73fcd3eb4a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistry.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistry.kt @@ -10,9 +10,9 @@ import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider import com.datadog.android.internal.heatmaps.HeatmapIdentifier import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider /** * Session Replay may be initialized before RUM — the SDK does not mandate a registration order. @@ -27,9 +27,7 @@ internal class LazyHeatmapIdentifierRegistry( private val sdkCore: FeatureSdkCore ) : HeatmapIdentifierRegistry { - // null → not yet resolved - // NoOpHeatmapIdentifierRegistry → RUM absent or not a provider (terminal, won't retry) - // anything else → the real registry + // null = not yet attempted; UNAVAILABLE = permanently failed; anything else = real registry private var resolved: HeatmapIdentifierRegistry? = null private fun delegate(): HeatmapIdentifierRegistry? { @@ -62,8 +60,8 @@ internal class LazyHeatmapIdentifierRegistry( } companion object { - // Sentinel: RUM is registered but doesn't implement HeatmapIdentifierRegistryProvider. - // We stop retrying once we know this — it won't change without an SDK restart. + // Sentinel for a permanent failure (RUM registered but not a HeatmapIdentifierRegistryProvider). + // Won't change without an SDK restart, so we stop retrying once set. private val UNAVAILABLE: HeatmapIdentifierRegistry = NoOpHeatmapIdentifierRegistry() const val RUM_FEATURE_NOT_A_REGISTRY_PROVIDER = diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierResolver.kt index b4530eea5d..e873e75b16 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierResolver.kt @@ -12,10 +12,10 @@ import android.view.ViewGroup import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting import com.datadog.android.api.InternalLogger +import com.datadog.android.heatmaps.heatmapViewKey import com.datadog.android.internal.heatmaps.HeatmapIdentifier import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.heatmapViewKey -import com.datadog.android.internal.heatmaps.isValidTapTarget +import com.datadog.android.internal.utils.isValidTapTarget internal class HeatmapIdentifierResolver( private val appPackageName: String, @@ -134,7 +134,7 @@ internal class HeatmapIdentifierResolver( private fun logHashingFailure(error: Throwable) { internalLogger.log( InternalLogger.Level.WARN, - listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + InternalLogger.Target.USER, { HASHING_FAILURE_MESSAGE }, error ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt index 0eaa216123..67955fe7f9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt @@ -10,7 +10,7 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.internal.heatmaps.isValidTapTarget +import com.datadog.android.internal.utils.isValidTapTarget import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.R import com.datadog.android.sessionreplay.TextAndInputPrivacy diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistryTest.kt index 76aaeba806..0fd29e382e 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistryTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/LazyHeatmapIdentifierRegistryTest.kt @@ -10,9 +10,9 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider import com.datadog.android.internal.heatmaps.HeatmapIdentifier import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.LazyHeatmapIdentifierRegistry.Companion.RUM_FEATURE_NOT_A_REGISTRY_PROVIDER import com.datadog.android.utils.verifyLog diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierContractTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierContractTest.kt index 38fc2c6ec6..cd0f5d0f70 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierContractTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/HeatmapIdentifierContractTest.kt @@ -13,9 +13,9 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider +import com.datadog.android.heatmaps.heatmapViewKey import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider -import com.datadog.android.internal.heatmaps.heatmapViewKey import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerHeatmapIdentifierTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerHeatmapIdentifierTest.kt index 016fba6b53..01e96d8aa5 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerHeatmapIdentifierTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerHeatmapIdentifierTest.kt @@ -10,9 +10,9 @@ import android.content.res.Resources import android.view.View import android.view.ViewGroup import com.datadog.android.api.InternalLogger +import com.datadog.android.heatmaps.heatmapViewKey import com.datadog.android.internal.heatmaps.HeatmapIdentifier import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry -import com.datadog.android.internal.heatmaps.heatmapViewKey import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator