Skip to content

Commit c1aa2d1

Browse files
committed
RUM-8415 Add SwiftUI view auto-instrumentation
1 parent 24720ef commit c1aa2d1

17 files changed

+1060
-79
lines changed

Datadog/Datadog.xcodeproj/project.pbxproj

+66
Large diffs are not rendered by default.

DatadogInternal/Sources/Utils/CustomDump.swift

+13-4
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
* SOFTWARE.
2929
*/
3030

31-
#if DEBUG
32-
3331
// swiftlint:disable function_default_parameter_at_end
3432

3533
import Foundation
@@ -49,7 +47,7 @@ import Foundation
4947
/// components. The default is `Int.max`.
5048
/// - Returns: The instance passed as `value`.
5149
@discardableResult
52-
internal func customDump<T>(
50+
public func customDump<T>(
5351
_ value: T,
5452
name: String? = nil,
5553
indent: Int = 0,
@@ -550,7 +548,18 @@ public struct FileHandlerOutputStream: TextOutputStream {
550548
}
551549
}
552550
}
553-
554551
// swiftlint:enable function_default_parameter_at_end
555552

553+
#if DEBUG
554+
/// Dumps the given value's contents in a file.
555+
public func dump<T>(_ value: T, filename: String) throws {
556+
let manager = FileManager.default
557+
let url = manager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(filename) //swiftlint:disable:this force_unwrapping
558+
manager.createFile(atPath: url.path, contents: nil, attributes: nil)
559+
let handle = try FileHandle(forWritingTo: url)
560+
var stream = FileHandlerOutputStream(handle)
561+
customDump(value, to: &stream)
562+
print("Dump:", url)
563+
handle.closeFile()
564+
}
556565
#endif
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
/// Protocol for dumping objects to a text stream.
8+
///
9+
/// The `Dumper` protocol provides a standard interface for converting objects to string
10+
/// representations and writing them to a text output stream.
11+
public protocol Dumper {
12+
func dump<T, TargetStream>(_ value: T, to target: inout TargetStream) where TargetStream: TextOutputStream
13+
}
14+
15+
/// Default implementation of the `Dumper` protocol that uses `customDump` to generate string representations.
16+
///
17+
/// This implementation provides structured output for complex Swift objects by leveraging
18+
/// the `customDump` functionality, which creates more readable output than the standard
19+
/// `print` or `description` approaches.
20+
public class DefaultDumper: Dumper {
21+
public init() {}
22+
23+
/// Dumps the value to the target stream using `customDump`.
24+
///
25+
/// - Parameters:
26+
/// - value: The value to dump.
27+
/// - target: The stream to write the dump to.
28+
public func dump<T, TargetStream>(_ value: T, to target: inout TargetStream) where TargetStream: TextOutputStream {
29+
customDump(value, to: &target)
30+
}
31+
}

DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ internal final class RUMInstrumentation: RUMCommandPublisher {
6363
// and only swizzle `UIViewController` if UIKit instrumentation is configured:
6464
let viewsHandler = RUMViewsHandler(
6565
dateProvider: dateProvider,
66-
predicate: uiKitRUMViewsPredicate,
66+
uiKitPredicate: uiKitRUMViewsPredicate,
67+
swiftUIPredicate: nil,
68+
swiftUIViewNameExtractor: nil,
6769
notificationCenter: notificationCenter
6870
)
6971
let viewControllerSwizzler: UIViewControllerSwizzler? = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
import DatadogInternal
8+
9+
/// A description of the RUM View returned from the predicate.
10+
public struct RUMView {
11+
/// The RUM View name, appearing as `VIEW NAME` in RUM Explorer.
12+
public var name: String
13+
14+
/// The RUM View path, appearing as `VIEW PATH GROUP` / `VIEW URL` in RUM Explorer.
15+
/// If set `nil`, the view controller class name will be used.
16+
public var path: String?
17+
18+
/// Additional attributes to associate with the RUM View.
19+
public var attributes: [AttributeKey: AttributeValue]
20+
21+
/// Whether this view is modal, but should not be tracked with `startView` and `stopView`
22+
/// When this is `true`, the view previous to this one will be stopped, but this one will not be
23+
/// started. When this view is dismissed, the previous view will be started.
24+
public var isUntrackedModal: Bool
25+
26+
/// Initializes the RUM View description.
27+
/// - Parameters:
28+
/// - path: the RUM View path, appearing as `PATH` in RUM Explorer.
29+
/// - attributes: additional attributes to associate with the RUM View.
30+
@available(*, deprecated, message: "This initializer is renamed to `init(name:attributes:)`.")
31+
public init(path: String, attributes: [AttributeKey: AttributeValue] = [:]) {
32+
self.name = path
33+
self.path = path
34+
self.attributes = attributes
35+
self.isUntrackedModal = false
36+
}
37+
38+
/// Initializes the RUM View description.
39+
/// - Parameters:
40+
/// - name: the RUM View name, appearing as `VIEW NAME` in RUM Explorer.
41+
/// - attributes: additional attributes to associate with the RUM View.
42+
/// - isUntrackedModal: true if this view is modal, but should not call startView / stopView.
43+
public init(name: String, attributes: [AttributeKey: AttributeValue] = [:], isUntrackedModal: Bool = false) {
44+
self.name = name
45+
self.path = nil // the "VIEW URL" will default to view controller class name
46+
self.attributes = attributes
47+
self.isUntrackedModal = isUntrackedModal
48+
}
49+
}

DatadogRUM/Sources/Instrumentation/Views/RUMViewsHandler.swift

+34-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Foundation
88
import UIKit
99
import DatadogInternal
1010

11+
// MARK: - RUMViewsHandler
1112
internal final class RUMViewsHandler {
1213
/// RUM representation of a View.
1314
private struct View {
@@ -33,9 +34,17 @@ internal final class RUMViewsHandler {
3334
/// The current date provider.
3435
private let dateProvider: DateProvider
3536

36-
/// `UIKit` view predicate. `nil`, if `UIKit` auto-instrumentations is
37+
/// `UIKit` view predicate. `nil` if `UIKit` auto-instrumentations is
3738
/// disabled.
38-
private let predicate: UIKitRUMViewsPredicate?
39+
private let uiKitPredicate: UIKitRUMViewsPredicate?
40+
41+
/// `SwiftUI` view predicate. `nil` if `SwiftUI` auto-instrumentations is
42+
/// disabled.
43+
private let swiftUIPredicate: SwiftUIRUMViewsPredicate?
44+
45+
/// `SwiftUI` view name extractor.
46+
/// Extracts `SwiftUI` view name from view hierarchy.
47+
private let swiftUIViewNameExtractor: SwiftUIViewNameExtractor?
3948

4049
/// The notification center where this handler observes following `UIApplication` notifications:
4150
/// - `.didEnterBackgroundNotification`
@@ -64,11 +73,15 @@ internal final class RUMViewsHandler {
6473
/// a set of `UIApplication` notifications.
6574
init(
6675
dateProvider: DateProvider,
67-
predicate: UIKitRUMViewsPredicate?,
76+
uiKitPredicate: UIKitRUMViewsPredicate?,
77+
swiftUIPredicate: SwiftUIRUMViewsPredicate?,
78+
swiftUIViewNameExtractor: SwiftUIViewNameExtractor?,
6879
notificationCenter: NotificationCenter
6980
) {
7081
self.dateProvider = dateProvider
71-
self.predicate = predicate
82+
self.uiKitPredicate = uiKitPredicate
83+
self.swiftUIPredicate = swiftUIPredicate
84+
self.swiftUIViewNameExtractor = swiftUIViewNameExtractor
7285
self.notificationCenter = notificationCenter
7386

7487
notificationCenter.addObserver(
@@ -196,14 +209,29 @@ internal final class RUMViewsHandler {
196209
}
197210
}
198211

212+
// MARK: - UIViewControllerHandler
199213
extension RUMViewsHandler: UIViewControllerHandler {
200214
func notify_viewDidAppear(viewController: UIViewController, animated: Bool) {
201215
let identity = ViewIdentifier(viewController)
202216
if let view = stack.first(where: { $0.identity == identity }) {
203217
// If the stack already contains the view controller, just restarts the view.
204218
// This prevents from calling the predicate when unnecessary.
205219
add(view: view)
206-
} else if let rumView = predicate?.rumView(for: viewController) {
220+
} else if let rumView = uiKitPredicate?.rumView(for: viewController) {
221+
add(
222+
view: .init(
223+
identity: identity,
224+
name: rumView.name,
225+
path: rumView.path ?? viewController.canonicalClassName,
226+
isUntrackedModal: rumView.isUntrackedModal,
227+
attributes: rumView.attributes,
228+
instrumentationType: .uikit
229+
)
230+
)
231+
} else if let swiftUIPredicate,
232+
let swiftUIViewNameExtractor,
233+
let rumViewName = swiftUIViewNameExtractor.extractName(from: viewController),
234+
let rumView = swiftUIPredicate.rumView(for: rumViewName) {
207235
add(
208236
view: .init(
209237
identity: identity,
@@ -233,6 +261,7 @@ extension RUMViewsHandler: UIViewControllerHandler {
233261
}
234262
}
235263

264+
// MARK: - SwiftUIViewHandler
236265
extension RUMViewsHandler: SwiftUIViewHandler {
237266
/// Respond to a `SwiftUI.View.onAppear` event.
238267
///
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
/// Protocol defining the predicate for SwiftUI view tracking in RUM.
8+
///
9+
/// The SDK uses this predicate to determine whether a detected SwiftUI view should be tracked as a RUM view.
10+
/// When a SwiftUI view is detected, the SDK first extracts its name,
11+
/// then passes that name to this predicate to convert it into RUM view parameters or filter it out.
12+
///
13+
/// Implement this protocol to customize which SwiftUI views are tracked and how they appear in the RUM Explorer.
14+
public protocol SwiftUIRUMViewsPredicate {
15+
/// Converts an extracted SwiftUI view name into RUM view parameters, or filters it out.
16+
///
17+
/// - Parameter extractedViewName: The name of the SwiftUI view detected by the SDK.
18+
/// - Returns: RUM view parameters if the view should be tracked, or `nil` to ignore the view.
19+
func rumView(for extractedViewName: String) -> RUMView?
20+
}
21+
22+
/// Default implementation of `SwiftUIRUMViewsPredicate`.
23+
///
24+
/// This implementation tracks all detected SwiftUI views with their extracted names.
25+
/// The view name in RUM Explorer will match the name extracted from the SwiftUI view.
26+
public struct DefaultSwiftUIRUMViewsPredicate: SwiftUIRUMViewsPredicate {
27+
public func rumView(for extractedViewName: String) -> RUMView? {
28+
return RUMView(name: extractedViewName)
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
import UIKit
8+
import DatadogInternal
9+
10+
extension SwiftUIReflectionBasedViewNameExtractor {
11+
// MARK: - Path Traversal
12+
@usableFromInline
13+
internal func extractHostingControllerPath(with reflector: ReflectorType) -> String? {
14+
if let hostView = reflector.descendantIfPresent("host", "_rootView", "content", "storage", "view") {
15+
var output = ""
16+
dumper.dump(hostView, to: &output)
17+
return output
18+
}
19+
20+
if let hostView = reflector.descendantIfPresent("host", "_rootView") {
21+
var output = ""
22+
dumper.dump(hostView, to: &output)
23+
return output
24+
}
25+
return nil
26+
}
27+
28+
// MARK: - String Parsing
29+
@usableFromInline
30+
internal func extractViewNameFromHostingViewController(_ input: String) -> String? {
31+
// First pattern: Extract from generic format with angle brackets
32+
if let lastOpenBracket = input.lastIndex(of: "<"),
33+
let closingBracket = input[lastOpenBracket...].firstIndex(of: ">"),
34+
lastOpenBracket < closingBracket {
35+
var betweenBrackets = input[input.index(after: lastOpenBracket)..<closingBracket]
36+
37+
// Handle generic parameters by taking the first part before comma
38+
betweenBrackets = betweenBrackets.split(separator: ",").first ?? betweenBrackets
39+
40+
// Extract name after the first dot (to get the type name without namespace)
41+
if let fristDotIndex = betweenBrackets.firstIndex(of: ".") {
42+
let viewName = betweenBrackets[betweenBrackets.index(after: fristDotIndex)...]
43+
return String(viewName)
44+
}
45+
46+
// If no dots found, return the whole content between brackets
47+
return String(betweenBrackets)
48+
}
49+
50+
// Second pattern: Extract from format with parentheses at the end
51+
// Example: "MyApp.(unknown context at $10cc64f58).(unknown context at $10cc64f64).HomeView()"
52+
if input.hasSuffix("()") {
53+
// Find the last component before the parentheses
54+
let withoutParens = input.dropLast(2)
55+
56+
// Get the last component after a dot
57+
if let lastDotIndex = withoutParens.lastIndex(of: ".") {
58+
let viewName = withoutParens[withoutParens.index(after: lastDotIndex)...]
59+
return String(viewName)
60+
}
61+
}
62+
63+
return nil
64+
}
65+
}

0 commit comments

Comments
 (0)