Skip to content

Commit

Permalink
Merge pull request #1754 from DataDog/maxep/RUM-2813/sr-webview-privacy
Browse files Browse the repository at this point in the history
RUM-2813 Add webview replay configuration
  • Loading branch information
maxep authored Apr 9, 2024
2 parents e25a3bf + 92dc3b0 commit 6c4d5d1
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 30 deletions.
4 changes: 4 additions & 0 deletions Datadog/Datadog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2650,6 +2651,7 @@
D270CDDC2B46E3DB0002EACD /* URLSessionDataDelegateSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzler.swift; sourceTree = "<group>"; };
D270CDDF2B46E5A50002EACD /* URLSessionDataDelegateSwizzlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDataDelegateSwizzlerTests.swift; sourceTree = "<group>"; };
D2777D9C29F6A75800FFBB40 /* TelemetryReceiverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryReceiverTests.swift; sourceTree = "<group>"; };
D27CBD992BB5DBBB00C766AA /* Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mocks.swift; sourceTree = "<group>"; };
D286626D2A43487500852CE3 /* Datadog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Datadog.swift; sourceTree = "<group>"; };
D28F836729C9E71C00EF8EA2 /* DDSpanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDSpanTests.swift; sourceTree = "<group>"; };
D28F836A29C9E7A300EF8EA2 /* TracingURLSessionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingURLSessionHandlerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3140,6 +3142,7 @@
children = (
D297324F2A5C109A00827599 /* MessageEmitterTests.swift */,
D29732502A5C109A00827599 /* WebViewTrackingTests.swift */,
D27CBD992BB5DBBB00C766AA /* Mocks.swift */,
);
name = DatadogWebViewTrackingTests;
path = ../DatadogWebViewTracking/Tests;
Expand Down Expand Up @@ -7471,6 +7474,7 @@
files = (
D29732532A5C109A00827599 /* WebViewTrackingTests.swift in Sources */,
D29732512A5C109A00827599 /* MessageEmitterTests.swift in Sources */,
D27CBD9A2BB5DBBB00C766AA /* Mocks.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
64 changes: 55 additions & 9 deletions DatadogWebViewTracking/Sources/WebViewTracking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,61 @@ 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 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 {
/// 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<String> = [],
logsSampleRate: Float = 100,
sessionReplayConfiguration: SessionReplayConfiguration? = nil,
in core: DatadogCoreProtocol = CoreRegistry.default
) {
enable(
tracking: webView.configuration.userContentController,
hosts: hosts,
hostsSanitizer: HostsSanitizer(),
logsSampleRate: logsSampleRate,
sessionReplayConfiguration: sessionReplayConfiguration,
in: core
)
}
Expand Down Expand Up @@ -74,13 +107,14 @@ public enum WebViewTracking {
hosts: Set<String>,
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

Expand All @@ -106,15 +140,27 @@ public enum WebViewTracking {
.map { return "\"\($0)\"" }
.joined(separator: ",")

let privacyLevel = sessionReplayConfiguration?.privacyLevel ?? .mask

// Share native capabilities with Browser SDK
// Share native capabilities with Browser SDK
let capabilities = sessionReplayConfiguration != nil ? "\"records\"" : ""

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)'
}
}
"""

Expand Down
50 changes: 50 additions & 0 deletions DatadogWebViewTracking/Tests/Mocks.swift
Original file line number Diff line number Diff line change
@@ -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()!
}
}
91 changes: 70 additions & 21 deletions DatadogWebViewTracking/Tests/WebViewTrackingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -53,6 +97,7 @@ class WebViewTrackingTests: XCTestCase {
hosts: ["datadoghq.com"],
hostsSanitizer: mockSanitizer,
logsSampleRate: 30,
sessionReplayConfiguration: nil,
in: PassthroughCoreMock()
)

Expand Down Expand Up @@ -84,6 +129,7 @@ class WebViewTrackingTests: XCTestCase {
hosts: ["datadoghq.com"],
hostsSanitizer: mockSanitizer,
logsSampleRate: 100,
sessionReplayConfiguration: nil,
in: PassthroughCoreMock()
)
}
Expand Down Expand Up @@ -162,6 +208,7 @@ class WebViewTrackingTests: XCTestCase {
hosts: ["datadoghq.com"],
hostsSanitizer: HostsSanitizerMock(),
logsSampleRate: 100,
sessionReplayConfiguration: nil,
in: core
)

Expand Down Expand Up @@ -214,6 +261,7 @@ class WebViewTrackingTests: XCTestCase {
hosts: ["datadoghq.com"],
hostsSanitizer: HostsSanitizerMock(),
logsSampleRate: 100,
sessionReplayConfiguration: nil,
in: core
)

Expand Down Expand Up @@ -305,6 +353,7 @@ class WebViewTrackingTests: XCTestCase {
hosts: ["datadoghq.com"],
hostsSanitizer: HostsSanitizerMock(),
logsSampleRate: 100,
sessionReplayConfiguration: nil,
in: core
)

Expand Down

0 comments on commit 6c4d5d1

Please sign in to comment.