Skip to content

Commit 622893b

Browse files
committed
feat: Add iOS overhead for SessionReplayFeature
This adds a Feature that will pass through Flutter generated Session Replay Records into SRSegments. The iOS Feature and RequestBuilder take care of serializing data to disk, assembling and compressing the SRSegments to send to intake. The feature also takes care of broadcasting RUM context changes back to Flutter. refs: RUM-9551
1 parent 5f328a0 commit 622893b

26 files changed

+2628
-11
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
PODS:
2+
- datadog_flutter_plugin (0.0.1):
3+
- DatadogCore (= 2.25.0)
4+
- DatadogCrashReporting (= 2.25.0)
5+
- DatadogInternal (= 2.25.0)
6+
- DatadogLogs (= 2.25.0)
7+
- DatadogRUM (= 2.25.0)
8+
- DictionaryCoder (= 1.0.8)
9+
- Flutter
10+
- datadog_session_replay (0.0.1):
11+
- DatadogCore (~> 2)
12+
- Flutter
13+
- DatadogCore (2.25.0):
14+
- DatadogInternal (= 2.25.0)
15+
- DatadogCrashReporting (2.25.0):
16+
- DatadogInternal (= 2.25.0)
17+
- PLCrashReporter (~> 1.12.0)
18+
- DatadogInternal (2.25.0)
19+
- DatadogLogs (2.25.0):
20+
- DatadogInternal (= 2.25.0)
21+
- DatadogRUM (2.25.0):
22+
- DatadogInternal (= 2.25.0)
23+
- DictionaryCoder (1.0.8)
24+
- Flutter (1.0.0)
25+
- integration_test (0.0.1):
26+
- Flutter
27+
- PLCrashReporter (1.12.0)
28+
29+
DEPENDENCIES:
30+
- datadog_flutter_plugin (from `.symlinks/plugins/datadog_flutter_plugin/ios`)
31+
- datadog_session_replay (from `.symlinks/plugins/datadog_session_replay/ios`)
32+
- Flutter (from `Flutter`)
33+
- integration_test (from `.symlinks/plugins/integration_test/ios`)
34+
35+
SPEC REPOS:
36+
trunk:
37+
- DatadogCore
38+
- DatadogCrashReporting
39+
- DatadogInternal
40+
- DatadogLogs
41+
- DatadogRUM
42+
- DictionaryCoder
43+
- PLCrashReporter
44+
45+
EXTERNAL SOURCES:
46+
datadog_flutter_plugin:
47+
:path: ".symlinks/plugins/datadog_flutter_plugin/ios"
48+
datadog_session_replay:
49+
:path: ".symlinks/plugins/datadog_session_replay/ios"
50+
Flutter:
51+
:path: Flutter
52+
integration_test:
53+
:path: ".symlinks/plugins/integration_test/ios"
54+
55+
SPEC CHECKSUMS:
56+
datadog_flutter_plugin: 0536db97da04dda6e0198a732efb599a179cd316
57+
datadog_session_replay: 50aac665d5a87dc45b9cb2df38c779e6cf43f9b0
58+
DatadogCore: a068d264191f687d00125aafa8042110cf886cd7
59+
DatadogCrashReporting: b19d80a98b1eb51bdb3ec5b1a7f339769fb70b95
60+
DatadogInternal: e9b6cef84448ee32f73bc9b37646708a65c1b8ae
61+
DatadogLogs: 623d89158ef3a4a7545a8fbce9afa4cedc576324
62+
DatadogRUM: ff0661b42124d90c9ff2538c3cc1b806ddf7620c
63+
DictionaryCoder: 5f84fff69f54cb806071538430bdafe04a89d658
64+
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
65+
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
66+
PLCrashReporter: db59ef96fa3d25f3650040d02ec2798cffee75f2
67+
68+
PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
69+
70+
COCOAPODS: 1.16.2

packages/datadog_session_replay/example/ios/Runner.xcodeproj/project.pbxproj

Lines changed: 166 additions & 2 deletions
Large diffs are not rendered by default.

