Skip to content

Commit 56efc15

Browse files
committed
Heatmaps feature
1 parent b54d77f commit 56efc15

File tree

67 files changed

+2450
-226
lines changed

Some content is hidden

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

67 files changed

+2450
-226
lines changed

dd-sdk-android-internal/api/apiSurface

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ class com.datadog.android.internal.data.SharedPreferencesStorage : PreferencesSt
5151
override fun clear()
5252
data class com.datadog.android.internal.flags.RumFlagEvaluationMessage
5353
constructor(String, Any)
54+
interface com.datadog.android.internal.identity.ViewIdentityResolver
55+
fun setCurrentScreen(String?)
56+
fun onWindowRefreshed(android.view.View)
57+
fun resolveViewIdentity(android.view.View): String?
58+
companion object
59+
const val FEATURE_CONTEXT_KEY: String
60+
class com.datadog.android.internal.identity.ViewIdentityResolverImpl : ViewIdentityResolver
61+
constructor(String)
62+
override fun setCurrentScreen(String?)
63+
override fun onWindowRefreshed(android.view.View)
64+
override fun resolveViewIdentity(android.view.View): String?
65+
companion object
5466
enum com.datadog.android.internal.network.GraphQLHeaders
5567
constructor(String)
5668
- DD_GRAPHQL_NAME_HEADER

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,36 @@ public final class com/datadog/android/internal/flags/RumFlagEvaluationMessage {
106106
public fun toString ()Ljava/lang/String;
107107
}
108108

