Skip to content

PANA-5681: Add ViewIdentityResolver infrastructure#3202

Open
gonzalezreal wants to merge 1 commit intofeature/heatmapsfrom
gonzalezreal/PANA-5681/view-identity-resolver
Open

PANA-5681: Add ViewIdentityResolver infrastructure#3202
gonzalezreal wants to merge 1 commit intofeature/heatmapsfrom
gonzalezreal/PANA-5681/view-identity-resolver

Conversation

@gonzalezreal
Copy link

@gonzalezreal gonzalezreal commented Feb 25, 2026

What does this PR do?

Adds ViewIdentityResolver interface and ViewIdentityResolverImpl to dd-sdk-android-internal. This component assigns stable, globally unique identifiers to Android Views by hashing their canonical paths with MD5. These identifiers will be used to correlate RUM actions with Session Replay wireframes for heatmap visualization.

Motivation

Support mobile heatmaps. This is PR 1 of 4, the foundational infrastructure that RUM and Session Replay will build on.

Additional Notes

Ported from #3164. Self-contained with no dependency on schema changes.

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cebbd7bc94

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/**
* Sets the current screen identifier. Takes precedence over Activity-based detection.
* @param identifier the screen identifier (typically RUM view URL), or null to clear
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really clear it? if so what's the case?

@MockitoSettings(strictness = Strictness.LENIENT)
internal class ViewIdentityResolverTest {

private lateinit var testedManager: ViewIdentityResolverImpl
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private lateinit var testedManager: ViewIdentityResolverImpl
private lateinit var testedViewIdentityResolver: ViewIdentityResolverImpl

* Cleared when screen changes (namespace depends on currentRumViewIdentifier).
*/
@Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null
private val rootScreenNamespaceCache: MutableMap<View, String> =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel the name is a bit confusing here, "screen" is a concept of heatmaps which represents the whole visible interface according to the RFC, "rootView" is a Android term meaning the root of the view tree hireacy. I think here is more like "screenNamespaceCache indexed by rootView"


/** Priority 1: Use RUM view identifier if available (set via RumMonitor.startView). */
private fun getNamespaceFromRumView(): String? {
return currentRumViewIdentifier.get()?.let { viewName ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return currentRumViewIdentifier.get()?.let { viewName ->
return currentRumViewIdentifier.get()?.let { viewUrl ->

/** Priority 1: Use RUM view identifier if available (set via RumMonitor.startView). */
private fun getNamespaceFromRumView(): String? {
return currentRumViewIdentifier.get()?.let { viewName ->
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}"
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewUrl)}"

return input.replace("%", "%25").replace("/", "%2F")
}

private fun md5Hex(input: String): String? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have the exact same logic in MD5HashGenerator, maybe it's time to move it into internal and reuse it here.

Copy link
Member

@0xnm 0xnm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went only through the production source files so far and left some comments.

fun resolveViewIdentity(android.view.View): String?
companion object
const val FEATURE_CONTEXT_KEY: String
class com.datadog.android.internal.identity.ViewIdentityResolverImpl : ViewIdentityResolver
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a small trick we use to not expose Impl classes is to create a property (in this case it will be a function create(arg)) in the companion object of ViewIdentityResolver.

fun onWindowRefreshed(root: View)

/**
* Resolves the stable identity for a view (32 hex chars), or null if the view is detached.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: probably we can omit 32 hex chars from the description, it is implementation detail

/**
* Key used to store the ViewIdentityResolver instance in the feature context.
*/
const val FEATURE_CONTEXT_KEY: String = "_dd.view_identity_resolver"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is quite dangerous. What is the purpose to store such object in the feature context given that we have direct access to the type?

* resources.getResourceName() which is expensive.
*/
@Suppress("UnsafeThirdPartyFunctionCall") // LinkedHashMap constructor doesn't throw
private val resourceNameCache: MutableMap<Int, String> = Collections.synchronizedMap(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think synchronizedMap is a good choice here, maybe ConcurrentHashMap is better, given that after some time the number of reads will be much bigger than number of writes.

@Suppress("UnsafeThirdPartyFunctionCall") // LinkedHashMap constructor doesn't throw
private val resourceNameCache: MutableMap<Int, String> = Collections.synchronizedMap(
object : LinkedHashMap<Int, String>(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>?): Boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is androidx.cache.LruCache class though


@Synchronized
override fun setCurrentScreen(identifier: String?) {
@Suppress("UnsafeThirdPartyFunctionCall") // type-safe: generics prevent VarHandle type mismatches
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should declare this call as safe instead, there is already java.util.concurrent.atomic.AtomicBoolean.getAndSet(kotlin.Boolean) declared.

/** The current RUM view identifier, set via setCurrentScreen(). */
private val currentRumViewIdentifier = AtomicReference<String?>(null)

@Synchronized
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is no need for the @Synchronized, AtomicReference.getAndSet already does atomic check guaranteeing the absence of concurency.

Comment on lines +88 to +94
@Synchronized
override fun onWindowRefreshed(root: View) {
indexTree(root)
}

@Synchronized
override fun resolveViewIdentity(view: View): String? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given these will be called on the main thread, we should avoid using Synchronized and use more granular synchronization primitives

/** Depth-first traversal of view hierarchy, computing and caching identity for each view. */
private fun traverseAndIndexViews(root: View, rootCanonicalPath: String) {
// Index the root view (all cache insertions happen here for consistency)
md5Hex(rootCanonicalPath)?.let { hash ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a point about SHA-1 vs MD5 in the RFC, did we expore it?

}
}

/** Priority 2: Fall back to Activity class name if root view has Activity context. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: probably we need to remove such comments, they don't really add any value

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants