@rejourneyco/react-native 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- package/src/types/index.ts +16 -9
|
@@ -22,63 +22,74 @@ import CommonCrypto
|
|
|
22
22
|
public final class RejourneyImpl: NSObject {
|
|
23
23
|
@objc public static let shared = RejourneyImpl()
|
|
24
24
|
@objc public static var sdkVersion = "1.0.1"
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
// MARK: - State Machine
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
private enum SessionState {
|
|
29
29
|
case idle
|
|
30
30
|
case active(sessionId: String, startTime: TimeInterval)
|
|
31
31
|
case paused(sessionId: String, startTime: TimeInterval)
|
|
32
32
|
case terminated
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
private var state: SessionState = .idle
|
|
36
36
|
private let stateLock = NSLock()
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
// MARK: - Internal Storage
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
private var currentUserIdentity: String?
|
|
41
41
|
private var internalEventStream: [[String: Any]] = []
|
|
42
42
|
private var backgroundStartTime: TimeInterval?
|
|
43
43
|
private var lastSessionConfig: [String: Any]?
|
|
44
44
|
private var lastApiUrl: String?
|
|
45
45
|
private var lastPublicKey: String?
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
// Session timeout threshold (60 seconds)
|
|
48
48
|
private let sessionTimeoutSeconds: TimeInterval = 60
|
|
49
|
-
|
|
49
|
+
private let sessionRolloverGraceSeconds: TimeInterval = 2
|
|
50
|
+
|
|
50
51
|
private let userIdentityKey = "com.rejourney.user.identity"
|
|
51
|
-
|
|
52
|
+
private let anonymousIdentityKey = "com.rejourney.anonymous.identity"
|
|
53
|
+
|
|
52
54
|
public override init() {
|
|
53
55
|
super.init()
|
|
54
56
|
setupLifecycleListeners()
|
|
55
57
|
_loadPersistedIdentity()
|
|
58
|
+
|
|
59
|
+
// Recover any session interrupted by a previous crash.
|
|
60
|
+
// Send the stored crash report after recovery restores auth/session context.
|
|
61
|
+
ReplayOrchestrator.shared.recoverInterruptedReplay { recoveredId in
|
|
62
|
+
if let recoveredId = recoveredId {
|
|
63
|
+
DiagnosticLog.notice("[Rejourney] Recovered crashed session: \(recoveredId)")
|
|
64
|
+
}
|
|
65
|
+
StabilityMonitor.shared.transmitStoredReport()
|
|
66
|
+
}
|
|
56
67
|
}
|
|
57
|
-
|
|
68
|
+
|
|
58
69
|
private func _loadPersistedIdentity() {
|
|
59
70
|
if let persisted = UserDefaults.standard.string(forKey: userIdentityKey), !persisted.isEmpty {
|
|
60
71
|
self.currentUserIdentity = persisted
|
|
61
72
|
DiagnosticLog.notice("[Rejourney] Restored persisted user identity: \(persisted)")
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
|
-
|
|
75
|
+
|
|
65
76
|
deinit {
|
|
66
77
|
NotificationCenter.default.removeObserver(self)
|
|
67
78
|
}
|
|
68
|
-
|
|
79
|
+
|
|
69
80
|
private func setupLifecycleListeners() {
|
|
70
81
|
let center = NotificationCenter.default
|
|
71
82
|
center.addObserver(self, selector: #selector(handleTermination), name: UIApplication.willTerminateNotification, object: nil)
|
|
72
83
|
center.addObserver(self, selector: #selector(handleBackgrounding), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
73
84
|
center.addObserver(self, selector: #selector(handleForegrounding), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
74
85
|
}
|
|
75
|
-
|
|
86
|
+
|
|
76
87
|
// MARK: - State Transitions
|
|
77
|
-
|
|
88
|
+
|
|
78
89
|
@objc private func handleTermination() {
|
|
79
90
|
stateLock.lock()
|
|
80
91
|
defer { stateLock.unlock() }
|
|
81
|
-
|
|
92
|
+
|
|
82
93
|
switch state {
|
|
83
94
|
case .active, .paused:
|
|
84
95
|
state = .terminated
|
|
@@ -88,11 +99,11 @@ public final class RejourneyImpl: NSObject {
|
|
|
88
99
|
break
|
|
89
100
|
}
|
|
90
101
|
}
|
|
91
|
-
|
|
102
|
+
|
|
92
103
|
@objc private func handleBackgrounding() {
|
|
93
104
|
stateLock.lock()
|
|
94
105
|
defer { stateLock.unlock() }
|
|
95
|
-
|
|
106
|
+
|
|
96
107
|
if case .active(let sid, let start) = state {
|
|
97
108
|
state = .paused(sessionId: sid, startTime: start)
|
|
98
109
|
backgroundStartTime = Date().timeIntervalSince1970
|
|
@@ -101,23 +112,22 @@ public final class RejourneyImpl: NSObject {
|
|
|
101
112
|
SegmentDispatcher.shared.shipPending()
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
|
|
115
|
+
|
|
105
116
|
@objc private func handleForegrounding() {
|
|
106
|
-
// Dispatch to avoid blocking the main thread notification handler
|
|
107
117
|
DispatchQueue.main.async { [weak self] in
|
|
108
118
|
self?._processForegrounding()
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
|
-
|
|
121
|
+
|
|
112
122
|
private func _processForegrounding() {
|
|
113
123
|
stateLock.lock()
|
|
114
|
-
|
|
124
|
+
|
|
115
125
|
guard case .paused(let sid, let start) = state else {
|
|
116
126
|
DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
|
|
117
127
|
stateLock.unlock()
|
|
118
128
|
return
|
|
119
129
|
}
|
|
120
|
-
|
|
130
|
+
|
|
121
131
|
// Check if we've been in background longer than the timeout
|
|
122
132
|
let backgroundDuration: TimeInterval
|
|
123
133
|
if let bgStart = backgroundStartTime {
|
|
@@ -126,33 +136,67 @@ public final class RejourneyImpl: NSObject {
|
|
|
126
136
|
backgroundDuration = 0
|
|
127
137
|
}
|
|
128
138
|
backgroundStartTime = nil
|
|
129
|
-
|
|
139
|
+
|
|
130
140
|
DiagnosticLog.notice("[Rejourney] App foregrounded after \(Int(backgroundDuration))s (timeout: \(Int(sessionTimeoutSeconds))s)")
|
|
131
|
-
|
|
141
|
+
|
|
132
142
|
if backgroundDuration > sessionTimeoutSeconds {
|
|
133
143
|
// End current session and start a new one
|
|
134
144
|
state = .idle
|
|
135
145
|
stateLock.unlock()
|
|
136
|
-
|
|
146
|
+
|
|
137
147
|
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '\(sid)' and creating new one")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
|
|
149
|
+
let restartLock = NSLock()
|
|
150
|
+
var restartStarted = false
|
|
151
|
+
let triggerRestart: (String) -> Void = { [weak self] source in
|
|
152
|
+
restartLock.lock()
|
|
153
|
+
defer { restartLock.unlock() }
|
|
154
|
+
guard !restartStarted else { return }
|
|
155
|
+
restartStarted = true
|
|
156
|
+
DiagnosticLog.notice("[Rejourney] Session rollover trigger source=\(source), oldSession=\(sid)")
|
|
157
|
+
DispatchQueue.main.async {
|
|
158
|
+
self?._startNewSessionAfterTimeout()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + sessionRolloverGraceSeconds) {
|
|
163
|
+
restartLock.lock()
|
|
164
|
+
let shouldWarn = !restartStarted
|
|
165
|
+
restartLock.unlock()
|
|
166
|
+
if shouldWarn {
|
|
167
|
+
DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (\(Int(self.sessionRolloverGraceSeconds * 1000))ms), forcing new session start")
|
|
168
|
+
}
|
|
169
|
+
triggerRestart("grace_timeout")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
DispatchQueue.global(qos: .utility).async {
|
|
173
|
+
ReplayOrchestrator.shared.endReplayWithReason("background_timeout") { success, uploaded in
|
|
143
174
|
DiagnosticLog.notice("[Rejourney] Old session ended (success: \(success), uploaded: \(uploaded))")
|
|
144
|
-
|
|
145
|
-
DispatchQueue.main.async {
|
|
146
|
-
self?._startNewSessionAfterTimeout()
|
|
147
|
-
}
|
|
175
|
+
triggerRestart("end_replay_callback")
|
|
148
176
|
}
|
|
149
177
|
}
|
|
150
178
|
} else {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
179
|
+
let orchestratorSessionId = ReplayOrchestrator.shared.replayId
|
|
180
|
+
if orchestratorSessionId?.isEmpty ?? true {
|
|
181
|
+
state = .idle
|
|
182
|
+
stateLock.unlock()
|
|
183
|
+
DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
|
|
184
|
+
DispatchQueue.main.async { [weak self] in
|
|
185
|
+
self?._startNewSessionAfterTimeout()
|
|
186
|
+
}
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if let orchestratorSessionId, orchestratorSessionId != sid {
|
|
191
|
+
state = .active(sessionId: orchestratorSessionId, startTime: Date().timeIntervalSince1970)
|
|
192
|
+
stateLock.unlock()
|
|
193
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '\(orchestratorSessionId)' (was '\(sid)')")
|
|
194
|
+
} else {
|
|
195
|
+
// Resume existing session
|
|
196
|
+
state = .active(sessionId: sid, startTime: start)
|
|
197
|
+
stateLock.unlock()
|
|
198
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '\(sid)'")
|
|
199
|
+
}
|
|
156
200
|
|
|
157
201
|
// Record the foreground event with background duration
|
|
158
202
|
let bgMs = UInt64(backgroundDuration * 1000)
|
|
@@ -161,22 +205,22 @@ public final class RejourneyImpl: NSObject {
|
|
|
161
205
|
StabilityMonitor.shared.transmitStoredReport()
|
|
162
206
|
}
|
|
163
207
|
}
|
|
164
|
-
|
|
208
|
+
|
|
165
209
|
private func _startNewSessionAfterTimeout() {
|
|
166
210
|
guard let apiUrl = lastApiUrl, let publicKey = lastPublicKey else {
|
|
167
211
|
DiagnosticLog.caution("[Rejourney] Cannot restart session - missing API config")
|
|
168
212
|
return
|
|
169
213
|
}
|
|
170
|
-
|
|
214
|
+
|
|
171
215
|
let savedUserId = currentUserIdentity
|
|
172
|
-
|
|
216
|
+
|
|
173
217
|
DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: \(savedUserId ?? "nil"))")
|
|
174
|
-
|
|
218
|
+
|
|
175
219
|
// Use a faster path: directly call beginSessionFast if credentials are still valid
|
|
176
220
|
// This avoids the network roundtrip for credential re-fetch
|
|
177
221
|
DispatchQueue.main.async { [weak self] in
|
|
178
222
|
guard let self else { return }
|
|
179
|
-
|
|
223
|
+
|
|
180
224
|
// Try the fast path first - if credentials are still valid
|
|
181
225
|
if let existingCred = DeviceRegistrar.shared.uploadCredential, DeviceRegistrar.shared.credentialValid {
|
|
182
226
|
DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
|
|
@@ -195,34 +239,34 @@ public final class RejourneyImpl: NSObject {
|
|
|
195
239
|
captureSettings: self.lastSessionConfig
|
|
196
240
|
)
|
|
197
241
|
}
|
|
198
|
-
|
|
242
|
+
|
|
199
243
|
// Poll for session to be ready (up to 3 seconds)
|
|
200
244
|
self._waitForSessionReady(savedUserId: savedUserId, attempts: 0)
|
|
201
245
|
}
|
|
202
246
|
}
|
|
203
|
-
|
|
247
|
+
|
|
204
248
|
private func _waitForSessionReady(savedUserId: String?, attempts: Int) {
|
|
205
249
|
let maxAttempts = 30 // 3 seconds max (30 * 100ms)
|
|
206
|
-
|
|
250
|
+
|
|
207
251
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
208
252
|
guard let self else { return }
|
|
209
|
-
|
|
253
|
+
|
|
210
254
|
// Check if ReplayOrchestrator has generated a new session ID
|
|
211
255
|
if let newSid = ReplayOrchestrator.shared.replayId, !newSid.isEmpty {
|
|
212
256
|
let start = Date().timeIntervalSince1970
|
|
213
|
-
|
|
257
|
+
|
|
214
258
|
self.stateLock.lock()
|
|
215
259
|
self.state = .active(sessionId: newSid, startTime: start)
|
|
216
260
|
self.stateLock.unlock()
|
|
217
|
-
|
|
261
|
+
|
|
218
262
|
ReplayOrchestrator.shared.activateGestureRecording()
|
|
219
|
-
|
|
263
|
+
|
|
220
264
|
// Re-apply user identity if it was set
|
|
221
265
|
if let userId = savedUserId, userId != "anonymous", !userId.hasPrefix("anon_") {
|
|
222
266
|
ReplayOrchestrator.shared.associateUser(userId)
|
|
223
267
|
DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '\(userId)' to new session \(newSid)")
|
|
224
268
|
}
|
|
225
|
-
|
|
269
|
+
|
|
226
270
|
DiagnosticLog.replayBegan(newSid)
|
|
227
271
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
|
|
228
272
|
} else if attempts < maxAttempts {
|
|
@@ -233,9 +277,9 @@ public final class RejourneyImpl: NSObject {
|
|
|
233
277
|
}
|
|
234
278
|
}
|
|
235
279
|
}
|
|
236
|
-
|
|
280
|
+
|
|
237
281
|
// MARK: - Public API
|
|
238
|
-
|
|
282
|
+
|
|
239
283
|
@objc(startSession:apiUrl:publicKey:resolve:reject:)
|
|
240
284
|
public func startSession(
|
|
241
285
|
_ userId: String,
|
|
@@ -254,7 +298,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
254
298
|
reject: reject
|
|
255
299
|
)
|
|
256
300
|
}
|
|
257
|
-
|
|
301
|
+
|
|
258
302
|
@objc(startSessionWithOptions:resolve:reject:)
|
|
259
303
|
public func startSessionWithOptions(
|
|
260
304
|
_ options: NSDictionary,
|
|
@@ -265,29 +309,29 @@ public final class RejourneyImpl: NSObject {
|
|
|
265
309
|
DiagnosticLog.setVerbose(true)
|
|
266
310
|
DiagnosticLog.notice("[Rejourney] Debug mode ENABLED - verbose logging active")
|
|
267
311
|
}
|
|
268
|
-
|
|
312
|
+
|
|
269
313
|
let startParams = PerformanceSnapshot.capture()
|
|
270
|
-
|
|
314
|
+
|
|
271
315
|
let userId = options["userId"] as? String ?? "anonymous"
|
|
272
316
|
let apiUrl = options["apiUrl"] as? String ?? "https://api.rejourney.co"
|
|
273
317
|
let publicKey = options["publicKey"] as? String ?? ""
|
|
274
|
-
|
|
318
|
+
|
|
275
319
|
guard !publicKey.isEmpty else {
|
|
276
320
|
reject("INVALID_KEY", "publicKey is required", nil)
|
|
277
321
|
return
|
|
278
322
|
}
|
|
279
|
-
|
|
323
|
+
|
|
280
324
|
var config: [String: Any] = [:]
|
|
281
325
|
if let val = options["captureScreen"] as? Bool { config["captureScreen"] = val }
|
|
282
326
|
if let val = options["captureAnalytics"] as? Bool { config["captureAnalytics"] = val }
|
|
283
327
|
if let val = options["captureCrashes"] as? Bool { config["captureCrashes"] = val }
|
|
284
328
|
if let val = options["captureANR"] as? Bool { config["captureANR"] = val }
|
|
285
329
|
if let val = options["wifiOnly"] as? Bool { config["wifiOnly"] = val }
|
|
286
|
-
|
|
330
|
+
|
|
287
331
|
if let fps = options["fps"] as? Int {
|
|
288
332
|
config["captureRate"] = 1.0 / Double(max(1, min(fps, 30)))
|
|
289
333
|
}
|
|
290
|
-
|
|
334
|
+
|
|
291
335
|
if let quality = options["quality"] as? String {
|
|
292
336
|
switch quality.lowercased() {
|
|
293
337
|
case "low": config["imgCompression"] = 0.4
|
|
@@ -295,14 +339,14 @@ public final class RejourneyImpl: NSObject {
|
|
|
295
339
|
default: config["imgCompression"] = 0.5
|
|
296
340
|
}
|
|
297
341
|
}
|
|
298
|
-
|
|
342
|
+
|
|
299
343
|
// Critical: Ensure async dispatch to allow React Native bridge to return
|
|
300
344
|
DispatchQueue.main.async { [weak self] in
|
|
301
345
|
guard let self else {
|
|
302
346
|
resolve(["success": false, "sessionId": "", "error": "Instance released"])
|
|
303
347
|
return
|
|
304
348
|
}
|
|
305
|
-
|
|
349
|
+
|
|
306
350
|
self.stateLock.lock()
|
|
307
351
|
if case .active(let sid, _) = self.state {
|
|
308
352
|
self.stateLock.unlock()
|
|
@@ -310,43 +354,46 @@ public final class RejourneyImpl: NSObject {
|
|
|
310
354
|
return
|
|
311
355
|
}
|
|
312
356
|
self.stateLock.unlock()
|
|
313
|
-
|
|
357
|
+
|
|
314
358
|
if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
315
359
|
self.currentUserIdentity = userId
|
|
316
360
|
}
|
|
317
|
-
|
|
361
|
+
|
|
318
362
|
// Store config for session restart after background timeout
|
|
319
363
|
self.lastSessionConfig = config
|
|
320
364
|
self.lastApiUrl = apiUrl
|
|
321
365
|
self.lastPublicKey = publicKey
|
|
322
|
-
|
|
366
|
+
|
|
323
367
|
TelemetryPipeline.shared.endpoint = apiUrl
|
|
324
368
|
SegmentDispatcher.shared.endpoint = apiUrl
|
|
325
369
|
DeviceRegistrar.shared.endpoint = apiUrl
|
|
326
|
-
|
|
370
|
+
|
|
371
|
+
// Activate native network interception
|
|
372
|
+
RejourneyURLProtocol.enable()
|
|
373
|
+
|
|
327
374
|
ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
|
|
328
|
-
|
|
375
|
+
|
|
329
376
|
// Allow orchestrator time to spin up
|
|
330
377
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
331
378
|
let sid = ReplayOrchestrator.shared.replayId ?? UUID().uuidString
|
|
332
379
|
let start = Date().timeIntervalSince1970
|
|
333
|
-
|
|
380
|
+
|
|
334
381
|
self.stateLock.lock()
|
|
335
382
|
self.state = .active(sessionId: sid, startTime: start)
|
|
336
383
|
self.stateLock.unlock()
|
|
337
|
-
|
|
384
|
+
|
|
338
385
|
ReplayOrchestrator.shared.activateGestureRecording()
|
|
339
|
-
|
|
386
|
+
|
|
340
387
|
if userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
341
388
|
ReplayOrchestrator.shared.associateUser(userId)
|
|
342
389
|
}
|
|
343
|
-
|
|
390
|
+
|
|
344
391
|
DiagnosticLog.replayBegan(sid)
|
|
345
392
|
resolve(["success": true, "sessionId": sid])
|
|
346
393
|
}
|
|
347
394
|
}
|
|
348
395
|
}
|
|
349
|
-
|
|
396
|
+
|
|
350
397
|
@objc(stopSession:reject:)
|
|
351
398
|
public func stopSession(
|
|
352
399
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -354,24 +401,27 @@ public final class RejourneyImpl: NSObject {
|
|
|
354
401
|
) {
|
|
355
402
|
DispatchQueue.main.async { [weak self] in
|
|
356
403
|
guard let self else { return }
|
|
357
|
-
|
|
404
|
+
|
|
358
405
|
var targetSid = ""
|
|
359
|
-
|
|
406
|
+
|
|
360
407
|
self.stateLock.lock()
|
|
361
408
|
if case .active(let sid, _) = self.state {
|
|
362
409
|
targetSid = sid
|
|
363
410
|
}
|
|
364
411
|
self.state = .idle
|
|
365
412
|
self.stateLock.unlock()
|
|
366
|
-
|
|
413
|
+
|
|
414
|
+
// Disable native network interception
|
|
415
|
+
RejourneyURLProtocol.disable()
|
|
416
|
+
|
|
367
417
|
guard !targetSid.isEmpty else {
|
|
368
418
|
resolve(["success": true, "sessionId": "", "uploadSuccess": true])
|
|
369
419
|
return
|
|
370
420
|
}
|
|
371
|
-
|
|
372
|
-
ReplayOrchestrator.shared.
|
|
421
|
+
|
|
422
|
+
ReplayOrchestrator.shared.endReplayWithReason("user_initiated") { success, uploaded in
|
|
373
423
|
DiagnosticLog.replayEnded(targetSid)
|
|
374
|
-
|
|
424
|
+
|
|
375
425
|
resolve([
|
|
376
426
|
"success": success,
|
|
377
427
|
"sessionId": targetSid,
|
|
@@ -380,7 +430,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
380
430
|
}
|
|
381
431
|
}
|
|
382
432
|
}
|
|
383
|
-
|
|
433
|
+
|
|
384
434
|
@objc(getSessionId:reject:)
|
|
385
435
|
public func getSessionId(
|
|
386
436
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -388,7 +438,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
388
438
|
) {
|
|
389
439
|
stateLock.lock()
|
|
390
440
|
defer { stateLock.unlock() }
|
|
391
|
-
|
|
441
|
+
|
|
392
442
|
switch state {
|
|
393
443
|
case .active(let sid, _), .paused(let sid, _):
|
|
394
444
|
resolve(sid)
|
|
@@ -396,7 +446,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
396
446
|
resolve(nil)
|
|
397
447
|
}
|
|
398
448
|
}
|
|
399
|
-
|
|
449
|
+
|
|
400
450
|
@objc(setUserIdentity:resolve:reject:)
|
|
401
451
|
public func setUserIdentity(
|
|
402
452
|
_ userId: String,
|
|
@@ -405,11 +455,11 @@ public final class RejourneyImpl: NSObject {
|
|
|
405
455
|
) {
|
|
406
456
|
if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
407
457
|
currentUserIdentity = userId
|
|
408
|
-
|
|
458
|
+
|
|
409
459
|
// Persist natively
|
|
410
460
|
UserDefaults.standard.set(userId, forKey: userIdentityKey)
|
|
411
461
|
DiagnosticLog.notice("[Rejourney] Persisted user identity: \(userId)")
|
|
412
|
-
|
|
462
|
+
|
|
413
463
|
ReplayOrchestrator.shared.associateUser(userId)
|
|
414
464
|
} else if userId == "anonymous" || userId.isEmpty {
|
|
415
465
|
// Clear identity
|
|
@@ -417,10 +467,10 @@ public final class RejourneyImpl: NSObject {
|
|
|
417
467
|
UserDefaults.standard.removeObject(forKey: userIdentityKey)
|
|
418
468
|
DiagnosticLog.notice("[Rejourney] Cleared user identity")
|
|
419
469
|
}
|
|
420
|
-
|
|
470
|
+
|
|
421
471
|
resolve(["success": true])
|
|
422
472
|
}
|
|
423
|
-
|
|
473
|
+
|
|
424
474
|
@objc(getUserIdentity:reject:)
|
|
425
475
|
public func getUserIdentity(
|
|
426
476
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -428,7 +478,31 @@ public final class RejourneyImpl: NSObject {
|
|
|
428
478
|
) {
|
|
429
479
|
resolve(currentUserIdentity)
|
|
430
480
|
}
|
|
431
|
-
|
|
481
|
+
|
|
482
|
+
@objc(setAnonymousId:resolve:reject:)
|
|
483
|
+
public func setAnonymousId(
|
|
484
|
+
_ anonymousId: String,
|
|
485
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
486
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
487
|
+
) {
|
|
488
|
+
if anonymousId.isEmpty {
|
|
489
|
+
UserDefaults.standard.removeObject(forKey: anonymousIdentityKey)
|
|
490
|
+
} else {
|
|
491
|
+
UserDefaults.standard.set(anonymousId, forKey: anonymousIdentityKey)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
resolve(["success": true])
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@objc(getAnonymousId:reject:)
|
|
498
|
+
public func getAnonymousId(
|
|
499
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
500
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
501
|
+
) {
|
|
502
|
+
let stored = UserDefaults.standard.string(forKey: anonymousIdentityKey)
|
|
503
|
+
resolve(stored)
|
|
504
|
+
}
|
|
505
|
+
|
|
432
506
|
@objc(logEvent:details:resolve:reject:)
|
|
433
507
|
public func logEvent(
|
|
434
508
|
_ eventType: String,
|
|
@@ -445,7 +519,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
445
519
|
resolve(["success": true])
|
|
446
520
|
return
|
|
447
521
|
}
|
|
448
|
-
|
|
522
|
+
|
|
449
523
|
// Handle JS error events - route through TelemetryPipeline as type:"error"
|
|
450
524
|
// so the backend ingest worker processes them into the errors table
|
|
451
525
|
if eventType == "error" {
|
|
@@ -456,7 +530,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
456
530
|
resolve(["success": true])
|
|
457
531
|
return
|
|
458
532
|
}
|
|
459
|
-
|
|
533
|
+
|
|
460
534
|
// Handle dead_tap events from JS-side detection
|
|
461
535
|
// Native view hierarchy inspection is unreliable in React Native,
|
|
462
536
|
// so dead tap detection runs in JS and reports back via logEvent.
|
|
@@ -469,7 +543,17 @@ public final class RejourneyImpl: NSObject {
|
|
|
469
543
|
resolve(["success": true])
|
|
470
544
|
return
|
|
471
545
|
}
|
|
472
|
-
|
|
546
|
+
|
|
547
|
+
// Handle console log events - preserve type:"log" with level and message
|
|
548
|
+
// so the dashboard replay can display them in the console terminal
|
|
549
|
+
if eventType == "log" {
|
|
550
|
+
let level = details["level"] as? String ?? "log"
|
|
551
|
+
let message = details["message"] as? String ?? ""
|
|
552
|
+
TelemetryPipeline.shared.recordConsoleLogEvent(level: level, message: message)
|
|
553
|
+
resolve(["success": true])
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
|
|
473
557
|
// All other events go through custom event recording
|
|
474
558
|
var payload = "{}"
|
|
475
559
|
if let data = try? JSONSerialization.data(withJSONObject: details),
|
|
@@ -479,7 +563,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
479
563
|
ReplayOrchestrator.shared.recordCustomEvent(name: eventType, payload: payload)
|
|
480
564
|
resolve(["success": true])
|
|
481
565
|
}
|
|
482
|
-
|
|
566
|
+
|
|
483
567
|
@objc(screenChanged:resolve:reject:)
|
|
484
568
|
public func screenChanged(
|
|
485
569
|
_ screenName: String,
|
|
@@ -490,7 +574,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
490
574
|
ReplayOrchestrator.shared.logScreenView(screenName)
|
|
491
575
|
resolve(["success": true])
|
|
492
576
|
}
|
|
493
|
-
|
|
577
|
+
|
|
494
578
|
@objc(onScroll:resolve:reject:)
|
|
495
579
|
public func onScroll(
|
|
496
580
|
_ offsetY: Double,
|
|
@@ -500,7 +584,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
500
584
|
ReplayOrchestrator.shared.logScrollAction()
|
|
501
585
|
resolve(["success": true])
|
|
502
586
|
}
|
|
503
|
-
|
|
587
|
+
|
|
504
588
|
@objc(markVisualChange:importance:resolve:reject:)
|
|
505
589
|
public func markVisualChange(
|
|
506
590
|
_ reason: String,
|
|
@@ -513,7 +597,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
513
597
|
}
|
|
514
598
|
resolve(true)
|
|
515
599
|
}
|
|
516
|
-
|
|
600
|
+
|
|
517
601
|
@objc(onExternalURLOpened:resolve:reject:)
|
|
518
602
|
public func onExternalURLOpened(
|
|
519
603
|
_ urlScheme: String,
|
|
@@ -523,7 +607,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
523
607
|
ReplayOrchestrator.shared.recordCustomEvent(name: "external_url_opened", payload: "{\"scheme\":\"\(urlScheme)\"}")
|
|
524
608
|
resolve(["success": true])
|
|
525
609
|
}
|
|
526
|
-
|
|
610
|
+
|
|
527
611
|
@objc(onOAuthStarted:resolve:reject:)
|
|
528
612
|
public func onOAuthStarted(
|
|
529
613
|
_ provider: String,
|
|
@@ -533,7 +617,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
533
617
|
ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_started", payload: "{\"provider\":\"\(provider)\"}")
|
|
534
618
|
resolve(["success": true])
|
|
535
619
|
}
|
|
536
|
-
|
|
620
|
+
|
|
537
621
|
@objc(onOAuthCompleted:success:resolve:reject:)
|
|
538
622
|
public func onOAuthCompleted(
|
|
539
623
|
_ provider: String,
|
|
@@ -544,7 +628,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
544
628
|
ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_completed", payload: "{\"provider\":\"\(provider)\",\"success\":\(success)}")
|
|
545
629
|
resolve(["success": true])
|
|
546
630
|
}
|
|
547
|
-
|
|
631
|
+
|
|
548
632
|
@objc(maskViewByNativeID:resolve:reject:)
|
|
549
633
|
public func maskViewByNativeID(
|
|
550
634
|
_ nativeID: String,
|
|
@@ -558,7 +642,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
558
642
|
}
|
|
559
643
|
resolve(["success": true])
|
|
560
644
|
}
|
|
561
|
-
|
|
645
|
+
|
|
562
646
|
@objc(unmaskViewByNativeID:resolve:reject:)
|
|
563
647
|
public func unmaskViewByNativeID(
|
|
564
648
|
_ nativeID: String,
|
|
@@ -572,12 +656,12 @@ public final class RejourneyImpl: NSObject {
|
|
|
572
656
|
}
|
|
573
657
|
resolve(["success": true])
|
|
574
658
|
}
|
|
575
|
-
|
|
659
|
+
|
|
576
660
|
private func findView(by identifier: String) -> UIView? {
|
|
577
661
|
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
|
|
578
662
|
return scanView(window, id: identifier)
|
|
579
663
|
}
|
|
580
|
-
|
|
664
|
+
|
|
581
665
|
private func scanView(_ node: UIView, id: String) -> UIView? {
|
|
582
666
|
if node.accessibilityIdentifier == id || node.nativeID == id {
|
|
583
667
|
return node
|
|
@@ -589,7 +673,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
589
673
|
}
|
|
590
674
|
return nil
|
|
591
675
|
}
|
|
592
|
-
|
|
676
|
+
|
|
593
677
|
@objc(setDebugMode:resolve:reject:)
|
|
594
678
|
public func setDebugMode(
|
|
595
679
|
_ enabled: Bool,
|
|
@@ -599,7 +683,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
599
683
|
DiagnosticLog.setVerbose(enabled)
|
|
600
684
|
resolve(["success": true])
|
|
601
685
|
}
|
|
602
|
-
|
|
686
|
+
|
|
603
687
|
@objc(setRemoteConfigWithRejourneyEnabled:recordingEnabled:sampleRate:maxRecordingMinutes:resolve:reject:)
|
|
604
688
|
public func setRemoteConfig(
|
|
605
689
|
rejourneyEnabled: Bool,
|
|
@@ -609,47 +693,32 @@ public final class RejourneyImpl: NSObject {
|
|
|
609
693
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
610
694
|
reject: @escaping RCTPromiseRejectBlock
|
|
611
695
|
) {
|
|
612
|
-
DiagnosticLog.
|
|
613
|
-
|
|
696
|
+
DiagnosticLog.trace("[Rejourney] setRemoteConfig: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate), maxRecording=\(maxRecordingMinutes)min")
|
|
697
|
+
|
|
614
698
|
ReplayOrchestrator.shared.setRemoteConfig(
|
|
615
699
|
rejourneyEnabled: rejourneyEnabled,
|
|
616
700
|
recordingEnabled: recordingEnabled,
|
|
617
701
|
sampleRate: sampleRate,
|
|
618
702
|
maxRecordingMinutes: maxRecordingMinutes
|
|
619
703
|
)
|
|
620
|
-
|
|
704
|
+
|
|
621
705
|
resolve(["success": true])
|
|
622
706
|
}
|
|
623
|
-
|
|
707
|
+
|
|
624
708
|
@objc(setSDKVersion:)
|
|
625
709
|
public func setSDKVersion(_ version: String) {
|
|
626
710
|
RejourneyImpl.sdkVersion = version
|
|
627
711
|
}
|
|
628
|
-
|
|
712
|
+
|
|
629
713
|
@objc(getSDKMetrics:reject:)
|
|
630
714
|
public func getSDKMetrics(
|
|
631
715
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
632
716
|
reject: @escaping RCTPromiseRejectBlock
|
|
633
717
|
) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
"uploadFailureCount": 0,
|
|
637
|
-
"retryAttemptCount": 0,
|
|
638
|
-
"circuitBreakerOpenCount": 0,
|
|
639
|
-
"memoryEvictionCount": 0,
|
|
640
|
-
"offlinePersistCount": 0,
|
|
641
|
-
"sessionStartCount": (ReplayOrchestrator.shared.replayId != nil) ? 1 : 0,
|
|
642
|
-
"crashCount": 0,
|
|
643
|
-
"uploadSuccessRate": 1.0,
|
|
644
|
-
"avgUploadDurationMs": 0.0,
|
|
645
|
-
"currentQueueDepth": 0,
|
|
646
|
-
"lastUploadTime": NSNull(),
|
|
647
|
-
"lastRetryTime": NSNull(),
|
|
648
|
-
"totalBytesUploaded": 0,
|
|
649
|
-
"totalBytesEvicted": 0
|
|
650
|
-
])
|
|
718
|
+
let queueDepth = TelemetryPipeline.shared.getQueueDepth()
|
|
719
|
+
resolve(SegmentDispatcher.shared.sdkTelemetrySnapshot(currentQueueDepth: queueDepth))
|
|
651
720
|
}
|
|
652
|
-
|
|
721
|
+
|
|
653
722
|
@objc(getDeviceInfo:reject:)
|
|
654
723
|
public func getDeviceInfo(
|
|
655
724
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -657,11 +726,11 @@ public final class RejourneyImpl: NSObject {
|
|
|
657
726
|
) {
|
|
658
727
|
let device = UIDevice.current
|
|
659
728
|
let screen = UIScreen.main
|
|
660
|
-
|
|
729
|
+
|
|
661
730
|
resolve([
|
|
662
731
|
"platform": "ios",
|
|
663
732
|
"osVersion": device.systemVersion,
|
|
664
|
-
"model": device.model,
|
|
733
|
+
"model": (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model,
|
|
665
734
|
"deviceName": device.name,
|
|
666
735
|
"screenWidth": Int(screen.bounds.width * screen.scale),
|
|
667
736
|
"screenHeight": Int(screen.bounds.height * screen.scale),
|
|
@@ -670,7 +739,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
670
739
|
"bundleId": Bundle.main.bundleIdentifier ?? "unknown"
|
|
671
740
|
])
|
|
672
741
|
}
|
|
673
|
-
|
|
742
|
+
|
|
674
743
|
@objc(debugCrash)
|
|
675
744
|
public func debugCrash() {
|
|
676
745
|
DispatchQueue.main.async {
|
|
@@ -678,14 +747,14 @@ public final class RejourneyImpl: NSObject {
|
|
|
678
747
|
_ = arr[1]
|
|
679
748
|
}
|
|
680
749
|
}
|
|
681
|
-
|
|
750
|
+
|
|
682
751
|
@objc(debugTriggerANR:)
|
|
683
752
|
public func debugTriggerANR(_ durationMs: Double) {
|
|
684
753
|
DispatchQueue.main.async {
|
|
685
754
|
Thread.sleep(forTimeInterval: durationMs / 1000.0)
|
|
686
755
|
}
|
|
687
756
|
}
|
|
688
|
-
|
|
757
|
+
|
|
689
758
|
@objc(getSDKVersion:reject:)
|
|
690
759
|
public func getSDKVersion(
|
|
691
760
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -693,7 +762,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
693
762
|
) {
|
|
694
763
|
resolve(Self.sdkVersion)
|
|
695
764
|
}
|
|
696
|
-
|
|
765
|
+
|
|
697
766
|
@objc(setUserData:value:resolve:reject:)
|
|
698
767
|
public func setUserData(
|
|
699
768
|
_ key: String,
|
|
@@ -704,16 +773,16 @@ public final class RejourneyImpl: NSObject {
|
|
|
704
773
|
ReplayOrchestrator.shared.attachAttribute(key: key, value: value)
|
|
705
774
|
resolve(nil)
|
|
706
775
|
}
|
|
707
|
-
|
|
776
|
+
|
|
708
777
|
private func computeHash() -> String {
|
|
709
778
|
let uuid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
|
|
710
779
|
guard let data = uuid.data(using: .utf8) else { return "" }
|
|
711
|
-
|
|
780
|
+
|
|
712
781
|
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
713
782
|
data.withUnsafeBytes {
|
|
714
783
|
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
|
|
715
784
|
}
|
|
716
|
-
|
|
785
|
+
|
|
717
786
|
return buffer.map { String(format: "%02x", $0) }.joined()
|
|
718
787
|
}
|
|
719
788
|
}
|