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