109+
public final class com/datadog/android/internal/identity/NoOpViewIdentityResolver : com/datadog/android/internal/identity/ViewIdentityResolver {
110+
public fun <init> ()V
111+
public fun onWindowRefreshed (Landroid/view/View;)V
112+
public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
113+
public fun setCurrentScreen (Ljava/lang/String;)V
114+
}
115+
116+
public abstract interface class com/datadog/android/internal/identity/ViewIdentityResolver {
117+
public static final field Companion Lcom/datadog/android/internal/identity/ViewIdentityResolver$Companion;
118+
public static final field FEATURE_CONTEXT_KEY Ljava/lang/String;
119+
public abstract fun onWindowRefreshed (Landroid/view/View;)V
120+
public abstract fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
121+
public abstract fun setCurrentScreen (Ljava/lang/String;)V
122+
}
123+
124+
public final class com/datadog/android/internal/identity/ViewIdentityResolver$Companion {
125+
public static final field FEATURE_CONTEXT_KEY Ljava/lang/String;
126+
}
127+
128+
public final class com/datadog/android/internal/identity/ViewIdentityResolverImpl : com/datadog/android/internal/identity/ViewIdentityResolver {
129+
public static final field Companion Lcom/datadog/android/internal/identity/ViewIdentityResolverImpl$Companion;
130+
public fun <init> (Ljava/lang/String;)V
131+
public fun onWindowRefreshed (Landroid/view/View;)V
132+
public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
133+
public fun setCurrentScreen (Ljava/lang/String;)V
134+
}
135+
136+
public final class com/datadog/android/internal/identity/ViewIdentityResolverImpl$Companion {
137+
}
138+
109139
public final class com/datadog/android/internal/network/GraphQLHeaders : java/lang/Enum {
110140
public static final field DD_GRAPHQL_NAME_HEADER Lcom/datadog/android/internal/network/GraphQLHeaders;
111141
public static final field DD_GRAPHQL_PAYLOAD_HEADER Lcom/datadog/android/internal/network/GraphQLHeaders;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
package com.datadog.android.internal.identity
7+
8+
import android.view.View
9+
import com.datadog.tools.annotation.NoOpImplementation
10+
11+
/**
12+
* Resolves globally unique, stable identities for Android Views based on their canonical path
13+
* in the view hierarchy. Used for heatmap correlation between RUM actions and Session Replay.
14+
*/
15+
@NoOpImplementation(publicNoOpImplementation = true)
16+
interface ViewIdentityResolver {
17+
18+
/**
19+
* Sets the current screen identifier. Takes precedence over Activity-based detection.
20+
* @param identifier the screen identifier (typically RUM view URL), or null to clear
21+
*/
22+
fun setCurrentScreen(identifier: String?)
23+
24+
/**
25+
* Indexes a view tree for efficient identity lookups.
26+
* @param root the root view of the window
27+
*/
28+
fun onWindowRefreshed(root: View)
29+
30+
/**
31+
* Resolves the stable identity for a view (32 hex chars), or null if the view is detached.
32+
* @param view the view to identify
33+
* @return the stable identity hash, or null if it cannot be computed
34+
*/
35+
fun resolveViewIdentity(view: View): String?
36+
37+
companion object {
38+
/**
39+
* Key used to store the ViewIdentityResolver instance in the feature context.
40+
*/
41+
const val FEATURE_CONTEXT_KEY: String = "_dd.view_identity_resolver"
42+
}
43+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
package com.datadog.android.internal.identity
7+
8+
import android.app.Activity
9+
import android.content.Context
10+
import android.content.ContextWrapper
11+
import android.content.res.Resources
12+
import android.view.View
13+
import android.view.ViewGroup
14+
import com.datadog.android.internal.utils.toHexString
15+
import java.security.MessageDigest
16+
import java.security.NoSuchAlgorithmException
17+
import java.util.Collections
18+
import java.util.Stack
19+
import java.util.WeakHashMap
20+
import java.util.concurrent.atomic.AtomicReference
21+
22+
/**
23+
* Implementation of [ViewIdentityResolver] that generates globally unique, stable identifiers
24+
* for Android Views by computing and hashing their canonical path in the view hierarchy.
25+
*
26+
* Thread-safe: [setCurrentScreen] is called from a RUM worker thread while [onWindowRefreshed]
27+
* and [resolveViewIdentity] are called from the main thread.
28+
*
29+
* @param appIdentifier The application package name used as the root of canonical paths
30+
*/
31+
@Suppress("TooManyFunctions")
32+
class ViewIdentityResolverImpl(
33+
private val appIdentifier: String
34+
) : ViewIdentityResolver {
35+
36+
/**
37+
* Cache: Resource ID (Int) → Resource name (String).
38+
* Example: 2131230001 → "com.example.app:id/login_button"
39+
*
40+
* LRU cache with fixed size. Never explicitly cleared - resource ID mappings
41+
* are global and don't change based on screen. Avoids repeated calls to
42+
* resources.getResourceName() which is expensive.
43+
*/
44+
@Suppress("UnsafeThirdPartyFunctionCall") // LinkedHashMap constructor doesn't throw
45+
private val resourceNameCache: MutableMap<Int, String> = Collections.synchronizedMap(
46+
object : LinkedHashMap<Int, String>(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) {
47+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>?): Boolean {
48+
return size > RESOURCE_NAME_CACHE_SIZE
49+
}
50+
}
51+
)
52+
53+
/**
54+
* Cache: View reference → PathData (canonical path + identity hash).
55+
* Example: Button@0x7f3a → PathData("com.app/view:Home/login_button", "a1b2c3...")
56+
*
57+
* WeakHashMap so entries are removed when Views are garbage collected.
58+
* Cleared when screen changes (paths include screen namespace, so become invalid).
59+
*/
60+
@Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null
61+
private val viewPathDataCache: MutableMap<View, PathData> =
62+
Collections.synchronizedMap(WeakHashMap())
63+
64+
/**
65+
* Cache: Root view reference → Screen namespace string.
66+
* Example: DecorView@0x1a2b → "view:HomeScreen"
67+
*
68+
* Avoids recomputing namespace (which may involve walking context chain).
69+
* Cleared when screen changes (namespace depends on currentRumViewIdentifier).
70+
*/
71+
@Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null
72+
private val rootScreenNamespaceCache: MutableMap<View, String> =
73+
Collections.synchronizedMap(WeakHashMap())
74+
75+
/** The current RUM view identifier, set via setCurrentScreen(). */
76+
private val currentRumViewIdentifier = AtomicReference<String?>(null)
77+
78+
@Synchronized
79+
override fun setCurrentScreen(identifier: String?) {
80+
@Suppress("UnsafeThirdPartyFunctionCall") // type-safe: generics prevent VarHandle type mismatches
81+
val previous = currentRumViewIdentifier.getAndSet(identifier)
82+
if (previous != identifier) {
83+
rootScreenNamespaceCache.clear()
84+
viewPathDataCache.clear()
85+
}
86+
}
87+
88+
@Synchronized
89+
override fun onWindowRefreshed(root: View) {
90+
indexTree(root)
91+
}
92+
93+
@Synchronized
94+
override fun resolveViewIdentity(view: View): String? {
95+
return viewPathDataCache[view]?.identityHash
96+
}
97+
98+
private fun indexTree(root: View) {
99+
val screenNamespace = getScreenNamespace(root)
100+
val rootCanonicalPath = buildRootCanonicalPath(root, screenNamespace)
101+
102+
traverseAndIndexViews(root, rootCanonicalPath)
103+
}
104+
105+
/** Builds the canonical path for the root view (used as prefix for all descendants). */
106+
private fun buildRootCanonicalPath(root: View, screenNamespace: String): String {
107+
val rootPathSegment = getViewPathSegment(root, null)
108+
// Root view (e.g., DecorView) is not interactable, so we don't cache its identity.
109+
// We only need its path as the prefix for descendant paths.
110+
return "$appIdentifier/$screenNamespace/$rootPathSegment"
111+
}
112+
113+
/** Depth-first traversal of view hierarchy, computing and caching identity for each view. */
114+
private fun traverseAndIndexViews(root: View, rootCanonicalPath: String) {
115+
// Index the root view (all cache insertions happen here for consistency)
116+
md5Hex(rootCanonicalPath)?.let { hash ->
117+
viewPathDataCache[root] = PathData(rootCanonicalPath, hash)
118+
}
119+
120+
val stack = Stack<ViewWithCanonicalPath>()
121+
stack.push(ViewWithCanonicalPath(root, rootCanonicalPath))
122+
123+
while (stack.isNotEmpty()) {
124+
val (parent, parentPath) = stack.pop()
125+
if (parent is ViewGroup) {
126+
indexChildrenOf(parent, parentPath, stack)
127+
}
128+
}
129+
}
130+
131+
private fun indexChildrenOf(
132+
parent: ViewGroup,
133+
parentPath: String,
134+
stack: Stack<ViewWithCanonicalPath>
135+
) {
136+
for (i in 0 until parent.childCount) {
137+
val child = parent.getChildAt(i)
138+
val childPath = "$parentPath/${getViewPathSegment(child, parent)}"
139+
val childHash = md5Hex(childPath) ?: continue
140+
141+
viewPathDataCache[child] = PathData(childPath, childHash)
142+
stack.push(ViewWithCanonicalPath(child, childPath))
143+
}
144+
}
145+
146+
private fun getScreenNamespace(rootView: View): String {
147+
rootScreenNamespaceCache[rootView]?.let { return it }
148+
149+
val screenNamespace = getNamespaceFromRumView()
150+
?: getNamespaceFromActivity(rootView)
151+
?: getNamespaceFromRootResourceId(rootView)
152+
?: getNamespaceFromRootClassName(rootView)
153+
154+
rootScreenNamespaceCache[rootView] = screenNamespace
155+
return screenNamespace
156+
}
157+
158+
/** Priority 1: Use RUM view identifier if available (set via RumMonitor.startView). */
159+
private fun getNamespaceFromRumView(): String? {
160+
return currentRumViewIdentifier.get()?.let { viewName ->
161+
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}"
162+
}
163+
}
164+
165+
/** Priority 2: Fall back to Activity class name if root view has Activity context. */
166+
private fun getNamespaceFromActivity(rootView: View): String? {
167+
return findActivity(rootView)?.let { activity ->
168+
"$NAMESPACE_ACTIVITY_PREFIX${escapePathComponent(activity::class.java.name)}"
169+
}
170+
}
171+
172+
/** Priority 3: Fall back to root view's resource ID if it has one. */
173+
private fun getNamespaceFromRootResourceId(rootView: View): String? {
174+
return getResourceName(rootView)?.let { resourceName ->
175+
"$NAMESPACE_ROOT_ID_PREFIX${escapePathComponent(resourceName)}"
176+
}
177+
}
178+
179+
/** Priority 4: Last resort - use root view's class name. */
180+
private fun getNamespaceFromRootClassName(rootView: View): String {
181+
return "$NAMESPACE_ROOT_CLASS_PREFIX${escapePathComponent(rootView.javaClass.name)}"
182+
}
183+
184+
private fun getViewPathSegment(view: View, parentView: ViewGroup?): String {
185+
val resourceName = getResourceName(view)
186+
if (resourceName != null) return escapePathComponent(resourceName)
187+
188+
val siblingIndex = countPrecedingSiblingsOfSameClass(view, parentView)
189+
return "$LOCAL_KEY_CLASS_PREFIX${escapePathComponent(view.javaClass.name)}#$siblingIndex"
190+
}
191+
192+
/**
193+
* Counts how many siblings of the same class appear before this view in the parent.
194+
* Used to disambiguate views without resource IDs (e.g., "TextView#0", "TextView#1").
195+
* Returns 0 if parentView is null (root view case).
196+
*/
197+
private fun countPrecedingSiblingsOfSameClass(view: View, parentView: ViewGroup?): Int {
198+
if (parentView == null) return 0
199+
200+
var count = 0
201+
val viewClass = view.javaClass
202+
for (i in 0 until parentView.childCount) {
203+
val sibling = parentView.getChildAt(i)
204+
if (sibling === view) break
205+
if (sibling.javaClass == viewClass) count++
206+
}
207+
return count
208+
}
209+
210+
private fun getResourceName(view: View): String? {
211+
val id = view.id
212+
if (id == View.NO_ID) return null
213+
214+
return resourceNameCache[id] ?: try {
215+
view.resources?.getResourceName(id)?.also { name ->
216+
resourceNameCache[id] = name
217+
}
218+
} catch (_: Resources.NotFoundException) {
219+
null
220+
}
221+
}
222+
223+
private data class ViewWithCanonicalPath(val view: View, val canonicalPath: String)
224+
private data class PathData(val canonicalPath: String, val identityHash: String)
225+
226+
companion object {
227+
private const val RESOURCE_NAME_CACHE_SIZE = 500
228+
private const val DEFAULT_LOAD_FACTOR = 0.75f
229+
private const val NAMESPACE_VIEW_PREFIX = "view:"
230+
private const val NAMESPACE_ACTIVITY_PREFIX = "act:"
231+
private const val NAMESPACE_ROOT_ID_PREFIX = "root-id:"
232+
private const val NAMESPACE_ROOT_CLASS_PREFIX = "root-cls:"
233+
private const val LOCAL_KEY_CLASS_PREFIX = "cls:"
234+
}
235+
}
236+
237+
private fun escapePathComponent(input: String): String {
238+
return input.replace("%", "%25").replace("/", "%2F")
239+
}
240+
241+
private fun md5Hex(input: String): String? {
242+
return try {
243+
val messageDigest = MessageDigest.getInstance("MD5")
244+
messageDigest.update(input.toByteArray(Charsets.UTF_8))
245+
messageDigest.digest().toHexString()
246+
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: NoSuchAlgorithmException) {
247+
null
248+
}
249+
}
250+
251+
@Suppress("ReturnCount")
252+
private fun findActivity(view: View): Activity? {
253+
var ctx: Context? = view.context ?: return null
254+
while (ctx is ContextWrapper) {
255+
if (ctx is Activity) return ctx
256+
ctx = ctx.baseContext
257+
}
258+
return null
259+
}

0 commit comments

Comments
 (0)