Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benchmarks/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2095,7 +2095,7 @@ SPEC CHECKSUMS:
DatadogLogs: 20381f731050c500c85d36f4799cf0f28a56915e
DatadogRUM: 4f863c5330d85d6b54bb372d830929ad3b5006ab
DatadogSDKReactNative: 2d03d9524f43e93a40a466b53982bd303b045e1a
DatadogSDKReactNativeSessionReplay: 9225e86628c76a06d11c80200e06f10738018e59
DatadogSDKReactNativeSessionReplay: 1127faae3e8ee11e6b4b480053e1fbfe382f653d
DatadogSDKReactNativeWebView: 25996d34b0b283c37d98e2e079e00b925d09edd1
DatadogSessionReplay: aef317ccd62a089691899bc7153177deec3c66d6
DatadogTrace: c51a2bde28197cb1bab50031c83a82e3a1dbcf30
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function SessionReplayScenario(props: SessionReplayScenarioProps): React.JSX.Ele
textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS,
imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE,
touchPrivacyLevel: TouchPrivacyLevel.SHOW,
enableHeatmaps: true,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ℹ️ Enabling heatmaps on the benchmarks app

}).then(() => {
setIsReady(true);
console.log("Session replay - start recording");
Expand Down
22 changes: 19 additions & 3 deletions packages/core/ios/Sources/DdRumImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public class DdRumImplementation: NSObject {
lazy var heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = heatmapIdentifierRegistryProvider()
private let mainDispatchQueue: DispatchQueueType
private let uiManager: RCTUIManager
private let rootViewProvider: () -> UIView?
private let heatmapIdentifierRegistryProvider: () -> HeatmapIdentifierRegistry?
private let rumProvider: () -> RUMMonitorProtocol
private let rumInternalProvider: () -> RUMMonitorInternalProtocol?
Expand All @@ -104,12 +105,14 @@ public class DdRumImplementation: NSObject {
internal init(
mainDispatchQueue: DispatchQueueType,
uiManager: RCTUIManager,
rootViewProvider: @escaping () -> UIView?,
heatmapIdentifierRegistryProvider: @escaping () -> HeatmapIdentifierRegistry?,
rumProvider: @escaping () -> RUMMonitorProtocol,
rumInternalProvider: @escaping () -> RUMMonitorInternalProtocol?
) {
self.mainDispatchQueue = mainDispatchQueue
self.uiManager = uiManager
self.rootViewProvider = rootViewProvider
self.heatmapIdentifierRegistryProvider = heatmapIdentifierRegistryProvider
self.rumProvider = rumProvider
self.rumInternalProvider = rumInternalProvider
Expand All @@ -120,6 +123,7 @@ public class DdRumImplementation: NSObject {
self.init(
mainDispatchQueue: DispatchQueue.main,
uiManager: bridge.uiManager,
rootViewProvider: { UIWindow.reactRootView() },
heatmapIdentifierRegistryProvider: { CoreRegistry.default.heatmapIdentifierRegistry },
rumProvider: { RUMMonitor.shared() },
rumInternalProvider: { RUMMonitor.shared()._internal }
Expand Down Expand Up @@ -156,14 +160,17 @@ public class DdRumImplementation: NSObject {
let touch,
let reactTag = touch["reactTag"] as? NSNumber,
let x = touch["x"] as? NSNumber,
let y = touch["y"] as? NSNumber
let y = touch["y"] as? NSNumber,
let pageX = touch["pageX"] as? NSNumber,
let pageY = touch["pageY"] as? NSNumber
{
addAction(
at: Date(timeIntervalSince1970: timestampMs / 1_000),
type: RUMActionType(from: type),
name: name,
reactTag: reactTag,
location: .init(x: CGFloat(truncating: x), y: CGFloat(truncating: y)),
pageLocation: .init(x: CGFloat(truncating: pageX), y: CGFloat(truncating: pageY)),
attributes: castAttributesToSwift(context)
)
} else {
Expand Down Expand Up @@ -362,10 +369,19 @@ public class DdRumImplementation: NSObject {
name: String,
reactTag: NSNumber,
location: CGPoint,
pageLocation: CGPoint,
attributes: [AttributeKey: AttributeValue]
) {
mainDispatchQueue.async { [uiManager, heatmapIdentifierRegistry, rumInternal] in
let heatmapAttributes: HeatmapAttributes? = uiManager.view(forReactTag: reactTag).flatMap { view in
mainDispatchQueue.async { [uiManager, rootViewProvider, heatmapIdentifierRegistry, rumInternal] in
var location = location
let view = uiManager.view(
forReactTag: reactTag,
location: &location,
pageLocation: pageLocation,
rootViewProvider: rootViewProvider
)

let heatmapAttributes: HeatmapAttributes? = view.flatMap { view in
guard let identifier = heatmapIdentifierRegistry?.heatmapIdentifier(for: ObjectIdentifier(view)) else {
return nil
}
Expand Down
88 changes: 88 additions & 0 deletions packages/core/ios/Sources/RNViewResolution.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

import React
import UIKit

internal extension UIWindow {
/// Returns the React Native root view in the current app's key window, if any.
static func reactRootView() -> UIView? {
return UIApplication.shared.activeKeyWindow?.findReactRootView()
}

private func findReactRootView() -> UIView? {
return findReactRootView(in: self)
}

private func findReactRootView(in view: UIView) -> UIView? {
if view.isReactRootView() {
return view
}
for subview in view.subviews {
if let root = findReactRootView(in: subview) {
return root
}
}
return nil
}
}

private extension UIApplication {
var activeKeyWindow: UIWindow? {
if #available(iOS 13.0, *) {
let activeScene = connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })
if #available(iOS 15.0, *) {
return activeScene?.keyWindow
}
return activeScene?.windows.first(where: { $0.isKeyWindow })
} else {
return windows.first(where: { $0.isKeyWindow })
}
}
}

internal extension RCTUIManager {
/// Returns the view for a given `reactTag`, with a hit-test fallback when the tag cannot be resolved.
///
/// Tries `view(forReactTag:)` first (fast path). If it returns `nil`, falls back to a hit-test
/// on the RN root view using `pageLocation` and overwrites `location` with the tap point in
/// the hit-tested view's coordinate space. This hardens the integration against RN potentially
/// deprecating `reactTag` view lookup on Fabric.
///
/// - Parameters:
/// - reactTag: The reactTag reported by the JS `GestureResponderEvent`.
/// - location: On input, the tap location in the original target view's coordinate space
/// (from `locationX/Y`). On output, the tap location in the resolved view's coordinate
/// space — unchanged on the fast path, recomputed when the fallback is used.
/// - pageLocation: The tap location in the RN root view's coordinate space (from `pageX/Y`).
/// RN computes these by converting the touch point from window to root-view coordinates,
/// so no additional conversion is needed before hit-testing.
/// - rootViewProvider: Closure returning the RN root view to hit-test against.
func view(
forReactTag reactTag: NSNumber,
location: inout CGPoint,
pageLocation: CGPoint,
rootViewProvider: () -> UIView?
) -> UIView? {
if let view = view(forReactTag: reactTag) {
return view
}
DdTelemetry.telemetryDebug(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could this end up being too noisy at some point?

I'm not sure if that's a problem but just in case.

Copy link
Copy Markdown
Contributor Author

@gonzalezreal gonzalezreal Apr 23, 2026

Choose a reason for hiding this comment

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

The id parameter de-duplicates entries, right?

id: "datadog_react_native:heatmap_fallback",
message: "Heatmap view resolution fell back to hit-test"
)
guard
let rootView = rootViewProvider(),
let hitView = rootView.hitTest(pageLocation, with: nil)
else {
return nil
}
location = rootView.convert(pageLocation, to: hitView)
Comment thread
gonzalezreal marked this conversation as resolved.
return hitView
}
}
80 changes: 74 additions & 6 deletions packages/core/ios/Tests/DdRumTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import React
internal class DdRumTests: XCTestCase {
private let mockNativeRUM = MockRUMMonitor()
private let mockUIManager = MockUIManager()
private let mockRootView = MockRootView()
private let mockHeatmapIdentifierRegistry = MockHeatmapIdentifierRegistry()
private var rum: DdRumImplementation! // swiftlint:disable:this implicitly_unwrapped_optional

private func mockResolve(args: Any?) {}
private func mockReject(args: String?, arg: String?, err: Error?) {}

Expand All @@ -27,6 +28,7 @@ internal class DdRumTests: XCTestCase {
rum = DdRumImplementation(
mainDispatchQueue: DispatchQueueMock(),
uiManager: self.mockUIManager,
rootViewProvider: { self.mockRootView },
heatmapIdentifierRegistryProvider: { self.mockHeatmapIdentifierRegistry },
rumProvider: { self.mockNativeRUM },
rumInternalProvider: { self.mockNativeRUM._internalMock }
Comment thread
gonzalezreal marked this conversation as resolved.
Expand All @@ -40,6 +42,7 @@ internal class DdRumTests: XCTestCase {
let rum = DdRumImplementation(
mainDispatchQueue: DispatchQueueMock(),
uiManager: MockUIManager(),
rootViewProvider: { nil },
heatmapIdentifierRegistryProvider: { nil },
rumProvider: { [unowned self] in
expectation.fulfill()
Expand Down Expand Up @@ -141,7 +144,9 @@ internal class DdRumTests: XCTestCase {
let touch: NSDictionary = [
"reactTag": 42,
"x": 10.0,
"y": 20.0
"y": 20.0,
"pageX": 100.0,
"pageY": 200.0
]

// When
Expand Down Expand Up @@ -172,13 +177,24 @@ internal class DdRumTests: XCTestCase {
)
}

func testAddActionWithTouchAndUnknownReactTag() throws {
func testAddActionFallsBackToHitTestWhenReactTagNotFound() throws {
// Given
let hitView = UIView(frame: CGRect(x: 50, y: 100, width: 200, height: 50))
mockRootView.addSubview(hitView)
mockRootView.hitTestResult = hitView

let identifier = HeatmapIdentifier(rawValue: "abc123")
mockHeatmapIdentifierRegistry.identifiers[ObjectIdentifier(hitView)] = identifier

let touch: NSDictionary = [
"reactTag": 999,
"x": 10.0,
"y": 20.0
"y": 20.0,
"pageX": 130.0,
"pageY": 220.0
]


// When
rum.addAction(
type: "tap",
name: "tap action",
Expand All @@ -188,7 +204,49 @@ internal class DdRumTests: XCTestCase {
resolve: mockResolve,
reject: mockReject
)


// Then
XCTAssertEqual(mockNativeRUM.calledMethods.count, 1)
XCTAssertEqual(mockRootView.receivedHitTestPoints, [CGPoint(x: 130, y: 220)])
XCTAssertEqual(
mockNativeRUM.calledMethods.last,
.addAction(
time: Date(timeIntervalSince1970: randomTimestamp / 1_000),
type: .tap,
name: "tap action",
heatmapAttributes: HeatmapAttributes(
identifier: identifier,
size: CGSize(width: 200, height: 50),
location: CGPoint(x: 80, y: 120)
)
)
)
}

func testAddActionWithTouchWhenFallbackHitTestMisses() throws {
// Given
mockRootView.hitTestResult = nil

let touch: NSDictionary = [
"reactTag": 999,
"x": 10.0,
"y": 20.0,
"pageX": 100.0,
"pageY": 200.0
]

// When
rum.addAction(
type: "tap",
name: "tap action",
touch: touch,
context: [:],
timestampMs: randomTimestamp,
resolve: mockResolve,
reject: mockReject
)

// Then
XCTAssertEqual(mockNativeRUM.calledMethods.count, 1)
XCTAssertEqual(
mockNativeRUM.calledMethods.last,
Expand Down Expand Up @@ -444,6 +502,16 @@ private class MockUIManager: RCTUIManager {
}
}

private class MockRootView: UIView {
var hitTestResult: UIView?
var receivedHitTestPoints: [CGPoint] = []

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
receivedHitTestPoints.append(point)
return hitTestResult
}
}

private final class MockHeatmapIdentifierRegistry: @unchecked Sendable, HeatmapIdentifierRegistry {
@ReadWriteLock
var identifiers: [ObjectIdentifier: HeatmapIdentifier] = [:]
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/rum/DdRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ type TouchData = {
reactTag: number;
x: number;
y: number;
pageX: number;
pageY: number;
};

const touchDataFromEvent = (
Expand All @@ -68,7 +70,9 @@ const touchDataFromEvent = (
return {
reactTag: Number(nativeEvent.target),
x: nativeEvent.locationX,
y: nativeEvent.locationY
y: nativeEvent.locationY,
pageX: nativeEvent.pageX,
pageY: nativeEvent.pageY
};
};

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/rum/__tests__/DdRum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,9 @@ describe('DdRum', () => {
nativeEvent: {
target: 42,
locationX: 10,
locationY: 20
locationY: 20,
pageX: 100,
pageY: 200
}
} as unknown) as GestureResponderEvent;

Expand All @@ -1728,7 +1730,7 @@ describe('DdRum', () => {
expect(NativeModules.DdRum.addAction).toHaveBeenCalledWith(
'TAP',
'tap button',
{ reactTag: 42, x: 10, y: 20 },
{ reactTag: 42, x: 10, y: 20, pageX: 100, pageY: 200 },
{},
expect.anything()
);
Expand Down
Loading