Skip to content

Commit 2248829

Browse files
authored
Merge pull request #3566 from DataDog/feature/heatmaps
Merge heatmaps into develop
2 parents 0d7a259 + c009ee6 commit 2248829

92 files changed

Lines changed: 5290 additions & 235 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dd-sdk-android-internal/api/apiSurface

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
data class com.datadog.android.heatmaps.CrossPlatformHeatmapActionData
2+
constructor(List<String>, String, Long, Long, Long?, Long?)
3+
interface com.datadog.android.heatmaps.HeatmapIdentifierRegistryProvider
4+
val heatmapIdentifierRegistry: com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry
5+
fun heatmapViewKey(android.view.View): Long
16
interface com.datadog.android.internal.attributes.LocalAttribute
27
enum Key
38
constructor(String)
@@ -60,6 +65,15 @@ class com.datadog.android.internal.data.SharedPreferencesStorage : PreferencesSt
6065
override fun clear()
6166
data class com.datadog.android.internal.flags.RumFlagEvaluationMessage
6267
constructor(String, Any)
68+
data class com.datadog.android.internal.heatmaps.HeatmapIdentifier
69+
constructor(String)
70+
companion object
71+
fun create(List<String>, String, String, (Throwable) -> Unit = {}): HeatmapIdentifier?
72+
interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry
73+
fun setHeatmapIdentifiers(Map<Long, HeatmapIdentifier>, String)
74+
fun getHeatmapIdentifier(Long, String): HeatmapIdentifier?
75+
companion object
76+
fun create(): HeatmapIdentifierRegistry
6377
class com.datadog.android.internal.lifecycle.ProcessLifecycleMonitor : android.app.Application.ActivityLifecycleCallbacks
6478
constructor(Callback)
6579
val activitiesResumedCounter: java.util.concurrent.atomic.AtomicInteger
@@ -271,6 +285,7 @@ fun StringBuilder.appendIfNotEmpty(String)
271285
fun StringBuilder.appendIfNotEmpty(Char)
272286
fun String.toBase64(): String
273287
fun String.fromBase64(): String?
288+
fun android.view.View.isValidTapTarget(): Boolean
274289
fun Thread.safeGetThreadId(): Long
275290
fun Thread.State.asString(): String
276291
fun Array<StackTraceElement>.loggableStackTrace(): String

dd-sdk-android-internal/api/dd-sdk-android-internal.api

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
public final class com/datadog/android/heatmaps/CrossPlatformHeatmapActionData {
2+
public fun <init> (Ljava/util/List;Ljava/lang/String;JJLjava/lang/Long;Ljava/lang/Long;)V
3+
public final fun component1 ()Ljava/util/List;
4+
public final fun component2 ()Ljava/lang/String;
5+
public final fun component3 ()J
6+
public final fun component4 ()J
7+
public final fun component5 ()Ljava/lang/Long;
8+
public final fun component6 ()Ljava/lang/Long;
9+
public final fun copy (Ljava/util/List;Ljava/lang/String;JJLjava/lang/Long;Ljava/lang/Long;)Lcom/datadog/android/heatmaps/CrossPlatformHeatmapActionData;
10+
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;
11+
public fun equals (Ljava/lang/Object;)Z
12+
public final fun getElementPath ()Ljava/util/List;
13+
public final fun getPositionX ()J
14+
public final fun getPositionY ()J
15+
public final fun getTargetHeight ()Ljava/lang/Long;
16+
public final fun getTargetWidth ()Ljava/lang/Long;
17+
public final fun getViewUrl ()Ljava/lang/String;
18+
public fun hashCode ()I
19+
public fun toString ()Ljava/lang/String;
20+
}
21+
22+
public abstract interface class com/datadog/android/heatmaps/HeatmapIdentifierRegistryProvider {
23+
public abstract fun getHeatmapIdentifierRegistry ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry;
24+
}
25+
26+
public final class com/datadog/android/heatmaps/HeatmapViewKeyKt {
27+
public static final fun heatmapViewKey (Landroid/view/View;)J
28+
}
29+
130
public abstract interface class com/datadog/android/internal/attributes/LocalAttribute {
231
}
332

@@ -123,6 +152,39 @@ public final class com/datadog/android/internal/flags/RumFlagEvaluationMessage {
123152
public fun toString ()Ljava/lang/String;
124153
}
125154

