Skip to content

Commit ccbe42a

Browse files
committed
RUMS-5681: Add support for heatmaps
1 parent 34820c5 commit ccbe42a

10 files changed

Lines changed: 1079 additions & 19 deletions

File tree

‎example/src/App.tsx‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@ import TraceScreen from './screens/TraceScreen';
88
import style from './screens/styles';
99
import { navigationRef } from './NavigationRoot';
1010
import { DdRumReactNavigationTracking, NavigationTrackingOptions, ParamsTrackingPredicate, ViewNamePredicate, ViewTrackingPredicate } from '@datadog/mobile-react-navigation';
11-
import { DatadogProvider, TrackingConsent, DdFlags } from '@datadog/mobile-react-native'
11+
import { DatadogProvider, TrackingConsent, DdFlags, DdLogs, DdSdkReactNative } from '@datadog/mobile-react-native'
1212
import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature';
13+
import {
14+
ImagePrivacyLevel,
15+
SessionReplay,
16+
TextAndInputPrivacyLevel,
17+
TouchPrivacyLevel,
18+
} from '@datadog/mobile-react-native-session-replay';
1319
import { OpenFeature, OpenFeatureProvider } from '@openfeature/react-sdk';
1420
import { Route } from "@react-navigation/native";
1521
import { NestedNavigator } from './screens/NestedNavigator/NestedNavigator';
16-
import { getDatadogConfig, onDatadogInitialization } from './ddUtils';
22+
import { getDatadogConfig } from './ddUtils';
1723

1824
const Tab = createBottomTabNavigator();
1925

@@ -69,7 +75,18 @@ const configuration = getDatadogConfig(TrackingConsent.GRANTED)
6975
// const configuration = new DatadogProviderConfiguration("fake_value", "fake_value");
7076

