@rejourneyco/react-native 1.0.8 → 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.
Files changed (42) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
  2. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  3. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  4. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  5. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
  9. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  10. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
  11. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  12. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  13. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  14. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  15. package/ios/Engine/DeviceRegistrar.swift +13 -3
  16. package/ios/Engine/RejourneyImpl.swift +199 -115
  17. package/ios/Recording/AnrSentinel.swift +58 -25
  18. package/ios/Recording/InteractionRecorder.swift +1 -0
  19. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  20. package/ios/Recording/ReplayOrchestrator.swift +204 -143
  21. package/ios/Recording/SegmentDispatcher.swift +8 -0
  22. package/ios/Recording/StabilityMonitor.swift +40 -32
  23. package/ios/Recording/TelemetryPipeline.swift +17 -0
  24. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  25. package/ios/Recording/VisualCapture.swift +54 -8
  26. package/ios/Rejourney.mm +27 -8
  27. package/ios/Utility/ImageBlur.swift +0 -1
  28. package/lib/commonjs/index.js +28 -15
  29. package/lib/commonjs/sdk/autoTracking.js +162 -11
  30. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  31. package/lib/module/index.js +28 -15
  32. package/lib/module/sdk/autoTracking.js +162 -11
  33. package/lib/module/sdk/networkInterceptor.js +84 -4
  34. package/lib/typescript/NativeRejourney.d.ts +5 -2
  35. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  36. package/lib/typescript/types/index.d.ts +14 -2
  37. package/package.json +4 -4
  38. package/src/NativeRejourney.ts +8 -5
  39. package/src/index.ts +37 -19
  40. package/src/sdk/autoTracking.ts +176 -11
  41. package/src/sdk/networkInterceptor.ts +110 -1
  42. package/src/types/index.ts +15 -3
@@ -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
- // End the old session asynchronously, then start new one
140
- // Use a background queue to avoid blocking main thread
141
- DispatchQueue.global(qos: .utility).async { [weak self] in
142
- ReplayOrchestrator.shared.endReplay { success, uploaded in
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
- // Start a new session with preserved config and user identity
145
- DispatchQueue.main.async {
146
- self?._startNewSessionAfterTimeout()
147
- }
175
+ triggerRestart("end_replay_callback")
148
176
  }
149
177
  }
150
178
  } else {
151
- // Resume existing session
152
- state = .active(sessionId: sid, startTime: start)
153
- stateLock.unlock()
154
-
155
- DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '\(sid)'")
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.endReplay { success, uploaded in
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,
@@ -610,22 +694,22 @@ public final class RejourneyImpl: NSObject {
610
694
  reject: @escaping RCTPromiseRejectBlock
611
695
  ) {
612
696
  DiagnosticLog.trace("[Rejourney] setRemoteConfig: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate), maxRecording=\(maxRecordingMinutes)min")
613
-
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,
@@ -634,7 +718,7 @@ public final class RejourneyImpl: NSObject {
634
718
  let queueDepth = TelemetryPipeline.shared.getQueueDepth()
635
719
  resolve(SegmentDispatcher.shared.sdkTelemetrySnapshot(currentQueueDepth: queueDepth))
636
720
  }
637
-
721
+
638
722
  @objc(getDeviceInfo:reject:)
639
723
  public func getDeviceInfo(
640
724
  resolve: @escaping RCTPromiseResolveBlock,
@@ -642,11 +726,11 @@ public final class RejourneyImpl: NSObject {
642
726
  ) {
643
727
  let device = UIDevice.current
644
728
  let screen = UIScreen.main
645
-
729
+
646
730
  resolve([
647
731
  "platform": "ios",
648
732
  "osVersion": device.systemVersion,
649
- "model": device.model,
733
+ "model": (DeviceRegistrar.shared.gatherDeviceProfile()["hwModel"] as? String) ?? device.model,
650
734
  "deviceName": device.name,
651
735
  "screenWidth": Int(screen.bounds.width * screen.scale),
652
736
  "screenHeight": Int(screen.bounds.height * screen.scale),
@@ -655,7 +739,7 @@ public final class RejourneyImpl: NSObject {
655
739
  "bundleId": Bundle.main.bundleIdentifier ?? "unknown"
656
740
  ])
657
741
  }
658
-
742
+
659
743
  @objc(debugCrash)
660
744
  public func debugCrash() {
661
745
  DispatchQueue.main.async {
@@ -663,14 +747,14 @@ public final class RejourneyImpl: NSObject {
663
747
  _ = arr[1]
664
748
  }
665
749
  }
666
-
750
+
667
751
  @objc(debugTriggerANR:)
668
752
  public func debugTriggerANR(_ durationMs: Double) {
669
753
  DispatchQueue.main.async {
670
754
  Thread.sleep(forTimeInterval: durationMs / 1000.0)
671
755
  }
672
756
  }
673
-
757
+
674
758
  @objc(getSDKVersion:reject:)
675
759
  public func getSDKVersion(
676
760
  resolve: @escaping RCTPromiseResolveBlock,
@@ -678,7 +762,7 @@ public final class RejourneyImpl: NSObject {
678
762
  ) {
679
763
  resolve(Self.sdkVersion)
680
764
  }
681
-
765
+
682
766
  @objc(setUserData:value:resolve:reject:)
683
767
  public func setUserData(
684
768
  _ key: String,
@@ -689,16 +773,16 @@ public final class RejourneyImpl: NSObject {
689
773
  ReplayOrchestrator.shared.attachAttribute(key: key, value: value)
690
774
  resolve(nil)
691
775
  }
692
-
776
+
693
777
  private func computeHash() -> String {
694
778
  let uuid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
695
779
  guard let data = uuid.data(using: .utf8) else { return "" }
696
-
780
+
697
781
  var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
698
782
  data.withUnsafeBytes {
699
783
  _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
700
784
  }
701
-
785
+
702
786
  return buffer.map { String(format: "%02x", $0) }.joined()
703
787
  }
704
788
  }