Skip to content

Commit faf8b33

Browse files
committed
Heatmaps feature
1 parent b54d77f commit faf8b33

File tree

65 files changed

+2356
-228
lines changed

Some content is hidden

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

65 files changed

+2356
-228
lines changed

dd-sdk-android-internal/api/apiSurface

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,15 @@ class com.datadog.android.rum.DdRumContentProvider : android.content.ContentProv
213213
var createTimeNs: Long
214214
annotation com.datadog.tools.annotation.NoOpImplementation
215215
constructor(Boolean = false)
216+
interface com.datadog.android.internal.ViewIdentityResolver
217+
fun setCurrentScreen(String?)
218+
fun onWindowRefreshed(android.view.View)
219+
fun resolveViewIdentity(android.view.View): String?
220+
companion object
221+
const val FEATURE_CONTEXT_KEY: String
222+
class com.datadog.android.internal.ViewIdentityResolverImpl : ViewIdentityResolver
223+
constructor(String)
224+
override fun setCurrentScreen(String?)
225+
override fun onWindowRefreshed(android.view.View)
226+
override fun resolveViewIdentity(android.view.View): String?
227+
companion object

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
public final class com/datadog/android/internal/NoOpViewIdentityResolver : com/datadog/android/internal/ViewIdentityResolver {
2+
public fun <init> ()V
3+
public fun onWindowRefreshed (Landroid/view/View;)V
4+
public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
5+
public fun setCurrentScreen (Ljava/lang/String;)V
6+
}
7+
8+
public abstract interface class com/datadog/android/internal/ViewIdentityResolver {
9+
public static final field Companion Lcom/datadog/android/internal/ViewIdentityResolver$Companion;
10+
public static final field FEATURE_CONTEXT_KEY Ljava/lang/String;
11+
public abstract fun onWindowRefreshed (Landroid/view/View;)V
12+
public abstract fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
13+
public abstract fun setCurrentScreen (Ljava/lang/String;)V
14+
}
15+
16+
public final class com/datadog/android/internal/ViewIdentityResolver$Companion {
17+
public static final field FEATURE_CONTEXT_KEY Ljava/lang/String;
18+
}
19+
20+
public final class com/datadog/android/internal/ViewIdentityResolverImpl : com/datadog/android/internal/ViewIdentityResolver {
21+
public static final field Companion Lcom/datadog/android/internal/ViewIdentityResolverImpl$Companion;
22+
public fun <init> (Ljava/lang/String;)V
23+
public fun onWindowRefreshed (Landroid/view/View;)V
24+
public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String;
25+
public fun setCurrentScreen (Ljava/lang/String;)V
26+
}
27+
28+
public final class com/datadog/android/internal/ViewIdentityResolverImpl$Companion {
29+
}
30+
131
public abstract interface class com/datadog/android/internal/attributes/LocalAttribute {
232
}
333

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
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: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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
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+
@Suppress("UnsafeThirdPartyFunctionCall") // LinkedHashMap constructor doesn't throw
37+
private val resourceNameCache: MutableMap<Int, String> = Collections.synchronizedMap(
38+
object : LinkedHashMap<Int, String>(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) {
39+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>?): Boolean {
40+
return size > RESOURCE_NAME_CACHE_SIZE
41+
}
42+
}
43+
)
44+
45+
@Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null
46+
private val viewPathDataCache: MutableMap<View, PathData> =
47+
Collections.synchronizedMap(WeakHashMap())
48+
49+
@Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null
50+
private val rootScreenNamespaceCache: MutableMap<View, String> =
51+
Collections.synchronizedMap(WeakHashMap())
52+
private val currentRumViewIdentifier = AtomicReference<String?>(null)
53+
54+
@Synchronized
55+
override fun setCurrentScreen(identifier: String?) {
56+
val previous = currentRumViewIdentifier.getAndSet(identifier)
57+
if (previous != identifier) {
58+
rootScreenNamespaceCache.clear()
59+
viewPathDataCache.clear()
60+
}
61+
}
62+
63+
@Synchronized
64+
override fun onWindowRefreshed(root: View) {
65+
indexTree(root)
66+
}
67+
68+
@Synchronized
69+
override fun resolveViewIdentity(view: View): String? {
70+
val pathData = viewPathDataCache[view] ?: computePathDataOnDemand(view)?.also {
71+
viewPathDataCache[view] = it
72+
}
73+
return pathData?.identityHash
74+
}
75+
76+
private fun indexTree(root: View) {
77+
val screenNamespace = getScreenNamespace(root)
78+
79+
val rootPathSegment = getViewPathSegment(root, null)
80+
val rootCanonicalPath = "$appIdentifier/$screenNamespace/$rootPathSegment"
81+
82+
val rootIdentityHash = md5Hex(rootCanonicalPath) ?: return
83+
viewPathDataCache[root] = PathData(rootCanonicalPath, rootIdentityHash)
84+
85+
val stack = Stack<ViewWithCanonicalPath>()
86+
stack.push(ViewWithCanonicalPath(root, rootCanonicalPath))
87+
88+
while (stack.isNotEmpty()) {
89+
val (currentView, currentCanonicalPath) = stack.pop()
90+
if (currentView is ViewGroup) {
91+
val childCount = currentView.childCount
92+
for (i in 0 until childCount) {
93+
val childView = currentView.getChildAt(i)
94+
val childPathSegment = getViewPathSegment(childView, currentView)
95+
val childCanonicalPath = "$currentCanonicalPath/$childPathSegment"
96+
val childIdentityHash = md5Hex(childCanonicalPath) ?: continue
97+
viewPathDataCache[childView] = PathData(childCanonicalPath, childIdentityHash)
98+
stack.push(ViewWithCanonicalPath(childView, childCanonicalPath))
99+
}
100+
}
101+
}
102+
}
103+
104+
@Suppress("ReturnCount")
105+
private fun computePathDataOnDemand(view: View): PathData? {
106+
val ancestorChain = mutableListOf<ViewWithParent>()
107+
var currentView: View? = view
108+
var cachedAncestorPathData: PathData? = null
109+
110+
while (currentView != null) {
111+
val cached = viewPathDataCache[currentView]
112+
if (cached != null) {
113+
cachedAncestorPathData = cached
114+
break
115+
}
116+
val parentView = currentView.parent as? ViewGroup
117+
ancestorChain.add(ViewWithParent(currentView, parentView))
118+
currentView = parentView
119+
}
120+
121+
if (ancestorChain.isEmpty()) return cachedAncestorPathData
122+
123+
val startIndex: Int
124+
val baseCanonicalPath: String
125+
126+
if (cachedAncestorPathData != null) {
127+
baseCanonicalPath = cachedAncestorPathData.canonicalPath
128+
startIndex = ancestorChain.lastIndex
129+
} else {
130+
val (rootView, rootParentView) = ancestorChain.lastOrNull() ?: return null
131+
if (ancestorChain.size == 1 && rootParentView == null) return null
132+
133+
val screenNamespace = getScreenNamespace(rootView)
134+
val rootPathSegment = getViewPathSegment(rootView, null)
135+
val rootCanonicalPath = "$appIdentifier/$screenNamespace/$rootPathSegment"
136+
val rootIdentityHash = md5Hex(rootCanonicalPath) ?: return null
137+
viewPathDataCache[rootView] = PathData(rootCanonicalPath, rootIdentityHash)
138+
139+
baseCanonicalPath = rootCanonicalPath
140+
startIndex = ancestorChain.lastIndex - 1
141+
}
142+
143+
var canonicalPath = baseCanonicalPath
144+
for (i in startIndex downTo 0) {
145+
val (ancestorView, parentView) = ancestorChain[i]
146+
val pathSegment = getViewPathSegment(ancestorView, parentView)
147+
canonicalPath = "$canonicalPath/$pathSegment"
148+
val identityHash = md5Hex(canonicalPath) ?: return null
149+
viewPathDataCache[ancestorView] = PathData(canonicalPath, identityHash)
150+
}
151+
152+
return viewPathDataCache[view]
153+
}
154+
155+
private fun getScreenNamespace(rootView: View): String {
156+
rootScreenNamespaceCache[rootView]?.let { return it }
157+
158+
val screenNamespace = currentRumViewIdentifier.get()
159+
?.let { "$NAMESPACE_VIEW_PREFIX${escapePathComponent(it)}" }
160+
?: findActivity(rootView)?.let { "$NAMESPACE_ACTIVITY_PREFIX${escapePathComponent(it::class.java.name)}" }
161+
?: getResourceName(rootView)?.let { "$NAMESPACE_ROOT_ID_PREFIX${escapePathComponent(it)}" }
162+
?: "$NAMESPACE_ROOT_CLASS_PREFIX${escapePathComponent(rootView.javaClass.name)}"
163+
164+
rootScreenNamespaceCache[rootView] = screenNamespace
165+
return screenNamespace
166+
}
167+
168+
private fun getViewPathSegment(view: View, parentView: ViewGroup?): String {
169+
val resourceName = getResourceName(view)
170+
if (resourceName != null) return escapePathComponent(resourceName)
171+
172+
val indexAmongSameClassSiblings = if (parentView != null) {
173+
var precedingCount = 0
174+
val viewClass = view.javaClass
175+
for (i in 0 until parentView.childCount) {
176+
val siblingView = parentView.getChildAt(i)
177+
if (siblingView === view) break
178+
if (siblingView.javaClass == viewClass) precedingCount++
179+
}
180+
precedingCount
181+
} else {
182+
0
183+
}
184+
185+
return "$LOCAL_KEY_CLASS_PREFIX${escapePathComponent(view.javaClass.name)}#$indexAmongSameClassSiblings"
186+
}
187+
188+
private fun getResourceName(view: View): String? {
189+
val id = view.id
190+
if (id == View.NO_ID) return null
191+
192+
return resourceNameCache[id] ?: try {
193+
view.resources?.getResourceName(id)?.also { name ->
194+
resourceNameCache[id] = name
195+
}
196+
} catch (_: Resources.NotFoundException) {
197+
null
198+
}
199+
}
200+
201+
private data class ViewWithCanonicalPath(val view: View, val canonicalPath: String)
202+
private data class ViewWithParent(val view: View, val parentView: ViewGroup?)
203+
private data class PathData(val canonicalPath: String, val identityHash: String)
204+
205+
companion object {
206+
private const val RESOURCE_NAME_CACHE_SIZE = 500
207+
private const val DEFAULT_LOAD_FACTOR = 0.75f
208+
private const val NAMESPACE_VIEW_PREFIX = "view:"
209+
private const val NAMESPACE_ACTIVITY_PREFIX = "act:"
210+
private const val NAMESPACE_ROOT_ID_PREFIX = "root-id:"
211+
private const val NAMESPACE_ROOT_CLASS_PREFIX = "root-cls:"
212+
private const val LOCAL_KEY_CLASS_PREFIX = "cls:"
213+
}
214+
}
215+
216+
private fun escapePathComponent(input: String): String {
217+
return input.replace("%", "%25").replace("/", "%2F")
218+
}
219+
220+
private fun md5Hex(input: String): String? {
221+
return try {
222+
val messageDigest = MessageDigest.getInstance("MD5")
223+
messageDigest.update(input.toByteArray(Charsets.UTF_8))
224+
messageDigest.digest().toHexString()
225+
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: NoSuchAlgorithmException) {
226+
null
227+
}
228+
}
229+
230+
@Suppress("ReturnCount")
231+
private fun findActivity(view: View): Activity? {
232+
var ctx: Context? = view.context ?: return null
233+
while (ctx is ContextWrapper) {
234+
if (ctx is Activity) return ctx
235+
ctx = ctx.baseContext
236+
}
237+
return null
238+
}

0 commit comments

Comments
 (0)