66
77package com.datadog.reactnative
88
9+ import android.os.Handler
10+ import android.os.Looper
911import android.util.Log
1012import com.datadog.android.rum.RumActionType
1113import com.datadog.android.rum.RumAttributes
1214import com.datadog.android.rum.RumErrorSource
1315import com.datadog.android.rum.RumResourceKind
1416import com.datadog.android.rum.RumResourceMethod
17+ import com.datadog.android.rum._RumInternalProxy
1518import com.datadog.android.rum.featureoperations.FailureReason
1619import com.facebook.react.bridge.Promise
1720import 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}
0 commit comments