7177
const handleDatadogInitialization = async () => {
72-
onDatadogInitialization();
78+
DdLogs.info('The RN Sdk was properly initialized')
79+
DdSdkReactNative.setUserInfo({id: "1337", name: "Xavier", email: "xg@example.com", extraInfo: { type: "premium" } })
80+
DdSdkReactNative.addAttributes({campaign: "ad-network"})
81+
82+
// Enable Session Replay.
83+
await SessionReplay.enable({
84+
replaySampleRate: 100,
85+
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS,
86+
imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE,
87+
touchPrivacyLevel: TouchPrivacyLevel.SHOW,
88+
enableHeatmaps: true,
89+
});
7390

7491
// Enable Datadog Flags feature.
7592
await DdFlags.enable();

‎example/src/ddUtils.tsx‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,6 @@ export function getDatadogConfig(trackingConsent: TrackingConsent) {
5757
return config
5858
}
5959

60-
export function onDatadogInitialization() {
61-
DdLogs.info('The RN Sdk was properly initialized')
62-
DdSdkReactNative.setUserInfo({id: "1337", name: "Xavier", email: "xg@example.com", extraInfo: { type: "premium" } })
63-
DdSdkReactNative.addAttributes({campaign: "ad-network"})
64-
}
65-
6660
// Legacy SDK Setup
6761
export function initializeDatadog(trackingConsent: TrackingConsent) {
6862

‎packages/core/android/src/main/kotlin/com/datadog/reactnative/DdRumImplementation.kt‎

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
package com.datadog.reactnative
88

9+
import android.os.Handler
10+
import android.os.Looper
911
import android.util.Log
1012
import com.datadog.android.rum.RumActionType
1113
import com.datadog.android.rum.RumAttributes
1214
import com.datadog.android.rum.RumErrorSource
1315
import com.datadog.android.rum.RumResourceKind
1416
import com.datadog.android.rum.RumResourceMethod
17+
import com.datadog.android.rum._RumInternalProxy
1518
import com.datadog.android.rum.featureoperations.FailureReason
1619
import com.facebook.react.bridge.Promise
1720
import com.facebook.react.bridge.ReadableArray
@@ -22,7 +25,13 @@ import java.util.Locale
2225
* The entry point to use Datadog's RUM feature.
2326
*/
2427
@Suppress("TooManyFunctions")
25-
class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrapper()) {
28+
class DdRumImplementation internal constructor(
29+
private val datadog: DatadogWrapper = DatadogSDKWrapper(),
30+
private val heatmapTouchResolver: HeatmapTouchResolver = HeatmapTouchResolver(),
31+
private val mainThreadExecutor: (() -> Unit) -> Unit = { action ->
32+
Handler(Looper.getMainLooper()).post(action)
33+
}
34+
) {
2635
/**
2736
* Start tracking a RUM View.
2837
* @param key The view unique key identifier.
@@ -62,6 +71,16 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe
6271
key = key,
6372
attributes = attributes
6473
)
74+
// KNOWN GAP (dd-sdk-android): Session Replay only emits a ViewEnd record for a view when
75+
// a later snapshot's RUM context resolves to a *different* view. The last view of any SR
76+
// session (heatmap or otherwise) never gets a ViewEnd, because SessionReplayFeature.onStop()
77+
// clears the recording queue instead of flushing a final ViewEnd for it, and there is no
78+
// "next" snapshot to diff against. This can't be fixed from the RN wrapper — forcing a
79+
// redraw here does not help: a snapshot taken with no active RUM view is dropped before
80+
// reaching the diff logic (RumContextDataHandler returns null for an invalid RUM context),
81+
// so it produces no ViewEnd, just a wasted draw and a spurious internal error log. Fixing
82+
// this requires dd-sdk-android itself to flush a synthetic ViewEnd for the previous RUM
83+
// context when recording stops.
6584
promise.resolve(null)
6685
}
6786

@@ -117,14 +136,21 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe
117136

118137
/**
119138
* Add a RUM Action.
139+
*
140+
* When heatmaps are enabled and this is a TAP action with touch data, the current view URL is
141+
* read synchronously (before any async view transition can change it), and `touch`'s fields
142+
* are extracted defensively: `touch` is built on the JS side from a caller-supplied gesture
143+
* event whose fields aren't guaranteed to be present, since `addAction`'s action context is
144+
* public API. If any of these lookups fail, this falls back to a plain action — heatmaps are
145+
* checked first since that's the cheapest condition and most likely to be false, short-
146+
* circuiting the rest for the common non-heatmap session.
120147
* @param type The action type (tap, scroll, swipe, click, custom).
121148
* @param name The action name.
122149
* @param touch The native touch data for tap actions, or null for other action types.
123-
* Currently unused on Android; reserved for heatmap support.
124150
* @param context The additional context to send.
125151
* @param timestampMs The timestamp when the action occurred (in milliseconds). If not provided, current timestamp will be used.
126152
*/
127-
@Suppress("LongParameterList", "UnusedParameter")
153+
@Suppress("LongParameterList")
128154
fun addAction(
129155
type: String,
130156
name: String,
@@ -136,12 +162,68 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe
136162
val attributes = context.toHashMap().toMutableMap().apply {
137163
put(RumAttributes.INTERNAL_TIMESTAMP, timestampMs.toLong())
138164
}
165+
166+
val eligibleAction = resolveHeatmapEligibility(type, touch)
167+
if (eligibleAction == null) {
168+
plainAddAction(type, name, attributes)
169+
promise.resolve(null)
170+
return
171+
}
172+
173+
// Promise is resolved before dispatching to main thread, matching the iOS pattern.
174+
promise.resolve(null)
175+
176+
mainThreadExecutor {
177+
val heatmapData = heatmapTouchResolver.resolve(
178+
eligibleAction.reactTag,
179+
eligibleAction.positionX,
180+
eligibleAction.positionY,
181+
eligibleAction.viewUrl
182+
)
183+
if (heatmapData != null) {
184+
eligibleAction.internalProxy.addActionWithHeatmap(
185+
type = type.asRumActionType(),
186+
name = name,
187+
crossPlatformHeatmapActionData = heatmapData,
188+
attributes = attributes
189+
)
190+
} else {
191+
plainAddAction(type, name, attributes)
192+
}
193+
}
194+
}
195+
196+
private data class HeatmapEligibleAction(
197+
val internalProxy: _RumInternalProxy,
198+
val viewUrl: String,
199+
val reactTag: Int,
200+
val positionX: Long,
201+
val positionY: Long
202+
)
203+
204+
private fun resolveHeatmapEligibility(type: String, touch: ReadableMap?): HeatmapEligibleAction? {
205+
if (!heatmapsEnabled || touch == null || !type.equals("tap", ignoreCase = true)) {
206+
return null
207+
}
208+
209+
val internalProxy = datadog.getRumMonitor()._getInternal()
210+
val viewUrl = internalProxy?.getCurrentViewUrl()
211+
val touchFields = touch.toTouchFieldsOrNull()
212+
213+
return if (internalProxy != null && viewUrl != null && touchFields != null) {
214+
val (reactTag, positionX, positionY) = touchFields
215+
HeatmapEligibleAction(internalProxy, viewUrl, reactTag, positionX, positionY)
216+
} else {
217+
null
218+
}
219+
}
220+
221+
private fun plainAddAction(type: String, name: String, attributes: Map<String, Any?>) {
139222
datadog.getRumMonitor().addAction(
140223
type = type.asRumActionType(),
141224
name = name,
142225
attributes = attributes
143226
)
144-
promise.resolve(null)
145227
}
146228

147229
/**
@@ -459,10 +541,33 @@ class DdRumImplementation(private val datadog: DatadogWrapper = DatadogSDKWrappe
459541
}
460542
}
461543

544+
private fun ReadableMap.toTouchFieldsOrNull(): Triple<Int, Long, Long>? {
545+
if (!hasKey("reactTag") || !hasKey("x") || !hasKey("y")) return null
546+
return runCatching {
547+
Triple(getInt("reactTag"), getDouble("x").toLong(), getDouble("y").toLong())
548+
}.getOrNull()
549+
}
550+
462551
// endregion
552+
463553
@Suppress("UndocumentedPublicClass")
464554
companion object {
465555
private const val MISSING_RESOURCE_SIZE = -1L
466556
internal const val NAME = "DdRum"
557+
558+
/**
559+
* Whether heatmap data should be attached to TAP actions.
560+
*/
561+
@Volatile
562+
var heatmapsEnabled: Boolean = false
563+
private set
564+
565+
/**
566+
* Sets whether heatmap data should be attached to TAP actions. Called exclusively by
567+
* [com.datadog.reactnative.sessionreplay.DdSessionReplayImplementation.enable].
568+
*/
569+
fun setHeatmapsEnabled(enabled: Boolean) {
570+
heatmapsEnabled = enabled
571+
}
467572
}
468573
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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.reactnative
8+
9+
import android.content.res.Resources
10+
import android.view.View
11+
import android.view.ViewGroup
12+
import com.datadog.android.heatmaps.CrossPlatformHeatmapActionData
13+
14+
/**
15+
* Resolves a React Native touch event into a [CrossPlatformHeatmapActionData] by walking the
16+
* Android view hierarchy to find the nearest valid tap-target ancestor, building the element path,
17+
* and packaging the result for [com.datadog.android.rum._RumInternalProxy.addActionWithHeatmap].
18+
*/
19+
internal class HeatmapTouchResolver(
20+
private val viewResolver: (Int) -> View? = { null }
21+
) {
22+
23+
/**
24+
* Returns [CrossPlatformHeatmapActionData] for the given tap, or null if no valid tap-target
25+
* ancestor could be found (in which case the caller should fall back to a plain addAction).
26+
*
27+
* The tap target is the nearest clickable-and-visible ancestor of the touched view: Session
28+
* Replay's `HeatmapIdentifierResolver` only assigns a `permanentId` to such views, so an inner
29+
* non-clickable child would produce a path that never matches an SR wireframe. The element
30+
* path is built root-first using the same path-component convention as that resolver, so the
31+
* resulting SHA-256 identifier matches the `permanentId` assigned to the SR wireframe.
32+
*/
33+
fun resolve(
34+
reactTag: Int,
35+
positionX: Long,
36+
positionY: Long,
37+
viewUrl: String
38+
): CrossPlatformHeatmapActionData? = runCatching {
39+
val view = viewResolver(reactTag)?.let { clickableVisibleAncestorOf(it) }
40+
val elementPath = view?.let { elementPathFromRootTo(it) }
41+
42+
if (view != null && !elementPath.isNullOrEmpty()) {
43+
val density = view.resources?.displayMetrics?.density ?: 1f
44+
val targetWidth = (view.width / density).toLong().takeIf { it > 0 }
45+
val targetHeight = (view.height / density).toLong().takeIf { it > 0 }
46+
47+
CrossPlatformHeatmapActionData(
48+
elementPath = elementPath,
49+
viewUrl = viewUrl,
50+
positionX = positionX,
51+
positionY = positionY,
52+
targetWidth = targetWidth,
53+
targetHeight = targetHeight
54+
)
55+
} else {
56+
null
57+
}
58+
}.getOrNull()
59+
60+
// region Private helpers
61+
62+
private fun clickableVisibleAncestorOf(view: View): View? {
63+
var current: View? = view
64+
while (current != null) {
65+
if (current.isClickable && current.visibility == View.VISIBLE) {
66+
return current
67+
}
68+
current = current.parent as? View
69+
}
70+
return null
71+
}
72+
73+
private fun elementPathFromRootTo(view: View): List<String> {
74+
val path = mutableListOf<String>()
75+
var current: View? = view
76+
while (current != null) {
77+
val parent = current.parent as? ViewGroup
78+
val typeIndex = if (parent != null) computeTypeIndex(current, parent) else 0
79+
path.add(pathComponentFor(current, typeIndex))
80+
current = parent
81+
}
82+
path.reverse()
83+
return path
84+
}
85+
86+
private fun computeTypeIndex(view: View, parent: ViewGroup): Int {
87+
val cls = view.javaClass
88+
var index = 0
89+
for (i in 0 until parent.childCount) {
90+
val child = parent.getChildAt(i) ?: continue
91+
if (child === view) break
92+
if (child.javaClass === cls) index++
93+
}
94+
return index
95+
}
96+
97+
private fun pathComponentFor(view: View, typeIndex: Int): String {
98+
val viewId = view.id
99+
if (viewId != View.NO_ID) {
100+
try {
101+
@Suppress("UnsafeThirdPartyFunctionCall")
102+
val name = view.resources?.getResourceName(viewId)
103+
if (!name.isNullOrEmpty()) {
104+
return "$name#$typeIndex"
105+
}
106+
} catch (_: Resources.NotFoundException) {
107+
}
108+
}
109+
return "cls:${view.javaClass.name}#$typeIndex"
110+
}
111+
112+
// endregion
113+
}

‎packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdRum.kt‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import com.facebook.react.bridge.ReactApplicationContext
1212
import com.facebook.react.bridge.ReactMethod
1313
import com.facebook.react.bridge.ReadableArray
1414
import com.facebook.react.bridge.ReadableMap
15+
import com.facebook.react.uimanager.UIManagerHelper
16+
import com.facebook.react.uimanager.common.ViewUtil
1517

1618
/**
1719
* The entry point to use Datadog's RUM feature.
@@ -22,7 +24,18 @@ class DdRum(
2224
datadogWrapper: DatadogWrapper = DatadogSDKWrapper()
2325
) : NativeDdRumSpec(reactContext) {
2426

25-
private val implementation = DdRumImplementation(datadog = datadogWrapper)
27+
private val implementation = DdRumImplementation(
28+
datadog = datadogWrapper,
29+
heatmapTouchResolver = HeatmapTouchResolver(viewResolver = { reactTag ->
30+
try {
31+
val uiManagerType = ViewUtil.getUIManagerType(reactTag)
32+
UIManagerHelper.getUIManager(reactApplicationContext, uiManagerType)
33+
?.resolveView(reactTag)
34+
} catch (_: Exception) {
35+
null
36+
}
37+
})
38+
)
2639

2740
override fun getName(): String = DdRumImplementation.NAME
2841

@@ -105,7 +118,6 @@ class DdRum(
105118
* @param type The action type (tap, scroll, swipe, click, custom).
106119
* @param name The action name.
107120
* @param touch The native touch data for tap actions, or null for other action types.
108-
* Currently unused on Android; reserved for heatmap support.
109121
* @param context The additional context to send.
110122
* @param timestampMs The timestamp when the action occurred (in milliseconds).
111123
* If not provided, current timestamp will be used.

0 commit comments

Comments
 (0)