From 4174d09edbb62169827fb1c6316d0e58ab9fd7dc Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Wed, 21 Feb 2024 08:54:18 +0100 Subject: [PATCH 01/69] RUM-2816 WebView Record Receiver --- Datadog/Datadog.xcodeproj/project.pbxproj | 20 ++++ .../WebRecordIntegrationTests.swift | 92 +++++++++++++++ .../Integrations/WebViewEventReceiver.swift | 47 +++----- .../Sources/Feature/Baggages.swift | 2 +- .../Sources/Feature/RUMContextReceiver.swift | 2 +- .../RequestBuilders/JSON/SegmentJSON.swift | 2 +- .../Feature/SessionReplayFeature.swift | 10 +- .../Feature/WebViewRecordReceiver.swift | 73 ++++++++++++ .../Processor/Diffing/Diff+SRWireframes.swift | 2 + .../UnsupportedViewRecorder.swift | 1 - .../ViewTreeSnapshotBuilder.swift | 1 + .../Feature/WebViewRecordReceiverTests.swift | 110 ++++++++++++++++++ .../UnsupportedViewRecorderTests.swift | 4 +- .../IntegrationTests.swift | 1 - ...RMultipleViewsRecordingScenarioTests.swift | 3 +- 15 files changed, 324 insertions(+), 46 deletions(-) create mode 100644 Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift create mode 100644 DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift create mode 100644 DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 8d6665944f..1dc35e9389 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -1229,6 +1229,9 @@ D2C1A57429C4F30000946C31 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2DA2385298D57AA00C6C7E6 /* DatadogInternal.framework */; }; D2C5D5282B83FD5300B63F36 /* WebViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AE740F2AD6EE4E008DB9BB /* WebViewMessageTests.swift */; }; D2C5D5292B83FD5400B63F36 /* WebViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AE740F2AD6EE4E008DB9BB /* WebViewMessageTests.swift */; }; + D2C5D52B2B84F6AB00B63F36 /* WebViewRecordReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C5D52A2B84F6AB00B63F36 /* WebViewRecordReceiver.swift */; }; + D2C5D52D2B84F6D800B63F36 /* WebViewRecordReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C5D52C2B84F6D800B63F36 /* WebViewRecordReceiverTests.swift */; }; + D2C5D5302B84F71200B63F36 /* WebRecordIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C5D52F2B84F71200B63F36 /* WebRecordIntegrationTests.swift */; }; D2C7E3AB28F97DCF0023B2CC /* BatteryStatusPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C7E3AA28F97DCF0023B2CC /* BatteryStatusPublisherTests.swift */; }; D2C7E3AE28FEBDA10023B2CC /* LaunchTimePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C7E3AD28FEBDA10023B2CC /* LaunchTimePublisher.swift */; }; D2CB6E0C27C50EAE00A62B57 /* DatadogCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 61133B85242393DE00786299 /* DatadogCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -2700,6 +2703,9 @@ D2BEEDB72B3360F50065F3AC /* URLSessionTaskDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionTaskDelegateSwizzlerTests.swift; sourceTree = ""; }; D2C1A55A29C4F2DF00946C31 /* DatadogTrace.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogTrace.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D2C1A57329C4F2E800946C31 /* DatadogTraceTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogTraceTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + D2C5D52A2B84F6AB00B63F36 /* WebViewRecordReceiver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewRecordReceiver.swift; sourceTree = ""; }; + D2C5D52C2B84F6D800B63F36 /* WebViewRecordReceiverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewRecordReceiverTests.swift; sourceTree = ""; }; + D2C5D52F2B84F71200B63F36 /* WebRecordIntegrationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebRecordIntegrationTests.swift; sourceTree = ""; }; D2C7E3AA28F97DCF0023B2CC /* BatteryStatusPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryStatusPublisherTests.swift; sourceTree = ""; }; D2C7E3AD28FEBDA10023B2CC /* LaunchTimePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchTimePublisher.swift; sourceTree = ""; }; D2CB6ED127C50EAE00A62B57 /* DatadogCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3301,6 +3307,7 @@ A73A54972B16406900E1F7E3 /* ResourcesFeature.swift */, 61054E3C2A6EE10A00AAA894 /* SessionReplayFeature.swift */, 61054E3E2A6EE10A00AAA894 /* RUMContextReceiver.swift */, + D2C5D52A2B84F6AB00B63F36 /* WebViewRecordReceiver.swift */, 61054E3F2A6EE10A00AAA894 /* SRContextPublisher.swift */, D22C5BCD2A98A65D0024CC1F /* Baggages.swift */, 61054E402A6EE10A00AAA894 /* RequestBuilders */, @@ -3592,6 +3599,7 @@ children = ( 61054F892A6EE1BA00AAA894 /* RUMContextReceiverTests.swift */, 61054F8A2A6EE1BA00AAA894 /* SRContextPublisherTests.swift */, + D2C5D52C2B84F6D800B63F36 /* WebViewRecordReceiverTests.swift */, 61054F8B2A6EE1BA00AAA894 /* RequestBuilder */, ); path = Feature; @@ -3635,6 +3643,7 @@ 610ABD492A69309900AFEA34 /* IntegrationUnitTests */ = { isa = PBXGroup; children = ( + D2C5D52E2B84F6E700B63F36 /* SessionReplay */, 6179DB542B60229D00E9E04E /* CrashReporting */, 61E8C5062B28896100E709B4 /* RUM */, 610ABD4A2A6930AB00AFEA34 /* Public */, @@ -5834,6 +5843,14 @@ path = Models; sourceTree = ""; }; + D2C5D52E2B84F6E700B63F36 /* SessionReplay */ = { + isa = PBXGroup; + children = ( + D2C5D52F2B84F71200B63F36 /* WebRecordIntegrationTests.swift */, + ); + path = SessionReplay; + sourceTree = ""; + }; D2DA238B298D588A00C6C7E6 /* DatadogInternalTests */ = { isa = PBXGroup; children = ( @@ -7540,6 +7557,7 @@ 61A1A44929643254007909E7 /* DatadogCoreProxy.swift in Sources */, D2A1EE3B287EECC000D28DFB /* CarrierInfoPublisherTests.swift in Sources */, D22743D829DEB8B4001A7EF9 /* VitalInfoTests.swift in Sources */, + D2C5D5302B84F71200B63F36 /* WebRecordIntegrationTests.swift in Sources */, 61FF282824B8A31E000B3D9B /* RUMEventMatcher.swift in Sources */, D29A9FD829DDC686005C54A4 /* UIKitRUMViewsPredicateTests.swift in Sources */, D29294E3291D652C00F8EFF9 /* ApplicationVersionPublisherTests.swift in Sources */, @@ -7725,6 +7743,7 @@ 61054E812A6EE10A00AAA894 /* UIStepperRecorder.swift in Sources */, 61054E632A6EE10A00AAA894 /* SessionReplayConfiguration.swift in Sources */, 61054E702A6EE10A00AAA894 /* TouchSnapshotProducer.swift in Sources */, + D2C5D52B2B84F6AB00B63F36 /* WebViewRecordReceiver.swift in Sources */, 61054E652A6EE10A00AAA894 /* AppWindowObserver.swift in Sources */, A74A72812B0CEE4900771FEB /* ResourceRequestBuilder.swift in Sources */, 61054E7B2A6EE10A00AAA894 /* UIViewRecorder.swift in Sources */, @@ -7795,6 +7814,7 @@ 61054FB52A6EE1BA00AAA894 /* UISliderRecorderTests.swift in Sources */, 61054FB22A6EE1BA00AAA894 /* UILabelRecorderTests.swift in Sources */, 61054FCE2A6EE1BA00AAA894 /* RUMContextObserverMock.swift in Sources */, + D2C5D52D2B84F6D800B63F36 /* WebViewRecordReceiverTests.swift in Sources */, 61054FBA2A6EE1BA00AAA894 /* UIImageViewRecorderTests.swift in Sources */, 61054FC02A6EE1BA00AAA894 /* UITextViewRecorderTests.swift in Sources */, 61054F9A2A6EE1BA00AAA894 /* CFType+SafetyTests.swift in Sources */, diff --git a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift new file mode 100644 index 0000000000..a3df8539e3 --- /dev/null +++ b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift @@ -0,0 +1,92 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +#if !os(tvOS) +import WebKit + +import TestUtilities +@testable import DatadogRUM +@testable import DatadogWebViewTracking +@_spi(Internal) @testable import DatadogSessionReplay + +class WebRecordIntegrationTests: XCTestCase { + // swiftlint:disable implicitly_unwrapped_optional + private var core: DatadogCoreProxy! // swiftlint:disable:this implicitly_unwrapped_optional + private var controller: WKUserContentControllerMock! + // swiftlint:enable implicitly_unwrapped_optional + + override func setUp() { + core = DatadogCoreProxy( + context: .mockWith( + env: "test", + version: "1.1.1", + serverTimeOffset: 123 + ) + ) + + controller = WKUserContentControllerMock() + let configuration = WKWebViewConfiguration() + configuration.userContentController = controller + let webView = WKWebView(frame: .zero, configuration: configuration) + WebViewTracking.enable(webView: webView, in: core) + } + + override func tearDown() { + core.flushAndTearDown() + core = nil + controller = nil + } + + func testWebRecordIntegration() throws { + // Given + let randomApplicationID: String = .mockRandom() + let randomUUID: UUID = .mockRandom() + let randomBrowserViewID: UUID = .mockRandom() + + SessionReplay.enable(with: SessionReplay.Configuration(replaySampleRate: 100), in: core) + RUM.enable(with: .mockWith(applicationID: randomApplicationID) { + $0.uuidGenerator = RUMUUIDGeneratorMock(uuid: randomUUID) + }, in: core) + + let body = """ + { + "eventType": "record", + "event": { + "timestamp" : \(1635932927012), + "type": 2 + }, + "view": { "id": "\(randomBrowserViewID.uuidString.lowercased())" } + } + """ + + // When + RUMMonitor.shared(in: core).startView(key: "web-view") + controller.send(body: body) + controller.flush() + + // Then + let segments = try core.waitAndReturnEventsData(ofFeature: SessionReplayFeature.name) + .map { try SegmentJSON($0, source: .ios) } + let segment = try XCTUnwrap(segments.first) + + let expectedUUID = randomUUID.uuidString.lowercased() + let expectedSlotID = "\(controller.hash as Int)" // explicitly get the NSObject.hash + + XCTAssertEqual(segment.applicationID, randomApplicationID) + XCTAssertEqual(segment.sessionID, expectedUUID) + XCTAssertEqual(segment.viewID, randomBrowserViewID.uuidString.lowercased()) + + let record = try XCTUnwrap(segment.records.first) + DDAssertDictionariesEqual(record, [ + "timestamp": 1_635_932_927_012 + 123.toInt64Milliseconds, + "type": 2, + "slotId": expectedSlotID + ]) + } +} + +#endif diff --git a/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift b/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift index 16abfb0132..65093fdff1 100644 --- a/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift +++ b/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift @@ -40,18 +40,6 @@ internal final class WebViewEventReceiver: FeatureMessageReceiver { return false } - write(event: event, to: core) - return true - } - - /// Writes a Browser RUM event to the core. - /// - /// The receiver will inject current RUM context and apply server-time offset to the event. - /// - /// - Parameters: - /// - event: The Browser RUM event. - /// - core: The core to write the event. - private func write(event: JSON, to core: DatadogCoreProtocol) { commandSubscriber.process( command: RUMKeepSessionAliveCommand( time: dateProvider.now, @@ -69,10 +57,9 @@ internal final class WebViewEventReceiver: FeatureMessageReceiver { var event = event if let date = event["date"] as? Int { - let viewID = (event["view"] as? JSON)?["id"] as? String - let serverTimeOffsetInMs = self.getOffsetInMs(viewID: viewID, context: context) - let correctedDate = Int64(date) + serverTimeOffsetInMs - event["date"] = correctedDate + if let id = (event["view"] as? JSON)?["id"] as? String { + event["date"] = Int64(date) + self.offset(forView: id, context: context) + } // Inject the container source and view id if let viewID = self.viewCache.lastView(before: date, hasReplay: true) { @@ -105,32 +92,24 @@ internal final class WebViewEventReceiver: FeatureMessageReceiver { core?.telemetry.error("Failed to decode `RUMCoreContext`", error: error) } } + + return true } // MARK: - Time offsets - private typealias Offset = Int64 - private typealias ViewIDOffsetPair = (viewID: String, offset: Offset) - private var viewIDOffsetPairs = [ViewIDOffsetPair]() + private var offsets: [(id: String, value: Int64)] = [] - private func getOffsetInMs(viewID: String?, context: DatadogContext) -> Offset { - guard let viewID = viewID else { - return 0 + private func offset(forView id: String, context: DatadogContext) -> Int64 { + if let found = offsets.first(where: { $0.id == id }) { + return found.value } - purgeOffsets() - let found = viewIDOffsetPairs.first { $0.viewID == viewID } - if let found = found { - return found.offset - } let offset = context.serverTimeOffset.toInt64Milliseconds - viewIDOffsetPairs.insert((viewID: viewID, offset: offset), at: 0) - return offset - } + offsets.insert((id, offset), at: 0) + // only retain 3 offsets + offsets = Array(offsets.prefix(3)) - private func purgeOffsets() { - while viewIDOffsetPairs.count > 3 { - _ = viewIDOffsetPairs.popLast() - } + return offset } } diff --git a/DatadogSessionReplay/Sources/Feature/Baggages.swift b/DatadogSessionReplay/Sources/Feature/Baggages.swift index de0ee4ef55..7f331740ff 100644 --- a/DatadogSessionReplay/Sources/Feature/Baggages.swift +++ b/DatadogSessionReplay/Sources/Feature/Baggages.swift @@ -19,7 +19,7 @@ internal enum RUMDependency { } /// The RUM context received from `DatadogCore`. -internal struct RUMContext: Decodable, Equatable { +internal struct RUMContext: Codable, Equatable { static let key = "rum" enum CodingKeys: String, CodingKey { diff --git a/DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift b/DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift index c8fcdc0f67..25d7e77f3b 100644 --- a/DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift +++ b/DatadogSessionReplay/Sources/Feature/RUMContextReceiver.swift @@ -35,7 +35,7 @@ internal class RUMContextReceiver: FeatureMessageReceiver, RUMContextObserver { do { // Extract the `RUMContext` or `nil` if RUM session is not sampled: - new = try context.baggages[RUMContext.key].map { try $0.decode() } + new = try context.baggages[RUMContext.key]?.decode() } catch { core.telemetry .error("Fails to decode RUM context from Session Replay", error: error) diff --git a/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift b/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift index 541e97fe38..11d6139fe0 100644 --- a/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift +++ b/DatadogSessionReplay/Sources/Feature/RequestBuilders/JSON/SegmentJSON.swift @@ -16,7 +16,7 @@ internal typealias JSONObject = [String: Any] /// Can be considered a temporary solution until we find a way to decode `[SRRecords]` unambiguously /// through `Codable` interface. internal struct SegmentJSON { - private enum Constants { + enum Constants { /// The `timestamp` is common to all records. /// see. https://github.com/DataDog/rum-events-format/blob/master/schemas/session-replay/common/_common-record-schema.json#L9 static let timestampKey = "timestamp" diff --git a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift index 8faf090742..1ca3e2e886 100644 --- a/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift +++ b/DatadogSessionReplay/Sources/Feature/SessionReplayFeature.swift @@ -44,13 +44,17 @@ internal class SessionReplayFeature: DatadogRemoteFeature { additionalNodeRecorders: configuration._additionalNodeRecorders ) let scheduler = MainThreadScheduler(interval: 0.1) - let messageReceiver = RUMContextReceiver() + let contextReceiver = RUMContextReceiver() + + self.messageReceiver = CombinedFeatureMessageReceiver([ + contextReceiver, + WebViewRecordReceiver() + ]) - self.messageReceiver = messageReceiver self.recordingCoordinator = RecordingCoordinator( scheduler: scheduler, privacy: configuration.defaultPrivacyLevel, - rumContextObserver: messageReceiver, + rumContextObserver: contextReceiver, srContextPublisher: SRContextPublisher(core: core), recorder: recorder, sampler: Sampler(samplingRate: configuration.debugSDK ? 100 : configuration.replaySampleRate) diff --git a/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift b/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift new file mode 100644 index 0000000000..188a3267e3 --- /dev/null +++ b/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift @@ -0,0 +1,73 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import DatadogInternal + +internal final class WebViewRecordReceiver: FeatureMessageReceiver { + internal struct WebRecord: Encodable { + /// The RUM application ID of all records. + let applicationID: String + /// The RUM session ID of all records. + let sessionID: String + /// The RUM view ID of all records. + let viewID: String + /// Records enriched with further information. + let records: [AnyEncodable] + } + + func receive(message: DatadogInternal.FeatureMessage, from core: DatadogInternal.DatadogCoreProtocol) -> Bool { + guard case let .webview(.record(event, view)) = message else { + return false + } + + core.scope(for: SessionReplayFeature.name)?.eventWriteContext { context, writer in + do { + // Extract the `RUMContext` or `nil` if RUM session is not sampled: + guard let rumContext = try context.baggages[RUMContext.key]?.decode(type: RUMContext.self) else { + return + } + + var event = event + + if let timestamp = event["timestamp"] as? Int { + event["timestamp"] = Int64(timestamp) + self.offset(forView: view.id, context: context) + } + + let record = WebRecord( + applicationID: rumContext.applicationID, + sessionID: rumContext.sessionID, + viewID: view.id, + records: [AnyEncodable(event)] + ) + + writer.write(value: record) + } catch { + core.telemetry + .error("Fails to decode RUM context from Session Replay", error: error) + return + } + } + + return true + } + + // MARK: - Time offsets + + private var offsets: [(id: String, value: Int64)] = [] + + private func offset(forView id: String, context: DatadogContext) -> Int64 { + if let found = offsets.first(where: { $0.id == id }) { + return found.value + } + + let offset = context.serverTimeOffset.toInt64Milliseconds + offsets.insert((id, offset), at: 0) + // only retain 3 offsets + offsets = Array(offsets.prefix(3)) + return offset + } +} diff --git a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift index 032ec09dc7..229aadfc20 100644 --- a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift +++ b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift @@ -35,6 +35,8 @@ extension SRWireframe: Diffable { return this.hashValue != other.hashValue case let (.placeholderWireframe(this), .placeholderWireframe(other)): return this.hashValue != other.hashValue + case let (.webviewWireframe(this), .webviewWireframe(other)): + return this.hashValue != other.hashValue default: return true } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorder.swift index ace25c2317..08edf2e978 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorder.swift @@ -17,7 +17,6 @@ internal struct UnsupportedViewRecorder: NodeRecorder { { _, context in context.viewControllerContext.isRootView(of: .activity) }, { _, context in context.viewControllerContext.isRootView(of: .swiftUI) }, { view, _ in view is UIProgressView }, - { view, _ in view is WKWebView }, { view, _ in view is UIActivityIndicatorView } ] // swiftlint:enable opening_brace diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift index fd0e65f8b0..f1d292115c 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift @@ -68,6 +68,7 @@ internal func createDefaultNodeRecorders() -> [NodeRecorder] { UITabBarRecorder(), UIPickerViewRecorder(), UIDatePickerRecorder(), + WKWebViewRecorder() ] } #endif diff --git a/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift b/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift new file mode 100644 index 0000000000..f62739d346 --- /dev/null +++ b/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift @@ -0,0 +1,110 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities + +@testable import DatadogInternal +@testable import DatadogSessionReplay + +class WebViewRecordReceiverTests: XCTestCase { + func testGivenRUMContextAvailable_whenReceivingWebRecord_itCreatesSegment() throws { + let rumContext: RUMContext = .mockRandom() + let core = PassthroughCoreMock( + context: .mockWith( + source: "react-native", + serverTimeOffset: .mockRandom(min: -10, max: 10).rounded(), + baggages: ["rum": FeatureBaggage(rumContext)] + ) + ) + + // Given + let receiver = WebViewRecordReceiver() + + let random = mockRandomAttributes() // because below we only mock partial web event, we use this random to make the test fuzzy + let webRecordMock: [String: Any] = [ + "timestamp": 100_000, + "type": 2 + ].merging(random, uniquingKeysWith: { old, _ in old }) + + let browserViewID: String = .mockRandom() + + // When + + let message = WebViewMessage.record(webRecordMock, WebViewMessage.View(id: browserViewID)) + let result = receiver.receive(message: .webview(message), from: core) + + // Then + let expectedWebSegmentWritten: [String: Any] = [ + "applicationID": rumContext.applicationID, + "sessionID": rumContext.sessionID, + "viewID": browserViewID, + "records": [ + [ + "timestamp": 100_000 + core.context.serverTimeOffset.toInt64Milliseconds, + "type": 2 + ].merging(random, uniquingKeysWith: { old, _ in old }) + ] + ] + + XCTAssertTrue(result, "It must accept the message") + XCTAssertEqual(core.events.count, 1, "It must write web segment to core") + let actualWebEventWritten = try XCTUnwrap(core.events.first) + DDAssertJSONEqual(AnyCodable(actualWebEventWritten), AnyCodable(expectedWebSegmentWritten)) + } + + func testGivenRUMContextNotAvailable_whenReceivingWebRecord_itIsDropped() throws { + let core = PassthroughCoreMock() + + // Given + XCTAssertNil(core.context.baggages["rum"]) + + let receiver = WebViewRecordReceiver() + + // When + let record = WebViewMessage.record(mockRandomAttributes(), WebViewMessage.View(id: .mockRandom())) + let result = receiver.receive(message: .webview(record), from: core) + + // Then + XCTAssertTrue(result, "It must accept the message") + XCTAssertTrue(core.events.isEmpty, "The event must be dropped") + } + + func testWhenReceivingOtherMessage_itRejectsIt() throws { + let core = PassthroughCoreMock() + + // Given + let receiver = WebViewRecordReceiver() + + // When + let otherMessage: FeatureMessage = .baggage(key: "message to other receiver", value: String.mockRandom()) + let result = receiver.receive(message: otherMessage, from: core) + + // Then + XCTAssertFalse(result, "It must reject messages addressed to other receivers") + } + + func testWhenReceivingInvalidBaggage_itSendsTelemetryError() throws { + // Given + let telemetry = TelemetryReceiverMock() + let core = PassthroughCoreMock( + context: .mockWith(baggages: ["rum": FeatureBaggage(123)]), + messageReceiver: telemetry + ) + + let receiver = WebViewRecordReceiver() + + // When + let record = WebViewMessage.record(mockRandomAttributes(), WebViewMessage.View(id: .mockRandom())) + XCTAssert( + receiver.receive(message: .webview(record), from: core) + ) + + // Then + let message = try XCTUnwrap(telemetry.messages.first?.asError?.message) + XCTAssert(message.contains("Fails to decode RUM context from Session Replay - typeMismatch")) + } +} diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift index a6a90b6d00..89037684f4 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/UnsupportedViewRecorderTests.swift @@ -16,12 +16,12 @@ class UnsupportedViewRecorderTests: XCTestCase { private let recorder = UnsupportedViewRecorder() private let unsupportedViews: [UIView] = [ - UIProgressView(), UIActivityIndicatorView(), WKWebView() + UIProgressView(), UIActivityIndicatorView() ].compactMap { $0 } private let expectedUnsupportedViewsClassNames = [ "UIProgressView", "UIActivityIndicatorView", "WKWebView" ] - private let otherViews = [UILabel(), UIView(), UIImageView(), UIScrollView()] + private let otherViews = [UILabel(), UIView(), UIImageView(), UIScrollView(), WKWebView()] /// `ViewAttributes` simulating common attributes of the view. private var viewAttributes: ViewAttributes = .mockAny() diff --git a/IntegrationTests/IntegrationScenarios/IntegrationTests.swift b/IntegrationTests/IntegrationScenarios/IntegrationTests.swift index c75f1f3247..5d4e1cf811 100644 --- a/IntegrationTests/IntegrationScenarios/IntegrationTests.swift +++ b/IntegrationTests/IntegrationScenarios/IntegrationTests.swift @@ -25,7 +25,6 @@ class IntegrationTests: XCTestCase { override func tearDownWithError() throws { server = nil - try super.tearDownWithError() } diff --git a/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift b/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift index 066b8abb20..fa7b113250 100644 --- a/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift +++ b/IntegrationTests/IntegrationScenarios/Scenarios/SessionReplay/SRMultipleViewsRecordingScenarioTests.swift @@ -43,12 +43,11 @@ class SRMultipleViewsRecordingScenarioTests: IntegrationTests, RUMCommonAsserts, static let minWireframesInFullSnapshot = 5 /// Total number of "incremental snapshot" records that send "wireframe mutation" data. - static let totalWireframeMutationRecords = 7 + static let totalWireframeMutationRecords = 5 /// Total number of "incremental snapshot" records that send "pointer interaction" data. static let totalTouchDataRecords = 10 } - func testSRMultipleViewsRecordingScenario() throws { // RUM endpoint in `HTTPServerMock` let rumEndpoint = server.obtainUniqueRecordingSession() From 1f5b758b4dc210e41f61f32b79a7a84bda9e7a0c Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Wed, 13 Mar 2024 14:22:43 +0100 Subject: [PATCH 02/69] Update testUnsupportedView()-allowAll-privacy.png.json --- .../Storyboards/UnsupportedViews.storyboard | 36 ++++--------------- ...nsupportedView()-allowAll-privacy.png.json | 2 +- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/DatadogSessionReplay/SRSnapshotTests/SRFixtures/Sources/SRFixtures/Resources/Storyboards/UnsupportedViews.storyboard b/DatadogSessionReplay/SRSnapshotTests/SRFixtures/Sources/SRFixtures/Resources/Storyboards/UnsupportedViews.storyboard index 4818923400..b3e69d1854 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRFixtures/Sources/SRFixtures/Resources/Storyboards/UnsupportedViews.storyboard +++ b/DatadogSessionReplay/SRSnapshotTests/SRFixtures/Sources/SRFixtures/Resources/Storyboards/UnsupportedViews.storyboard @@ -1,9 +1,9 @@ - + - + @@ -26,40 +26,23 @@ - - + - - - - - - - - - - - - + @@ -70,23 +53,18 @@ - + - - - - - diff --git a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/_snapshots_/pointers/testUnsupportedView()-allowAll-privacy.png.json b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/_snapshots_/pointers/testUnsupportedView()-allowAll-privacy.png.json index 9cf7aaa02d..6ccaf04733 100644 --- a/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/_snapshots_/pointers/testUnsupportedView()-allowAll-privacy.png.json +++ b/DatadogSessionReplay/SRSnapshotTests/SRSnapshotTests/_snapshots_/pointers/testUnsupportedView()-allowAll-privacy.png.json @@ -1 +1 @@ -{"hash":"60542c413a1bfb3875b961f0a2e50490a30ffc87"} \ No newline at end of file +{"hash":"8c803bf8fe88005af09b838159af0d517170090b"} \ No newline at end of file From 193975f9fd4cbf3401180503924f6f4d8aeadea5 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Thu, 21 Mar 2024 14:45:04 +0100 Subject: [PATCH 03/69] RUM-2816 Use corrected date --- .../Sources/Integrations/WebViewEventReceiver.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift b/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift index 65093fdff1..d82990e6e7 100644 --- a/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift +++ b/DatadogRUM/Sources/Integrations/WebViewEventReceiver.swift @@ -56,13 +56,16 @@ internal final class WebViewEventReceiver: FeatureMessageReceiver { let rum: RUMCoreContext = try rumBaggage.decode() var event = event - if let date = event["date"] as? Int { - if let id = (event["view"] as? JSON)?["id"] as? String { - event["date"] = Int64(date) + self.offset(forView: id, context: context) - } + if + let date = event["date"] as? Int, + let view = event["view"] as? JSON, + let id = view["id"] as? String + { + let correctedDate = Int64(date) + self.offset(forView: id, context: context) + event["date"] = correctedDate // Inject the container source and view id - if let viewID = self.viewCache.lastView(before: date, hasReplay: true) { + if let viewID = self.viewCache.lastView(before: correctedDate, hasReplay: true) { event[RUMViewEvent.CodingKeys.container.rawValue] = RUMViewEvent.Container( source: RUMViewEvent.Container.Source(rawValue: context.source) ?? .ios, view: RUMViewEvent.Container.View(id: viewID) From 0242681a06c0e4706b69d0ab52b96f64e0de8475 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Thu, 21 Mar 2024 14:55:39 +0100 Subject: [PATCH 04/69] RUM-2816 Apply rum view time offset --- .../Feature/WebViewRecordReceiver.swift | 20 ++----------------- .../Feature/WebViewRecordReceiverTests.swift | 10 +++++++--- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift b/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift index 188a3267e3..10794bdf7b 100644 --- a/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift +++ b/DatadogSessionReplay/Sources/Feature/WebViewRecordReceiver.swift @@ -33,8 +33,8 @@ internal final class WebViewRecordReceiver: FeatureMessageReceiver { var event = event - if let timestamp = event["timestamp"] as? Int { - event["timestamp"] = Int64(timestamp) + self.offset(forView: view.id, context: context) + if let timestamp = event["timestamp"] as? Int, let offset = rumContext.viewServerTimeOffset { + event["timestamp"] = Int64(timestamp) + offset.toInt64Milliseconds } let record = WebRecord( @@ -54,20 +54,4 @@ internal final class WebViewRecordReceiver: FeatureMessageReceiver { return true } - - // MARK: - Time offsets - - private var offsets: [(id: String, value: Int64)] = [] - - private func offset(forView id: String, context: DatadogContext) -> Int64 { - if let found = offsets.first(where: { $0.id == id }) { - return found.value - } - - let offset = context.serverTimeOffset.toInt64Milliseconds - offsets.insert((id, offset), at: 0) - // only retain 3 offsets - offsets = Array(offsets.prefix(3)) - return offset - } } diff --git a/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift b/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift index f62739d346..eeda1adf4b 100644 --- a/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift +++ b/DatadogSessionReplay/Tests/Feature/WebViewRecordReceiverTests.swift @@ -12,11 +12,15 @@ import TestUtilities class WebViewRecordReceiverTests: XCTestCase { func testGivenRUMContextAvailable_whenReceivingWebRecord_itCreatesSegment() throws { - let rumContext: RUMContext = .mockRandom() + let serverTimeOffset: TimeInterval = .mockRandom(min: -10, max: 10).rounded() + + let rumContext: RUMContext = .mockWith( + serverTimeOffset: serverTimeOffset + ) + let core = PassthroughCoreMock( context: .mockWith( source: "react-native", - serverTimeOffset: .mockRandom(min: -10, max: 10).rounded(), baggages: ["rum": FeatureBaggage(rumContext)] ) ) @@ -44,7 +48,7 @@ class WebViewRecordReceiverTests: XCTestCase { "viewID": browserViewID, "records": [ [ - "timestamp": 100_000 + core.context.serverTimeOffset.toInt64Milliseconds, + "timestamp": 100_000 + serverTimeOffset.toInt64Milliseconds, "type": 2 ].merging(random, uniquingKeysWith: { old, _ in old }) ] From 819b4dddba40d6e3563318051f4f9e4860087329 Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Fri, 29 Mar 2024 15:05:33 +0100 Subject: [PATCH 05/69] RUM-2813 Add webview replay configuration --- Datadog/Datadog.xcodeproj/project.pbxproj | 4 + .../Sources/WebViewTracking.swift | 75 +++++++++++++-- DatadogWebViewTracking/Tests/Mocks.swift | 50 ++++++++++ .../Tests/WebViewTrackingTests.swift | 91 ++++++++++++++----- 4 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 DatadogWebViewTracking/Tests/Mocks.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 1dc35e9389..e865d3ecf7 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -947,6 +947,7 @@ D270CDE12B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */; }; D2777D9D29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; D2777D9E29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; + D27CBD9A2BB5DBBB00C766AA /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27CBD992BB5DBBB00C766AA /* Mocks.swift */; }; D27D81C12A5D415200281CC2 /* CrashReporter.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 614ED36B260352DC00C8C519 /* CrashReporter.xcframework */; }; D27D81C22A5D415200281CC2 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B7885425C180CB002675B5 /* DatadogCrashReporting.framework */; }; D27D81C32A5D415200281CC2 /* DatadogInternal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D23039A5298D513C001A1FA3 /* DatadogInternal.framework */; }; @@ -2650,6 +2651,7 @@ D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = ""; }; D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = ""; }; D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiverTests.swift; sourceTree = ""; }; + D27CBD992BB5DBBB00C766AA /* Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = ""; }; D286626D2A43487500852CE3 /* Datadog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Datadog.swift; sourceTree = ""; }; D28F836729C9E71C00EF8EA2 /* DDSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSpanTests.swift; sourceTree = ""; }; D28F836A29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingURLSessionHandlerTests.swift; sourceTree = ""; }; @@ -3140,6 +3142,7 @@ children = ( D297324F2A5C109A00827599 /* MessageEmitterTests.swift */, D29732502A5C109A00827599 /* WebViewTrackingTests.swift */, + D27CBD992BB5DBBB00C766AA /* Mocks.swift */, ); name = DatadogWebViewTrackingTests; path = ../DatadogWebViewTracking/Tests; @@ -7471,6 +7474,7 @@ files = ( D29732532A5C109A00827599 /* WebViewTrackingTests.swift in Sources */, D29732512A5C109A00827599 /* MessageEmitterTests.swift in Sources */, + D27CBD9A2BB5DBBB00C766AA /* Mocks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DatadogWebViewTracking/Sources/WebViewTracking.swift b/DatadogWebViewTracking/Sources/WebViewTracking.swift index 167c04156c..054d31e862 100644 --- a/DatadogWebViewTracking/Sources/WebViewTracking.swift +++ b/DatadogWebViewTracking/Sources/WebViewTracking.swift @@ -25,21 +25,53 @@ import WebKit /// - Support users that have difficulty loading web pages on mobile devices public enum WebViewTracking { #if !os(tvOS) - /// Enables SDK to correlate Datadog RUM events and Logs from the WebView with native RUM session. + /// The Session Replay Configuration to capture records coming from the web-view. /// + /// Setting the Session Replay configuration in `WebViewTracking` will enable transmitting records from + /// the Datadog Browser SDK installed in the web page. Datadog will then be able to combine the native + /// and web recordings in a single replay. + public struct SessionReplayConfiguration { + /// Available privacy levels for content masking. + public enum PrivacyLevel: String { + /// Record all content. + case allow + + /// Mask all content. + case mask + + /// Mask input elements, but record all other content. + case maskUserInput = "mask_user_input" + } + + /// The privacy level to use for the web-view replay recording. + public var privacyLevel: PrivacyLevel + + /// Creates Webview Session Replay configuration. + /// + /// - Parameters: + /// - privacyLevel: The way sensitive content (e.g. text) should be masked. Default: `.mask`. + public init(privacyLevel: PrivacyLevel = .mask) { + self.privacyLevel = privacyLevel + } + } + + /// Enables SDK to correlate Datadog RUM events and Logs from the WebView with native RUM session. + /// /// If the content loaded in WebView uses Datadog Browser SDK (`v4.2.0+`) and matches specified /// `hosts`, web events will be correlated with the RUM session from native SDK. - /// + /// /// - Parameters: /// - webView: The web-view to track. /// - hosts: A set of hosts instrumented with Browser SDK to capture Datadog events from. /// - logsSampleRate: The sampling rate for logs coming from the WebView. Must be a value between `0` and `100`, /// where 0 means no logs will be sent and 100 means all will be uploaded. Default: `100`. + /// - sessionReplayConfiguration: Session Replay Configuration to enable linking Web and Native replays. /// - core: Datadog SDK core to use for tracking. public static func enable( webView: WKWebView, hosts: Set = [], logsSampleRate: Float = 100, + sessionReplayConfiguration: SessionReplayConfiguration? = nil, in core: DatadogCoreProtocol = CoreRegistry.default ) { enable( @@ -47,6 +79,7 @@ public enum WebViewTracking { hosts: hosts, hostsSanitizer: HostsSanitizer(), logsSampleRate: logsSampleRate, + sessionReplayConfiguration: sessionReplayConfiguration, in: core ) } @@ -74,13 +107,14 @@ public enum WebViewTracking { hosts: Set, hostsSanitizer: HostsSanitizing, logsSampleRate: Float, + sessionReplayConfiguration: SessionReplayConfiguration?, in core: DatadogCoreProtocol ) { let isTracking = controller.userScripts.contains { $0.source.starts(with: Self.jsCodePrefix) } guard !isTracking else { DD.logger.warn("`startTrackingDatadogEvents(core:hosts:)` was called more than once for the same WebView. Second call will be ignored. Make sure you call it only once.") return - } + } let bridgeName = DDScriptMessageHandler.name @@ -106,15 +140,38 @@ public enum WebViewTracking { .map { return "\"\($0)\"" } .joined(separator: ",") + let privacyLevel = sessionReplayConfiguration?.privacyLevel ?? .mask + + // Share native capabilities with Browser SDK + let capabilities: String = { + var capabilities: [String] = [] + + // Add 'records' capability when session-replay + // is configured. + if sessionReplayConfiguration != nil { + capabilities.append("records") + } + + return capabilities + .map { return "\"\($0)\"" } + .joined(separator: ",") + }() + let js = """ \(Self.jsCodePrefix) window.\(bridgeName) = { - send(msg) { - \(webkitMethodName)(msg) - }, - getAllowedWebViewHosts() { - return '[\(allowedWebViewHostsString)]' - } + send(msg) { + \(webkitMethodName)(msg) + }, + getAllowedWebViewHosts() { + return '[\(allowedWebViewHostsString)]' + }, + getCapabilities() { + return '[\(capabilities)]' + }, + getPrivacyLevel() { + return '\(privacyLevel.rawValue)' + } } """ diff --git a/DatadogWebViewTracking/Tests/Mocks.swift b/DatadogWebViewTracking/Tests/Mocks.swift new file mode 100644 index 0000000000..e993576f29 --- /dev/null +++ b/DatadogWebViewTracking/Tests/Mocks.swift @@ -0,0 +1,50 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation +import WebKit +import TestUtilities + +@testable import DatadogWebViewTracking + +final class DDUserContentController: WKUserContentController { + typealias NameHandlerPair = (name: String, handler: WKScriptMessageHandler) + private(set) var messageHandlers = [NameHandlerPair]() + + override func add(_ scriptMessageHandler: WKScriptMessageHandler, name: String) { + messageHandlers.append((name: name, handler: scriptMessageHandler)) + } + + override func removeScriptMessageHandler(forName name: String) { + messageHandlers = messageHandlers.filter { + return $0.name != name + } + } +} + +final class MockMessageHandler: NSObject, WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { } +} + +final class MockScriptMessage: WKScriptMessage { + let mockBody: Any + + init(body: Any) { + self.mockBody = body + } + + override var body: Any { return mockBody } +} + +extension WebViewTracking.SessionReplayConfiguration.PrivacyLevel: AnyMockable, RandomMockable { + public static func mockAny() -> Self { + .allow + } + + public static func mockRandom() -> Self { + [.allow, .mask, .maskUserInput].randomElement()! + } +} diff --git a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift index b44a5b4fc0..0e093164f4 100644 --- a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift +++ b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift @@ -12,36 +12,80 @@ import TestUtilities import DatadogInternal @testable import DatadogWebViewTracking -final class DDUserContentController: WKUserContentController { - typealias NameHandlerPair = (name: String, handler: WKScriptMessageHandler) - private(set) var messageHandlers = [NameHandlerPair]() +class WebViewTrackingTests: XCTestCase { + func testItAddsUserScript() throws { + let mockSanitizer = HostsSanitizerMock() + let controller = DDUserContentController() - override func add(_ scriptMessageHandler: WKScriptMessageHandler, name: String) { - messageHandlers.append((name: name, handler: scriptMessageHandler)) - } + let host: String = .mockRandom() - override func removeScriptMessageHandler(forName name: String) { - messageHandlers = messageHandlers.filter { - return $0.name != name + WebViewTracking.enable( + tracking: controller, + hosts: [host], + hostsSanitizer: mockSanitizer, + logsSampleRate: 30, + sessionReplayConfiguration: nil, + in: PassthroughCoreMock() + ) + + let script = try XCTUnwrap(controller.userScripts.last) + XCTAssertEqual(script.source, """ + /* DatadogEventBridge */ + window.DatadogEventBridge = { + send(msg) { + window.webkit.messageHandlers.DatadogEventBridge.postMessage(msg) + }, + getAllowedWebViewHosts() { + return '["\(host)"]' + }, + getCapabilities() { + return '[]' + }, + getPrivacyLevel() { + return 'mask' + } } + """) } -} -final class MockMessageHandler: NSObject, WKScriptMessageHandler { - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { } -} + func testItAddsUserScriptWithSessionReplay() throws { + let mockSanitizer = HostsSanitizerMock() + let controller = DDUserContentController() -final class MockScriptMessage: WKScriptMessage { - let mockBody: Any + let host: String = .mockRandom() + let sessionReplayConfiguration = WebViewTracking.SessionReplayConfiguration( + privacyLevel: .mockRandom() + ) - init(body: Any) { - self.mockBody = body - } + WebViewTracking.enable( + tracking: controller, + hosts: [host], + hostsSanitizer: mockSanitizer, + logsSampleRate: 30, + sessionReplayConfiguration: sessionReplayConfiguration, + in: PassthroughCoreMock() + ) - override var body: Any { return mockBody } -} + let script = try XCTUnwrap(controller.userScripts.last) + XCTAssertEqual(script.source, """ + /* DatadogEventBridge */ + window.DatadogEventBridge = { + send(msg) { + window.webkit.messageHandlers.DatadogEventBridge.postMessage(msg) + }, + getAllowedWebViewHosts() { + return '["\(host)"]' + }, + getCapabilities() { + return '["records"]' + }, + getPrivacyLevel() { + return '\(sessionReplayConfiguration.privacyLevel.rawValue)' + } + } + """) + } -class WebViewTrackingTests: XCTestCase { func testItAddsUserScriptAndMessageHandler() throws { let mockSanitizer = HostsSanitizerMock() let controller = DDUserContentController() @@ -53,6 +97,7 @@ class WebViewTrackingTests: XCTestCase { hosts: ["datadoghq.com"], hostsSanitizer: mockSanitizer, logsSampleRate: 30, + sessionReplayConfiguration: nil, in: PassthroughCoreMock() ) @@ -84,6 +129,7 @@ class WebViewTrackingTests: XCTestCase { hosts: ["datadoghq.com"], hostsSanitizer: mockSanitizer, logsSampleRate: 100, + sessionReplayConfiguration: nil, in: PassthroughCoreMock() ) } @@ -162,6 +208,7 @@ class WebViewTrackingTests: XCTestCase { hosts: ["datadoghq.com"], hostsSanitizer: HostsSanitizerMock(), logsSampleRate: 100, + sessionReplayConfiguration: nil, in: core ) @@ -214,6 +261,7 @@ class WebViewTrackingTests: XCTestCase { hosts: ["datadoghq.com"], hostsSanitizer: HostsSanitizerMock(), logsSampleRate: 100, + sessionReplayConfiguration: nil, in: core ) @@ -305,6 +353,7 @@ class WebViewTrackingTests: XCTestCase { hosts: ["datadoghq.com"], hostsSanitizer: HostsSanitizerMock(), logsSampleRate: 100, + sessionReplayConfiguration: nil, in: core ) From d511735154e3a01747a6693ec712e090b72c83ee Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Tue, 9 Apr 2024 16:09:15 +0200 Subject: [PATCH 06/69] Apply suggestions from code review Co-authored-by: Maciek Grzybowski --- DatadogWebViewTracking/Sources/WebViewTracking.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DatadogWebViewTracking/Sources/WebViewTracking.swift b/DatadogWebViewTracking/Sources/WebViewTracking.swift index 054d31e862..ad5724a96d 100644 --- a/DatadogWebViewTracking/Sources/WebViewTracking.swift +++ b/DatadogWebViewTracking/Sources/WebViewTracking.swift @@ -25,9 +25,9 @@ import WebKit /// - Support users that have difficulty loading web pages on mobile devices public enum WebViewTracking { #if !os(tvOS) - /// The Session Replay Configuration to capture records coming from the web-view. + /// The Session Replay configuration to capture records coming from the web view. /// - /// Setting the Session Replay configuration in `WebViewTracking` will enable transmitting records from + /// Setting the Session Replay configuration in `WebViewTracking` will enable transmitting replay data from /// the Datadog Browser SDK installed in the web page. Datadog will then be able to combine the native /// and web recordings in a single replay. public struct SessionReplayConfiguration { @@ -43,7 +43,7 @@ public enum WebViewTracking { case maskUserInput = "mask_user_input" } - /// The privacy level to use for the web-view replay recording. + /// The privacy level to use for the web view replay recording. public var privacyLevel: PrivacyLevel /// Creates Webview Session Replay configuration. @@ -65,7 +65,7 @@ public enum WebViewTracking { /// - hosts: A set of hosts instrumented with Browser SDK to capture Datadog events from. /// - logsSampleRate: The sampling rate for logs coming from the WebView. Must be a value between `0` and `100`, /// where 0 means no logs will be sent and 100 means all will be uploaded. Default: `100`. - /// - sessionReplayConfiguration: Session Replay Configuration to enable linking Web and Native replays. + /// - sessionReplayConfiguration: Session Replay configuration to enable linking Web and Native replays. /// - core: Datadog SDK core to use for tracking. public static func enable( webView: WKWebView, From 92dc3b0af4cfe54d1ddc165975808f011cad40ce Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Tue, 9 Apr 2024 16:13:10 +0200 Subject: [PATCH 07/69] Apply suggestions from code review --- .../Sources/WebViewTracking.swift | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/DatadogWebViewTracking/Sources/WebViewTracking.swift b/DatadogWebViewTracking/Sources/WebViewTracking.swift index ad5724a96d..764084ab14 100644 --- a/DatadogWebViewTracking/Sources/WebViewTracking.swift +++ b/DatadogWebViewTracking/Sources/WebViewTracking.swift @@ -143,19 +143,8 @@ public enum WebViewTracking { let privacyLevel = sessionReplayConfiguration?.privacyLevel ?? .mask // Share native capabilities with Browser SDK - let capabilities: String = { - var capabilities: [String] = [] - - // Add 'records' capability when session-replay - // is configured. - if sessionReplayConfiguration != nil { - capabilities.append("records") - } - - return capabilities - .map { return "\"\($0)\"" } - .joined(separator: ",") - }() + // Share native capabilities with Browser SDK + let capabilities = sessionReplayConfiguration != nil ? "\"records\"" : "" let js = """ \(Self.jsCodePrefix) From d524f2b67b6cf68c088669c3a41927c27da5543e Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Thu, 21 Mar 2024 14:00:21 +0100 Subject: [PATCH 08/69] RUM-3531 WebView slot cache --- Datadog/Datadog.xcodeproj/project.pbxproj | 28 +++--- .../WebRecordIntegrationTests.swift | 5 +- .../Mocks/SystemFrameworks/WebKitMocks.swift | 9 +- .../WebViewEventReceiverTests.swift | 5 +- .../Sources/Models/SRDataModels.swift | 10 ++- .../RecordsBuilder.swift | 0 .../WireframesBuilder.swift | 32 ++++++- .../Processor/Diffing/Diff+SRWireframes.swift | 1 + .../Sources/Processor/SnapshotProcessor.swift | 18 ++-- .../NodeRecorders/WKWebViewRecorder.swift | 76 +++++++++++----- .../ViewTreeRecordingContext.swift | 2 + .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 26 ++++++ .../ViewTreeSnapshotBuilder.swift | 12 ++- .../ViewTreeSnapshot/WebViewSlotCache.swift | 35 ++++++++ .../Tests/Mocks/RecorderMocks.swift | 63 +++++++++++-- .../RecordsBuilderTests.swift | 0 .../Processor/SnapshotProcessorTests.swift | 89 +++++++++++++++++++ .../WKWebViewRecorderTests.swift | 71 ++++++++++++--- .../WebViewSlotCacheTests.swift | 48 ++++++++++ .../Sources/DDScriptMessageHandler.swift | 4 +- DatadogWebViewTracking/Tests/Mocks.swift | 11 ++- .../Tests/WebViewTrackingTests.swift | 5 +- TestUtilities/Helpers/DDAssert.swift | 6 +- 23 files changed, 474 insertions(+), 82 deletions(-) rename DatadogSessionReplay/Sources/Processor/{SRDataModelsBuilder => Builders}/RecordsBuilder.swift (100%) rename DatadogSessionReplay/Sources/Processor/{SRDataModelsBuilder => Builders}/WireframesBuilder.swift (90%) create mode 100644 DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift rename DatadogSessionReplay/Tests/Processor/{SRDataModelsBuilder => Builders}/RecordsBuilderTests.swift (100%) create mode 100644 DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index e865d3ecf7..1c3e6a445e 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -481,8 +481,6 @@ 61F2728B25C9561A00D54BF8 /* PLCrashReporterIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F2728A25C9561A00D54BF8 /* PLCrashReporterIntegration.swift */; }; 61F272B125C95ED800D54BF8 /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F2729A25C95EB200D54BF8 /* Mocks.swift */; }; 61F74AF426F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F74AF326F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift */; }; - 61F930CB2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; - 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; 61F930BE2BA1ACAC005F0EE2 /* Storage+TLV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */; }; 61F930BF2BA1ACAC005F0EE2 /* Storage+TLV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */; }; 61F930C22BA1C41A005F0EE2 /* TLVBlockReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C12BA1C41A005F0EE2 /* TLVBlockReader.swift */; }; @@ -491,6 +489,8 @@ 61F930C62BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C42BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift */; }; 61F930C82BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */; }; 61F930C92BA1C51C005F0EE2 /* Storage+TLVTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */; }; + 61F930CB2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; + 61F930CC2BA213AC005F0EE2 /* AppHang.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F930CA2BA213AC005F0EE2 /* AppHang.swift */; }; 61F9CABA2513A7F5000A5E61 /* RUMSessionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F9CA982513977A000A5E61 /* RUMSessionMatcher.swift */; }; 61FC5F3525CC1898006BB4DE /* CrashContextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FC5F3425CC1898006BB4DE /* CrashContextProviderTests.swift */; }; 61FDBA1326971953001D9D43 /* CrashReportMinifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FDBA1226971953001D9D43 /* CrashReportMinifier.swift */; }; @@ -736,6 +736,7 @@ D23354FD2A42E32000AFCAE2 /* InternalExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */; }; D234613128B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; D234613228B7713000055D4C /* FeatureContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D234613028B7712F00055D4C /* FeatureContextTests.swift */; }; + D23BFFEE2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */; }; D23F8E5229DDCD28001CFAE8 /* UIViewControllerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F3CDA2251118FB00C816E5 /* UIViewControllerHandler.swift */; }; D23F8E5329DDCD28001CFAE8 /* RUMCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C3E63A24BF1A4B008053F2 /* RUMCommand.swift */; }; D23F8E5429DDCD28001CFAE8 /* ValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611529A425E3DD51004F740E /* ValuePublisher.swift */; }; @@ -947,6 +948,7 @@ D270CDE12B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */; }; D2777D9D29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; D2777D9E29F6A75800FFBB40 /* TelemetryReceiverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */; }; + D27B8A402BB171BA0031D37E /* WebViewSlotCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */; }; D27CBD9A2BB5DBBB00C766AA /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27CBD992BB5DBBB00C766AA /* Mocks.swift */; }; D27D81C12A5D415200281CC2 /* CrashReporter.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 614ED36B260352DC00C8C519 /* CrashReporter.xcframework */; }; D27D81C22A5D415200281CC2 /* DatadogCrashReporting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B7885425C180CB002675B5 /* DatadogCrashReporting.framework */; }; @@ -2410,11 +2412,11 @@ 61F3CDA62512144600C816E5 /* UIKitRUMViewsPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRUMViewsPredicate.swift; sourceTree = ""; }; 61F3CDAA25121FB500C816E5 /* UIViewControllerSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerSwizzlerTests.swift; sourceTree = ""; }; 61F74AF326F20E4600E5F5ED /* DebugCrashReportingWithRUMViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugCrashReportingWithRUMViewController.swift; sourceTree = ""; }; - 61F930CA2BA213AC005F0EE2 /* AppHang.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHang.swift; sourceTree = ""; }; 61F930BD2BA1ACAC005F0EE2 /* Storage+TLV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+TLV.swift"; sourceTree = ""; }; 61F930C12BA1C41A005F0EE2 /* TLVBlockReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockReader.swift; sourceTree = ""; }; 61F930C42BA1C4EB005F0EE2 /* TLVBlockReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLVBlockReaderTests.swift; sourceTree = ""; }; 61F930C72BA1C51C005F0EE2 /* Storage+TLVTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+TLVTests.swift"; sourceTree = ""; }; + 61F930CA2BA213AC005F0EE2 /* AppHang.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHang.swift; sourceTree = ""; }; 61F9CA982513977A000A5E61 /* RUMSessionMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSessionMatcher.swift; sourceTree = ""; }; 61FB222C244A21ED00902D19 /* LoggingFeatureMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingFeatureMocks.swift; sourceTree = ""; }; 61FB222F244E1BE900902D19 /* DatadogLogsFeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatadogLogsFeatureTests.swift; sourceTree = ""; }; @@ -2587,6 +2589,7 @@ D23354FB2A42E32000AFCAE2 /* InternalExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalExtended.swift; sourceTree = ""; }; D234613028B7712F00055D4C /* FeatureContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureContextTests.swift; sourceTree = ""; }; D236BE2729520FED00676E67 /* CrashReportReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportReceiver.swift; sourceTree = ""; }; + D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSlotCacheTests.swift; sourceTree = ""; }; D23F8E9929DDCD28001CFAE8 /* DatadogRUM.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DatadogRUM.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D23F8ECD29DDCD38001CFAE8 /* DatadogRUMTests tvOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "DatadogRUMTests tvOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D240684D27CE6C9E00C04F44 /* Example tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Example tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2651,6 +2654,7 @@ D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = ""; }; D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = ""; }; D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiverTests.swift; sourceTree = ""; }; + D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSlotCache.swift; sourceTree = ""; }; D27CBD992BB5DBBB00C766AA /* Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = ""; }; D286626D2A43487500852CE3 /* Datadog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Datadog.swift; sourceTree = ""; }; D28F836729C9E71C00EF8EA2 /* DDSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSpanTests.swift; sourceTree = ""; }; @@ -3272,10 +3276,11 @@ 61054E242A6EE10A00AAA894 /* ViewTreeSnapshot.swift */, 61054E252A6EE10A00AAA894 /* ViewTreeSnapshotBuilder.swift */, 61054E262A6EE10A00AAA894 /* ViewTreeRecorder.swift */, - 61054E272A6EE10A00AAA894 /* NodeRecorders */, 61054E372A6EE10A00AAA894 /* ViewAttributes+Copy.swift */, 61054E382A6EE10A00AAA894 /* ViewTreeRecordingContext.swift */, 61054E392A6EE10A00AAA894 /* NodeIDGenerator.swift */, + D27B8A3F2BB171BA0031D37E /* WebViewSlotCache.swift */, + 61054E272A6EE10A00AAA894 /* NodeRecorders */, ); path = ViewTreeSnapshot; sourceTree = ""; @@ -3352,7 +3357,7 @@ A7B932F42B1F694000AE6477 /* ResourcesProcessor.swift */, 61054E492A6EE10A00AAA894 /* Privacy */, 61054E4C2A6EE10A00AAA894 /* Diffing */, - 61054E4F2A6EE10A00AAA894 /* SRDataModelsBuilder */, + 61054E4F2A6EE10A00AAA894 /* Builders */, 61054E522A6EE10A00AAA894 /* Flattening */, ); path = Processor; @@ -3375,13 +3380,13 @@ path = Diffing; sourceTree = ""; }; - 61054E4F2A6EE10A00AAA894 /* SRDataModelsBuilder */ = { + 61054E4F2A6EE10A00AAA894 /* Builders */ = { isa = PBXGroup; children = ( 61054E502A6EE10A00AAA894 /* RecordsBuilder.swift */, 61054E512A6EE10A00AAA894 /* WireframesBuilder.swift */, ); - path = SRDataModelsBuilder; + path = Builders; sourceTree = ""; }; 61054E522A6EE10A00AAA894 /* Flattening */ = { @@ -3452,7 +3457,7 @@ children = ( 61054F4F2A6EE1BA00AAA894 /* Privacy */, 61054F512A6EE1BA00AAA894 /* Diffing */, - 61054F542A6EE1BA00AAA894 /* SRDataModelsBuilder */, + 61054F542A6EE1BA00AAA894 /* Builders */, 61054F562A6EE1BA00AAA894 /* SnapshotProcessorTests.swift */, A7D9528B2B28C18D004C79B1 /* ResourceProcessorTests.swift */, 61054F572A6EE1BA00AAA894 /* Flattening */, @@ -3477,12 +3482,12 @@ path = Diffing; sourceTree = ""; }; - 61054F542A6EE1BA00AAA894 /* SRDataModelsBuilder */ = { + 61054F542A6EE1BA00AAA894 /* Builders */ = { isa = PBXGroup; children = ( 61054F552A6EE1BA00AAA894 /* RecordsBuilderTests.swift */, ); - path = SRDataModelsBuilder; + path = Builders; sourceTree = ""; }; 61054F572A6EE1BA00AAA894 /* Flattening */ = { @@ -3546,6 +3551,7 @@ 61054F662A6EE1BA00AAA894 /* ViewTreeRecordingContextTests.swift */, 61054F672A6EE1BA00AAA894 /* ViewTreeSnapshotBuilderTests.swift */, 61054F682A6EE1BA00AAA894 /* NodeIDGeneratorTests.swift */, + D23BFFED2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift */, 61054F692A6EE1BA00AAA894 /* NodeRecorders */, 61054F792A6EE1BA00AAA894 /* ViewTreeRecorderTests.swift */, 61054F7A2A6EE1BA00AAA894 /* ViewTreeSnapshotTests.swift */, @@ -7761,6 +7767,7 @@ 61054E962A6EE10A00AAA894 /* Diff+SRWireframes.swift in Sources */, D22C5BD02A98A6660024CC1F /* Baggages.swift in Sources */, 61054E902A6EE10A00AAA894 /* SegmentJSON.swift in Sources */, + D27B8A402BB171BA0031D37E /* WebViewSlotCache.swift in Sources */, 61054E672A6EE10A00AAA894 /* Recorder.swift in Sources */, 61054E6B2A6EE10A00AAA894 /* CFType+Safety.swift in Sources */, 61054E852A6EE10A00AAA894 /* UISegmentRecorder.swift in Sources */, @@ -7882,6 +7889,7 @@ 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */, 61054FBF2A6EE1BA00AAA894 /* UIPickerViewRecorderTests.swift in Sources */, + D23BFFEE2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift in Sources */, 61054FAE2A6EE1BA00AAA894 /* TouchIdentifierGeneratorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift index a3df8539e3..1738ccd2fe 100644 --- a/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift +++ b/Datadog/IntegrationUnitTests/SessionReplay/WebRecordIntegrationTests.swift @@ -43,6 +43,7 @@ class WebRecordIntegrationTests: XCTestCase { func testWebRecordIntegration() throws { // Given + let webView = WKWebView() let randomApplicationID: String = .mockRandom() let randomUUID: UUID = .mockRandom() let randomBrowserViewID: UUID = .mockRandom() @@ -65,7 +66,7 @@ class WebRecordIntegrationTests: XCTestCase { // When RUMMonitor.shared(in: core).startView(key: "web-view") - controller.send(body: body) + controller.send(body: body, from: webView) controller.flush() // Then @@ -74,7 +75,7 @@ class WebRecordIntegrationTests: XCTestCase { let segment = try XCTUnwrap(segments.first) let expectedUUID = randomUUID.uuidString.lowercased() - let expectedSlotID = "\(controller.hash as Int)" // explicitly get the NSObject.hash + let expectedSlotID = String(webView.hash) XCTAssertEqual(segment.applicationID, randomApplicationID) XCTAssertEqual(segment.sessionID, expectedUUID) diff --git a/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift b/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift index d3bdc275dc..0382373eff 100644 --- a/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift +++ b/DatadogCore/Tests/Datadog/Mocks/SystemFrameworks/WebKitMocks.swift @@ -22,9 +22,9 @@ final class WKUserContentControllerMock: WKUserContentController { handlers[name] = nil } - func send(body: Any) { + func send(body: Any, from webView: WKWebView? = nil) { let handler = handlers[DDScriptMessageHandler.name] - let message = WKScriptMessageMock(body: body, name: DDScriptMessageHandler.name) + let message = WKScriptMessageMock(body: body, name: DDScriptMessageHandler.name, webView: webView) handler?.userContentController(self, didReceive: message) } @@ -41,14 +41,17 @@ final class WKUserContentControllerMock: WKUserContentController { private final class WKScriptMessageMock: WKScriptMessage { private let _body: Any private let _name: String + private weak var _webView: WKWebView? - init(body: Any, name: String) { + init(body: Any, name: String, webView: WKWebView? = nil) { _body = body _name = name + _webView = webView } override var body: Any { _body } override var name: String { _name } + override weak var webView: WKWebView? { _webView } } #endif diff --git a/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift b/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift index 11365e5b16..a6c225b848 100644 --- a/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift +++ b/DatadogRUM/Tests/Integrations/WebViewEventReceiverTests.swift @@ -165,10 +165,7 @@ class WebViewEventReceiverTests: XCTestCase { func testGivenRUMContextAvailable_whenReceivingWebEvent_itGetsEnrichedWithOtherMobileContextAndWritten() throws { let core = PassthroughCoreMock( - context: .mockWith( - source: "react-native", - serverTimeOffset: .mockRandom(min: -10, max: 10).rounded() - ) + context: .mockWith(source: "react-native") ) // Given diff --git a/DatadogSessionReplay/Sources/Models/SRDataModels.swift b/DatadogSessionReplay/Sources/Models/SRDataModels.swift index 61a2806adc..fbc7613f95 100644 --- a/DatadogSessionReplay/Sources/Models/SRDataModels.swift +++ b/DatadogSessionReplay/Sources/Models/SRDataModels.swift @@ -452,6 +452,9 @@ public struct SRWebviewWireframe: Codable, Hashable { /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. public let id: Int64 + /// Whether this web-view is visible or not. + public let isVisible: Bool? + /// The style of this wireframe. public let shapeStyle: SRShapeStyle? @@ -475,6 +478,7 @@ public struct SRWebviewWireframe: Codable, Hashable { case clip = "clip" case height = "height" case id = "id" + case isVisible = "isVisible" case shapeStyle = "shapeStyle" case slotId = "slotId" case type = "type" @@ -970,6 +974,9 @@ public struct SRIncrementalSnapshotRecord: Codable { /// Defines the unique ID of the wireframe. This is persistent throughout the view lifetime. public let id: Int64 + /// Whether this web-view is visible or not. + public let isVisible: Bool? + /// The style of this wireframe. public let shapeStyle: SRShapeStyle? @@ -993,6 +1000,7 @@ public struct SRIncrementalSnapshotRecord: Codable { case clip = "clip" case height = "height" case id = "id" + case isVisible = "isVisible" case shapeStyle = "shapeStyle" case slotId = "slotId" case type = "type" @@ -1322,4 +1330,4 @@ public enum SRRecord: Codable { } } #endif -// Generated from https://github.com/DataDog/rum-events-format/tree/c3747b3facf75e51cbad4c32f77ec3894f5a7249 +// Generated from https://github.com/DataDog/rum-events-format/tree/be033e3251da4a20891a774f9843c489a693c80d diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift b/DatadogSessionReplay/Sources/Processor/Builders/RecordsBuilder.swift similarity index 100% rename from DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/RecordsBuilder.swift rename to DatadogSessionReplay/Sources/Processor/Builders/RecordsBuilder.swift diff --git a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift similarity index 90% rename from DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift rename to DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift index 993167f904..6a447be89c 100644 --- a/DatadogSessionReplay/Sources/Processor/SRDataModelsBuilder/WireframesBuilder.swift +++ b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift @@ -20,6 +20,34 @@ public typealias WireframeID = NodeID /// Note: `WireframesBuilder` is used by `Processor` on a single background thread. @_spi(Internal) public class SessionReplayWireframesBuilder { + /// The cache of webview slots in memory during snapshot. + private(set) var webviews: [Int: WebViewSlot] + + /// Creates a builder for builder wireframes in snapshot processing. + /// + /// The builder takes optional webview slots in cache that can be updated + /// while traversing the node. The cache will be used to create wireframes + /// that are not visible be still need to be kept by the player. + /// + /// - Parameter webviews: The webview slot cache. + init(webviews: [Int: WebViewSlot] = [:]) { + self.webviews = webviews + } + + /// Removes a webview slot. + /// + /// Any node builder should remove the slot that is visible so it can be + /// placed at the right index in the wireframe list. Any remaining slot in + /// cache are considered hidden. + /// + /// - Parameter id: The id of the slot to remove. + func removeWebView(withSlotID id: Int) { + webviews[id] = nil + } +} + +@_spi(Internal) +extension SessionReplayWireframesBuilder { /// A set of fallback values to use if the actual value cannot be read or converted. /// /// The idea is to always provide value, which would make certain element visible in the player. @@ -186,13 +214,15 @@ public class SessionReplayWireframesBuilder { borderWidth: CGFloat? = nil, backgroundColor: CGColor? = nil, cornerRadius: CGFloat? = nil, - opacity: CGFloat? = nil + opacity: CGFloat? = nil, + isVisible: Bool? = nil ) -> SRWireframe { let wireframe = SRWebviewWireframe( border: createShapeBorder(borderColor: borderColor, borderWidth: borderWidth), clip: clip, height: Int64(withNoOverflow: frame.height), id: id, + isVisible: isVisible, shapeStyle: createShapeStyle(backgroundColor: backgroundColor, cornerRadius: cornerRadius, opacity: opacity), slotId: slotId, width: Int64(withNoOverflow: frame.size.width), diff --git a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift index 229aadfc20..c5e197ba1a 100644 --- a/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift +++ b/DatadogSessionReplay/Sources/Processor/Diffing/Diff+SRWireframes.swift @@ -196,6 +196,7 @@ extension SRWebviewWireframe: MutableWireframe { clip: use(clip, ifDifferentThan: other.clip), height: use(height, ifDifferentThan: other.height), id: id, + isVisible: use(isVisible, ifDifferentThan: other.isVisible), shapeStyle: use(shapeStyle, ifDifferentThan: other.shapeStyle), slotId: slotId, width: use(width, ifDifferentThan: other.width), diff --git a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift index d2d136173c..6862b8524b 100644 --- a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift +++ b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift @@ -33,8 +33,6 @@ internal protocol SnapshotProcessing { internal class SnapshotProcessor: SnapshotProcessing { /// Flattens VTS received from `Recorder` by removing invisible nodes. private let nodesFlattener = NodesFlattener() - /// Builds SR wireframes to describe UI elements. - private let wireframesBuilder = WireframesBuilder() /// Builds SR records to transport SR wireframes. private let recordsBuilder: RecordsBuilder @@ -78,10 +76,18 @@ internal class SnapshotProcessor: SnapshotProcessing { } private func processSync(viewTreeSnapshot: ViewTreeSnapshot, touchSnapshot: TouchSnapshot?) { - let flattenedNodes = nodesFlattener.flattenNodes(in: viewTreeSnapshot) - let wireframes: [SRWireframe] = flattenedNodes - .map { node in node.wireframesBuilder } - .flatMap { nodeBuilder in nodeBuilder.buildWireframes(with: wireframesBuilder) } + let builder = WireframesBuilder(webviews: viewTreeSnapshot.webviews) + let nodes = nodesFlattener.flattenNodes(in: viewTreeSnapshot) + + // build wireframe from nodes + var wireframes: [SRWireframe] = nodes.flatMap { node in + node.wireframesBuilder.buildWireframes(with: builder) + } + + // build hidden webview wireframe and place them at the beginning + wireframes = builder.webviews.values.flatMap { webview in + webview.hiddenWireframes(with: builder) + } + wireframes interceptWireframes?(wireframes) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift index 0f4e48aac2..1f52067a08 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift @@ -12,49 +12,83 @@ internal class WKWebViewRecorder: NodeRecorder { let identifier = UUID() func semantics(of view: UIView, with attributes: ViewAttributes, in context: ViewTreeRecordingContext) -> NodeSemantics? { - guard let webView = view as? WKWebView else { + guard let webview = view as? WKWebView else { return nil } - guard attributes.isVisible else { - return InvisibleElement.constant - } - - let builder = WKWebViewWireframesBuilder( - wireframeID: context.ids.nodeID(view: view, nodeRecorder: self), - slotID: webView.configuration.userContentController.hash, - attributes: attributes - ) + let slot = WKWebViewSlot(webview: webview) + // Add or update the webview slot in cache + context.webviewCache.update(slot) + let builder = WKWebViewWireframesBuilder(slot: slot, attributes: attributes) let node = Node(viewAttributes: attributes, wireframesBuilder: builder) return SpecificElement(subtreeStrategy: .ignore, nodes: [node]) } } +/// The slot recorded for a `WKWebView`. +internal struct WKWebViewSlot: WebViewSlot { + /// Weak reference to the web view represented by this slot. + /// + /// If the webview become `nil`, the slot will diseappear at the + /// next recording cycle during `reset` + weak var webview: WKWebView? + + /// The slot id. + let id: Int + + init(webview: WKWebView) { + self.webview = webview + self.id = webview.hash + } + + func purge() -> WebViewSlot? { + webview.map(WKWebViewSlot.init(webview:)) + } + + func hiddenWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] { + return [ + builder.createWebViewWireframe( + id: Int64(id), + frame: .zero, + slotId: String(id), + isVisible: false + ) + ] + } +} + internal struct WKWebViewWireframesBuilder: NodeWireframesBuilder { - let wireframeID: WireframeID - /// The slot identifier of the webview controller. - let slotID: Int - /// Attributes of the `UIView`. + /// The webview slot. + let slot: WebViewSlot + let attributes: ViewAttributes - var wireframeRect: CGRect { - attributes.frame - } + var wireframeRect: CGRect { attributes.frame } func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { + guard attributes.isVisible else { + // ignore hidden webview, the wireframes will be built + // by the slot itself + return [] + } + + /// Remove the slot from the builder because it has an associated node + builder.removeWebView(withSlotID: slot.id) return [ builder.createWebViewWireframe( - id: wireframeID, - frame: wireframeRect, - slotId: String(slotID), + id: Int64(slot.id), + frame: attributes.frame, + slotId: String(slot.id), borderColor: attributes.layerBorderColor, borderWidth: attributes.layerBorderWidth, backgroundColor: attributes.backgroundColor, cornerRadius: attributes.layerCornerRadius, - opacity: attributes.alpha + opacity: attributes.alpha, + isVisible: true ) ] } } + #endif diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift index 46b5b1c7e5..715af0ca7c 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeRecordingContext.swift @@ -22,6 +22,8 @@ public struct SessionReplayViewTreeRecordingContext { public let ids: NodeIDGenerator /// Variable view controller related context var viewControllerContext: ViewControllerContext = .init() + /// Webviews caching. + let webviewCache: WebViewSlotCache } // This alias enables us to have a more unique name exposed through public-internal access level diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index abf5c5fd30..2b763fb394 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -27,6 +27,8 @@ internal struct ViewTreeSnapshot { let nodes: [Node] /// An array of resource references recorded for this snapshot - sequenced in DFS order. let resources: [Resource] + /// An array of webview nodes recorded for this and past snapshots. + let webviews: [Int: WebViewSlot] } /// An individual node in `ViewTreeSnapshot`. A `SessionReplayNode` describes a single view - similar: an array of nodes describes @@ -67,6 +69,30 @@ public protocol SessionReplayResource { /// This alias enables us to have a more unique name exposed through public-internal access level internal typealias Resource = SessionReplayResource +/// An individual webview slots in `ViewTreeSnapshot`. A `WebViewSlot` describes a single webview +/// identified by a slot id. +internal protocol WebViewSlot { + /// The slot id. + var id: Int { get } + + /// Purge the webview slot. + /// + /// The slot will be purged before recording a snapshot, the implementation + /// must return `nil` if the webview was deallocated. + /// + /// - Returns: The remaining slot or `nil` if deallocated. + func purge() -> WebViewSlot? + + /// Creates hidden wireframes for this slot. + /// + /// This build method will be called as a fallback if the slot was not recorded + /// as a ``SessionReplayNode``. + /// + /// - Parameter builder: the generic builder for constructing SR data models. + /// - Returns: one wireframe that describe a webview slot in SR. + func hiddenWireframes(with builder: WireframesBuilder) -> [SRWireframe] +} + /// Attributes of the `UIView` that the node was created for. /// /// It is used by the `Recorder` to capture view attributes on the main thread. diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift index f1d292115c..fbac433e76 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshotBuilder.swift @@ -16,6 +16,8 @@ internal struct ViewTreeSnapshotBuilder { let viewTreeRecorder: ViewTreeRecorder /// Generates stable IDs for traversed views. let idsGenerator: NodeIDGenerator + /// The webview slots caching. + let webviewCache = WebViewSlotCache() /// Builds the `ViewTreeSnapshot` for given root view. /// @@ -25,10 +27,15 @@ internal struct ViewTreeSnapshotBuilder { /// are computed relatively to the `rootView` (e.g. the `x` and `y` position of all descendant nodes is given /// as its position in the root, no matter of nesting level). func createSnapshot(of rootView: UIView, with recorderContext: Recorder.Context) -> ViewTreeSnapshot { + // Purge the webviews cache before taking snapshot. + // It will remove deallocated webview slots + webviewCache.purge() + let context = ViewTreeRecordingContext( recorder: recorderContext, coordinateSpace: rootView, - ids: idsGenerator + ids: idsGenerator, + webviewCache: webviewCache ) let recording = viewTreeRecorder.record(rootView, in: context) let snapshot = ViewTreeSnapshot( @@ -36,7 +43,8 @@ internal struct ViewTreeSnapshotBuilder { context: recorderContext, viewportSize: rootView.bounds.size, nodes: recording.nodes, - resources: recording.resources + resources: recording.resources, + webviews: webviewCache.slots ) return snapshot } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift new file mode 100644 index 0000000000..ff804a62b7 --- /dev/null +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCache.swift @@ -0,0 +1,35 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import Foundation + +/// Keeps slots in cache during webviews lifecycle. +/// +/// Uppon recording, the cache should be reset to clear deallocated web-views. +/// +/// **Note**: This class is not thread-safe and must be called on the main thread during recording only. +internal final class WebViewSlotCache { + /// The current dictionary of slots. + private(set) var slots: [Int: WebViewSlot] = [:] + + /// Inserts the given slot into the cache. + /// + /// If a slot id is already contained in the cache, the new slot replaces + /// the existing one. + /// + /// - Parameter slot: A slot to insert into the cache + /// - Complexity O(1) + func update(_ slot: WebViewSlot) { + slots[slot.id] = slot + } + + /// Purges the cache by removing deallocated webviews. + /// + /// - Complexity O(n) + func purge() { + slots = slots.compactMapValues { $0.purge() } + } +} diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 8196086683..317a57f214 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -34,7 +34,8 @@ extension ViewTreeSnapshot: AnyMockable, RandomMockable { context: .mockRandom(), viewportSize: .mockRandom(), nodes: .mockRandom(count: .random(in: (5..<50))), - resources: .mockRandom(count: .random(in: (5..<50))) + resources: .mockRandom(count: .random(in: (5..<50))), + webviews: .mockRandom() ) } @@ -43,14 +44,16 @@ extension ViewTreeSnapshot: AnyMockable, RandomMockable { context: Recorder.Context = .mockAny(), viewportSize: CGSize = .mockAny(), nodes: [Node] = .mockAny(), - resources: [Resource] = .mockAny() + resources: [Resource] = .mockAny(), + webviews: [Int: WebViewSlot] = .mockAny() ) -> ViewTreeSnapshot { return ViewTreeSnapshot( date: date, context: context, viewportSize: viewportSize, nodes: nodes, - resources: resources + resources: resources, + webviews: webviews ) } } @@ -297,7 +300,7 @@ extension UIImageResource: RandomMockable { } } -extension Collection where Element == Resource { +extension Sequence where Element == Resource { static func mockAny() -> [Resource] { return [MockResource].mockAny() } @@ -307,6 +310,49 @@ extension Collection where Element == Resource { } } +struct WebViewSlotMock: WebViewSlot, AnyMockable, RandomMockable { + let id: Int + let shouldPurge: Bool + let hiddenWireframe: SRWireframe + + init(id: Int, shouldPurge: Bool = false, hiddenWireframe: SRWireframe = .mockAny()) { + self.id = id + self.shouldPurge = shouldPurge + self.hiddenWireframe = hiddenWireframe + } + + func purge() -> WebViewSlot? { shouldPurge ? nil : self } + + func hiddenWireframes(with builder: DatadogSessionReplay.SessionReplayWireframesBuilder) -> [SRWireframe] { + [hiddenWireframe] + } + + static func mockAny() -> WebViewSlotMock { + .mockWith() + } + + static func mockWith(id: Int = .mockAny()) -> WebViewSlotMock { + WebViewSlotMock( + id: id, + hiddenWireframe: .mockRandomWith(id: Int64(id)) + ) + } + + static func mockRandom() -> WebViewSlotMock { + WebViewSlotMock(id: .mockRandom(), hiddenWireframe: .mockRandom()) + } +} + +extension Dictionary where Key == Int, Value == WebViewSlot { + static func mockAny() -> [Int: WebViewSlot] { + return [Int: WebViewSlotMock].mockAny() + } + + static func mockRandom() -> [Int: WebViewSlot] { + return [Int: WebViewSlotMock].mockRandom() + } +} + extension SpecificElement { static func mockAny() -> SpecificElement { SpecificElement(subtreeStrategy: .mockRandom(), nodes: []) @@ -344,19 +390,22 @@ extension ViewTreeRecordingContext: AnyMockable, RandomMockable { return .init( recorder: .mockRandom(), coordinateSpace: UIView.mockRandom(), - ids: NodeIDGenerator() + ids: NodeIDGenerator(), + webviewCache: WebViewSlotCache() ) } static func mockWith( recorder: Recorder.Context = .mockAny(), coordinateSpace: UICoordinateSpace = UIView.mockAny(), - ids: NodeIDGenerator = NodeIDGenerator() + ids: NodeIDGenerator = NodeIDGenerator(), + webviewCache: WebViewSlotCache = WebViewSlotCache() ) -> ViewTreeRecordingContext { return .init( recorder: recorder, coordinateSpace: coordinateSpace, - ids: ids + ids: ids, + webviewCache: webviewCache ) } } diff --git a/DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift b/DatadogSessionReplay/Tests/Processor/Builders/RecordsBuilderTests.swift similarity index 100% rename from DatadogSessionReplay/Tests/Processor/SRDataModelsBuilder/RecordsBuilderTests.swift rename to DatadogSessionReplay/Tests/Processor/Builders/RecordsBuilderTests.swift diff --git a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift index 9e30dcb07b..3e0fc36a40 100644 --- a/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift +++ b/DatadogSessionReplay/Tests/Processor/SnapshotProcessorTests.swift @@ -194,6 +194,95 @@ class SnapshotProcessorTests: XCTestCase { XCTAssertEqual(core.recordsCountByViewID?.values.map { $0 }, [4, 4]) } + func testWhenProcessingViewTreeSnapshot_itIncludeWebViewSlotFromNode() throws { + let time = Date() + let rum: RUMContext = .mockWith(serverTimeOffset: 0) + + // Given + let core = PassthroughCoreMock() + let srContextPublisher = SRContextPublisher(core: core) + let webviewCache = WebViewSlotCache() + let processor = SnapshotProcessor( + queue: NoQueue(), + recordWriter: recordWriter, + srContextPublisher: srContextPublisher, + telemetry: TelemetryMock() + ) + + let hiddenSlot = WebViewSlotMock.mockWith(id: .mockRandom()) + let visibleSlot = WebViewSlotMock.mockWith(id: .mockRandom()) + let builder = WKWebViewWireframesBuilder(slot: visibleSlot, attributes: .mockAny()) + let node = Node(viewAttributes: .mockAny(), wireframesBuilder: builder) + + // When + webviewCache.update(hiddenSlot) + webviewCache.update(visibleSlot) + + let snapshot = ViewTreeSnapshot( + date: time, + context: .init(privacy: .allow, rumContext: rum, date: time), + viewportSize: .mockRandom(minWidth: 1_000, minHeight: 1_000), + nodes: [node], + resources: [], + webviews: webviewCache.slots + ) + + processor.process(viewTreeSnapshot: snapshot, touchSnapshot: nil) + + // Then + XCTAssertEqual(recordWriter.records.count, 1) + + let enrichedRecord = try XCTUnwrap(recordWriter.records.first) + XCTAssertEqual(enrichedRecord.applicationID, rum.applicationID) + XCTAssertEqual(enrichedRecord.sessionID, rum.sessionID) + XCTAssertEqual(enrichedRecord.viewID, rum.viewID) + + XCTAssertEqual(enrichedRecord.records.count, 3) + XCTAssertTrue(enrichedRecord.records[2].isFullSnapshotRecord) + let fullSnapshotRecord = try XCTUnwrap(enrichedRecord.records[2].fullSnapshot) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.count, 2) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.first?.id, Int64(hiddenSlot.id), "The hidden webview wireframe should be first") + XCTAssertEqual(fullSnapshotRecord.data.wireframes.last?.id, Int64(visibleSlot.id), "The visible webview wireframe should be last") + } + + func testWhenProcessingViewTreeSnapshot_itIncludeWebViewSlotFromCache() throws { + let time = Date() + let rum: RUMContext = .mockWith(serverTimeOffset: 0) + + // Given + let core = PassthroughCoreMock() + let srContextPublisher = SRContextPublisher(core: core) + let processor = SnapshotProcessor( + queue: NoQueue(), + recordWriter: recordWriter, + srContextPublisher: srContextPublisher, + telemetry: TelemetryMock() + ) + + let hiddenWireframe: SRWireframe = .mockRandom() + let webviewSlot = WebViewSlotMock(id: .mockAny(), hiddenWireframe: hiddenWireframe) + let viewTree = generateSimpleViewTree() + + // When + snapshotBuilder.webviewCache.update(webviewSlot) + let snapshot = generateViewTreeSnapshot(for: viewTree, date: time, rumContext: rum) + processor.process(viewTreeSnapshot: snapshot, touchSnapshot: nil) + + // Then + XCTAssertEqual(recordWriter.records.count, 1) + + let enrichedRecord = try XCTUnwrap(recordWriter.records.first) + XCTAssertEqual(enrichedRecord.applicationID, rum.applicationID) + XCTAssertEqual(enrichedRecord.sessionID, rum.sessionID) + XCTAssertEqual(enrichedRecord.viewID, rum.viewID) + + XCTAssertEqual(enrichedRecord.records.count, 3) + XCTAssertTrue(enrichedRecord.records[2].isFullSnapshotRecord) + let fullSnapshotRecord = try XCTUnwrap(enrichedRecord.records[2].fullSnapshot) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.count, 3) + XCTAssertEqual(fullSnapshotRecord.data.wireframes.first?.id, hiddenWireframe.id, "The hidden webview wireframe should be first") + } + // MARK: - Processing `TouchSnapshots` func testWhenProcessingTouchSnapshot_itWritesRecordsThatContinueCurrentSegment() throws { diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift index f03be46989..bbc05e9354 100644 --- a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorderTests.swift @@ -31,10 +31,14 @@ class WKWebViewRecorderTests: XCTestCase { let viewAttributes: ViewAttributes = .mock(fixture: .invisible) // When - let semantics = try XCTUnwrap(recorder.semantics(of: webView, with: viewAttributes, in: .mockAny())) + let semantics = try XCTUnwrap(recorder.semantics(of: webView, with: viewAttributes, in: .mockAny()) as? SpecificElement) // Then - XCTAssertTrue(semantics is InvisibleElement) + XCTAssertEqual(semantics.subtreeStrategy, .ignore, "WebView's subtree should not be recorded") + + let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? WKWebViewWireframesBuilder) + let wireframes = builder.buildWireframes(with: WireframesBuilder()) + XCTAssert(wireframes.isEmpty) } func testWhenWebViewIsVisible() throws { @@ -48,23 +52,44 @@ class WKWebViewRecorderTests: XCTestCase { XCTAssertEqual(semantics.subtreeStrategy, .ignore, "WebView's subtree should not be recorded") let builder = try XCTUnwrap(semantics.nodes.first?.wireframesBuilder as? WKWebViewWireframesBuilder) - XCTAssertEqual(builder.attributes, viewAttributes) + let wireframes = builder.buildWireframes(with: WireframesBuilder()) + XCTAssertFalse(wireframes.isEmpty) + } + + func testHiddenWebViewSlot() throws { + // Given + let slot = WKWebViewSlot(webview: webView) + + // When + let wireframes = slot.hiddenWireframes(with: WireframesBuilder()) + + // Then + XCTAssertEqual(wireframes.count, 1) + + guard case let .webviewWireframe(wireframe) = wireframes.first else { + return XCTFail("First wireframe needs to be webviewWireframe case") + } + + XCTAssertEqual(wireframe.id, Int64(webView.hash)) + XCTAssertEqual(wireframe.slotId, String(webView.hash)) + XCTAssertNil(wireframe.clip) + XCTAssertEqual(wireframe.x, 0) + XCTAssertEqual(wireframe.y, 0) + XCTAssertEqual(wireframe.width, 0) + XCTAssertEqual(wireframe.height, 0) + XCTAssertFalse(wireframe.isVisible ?? true) } - func testWebViewWireframeBuilder() throws { + func testVisibleWebViewSlot() throws { // Given - let id: WireframeID = .mockRandom() - let slotId: Int = .mockRandom() let attributes: ViewAttributes = .mock(fixture: .visible()) + let slot = WKWebViewSlot(webview: webView) - let builder = WKWebViewWireframesBuilder( - wireframeID: id, - slotID: slotId, - attributes: attributes - ) + let builder = WireframesBuilder(webviews: [slot.id: slot]) // When - let wireframes = builder.buildWireframes(with: WireframesBuilder()) + let wireframes = WKWebViewWireframesBuilder(slot: slot, attributes: attributes) + .buildWireframes(with: builder) // Then XCTAssertEqual(wireframes.count, 1) @@ -73,12 +98,30 @@ class WKWebViewRecorderTests: XCTestCase { return XCTFail("First wireframe needs to be webviewWireframe case") } - XCTAssertEqual(wireframe.id, id) - XCTAssertEqual(wireframe.slotId, String(slotId)) + XCTAssertEqual(wireframe.id, Int64(webView.hash)) + XCTAssertEqual(wireframe.slotId, String(webView.hash)) XCTAssertNil(wireframe.clip) XCTAssertEqual(wireframe.x, Int64(withNoOverflow: attributes.frame.minX)) XCTAssertEqual(wireframe.y, Int64(withNoOverflow: attributes.frame.minY)) XCTAssertEqual(wireframe.width, Int64(withNoOverflow: attributes.frame.width)) XCTAssertEqual(wireframe.height, Int64(withNoOverflow: attributes.frame.height)) + XCTAssertTrue(wireframe.isVisible ?? false) + XCTAssertNil(builder.webviews[slot.id], "webview slot should be removed from builder") } + +// This test should pass but it doesn't because `WKWebView` apparently leaks. +// +// func testDeallocatedWebViewSlot() throws { +// var slot: WebViewSlot? = WKWebViewSlot(webview: webView) +// slot = slot?.reset() +// XCTAssertNotNil(slot) +// +// autoreleasepool { +// let webView = WKWebView() +// slot = WKWebViewSlot(webview: webView) +// } +// +// slot = slot?.reset() +// XCTAssertNil(slot) +// } } diff --git a/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift new file mode 100644 index 0000000000..3c00275a0e --- /dev/null +++ b/DatadogSessionReplay/Tests/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/WebViewSlotCacheTests.swift @@ -0,0 +1,48 @@ +/* + * 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 2019-Present Datadog, Inc. + */ + +import XCTest +import TestUtilities + +@_spi(Internal) +@testable import DatadogSessionReplay + +class WebViewSlotCacheTests: XCTestCase { + func testAddingWebViewSlotToCache() { + let cache = WebViewSlotCache() + cache.update(WebViewSlotMock(id: 1)) + XCTAssertEqual(cache.slots.count, 1) + cache.update(WebViewSlotMock(id: 2)) + XCTAssertEqual(cache.slots.count, 2) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3, "cache slot should override existing slot") + } + + func testPurgeViewSlotCache() { + let cache = WebViewSlotCache() + + cache.update(WebViewSlotMock(id: 1)) + cache.update(WebViewSlotMock(id: 2)) + cache.update(WebViewSlotMock(id: 3)) + XCTAssertEqual(cache.slots.count, 3) + cache.purge() + XCTAssertEqual(cache.slots.count, 3) + + cache.update(WebViewSlotMock(id: 1)) + cache.update(WebViewSlotMock(id: 2, shouldPurge: true)) + cache.update(WebViewSlotMock(id: 3, shouldPurge: true)) + XCTAssertEqual(cache.slots.count, 3) + cache.purge() + XCTAssertEqual(cache.slots.count, 1) + XCTAssertNotNil(cache.slots[1]) + + cache.update(WebViewSlotMock(id: 1, shouldPurge: true)) + cache.purge() + XCTAssertEqual(cache.slots.count, 0) + } +} diff --git a/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift b/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift index aaba9df396..cebbedee73 100644 --- a/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift +++ b/DatadogWebViewTracking/Sources/DDScriptMessageHandler.swift @@ -28,11 +28,11 @@ internal class DDScriptMessageHandler: NSObject, WKScriptMessageHandler { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { - let hash = userContentController.hash + let hash = message.webView.map { String($0.hash) } // message.body must be called within UI thread let body = message.body queue.async { - self.emitter.send(body: body, slotId: String(hash)) + self.emitter.send(body: body, slotId: hash) } } } diff --git a/DatadogWebViewTracking/Tests/Mocks.swift b/DatadogWebViewTracking/Tests/Mocks.swift index e993576f29..92b3824f8e 100644 --- a/DatadogWebViewTracking/Tests/Mocks.swift +++ b/DatadogWebViewTracking/Tests/Mocks.swift @@ -30,13 +30,16 @@ final class MockMessageHandler: NSObject, WKScriptMessageHandler { } final class MockScriptMessage: WKScriptMessage { - let mockBody: Any + private let _body: Any + private weak var _webView: WKWebView? - init(body: Any) { - self.mockBody = body + init(body: Any, webView: WKWebView? = nil) { + _body = body + _webView = webView } - override var body: Any { return mockBody } + override var body: Any { _body } + override weak var webView: WKWebView? { _webView } } extension WebViewTracking.SessionReplayConfiguration.PrivacyLevel: AnyMockable, RandomMockable { diff --git a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift index 0e093164f4..dfbbdd8a5f 100644 --- a/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift +++ b/DatadogWebViewTracking/Tests/WebViewTrackingTests.swift @@ -329,6 +329,7 @@ class WebViewTrackingTests: XCTestCase { func testSendingWebRecordEvent() throws { let recordMessageExpectation = expectation(description: "Record message received") + let webView = WKWebView() let controller = DDUserContentController() let core = PassthroughCoreMock( @@ -338,7 +339,7 @@ class WebViewTrackingTests: XCTestCase { XCTAssertEqual(view.id, "64308fd4-83f9-48cb-b3e1-1e91f6721230") let matcher = JSONObjectMatcher(object: event) XCTAssertEqual(try? matcher.value("date"), 1_635_932_927_012) - XCTAssertEqual(try? matcher.value("slotId"), "\(controller.hash)") + XCTAssertEqual(try? matcher.value("slotId"), String(webView.hash)) recordMessageExpectation.fulfill() case .context: break @@ -366,7 +367,7 @@ class WebViewTrackingTests: XCTestCase { }, "view": { "id": "64308fd4-83f9-48cb-b3e1-1e91f6721230" } } - """) + """, webView: webView) messageHandler?.userContentController(controller, didReceive: webLogMessage) waitForExpectations(timeout: 1) diff --git a/TestUtilities/Helpers/DDAssert.swift b/TestUtilities/Helpers/DDAssert.swift index 213ef9b875..d70b97f6f1 100644 --- a/TestUtilities/Helpers/DDAssert.swift +++ b/TestUtilities/Helpers/DDAssert.swift @@ -72,8 +72,8 @@ private func _DDAssertReflectionEqual(_ expression1: @autoclosure () throws -> A switch (mirror1.displayStyle, mirror2.displayStyle) { case (.dictionary?, .dictionary?): // two dictionaries - let dictionary1 = value1 as! [String: Any] - let dictionary2 = value2 as! [String: Any] + let dictionary1 = value1 as! [AnyHashable: Any] + let dictionary2 = value2 as! [AnyHashable: Any] guard dictionary1.keys.count == dictionary2.keys.count else { throw DDAssertError.expectedFailure("dictionaries have different number of keys", keyPath: keyPath) @@ -84,7 +84,7 @@ private func _DDAssertReflectionEqual(_ expression1: @autoclosure () throws -> A throw DDAssertError.expectedFailure("dictionaries have different key names", keyPath: keyPath) } - try _DDAssertReflectionEqual(value1, value2, keyPath: keyPath + [key1]) + try _DDAssertReflectionEqual(value1, value2, keyPath: keyPath + [key1.description]) } return // dictionaries are equal From 6eff63035391a351c912608939e9e1106f8bf85a Mon Sep 17 00:00:00 2001 From: Maxime Epain Date: Fri, 5 Apr 2024 10:51:43 +0200 Subject: [PATCH 09/69] RUM-3135 Build hidden webview wireframes --- Datadog/Datadog.xcodeproj/project.pbxproj | 4 ++ .../Builders/WireframesBuilder.swift | 50 +++++++++------ .../Sources/Processor/SnapshotProcessor.swift | 6 +- .../NodeRecorders/WKWebViewRecorder.swift | 24 ++----- .../ViewTreeSnapshot/ViewTreeSnapshot.swift | 9 --- .../Tests/Mocks/RecorderMocks.swift | 23 +++---- .../Builders/WireframesBuilderTests.swift | 62 +++++++++++++++++++ .../Processor/SnapshotProcessorTests.swift | 6 +- .../WKWebViewRecorderTests.swift | 26 +------- TestUtilities/Mocks/FoundationMocks.swift | 4 ++ 10 files changed, 118 insertions(+), 96 deletions(-) create mode 100644 DatadogSessionReplay/Tests/Processor/Builders/WireframesBuilderTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 1c3e6a445e..3cd7a990b5 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -541,6 +541,7 @@ A7EA11622AB0CE6C00C73970 /* DDUIKitRUMActionsPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DA18062AB0CA4700F76337 /* DDUIKitRUMActionsPredicateTests.swift */; }; A7EA88562B17639A00FE2580 /* ResourcesWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7EA88552B17639A00FE2580 /* ResourcesWriter.swift */; }; A7F651302B7655DE004B0EDB /* UIImageResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6512F2B7655DE004B0EDB /* UIImageResourceTests.swift */; }; + D2056C212BBFE05A0085BC76 /* WireframesBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2056C202BBFE05A0085BC76 /* WireframesBuilderTests.swift */; }; D20605A3287464F40047275C /* ContextValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A2287464F40047275C /* ContextValuePublisher.swift */; }; D20605A4287464F40047275C /* ContextValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A2287464F40047275C /* ContextValuePublisher.swift */; }; D20605A6287476230047275C /* ServerOffsetPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20605A5287476230047275C /* ServerOffsetPublisher.swift */; }; @@ -2493,6 +2494,7 @@ B3BBBCBB265E71D100943419 /* VitalMemoryReaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalMemoryReaderTests.swift; sourceTree = ""; }; B3FC3C0626526EFF00DEED9E /* VitalInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VitalInfo.swift; sourceTree = ""; }; B3FC3C3B2653A97700DEED9E /* VitalInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VitalInfoTests.swift; sourceTree = ""; }; + D2056C202BBFE05A0085BC76 /* WireframesBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireframesBuilderTests.swift; sourceTree = ""; }; D20605A2287464F40047275C /* ContextValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextValuePublisher.swift; sourceTree = ""; }; D20605A5287476230047275C /* ServerOffsetPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerOffsetPublisher.swift; sourceTree = ""; }; D20605A82874C1CD0047275C /* NetworkConnectionInfoPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConnectionInfoPublisher.swift; sourceTree = ""; }; @@ -3486,6 +3488,7 @@ isa = PBXGroup; children = ( 61054F552A6EE1BA00AAA894 /* RecordsBuilderTests.swift */, + D2056C202BBFE05A0085BC76 /* WireframesBuilderTests.swift */, ); path = Builders; sourceTree = ""; @@ -7887,6 +7890,7 @@ 61054FA52A6EE1BA00AAA894 /* RecordsBuilderTests.swift in Sources */, 61054FD02A6EE1BA00AAA894 /* SRContextPublisherTests.swift in Sources */, 61054F9B2A6EE1BA00AAA894 /* QueueTests.swift in Sources */, + D2056C212BBFE05A0085BC76 /* WireframesBuilderTests.swift in Sources */, 61054F992A6EE1BA00AAA894 /* ColorsTests.swift in Sources */, 61054FBF2A6EE1BA00AAA894 /* UIPickerViewRecorderTests.swift in Sources */, D23BFFEE2BBECA1C00ED6DD6 /* WebViewSlotCacheTests.swift in Sources */, diff --git a/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift index 6a447be89c..24ea855be2 100644 --- a/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift +++ b/DatadogSessionReplay/Sources/Processor/Builders/WireframesBuilder.swift @@ -21,7 +21,7 @@ public typealias WireframeID = NodeID @_spi(Internal) public class SessionReplayWireframesBuilder { /// The cache of webview slots in memory during snapshot. - private(set) var webviews: [Int: WebViewSlot] + private var webviews: [Int: WebViewSlot] /// Creates a builder for builder wireframes in snapshot processing. /// @@ -33,17 +33,6 @@ public class SessionReplayWireframesBuilder { init(webviews: [Int: WebViewSlot] = [:]) { self.webviews = webviews } - - /// Removes a webview slot. - /// - /// Any node builder should remove the slot that is visible so it can be - /// placed at the right index in the wireframe list. Any remaining slot in - /// cache are considered hidden. - /// - /// - Parameter id: The id of the slot to remove. - func removeWebView(withSlotID id: Int) { - webviews[id] = nil - } } @_spi(Internal) @@ -205,34 +194,55 @@ extension SessionReplayWireframesBuilder { return .placeholderWireframe(value: wireframe) } - public func createWebViewWireframe( - id: Int64, + public func visibleWebViewWireframe( + id: Int, frame: CGRect, - slotId: String, clip: SRContentClip? = nil, borderColor: CGColor? = nil, borderWidth: CGFloat? = nil, backgroundColor: CGColor? = nil, cornerRadius: CGFloat? = nil, - opacity: CGFloat? = nil, - isVisible: Bool? = nil + opacity: CGFloat? = nil ) -> SRWireframe { let wireframe = SRWebviewWireframe( border: createShapeBorder(borderColor: borderColor, borderWidth: borderWidth), clip: clip, height: Int64(withNoOverflow: frame.height), - id: id, - isVisible: isVisible, + id: Int64(id), + isVisible: true, shapeStyle: createShapeStyle(backgroundColor: backgroundColor, cornerRadius: cornerRadius, opacity: opacity), - slotId: slotId, + slotId: String(id), width: Int64(withNoOverflow: frame.size.width), x: Int64(withNoOverflow: frame.minX), y: Int64(withNoOverflow: frame.minY) ) + /// Remove the slot from the builder because a wireframe + /// has been created. + webviews.removeValue(forKey: id) return .webviewWireframe(value: wireframe) } + internal func hiddenWebViewWireframes() -> [SRWireframe] { + defer { webviews.removeAll() } + return webviews.values.map { slot in + let wireframe = SRWebviewWireframe( + border: nil, + clip: nil, + height: 0, + id: Int64(slot.id), + isVisible: false, + shapeStyle: nil, + slotId: String(slot.id), + width: 0, + x: 0, + y: 0 + ) + + return .webviewWireframe(value: wireframe) + } + } + // MARK: - Private private func createShapeBorder(borderColor: CGColor?, borderWidth: CGFloat?) -> SRShapeBorder? { diff --git a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift index 6862b8524b..2d25227a4e 100644 --- a/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift +++ b/DatadogSessionReplay/Sources/Processor/SnapshotProcessor.swift @@ -84,10 +84,8 @@ internal class SnapshotProcessor: SnapshotProcessing { node.wireframesBuilder.buildWireframes(with: builder) } - // build hidden webview wireframe and place them at the beginning - wireframes = builder.webviews.values.flatMap { webview in - webview.hiddenWireframes(with: builder) - } + wireframes + // build hidden webview wireframes and place them at the beginning + wireframes = builder.hiddenWebViewWireframes() + wireframes interceptWireframes?(wireframes) diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift index 1f52067a08..3b3c77ec58 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/NodeRecorders/WKWebViewRecorder.swift @@ -16,6 +16,7 @@ internal class WKWebViewRecorder: NodeRecorder { return nil } + // Record all webviews regardless of their visibility let slot = WKWebViewSlot(webview: webview) // Add or update the webview slot in cache context.webviewCache.update(slot) @@ -45,17 +46,6 @@ internal struct WKWebViewSlot: WebViewSlot { func purge() -> WebViewSlot? { webview.map(WKWebViewSlot.init(webview:)) } - - func hiddenWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] { - return [ - builder.createWebViewWireframe( - id: Int64(id), - frame: .zero, - slotId: String(id), - isVisible: false - ) - ] - } } internal struct WKWebViewWireframesBuilder: NodeWireframesBuilder { @@ -69,23 +59,19 @@ internal struct WKWebViewWireframesBuilder: NodeWireframesBuilder { func buildWireframes(with builder: WireframesBuilder) -> [SRWireframe] { guard attributes.isVisible else { // ignore hidden webview, the wireframes will be built - // by the slot itself + // for hidden slot return [] } - /// Remove the slot from the builder because it has an associated node - builder.removeWebView(withSlotID: slot.id) return [ - builder.createWebViewWireframe( - id: Int64(slot.id), + builder.visibleWebViewWireframe( + id: slot.id, frame: attributes.frame, - slotId: String(slot.id), borderColor: attributes.layerBorderColor, borderWidth: attributes.layerBorderWidth, backgroundColor: attributes.backgroundColor, cornerRadius: attributes.layerCornerRadius, - opacity: attributes.alpha, - isVisible: true + opacity: attributes.alpha ) ] } diff --git a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift index 2b763fb394..e664eabb7e 100644 --- a/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift +++ b/DatadogSessionReplay/Sources/Recorder/ViewTreeSnapshotProducer/ViewTreeSnapshot/ViewTreeSnapshot.swift @@ -82,15 +82,6 @@ internal protocol WebViewSlot { /// /// - Returns: The remaining slot or `nil` if deallocated. func purge() -> WebViewSlot? - - /// Creates hidden wireframes for this slot. - /// - /// This build method will be called as a fallback if the slot was not recorded - /// as a ``SessionReplayNode``. - /// - /// - Parameter builder: the generic builder for constructing SR data models. - /// - Returns: one wireframe that describe a webview slot in SR. - func hiddenWireframes(with builder: WireframesBuilder) -> [SRWireframe] } /// Attributes of the `UIView` that the node was created for. diff --git a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift index 317a57f214..df28cc49d7 100644 --- a/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift +++ b/DatadogSessionReplay/Tests/Mocks/RecorderMocks.swift @@ -35,7 +35,7 @@ extension ViewTreeSnapshot: AnyMockable, RandomMockable { viewportSize: .mockRandom(), nodes: .mockRandom(count: .random(in: (5..<50))), resources: .mockRandom(count: .random(in: (5..<50))), - webviews: .mockRandom() + webviews: .mockRandom(count: .random(in: (5..<50))) ) } @@ -300,7 +300,7 @@ extension UIImageResource: RandomMockable { } } -extension Sequence where Element == Resource { +extension Array where Element == Resource { static func mockAny() -> [Resource] { return [MockResource].mockAny() } @@ -313,33 +313,24 @@ extension Sequence where Element == Resource { struct WebViewSlotMock: WebViewSlot, AnyMockable, RandomMockable { let id: Int let shouldPurge: Bool - let hiddenWireframe: SRWireframe - init(id: Int, shouldPurge: Bool = false, hiddenWireframe: SRWireframe = .mockAny()) { + init(id: Int, shouldPurge: Bool = false) { self.id = id self.shouldPurge = shouldPurge - self.hiddenWireframe = hiddenWireframe } func purge() -> WebViewSlot? { shouldPurge ? nil : self } - func hiddenWireframes(with builder: DatadogSessionReplay.SessionReplayWireframesBuilder) -> [SRWireframe] { - [hiddenWireframe] - } - static func mockAny() -> WebViewSlotMock { .mockWith() } static func mockWith(id: Int = .mockAny()) -> WebViewSlotMock { - WebViewSlotMock( - id: id, - hiddenWireframe: .mockRandomWith(id: Int64(id)) - ) + WebViewSlotMock(id: id) } static func mockRandom() -> WebViewSlotMock { - WebViewSlotMock(id: .mockRandom(), hiddenWireframe: .mockRandom()) + WebViewSlotMock(id: .mockRandom()) } } @@ -348,8 +339,8 @@ extension Dictionary where Key == Int, Value == WebViewSlot { return [Int: WebViewSlotMock].mockAny() } - static func mockRandom() -> [Int: WebViewSlot] { - return [Int: WebViewSlotMock].mockRandom() + public static func mockRandom(count: Int = 1) -> Dictionary { + return (0..