Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUM-2813 Add webview replay configuration #1754

Merged
merged 3 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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