packages/datadog_session_replay/example/ios/Runner.xcworkspace/contents.xcworkspacedata

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2025-Present Datadog, Inc.
4+
5+
import Foundation
6+
import Testing
7+
import Flutter
8+
import DatadogInternal
9+
10+
@testable import datadog_session_replay
11+
12+
extension FlutterError: EquatableInTests {
13+
14+
}
15+
16+
func checkResult(_ result: ResultStatus, expectedError: NSObject) {
17+
switch result {
18+
case .called(let value):
19+
#expect((value as? NSObject) === expectedError)
20+
default:
21+
#expect(Bool(false), "Unexpected result: \(result)")
22+
}
23+
}
24+
25+
func checkResult(_ result: ResultStatus, errorCode: String, errorMessage: String) throws {
26+
switch result {
27+
case .called(let value):
28+
let error = value as? FlutterError
29+
try #require(error != nil, "Unexpected error type: \(type(of: value))")
30+
#expect(error?.code == errorCode)
31+
#expect(error?.message == errorMessage)
32+
default:
33+
#expect(Bool(false), "Unexpected result: \(result)")
34+
}
35+
}
36+
37+
@Suite(.serialized)
38+
class SessionReplayPluginTests {
39+
let mockCore = PassthroughCoreMock()
40+
let mockChannel = FlutterMethodChannel()
41+
42+
init() {
43+
CoreRegistry.register(default: mockCore)
44+
}
45+
46+
deinit {
47+
CoreRegistry.unregisterDefault()
48+
}
49+
50+
@Test
51+
func returnNotImplemented_WhenUnknownMethod() throws {
52+
// Given
53+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
54+
let arguments: [String: Any] = [:]
55+
let methodCall = FlutterMethodCall(methodName: "unknown", arguments: arguments)
56+
57+
// When
58+
var status: ResultStatus = .notCalled
59+
plugin.handle(methodCall) { result in
60+
status = .called(value: result)
61+
}
62+
63+
// Then
64+
checkResult(status, expectedError: FlutterMethodNotImplemented)
65+
}
66+
67+
@Test
68+
func returnInvalidOperation_WhenBadArguments() throws {
69+
// Given
70+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
71+
let methodCall = FlutterMethodCall(methodName: "unknown", arguments: 22)
72+
73+
// When
74+
var status: ResultStatus = .notCalled
75+
plugin.handle(methodCall) { result in
76+
status = .called(value: result)
77+
}
78+
79+
// Then
80+
try checkResult(status, errorCode: "DatadogSdk:InvalidOperation", errorMessage: "No arguments in call to unknown")
81+
}
82+
83+
@Test
84+
func enablesFeature_WhenEnableMethodCall() {
85+
// Given
86+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
87+
let arguments: [String: Any] = [
88+
"configuration": [String: Any]()
89+
]
90+
let methodCall = FlutterMethodCall(methodName: "enable", arguments: arguments)
91+
92+
// When
93+
var status: ResultStatus = .notCalled
94+
plugin.handle(methodCall) { result in
95+
status = .called(value: result)
96+
}
97+
98+
// Then
99+
#expect(status == .called(value: nil))
100+
#expect(mockCore.get(feature: FlutterSessionReplayFeature.self) != nil)
101+
}
102+
103+
@Test
104+
func returnsError_WhenEnableMethodCall_MissingConfiguration() throws {
105+
// Given
106+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
107+
let arguments: [String: Any] = [:]
108+
let methodCall = FlutterMethodCall(methodName: "enable", arguments: arguments)
109+
110+
// When
111+
var status: ResultStatus = .notCalled
112+
plugin.handle(methodCall) { result in
113+
status = .called(value: result)
114+
}
115+
116+
// Then
117+
try checkResult(status, errorCode: "DatadogSdk:ContractViolation", errorMessage: "Missing parameter in call to enable")
118+
}
119+
120+
@Test
121+
func setsContext_WhenSetHasReplayMethodCall() throws {
122+
// Given
123+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
124+
let expectedValue: Bool = .mockRandom()
125+
let arguments: [String: Any] = [
126+
"hasReplay": expectedValue
127+
]
128+
let methodCall = FlutterMethodCall(methodName: "setHasReplay", arguments: arguments)
129+
130+
// When
131+
plugin.enable(configuration: .init())
132+
var status: ResultStatus = .notCalled
133+
plugin.handle(methodCall) { result in
134+
status = .called(value: result)
135+
}
136+
137+
// Then
138+
#expect(status == .called(value: nil))
139+
let value = try mockCore.context.baggages["sr_has_replay"]?.encode() as? Bool
140+
#expect(value == expectedValue)
141+
}
142+
143+
@Test
144+
func returnsError_WhenSetHasReplayMethodCall_InvalidParameter() throws {
145+
// Given
146+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
147+
let arguments: [String: Any] = [
148+
"hasReplay": "true"
149+
]
150+
let methodCall = FlutterMethodCall(methodName: "setHasReplay", arguments: arguments)
151+
152+
// When
153+
plugin.enable(configuration: .init())
154+
var status: ResultStatus = .notCalled
155+
plugin.handle(methodCall) { result in
156+
status = .called(value: result)
157+
}
158+
159+
// Then
160+
try checkResult(status, errorCode: "DatadogSdk:ContractViolation", errorMessage: "Missing parameter in call to setHasReplay")
161+
}
162+
163+
@Test
164+
func setsContext_WhenSetRecordCountMethodCall() throws {
165+
let plugin = DatadogSessionReplayPlugin(channel: FlutterMethodChannelMock())
166+
let expectedValue: Int = .mockRandom()
167+
let expectedViewId: String = .mockRandom()
168+
let arguments: [String: Any] = [
169+
"viewId": expectedViewId,
170+
"count": expectedValue
171+
]
172+
let methodCall = FlutterMethodCall(methodName: "setRecordCount", arguments: arguments)
173+
174+
// When
175+
plugin.enable(configuration: .init())
176+
var status: ResultStatus = .notCalled
177+
plugin.handle(methodCall) { result in
178+
status = .called(value: result)
179+
}
180+
181+
// Then
182+
#expect(status == .called(value: nil))
183+
let value = try mockCore.context.baggages["sr_records_count_by_view_id"]?.encode() as? [String: Any]
184+
#expect(value?.count == 1)
185+
#expect(value?[expectedViewId] as? Int == expectedValue)
186+
}
187+
188+
@Test
189+
func broadcastsRumContext_WhenContextChanges() throws {
190+
// Given
191+
let methodChannelMock = FlutterMethodChannelMock()
192+
let plugin = DatadogSessionReplayPlugin(channel: methodChannelMock)
193+
plugin.enable(configuration: .init())
194+
195+
// When
196+
let expectedRumContext: RUMContext = .mockRandom()
197+
let datadogContext: DatadogContext = .mockRandom(
198+
withBaggages: [
199+
RUMContext.key: .init(expectedRumContext)]
200+
)
201+
mockCore.send(message: .context(datadogContext), else: {})
202+
203+
// Then
204+
// Push to the back of the main queue, as method channel must send
205+
// callbacks on the main queue
206+
try DispatchQueue.main.sync {
207+
try #require(methodChannelMock.invocations.count == 1)
208+
let argument = methodChannelMock.invocations[0].arguments as? [String: Any?]
209+
try #require(argument != nil)
210+
if let argument = argument {
211+
#expect(argument["applicationId"] as? String == expectedRumContext.applicationID)
212+
#expect(argument["sessionId"] as? String == expectedRumContext.sessionID)
213+
#expect(argument["viewId"] as? String == expectedRumContext.viewID)
214+
#expect(argument["viewServerTimeOffset"] as? TimeInterval == expectedRumContext.viewServerTimeOffset)
215+
}
216+
}
217+
}
218+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2025-Present Datadog, Inc.
4+
5+
import Foundation
6+
import Testing
7+
8+
@testable import datadog_session_replay
9+
10+
@Test
11+
func setsReplayBaggage_WhenSetHasReplay() throws {
12+
// Given
13+
let core = PassthroughCoreMock()
14+
let config = FlutterSessionReplay.Configuration()
15+
let feature = try FlutterSessionReplayFeature(core: core, configuration: config)
16+
17+
// When
18+
let expectedValue: Bool = .mockRandom()
19+
feature.setHasReplay(expectedValue)
20+
21+
// Then
22+
let value = try core.context.baggages["sr_has_replay"]?.encode() as? Bool
23+
#expect(value == expectedValue)
24+
}
25+
26+
@Test
27+
func setsBackage_WhenSetRecordCount() throws {
28+
// Given
29+
let core = PassthroughCoreMock()
30+
let config = FlutterSessionReplay.Configuration()
31+
let feature = try FlutterSessionReplayFeature(core: core, configuration: config)
32+
33+
// When
34+
let viewId: String = .mockRandom()
35+
let expectedCount: Int = .mockRandom()
36+
feature.setRecordCount(for: viewId, count: expectedCount)
37+
38+
// Then
39+
let baggage = try core.context.baggages["sr_records_count_by_view_id"]?.encode() as? [String: Any]
40+
let value = baggage?[viewId] as? Int
41+
#expect(value == expectedCount)
42+
}
43+
44+
@Test
45+
func setsBackage_WhenSetRecordCount_MultipleViews() throws {
46+
// Given
47+
let core = PassthroughCoreMock()
48+
let config = FlutterSessionReplay.Configuration()
49+
let feature = try FlutterSessionReplayFeature(core: core, configuration: config)
50+
51+
// When
52+
let viewIdA: String = .mockRandom()
53+
let viewIdB: String = .mockRandom()
54+
let expectedCountA: Int = .mockRandom()
55+
let expectedCountB: Int = .mockRandom()
56+
feature.setRecordCount(for: viewIdA, count: expectedCountA)
57+
feature.setRecordCount(for: viewIdB, count: expectedCountB)
58+
59+
// Then
60+
let baggage = try core.context.baggages["sr_records_count_by_view_id"]?.encode() as? [String: Any]
61+
let valueA = baggage?[viewIdA] as? Int
62+
#expect(valueA == expectedCountA)
63+
let valueB = baggage?[viewIdB] as? Int
64+
#expect(valueB == expectedCountB)
65+
}

0 commit comments

Comments
 (0)