Skip to content

Commit 001ae88

Browse files
authored
Merge pull request #3098 from DataDog/gonzalezreal/PANA-8393/generic-embedded-content-view-tree
[PANA-8393] Support cross-platform embedded content in view-tree recording
2 parents fbd447b + 5d8d165 commit 001ae88

25 files changed

Lines changed: 1183 additions & 62 deletions

Datadog/Datadog.xcodeproj/project.pbxproj

Lines changed: 39 additions & 7 deletions
Large diffs are not rendered by default.

DatadogInternal/Sources/MessageBus/FeatureMessage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public enum FeatureMessage {
1616
/// Represent a Browser SDK event sent through the JS bridge.
1717
case webview(WebViewMessage)
1818

19+
/// Session Replay records produced by an embedded renderer.
20+
case embeddedContent(EmbeddedContentMessage)
21+
1922
/// A core context message.
2023
///
2124
/// The core will send updated context through the bus. Do not send new context values
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 Foundation
8+
9+
/// Session Replay data produced by an embedded renderer.
10+
public enum EmbeddedContentMessage {
11+
/// A batch of serialized Session Replay records.
12+
public struct RecordBatch {
13+
/// A single serialized Session Replay record.
14+
public typealias Record = [String: Any]
15+
16+
/// The serialized Session Replay records.
17+
public let records: [Record]
18+
19+
/// The identifier shared with the native view hosting the embedded content.
20+
public let slotID: String
21+
22+
/// The RUM view identifier associated with the records.
23+
public let viewID: String
24+
25+
/// Creates a batch of embedded Session Replay records.
26+
public init(records: [Record], slotID: String, viewID: String) {
27+
self.records = records
28+
self.slotID = slotID
29+
self.viewID = viewID
30+
}
31+
}
32+
33+
/// An encoded resource referenced by embedded Session Replay records.
34+
public struct Resource {
35+
/// The content identifier referenced by Session Replay records.
36+
public let identifier: String
37+
38+
/// The encoded resource bytes.
39+
public let data: Data
40+
41+
/// The resource media type.
42+
public let mimeType: String
43+
44+
/// Creates an embedded Session Replay resource.
45+
public init(identifier: String, data: Data, mimeType: String) {
46+
self.identifier = identifier
47+
self.data = data
48+
self.mimeType = mimeType
49+
}
50+
}
51+
52+
/// A batch of records associated with an embedded native container.
53+
case records(RecordBatch)
54+
55+
/// A resource referenced by embedded Session Replay records.
56+
case resource(Resource)
57+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
#if os(iOS)
8+
import UIKit
9+
10+
private var sessionReplaySlotIDKey: UInt8 = 0
11+
12+
extension UIView: DatadogExtended {}
13+
14+
@_spi(Internal)
15+
public extension DatadogExtension where ExtendedType: UIView {
16+
/// Identifies this view as a host slot for embedded Session Replay content.
17+
///
18+
/// The slot ID is supplied by the embedding SDK and is independent of the view's wireframe ID.
19+
var sessionReplaySlotID: String? {
20+
get {
21+
objc_getAssociatedObject(type, &sessionReplaySlotIDKey) as? String
22+
}
23+
nonmutating set {
24+
objc_setAssociatedObject(
25+
type,
26+
&sessionReplaySlotIDKey,
27+
newValue,
28+
.OBJC_ASSOCIATION_COPY_NONATOMIC
29+
)
30+
}
31+
}
32+
}
33+
#endif
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
#if os(iOS)
8+
import TestUtilities
9+
import Testing
10+
import UIKit
11+
12+
@_spi(Internal)
13+
@testable import DatadogInternal
14+
15+
@Suite(.datadogTesting)
16+
@MainActor
17+
struct UIViewSessionReplaySlotIDTests {
18+
@available(iOS 13.0, *)
19+
@Test
20+
func slotIDIsNilByDefault() {
21+
// given
22+
let view = UIView()
23+
24+
// when
25+
let slotID = view.dd.sessionReplaySlotID
26+
27+
// then
28+
#expect(slotID == nil)
29+
}
30+
31+
@available(iOS 13.0, *)
32+
@Test
33+
func slotIDIsStoredPerView() {
34+
// given
35+
let view = UIView()
36+
let otherView = UIView()
37+
38+
// when
39+
view.dd.sessionReplaySlotID = "renderer-slot"
40+
41+
// then
42+
#expect(view.dd.sessionReplaySlotID == "renderer-slot")
43+
#expect(otherView.dd.sessionReplaySlotID == nil)
44+
}
45+
46+
@available(iOS 13.0, *)
47+
@Test
48+
func settingSlotIDToNilClearsIt() {
49+
// given
50+
let view = UIView()
51+
view.dd.sessionReplaySlotID = "renderer-slot"
52+
53+
// when
54+
view.dd.sessionReplaySlotID = nil
55+
56+
// then
57+
#expect(view.dd.sessionReplaySlotID == nil)
58+
}
59+
}
60+
#endif
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
#if os(iOS)
8+
import Foundation
9+
import DatadogInternal
10+
11+
internal struct EmbeddedContentReceiver: FeatureMessageReceiver {
12+
internal struct EmbeddedRecord: Encodable {
13+
/// The native RUM application ID associated with the records.
14+
let applicationID: String
15+
/// The native RUM session ID associated with the records.
16+
let sessionID: String
17+
/// The embedded RUM view ID associated with the records.
18+
let viewID: String
19+
/// The embedded Session Replay records.
20+
let records: [AnyEncodable]
21+
}
22+
23+
/// Session Replay feature scope.
24+
let scope: FeatureScope
25+
26+
/// Shared writer for native and embedded Session Replay resources.
27+
let resourcesWriter: ResourcesWriting
28+
29+
/// Shared publisher for native and embedded Session Replay record counts.
30+
let srContextPublisher: SRContextPublisher
31+
32+
func receive(message: FeatureMessage, from core: DatadogCoreProtocol) -> Bool {
33+
guard case let .embeddedContent(embeddedContentMessage) = message else {
34+
return false
35+
}
36+
37+
scope.eventWriteContext { context, writer in
38+
guard
39+
let rumContext = context.additionalContext(ofType: RUMCoreContext.self),
40+
rumContext.sessionSampler.isSampled
41+
else {
42+
return
43+
}
44+
45+
switch embeddedContentMessage {
46+
case .records(let batch):
47+
guard !batch.records.isEmpty else {
48+
return
49+
}
50+
51+
let records = batch.records.map { record in
52+
var record = record
53+
record["slotId"] = batch.slotID
54+
return AnyEncodable(record)
55+
}
56+
57+
writer.write(
58+
value: EmbeddedRecord(
59+
applicationID: rumContext.applicationID,
60+
sessionID: rumContext.sessionID,
61+
viewID: batch.viewID,
62+
records: records
63+
)
64+
)
65+
srContextPublisher.incrementRecordCount(
66+
by: Int64(records.count),
67+
forViewID: batch.viewID
68+
)
69+
case .resource(let resource):
70+
resourcesWriter.write(
71+
resources: [
72+
EnrichedResource(
73+
identifier: resource.identifier,
74+
data: resource.data,
75+
mimeType: resource.mimeType,
76+
context: .init(rumContext.applicationID)
77+
)
78+
]
79+
)
80+
}
81+
}
82+
83+
return true
84+
}
85+
}
86+
#endif

DatadogSessionReplay/Sources/Feature/RecordingComponents.swift

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,27 @@ internal struct RecordingComponents {
1414

1515
init(
1616
core: DatadogCoreProtocol,
17-
configuration: SessionReplay.Configuration
17+
configuration: SessionReplay.Configuration,
18+
resourcesWriter: any ResourcesWriting,
19+
srContextPublisher: SRContextPublisher
1820
) throws {
1921
if #available(iOS 13.0, tvOS 13.0, *), configuration.featureFlags[.layerTreeRecording] {
2022
// This is purely defensive, as `SessionReplay.enable()` initializes on the main thread
2123
self = try runOnMainThreadSync {
22-
try .layerTreeRecordingComponents(core: core, configuration: configuration)
24+
try .layerTreeRecordingComponents(
25+
core: core,
26+
configuration: configuration,
27+
resourcesWriter: resourcesWriter,
28+
srContextPublisher: srContextPublisher
29+
)
2330
}
2431
} else {
25-
self = try .viewTreeRecordingComponents(core: core, configuration: configuration)
32+
self = try .viewTreeRecordingComponents(
33+
core: core,
34+
configuration: configuration,
35+
resourcesWriter: resourcesWriter,
36+
srContextPublisher: srContextPublisher
37+
)
2638
}
2739
}
2840

@@ -36,7 +48,9 @@ internal struct RecordingComponents {
3648

3749
private static func viewTreeRecordingComponents(
3850
core: DatadogCoreProtocol,
39-
configuration: SessionReplay.Configuration
51+
configuration: SessionReplay.Configuration,
52+
resourcesWriter: any ResourcesWriting,
53+
srContextPublisher: SRContextPublisher
4054
) throws -> Self {
4155
let processorsQueue = BackgroundAsyncQueue(label: "com.datadoghq.session-replay.processors", qos: .utility)
4256
// The telemetry queue targets the processors queue with a lower qos.
@@ -53,14 +67,14 @@ internal struct RecordingComponents {
5367

5468
let resourceProcessor = ResourceProcessor(
5569
queue: processorsQueue,
56-
resourcesWriter: ResourcesWriter(scope: core.scope(for: ResourcesFeature.self))
70+
resourcesWriter: resourcesWriter
5771
)
5872

5973
let snapshotProcessor = SnapshotProcessor(
6074
queue: processorsQueue,
6175
recordWriter: RecordWriter(core: core),
6276
resourceProcessor: resourceProcessor,
63-
srContextPublisher: SRContextPublisher(core: core),
77+
srContextPublisher: srContextPublisher,
6478
telemetry: telemetry
6579
)
6680

@@ -79,7 +93,7 @@ internal struct RecordingComponents {
7993
imagePrivacy: configuration.imagePrivacyLevel,
8094
touchPrivacy: configuration.touchPrivacyLevel,
8195
rumContextObserver: contextReceiver,
82-
srContextPublisher: SRContextPublisher(core: core),
96+
srContextPublisher: srContextPublisher,
8397
recorder: recorder,
8498
replaySampleRate: configuration.debugSDK ? 100 : configuration.replaySampleRate,
8599
telemetry: telemetry,
@@ -96,7 +110,9 @@ internal struct RecordingComponents {
96110
@MainActor
97111
private static func layerTreeRecordingComponents(
98112
core: DatadogCoreProtocol,
99-
configuration: SessionReplay.Configuration
113+
configuration: SessionReplay.Configuration,
114+
resourcesWriter: any ResourcesWriting,
115+
srContextPublisher: SRContextPublisher
100116
) throws -> Self {
101117
let processorsQueue = BackgroundAsyncQueue(label: "com.datadoghq.session-replay.processors", qos: .utility)
102118
// The telemetry queue targets the processors queue with a lower qos.
@@ -111,13 +127,13 @@ internal struct RecordingComponents {
111127
)
112128
let resourceProcessor = ResourceProcessor(
113129
queue: processorsQueue,
114-
resourcesWriter: ResourcesWriter(scope: core.scope(for: ResourcesFeature.self))
130+
resourcesWriter: resourcesWriter
115131
)
116132
let snapshotProcessor = LayerSnapshotProcessor(
117133
queue: processorsQueue,
118134
recordWriter: RecordWriter(core: core),
119135
resourceProcessor: resourceProcessor,
120-
replayContextPublisher: SRContextPublisher(core: core),
136+
replayContextPublisher: srContextPublisher,
121137
telemetry: telemetry
122138
)
123139

@@ -143,7 +159,7 @@ internal struct RecordingComponents {
143159
textAndInputPrivacy: configuration.textAndInputPrivacyLevel,
144160
imagePrivacy: configuration.imagePrivacyLevel,
145161
touchPrivacy: configuration.touchPrivacyLevel,
146-
srContextPublisher: SRContextPublisher(core: core),
162+
srContextPublisher: srContextPublisher,
147163
layerRecording: layerRecorder,
148164
replaySampleRate: configuration.debugSDK ? 100 : configuration.replaySampleRate,
149165
telemetry: telemetry,

DatadogSessionReplay/Sources/Feature/SRContextPublisher.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import DatadogInternal
1111
/// Publisher that sets Session Replay context for being utilized by other Features.
1212
internal class SRContextPublisher {
1313
private weak var core: DatadogCoreProtocol?
14+
private var recordCounts: [String: Int64] = [:]
1415

1516
init(core: DatadogCoreProtocol) {
1617
self.core = core
@@ -21,9 +22,18 @@ internal class SRContextPublisher {
2122
core?.set(context: SessionReplayCoreContext.HasReplay(value: value))
2223
}
2324

24-
/// Notifies other Features on the state of Session Replay records count.
25-
func setRecordsCountByViewID(_ value: [String: Int64]) {
26-
core?.set(context: SessionReplayCoreContext.RecordsCount(value: value))
25+
/// Increments the Session Replay record count for a RUM view.
26+
func incrementRecordCount(by count: Int64, forViewID viewID: String) {
27+
guard count > 0 else {
28+
return
29+
}
30+
31+
core?.set(
32+
context: {
33+
self.recordCounts[viewID, default: 0] += count
34+
return SessionReplayCoreContext.RecordsCount(value: self.recordCounts)
35+
}
36+
)
2737
}
2838
}
2939
#endif

0 commit comments

Comments
 (0)