155+
public final class com/datadog/android/internal/heatmaps/HeatmapIdentifier {
156+
public static final field Companion Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier$Companion;
157+
public fun <init> (Ljava/lang/String;)V
158+
public final fun component1 ()Ljava/lang/String;
159+
public final fun copy (Ljava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
160+
public static synthetic fun copy$default (Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
161+
public fun equals (Ljava/lang/Object;)Z
162+
public final fun getRawValue ()Ljava/lang/String;
163+
public fun hashCode ()I
164+
public fun toString ()Ljava/lang/String;
165+
}
166+
167+
public final class com/datadog/android/internal/heatmaps/HeatmapIdentifier$Companion {
168+
public final fun create (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
169+
public static synthetic fun create$default (Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier$Companion;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
170+
}
171+
172+
public abstract interface class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry {
173+
public static final field Companion Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry$Companion;
174+
public abstract fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
175+
public abstract fun setHeatmapIdentifiers (Ljava/util/Map;Ljava/lang/String;)V
176+
}
177+
178+
public final class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry$Companion {
179+
public final fun create ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry;
180+
}
181+
182+
public final class com/datadog/android/internal/heatmaps/NoOpHeatmapIdentifierRegistry : com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry {
183+
public fun <init> ()V
184+
public fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
185+
public fun setHeatmapIdentifiers (Ljava/util/Map;Ljava/lang/String;)V
186+
}
187+
126188
public final class com/datadog/android/internal/lifecycle/ProcessLifecycleMonitor : android/app/Application$ActivityLifecycleCallbacks {
127189
public fun <init> (Lcom/datadog/android/internal/lifecycle/ProcessLifecycleMonitor$Callback;)V
128190
public final fun getActivitiesResumedCounter ()Ljava/util/concurrent/atomic/AtomicInteger;
@@ -608,6 +670,10 @@ public final class com/datadog/android/internal/utils/StringExtKt {
608670
public static final fun toBase64 (Ljava/lang/String;)Ljava/lang/String;
609671
}
610672

673+
public final class com/datadog/android/internal/utils/TapTargetUtilsKt {
674+
public static final fun isValidTapTarget (Landroid/view/View;)Z
675+
}
676+
611677
public final class com/datadog/android/internal/utils/ThreadExtKt {
612678
public static final fun asString (Ljava/lang/Thread$State;)Ljava/lang/String;
613679
public static final fun loggableStackTrace ([Ljava/lang/StackTraceElement;)Ljava/lang/String;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.heatmaps
8+
9+
/**
10+
* Heatmap data for a RUM action, used by cross-platform SDKs to correlate a tap action with
11+
* a specific UI element.
12+
*
13+
* @property elementPath the path segments from the root component down to the tapped element
14+
* (e.g. `["root", "container", "submitButton"]`), using the same naming convention as the
15+
* cross-platform Session Replay layer so that the resulting identifier matches the wireframe.
16+
* @property viewUrl the RUM view URL returned by `_RumInternalProxy.getCurrentViewUrl()` at tap time.
17+
* @property positionX the x-coordinate of the tap relative to the target element, in dp.
18+
* @property positionY the y-coordinate of the tap relative to the target element, in dp.
19+
* @property targetWidth the width of the tapped element, in dp, or null if unavailable.
20+
* @property targetHeight the height of the tapped element, in dp, or null if unavailable.
21+
*/
22+
data class CrossPlatformHeatmapActionData(
23+
val elementPath: List<String>,
24+
val viewUrl: String,
25+
val positionX: Long,
26+
val positionY: Long,
27+
val targetWidth: Long?,
28+
val targetHeight: Long?
29+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.heatmaps
8+
9+
import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry
10+
11+
/**
12+
* Implemented by SDK features that own a [HeatmapIdentifierRegistry], allowing peer features
13+
* to obtain a typed reference via [com.datadog.android.api.feature.FeatureScope.unwrap].
14+
*/
15+
interface HeatmapIdentifierRegistryProvider {
16+
/**
17+
* The registry that maps view identity keys to their stable [HeatmapIdentifier]s for this
18+
* feature's current screen. Session Replay writes identifiers into this registry during
19+
* each traversal; the RUM layer reads from it when a tap action is sent.
20+
*/
21+
val heatmapIdentifierRegistry: HeatmapIdentifierRegistry
22+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.heatmaps
8+
9+
import android.view.View
10+
11+
// Polynomial coefficient — matches Java's standard hashCode convention.
12+
internal const val HEATMAP_VIEW_KEY_COEFFICIENT = 31L
13+
14+
/**
15+
* Returns the key used to store and look up a view's [HeatmapIdentifier] in a
16+
* [HeatmapIdentifierRegistry].
17+
*
18+
* The key combines the identity hash of the view with the identity hash of its direct parent
19+
* using a polynomial combination with coefficient 31 (the same coefficient used by Java's
20+
* standard `hashCode` convention). This makes the combinator non-commutative: swapping the
21+
* view and parent hashes produces a different key.
22+
*
23+
* Note: the returned value is an opaque 64-bit quantity and may be negative, since
24+
* [System.identityHashCode] returns a signed [Int] that is sign-extended on widening to [Long].
25+
* Callers must not assume the key is non-negative.
26+
*/
27+
fun heatmapViewKey(view: View): Long {
28+
val parentHash = view.parent?.let { System.identityHashCode(it) } ?: 0
29+
return HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(view).toLong() + parentHash.toLong()
30+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.heatmaps
8+
9+
import com.datadog.android.internal.utils.toHexString
10+
import java.io.UnsupportedEncodingException
11+
import java.net.URLEncoder
12+
import java.security.MessageDigest
13+
import java.security.NoSuchAlgorithmException
14+
15+
/**
16+
* A stable, globally unique identifier for a UI element, used to correlate
17+
* RUM tap actions with Session Replay wireframes for heatmap rendering.
18+
*
19+
* @property rawValue the hex-encoded SHA-256 hash string that uniquely identifies the element.
20+
*/
21+
data class HeatmapIdentifier(val rawValue: String) {
22+
23+
companion object {
24+
25+
private const val SEPARATOR = "/"
26+
27+
private const val SCREEN_NAMESPACE_VIEW_PREFIX = "view:"
28+
29+
/**
30+
* Creates a [HeatmapIdentifier] for the given UI element by hashing its canonical path,
31+
* or null if hashing fails.
32+
*
33+
* All string parameters must be **raw, unencoded** values — this function applies
34+
* percent-encoding internally. Cross-platform SDKs must pass raw strings (e.g. the
35+
* literal screen name returned by `getCurrentViewUrl()`, not a pre-encoded version)
36+
* so that the identifier is always produced by the same Android encoding logic.
37+
*
38+
* @param elementPath the path segments from the root view down to this element,
39+
* where each segment identifies a view in the hierarchy (e.g. its resource entry name).
40+
* @param screenName the current RUM view URL, used to scope identifiers to a screen.
41+
* The `view:` namespace prefix is applied internally — callers pass the raw URL
42+
* (e.g. `https://example.com/home`), not the prefixed form.
43+
* @param appPackageName the application package name (e.g. `com.example.app`),
44+
* used to globally namespace identifiers across apps.
45+
* @param onHashingFailure invoked with the caught exception if hashing fails.
46+
*/
47+
fun create(
48+
elementPath: List<String>,
49+
screenName: String,
50+
appPackageName: String,
51+
onHashingFailure: (Throwable) -> Unit = {}
52+
): HeatmapIdentifier? {
53+
return try {
54+
val path = canonicalPath(elementPath, screenName, appPackageName)
55+
HeatmapIdentifier(sha256Hex(path))
56+
} catch (e: NoSuchAlgorithmException) {
57+
// Unreachable on Android: SHA-256 is always available.
58+
onHashingFailure(e)
59+
null
60+
} catch (e: UnsupportedEncodingException) {
61+
// Thrown by URLEncoder.encode if UTF-8 is unavailable (unreachable on Android).
62+
onHashingFailure(e)
63+
null
64+
}
65+
}
66+
67+
private fun canonicalPath(
68+
elementPath: List<String>,
69+
screenName: String,
70+
appPackageName: String
71+
): String = buildString {
72+
append(escape(appPackageName))
73+
append(SEPARATOR)
74+
append(escape(SCREEN_NAMESPACE_VIEW_PREFIX + screenName))
75+
for (segment in elementPath) {
76+
append(SEPARATOR)
77+
append(escape(segment))
78+
}
79+
}
80+
81+
@Suppress("UnsafeThirdPartyFunctionCall") // UnsupportedEncodingException caught in create()
82+
private fun escape(input: String): String = URLEncoder.encode(input, "UTF-8")
83+
84+
@Suppress("UnsafeThirdPartyFunctionCall") // SHA-256 is always available on Android
85+
private fun sha256Hex(input: String): String {
86+
val digest = MessageDigest.getInstance("SHA-256")
87+
// digest(ByteArray): input is non-null, DigestException not thrown on this overload.
88+
@Suppress("UnsafeThirdPartyFunctionCall")
89+
return digest.digest(input.toByteArray(Charsets.UTF_8)).toHexString()
90+
}
91+
}
92+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.heatmaps
8+
9+
import com.datadog.tools.annotation.NoOpImplementation
10+
11+
/**
12+
* Stores and retrieves [HeatmapIdentifier]s keyed by composite view identity key
13+
* (see [heatmapViewKey]).
14+
*
15+
* Implementations must be thread-safe: the write side (Session Replay) may call
16+
* [setHeatmapIdentifiers] from a background traversal thread while the read side (RUM) calls
17+
* [getHeatmapIdentifier] from the main thread.
18+
*/
19+
@NoOpImplementation(publicNoOpImplementation = true)
20+
interface HeatmapIdentifierRegistry {
21+
22+
/**
23+
* Replaces the current snapshot with [identifiers], scoped to [screenName].
24+
*
25+
* @param identifiers a map of view identity keys (see [heatmapViewKey]) to their
26+
* [HeatmapIdentifier]s. Keys must be computed with [heatmapViewKey] at the time the
27+
* snapshot is captured so they match the keys produced at lookup time.
28+
* @param screenName the RUM view URL active when the snapshot was computed. Used to
29+
* guard against stale reads after screen navigation.
30+
*/
31+
fun setHeatmapIdentifiers(identifiers: Map<Long, HeatmapIdentifier>, screenName: String)
32+
33+
/**
34+
* Returns the [HeatmapIdentifier] for the view with the given [heatmapViewKey], or null if
35+
* the view is unknown or if [currentScreenName] does not match the screen that produced the
36+
* current snapshot (indicating the snapshot is stale).
37+
*
38+
* @param heatmapViewKey the composite identity key of the tapped view, as returned by [heatmapViewKey].
39+
* Must match the key used when populating [setHeatmapIdentifiers].
40+
* @param currentScreenName the RUM view URL active at the time of the tap.
41+
*/
42+
fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier?
43+
44+
companion object {
45+
46+
/**
47+
* Creates a default [HeatmapIdentifierRegistry] backed by an in-memory snapshot store.
48+
*/
49+
fun create(): HeatmapIdentifierRegistry = HeatmapIdentifierStore()
50+
}
51+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.internal.heatmaps
8+
9+
import java.util.concurrent.locks.ReentrantReadWriteLock
10+
import kotlin.concurrent.read
11+
import kotlin.concurrent.write
12+
13+
/**
14+
* Internal — construct via [HeatmapIdentifierRegistry.create].
15+
*
16+
* Writes (one per Session Replay traversal) atomically replace the entire snapshot so reads
17+
* always see a complete, consistent map — never a partial update. Reads are non-blocking
18+
* with respect to other reads.
19+
*
20+
* Note: there is no guarantee that a read sees the snapshot that was current at the exact
21+
* moment of a tap. If SR writes a new snapshot between the tap and the RUM lookup, the read
22+
* returns the newer snapshot. The screen name guard ensures a stale cross-screen snapshot
23+
* returns null — the worst case is a missed tap, not a wrong one.
24+
*/
25+
internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry {
26+
27+
private val lock = ReentrantReadWriteLock()
28+
29+
private var snapshotScreenName: String? = null
30+
private val identifiers: MutableMap<Long, HeatmapIdentifier> = mutableMapOf()
31+
32+
// Contract: both `screenName` (write side) and `currentScreenName` (read side) must be the
33+
// raw `rumContext.viewUrl` string without any normalization (no trailing slashes, no encoding).
34+
// A mismatch — e.g. from different formatting — silently returns null on every lookup.
35+
36+
override fun setHeatmapIdentifiers(identifiers: Map<Long, HeatmapIdentifier>, screenName: String) {
37+
lock.write {
38+
this.snapshotScreenName = screenName
39+
this.identifiers.clear()
40+
this.identifiers.putAll(identifiers)
41+
}
42+
}
43+
44+
override fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier? {
45+
return lock.read {
46+
if (snapshotScreenName == currentScreenName) identifiers[heatmapViewKey] else null
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)