Skip to content

Commit

Permalink
RUM-2813 Add webview replay configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
maxep committed Mar 29, 2024
1 parent e25a3bf commit 98c6a26
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 30 deletions.
3 changes: 3 additions & 0 deletions Datadog/Datadog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2650,6 +2650,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 +3141,7 @@
children = (
D297324F2A5C109A00827599 /* MessageEmitterTests.swift */,
D29732502A5C109A00827599 /* WebViewTrackingTests.swift */,
D27CBD992BB5DBBB00C766AA /* Mocks.swift */,
);
name = DatadogWebViewTrackingTests;
path = ../DatadogWebViewTracking/Tests;
Expand Down Expand Up @@ -7471,6 +7473,7 @@
files = (
D29732532A5C109A00827599 /* WebViewTrackingTests.swift in Sources */,
D29732512A5C109A00827599 /* MessageEmitterTests.swift in Sources */,
D27CBD9A2BB5DBBB00C766AA /* Mocks.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
73 changes: 65 additions & 8 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 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<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,6 +107,7 @@ 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) }
Expand Down Expand Up @@ -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)'
}
}
"""

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()!
}
}
93 changes: 71 additions & 22 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) { }
}

final class MockScriptMessage: WKScriptMessage {
let mockBody: Any
func testItAddsUserScriptWithSessionReplay() throws {
let mockSanitizer = HostsSanitizerMock()
let controller = DDUserContentController()

init(body: Any) {
self.mockBody = body
}
let host: String = .mockRandom()
let sessionReplayConfiguration = WebViewTracking.SessionReplayConfiguration(
privacyLevel: .mockRandom()
)

override var body: Any { return mockBody }
}
WebViewTracking.enable(
tracking: controller,
hosts: [host],
hostsSanitizer: mockSanitizer,
logsSampleRate: 30,
sessionReplayConfiguration: sessionReplayConfiguration,
in: PassthroughCoreMock()
)

class WebViewTrackingTests: XCTestCase {
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)'
}
}
""")
}

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 98c6a26

Please sign in to comment.