@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.
Files changed (52) hide show
  1. package/README.md +77 -3
  2. package/android/src/main/AndroidManifest.xml +6 -0
  3. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +143 -8
  4. package/android/src/main/java/com/rejourney/RejourneyOkHttpInitProvider.kt +68 -0
  5. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +21 -3
  6. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  7. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  8. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  9. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +93 -0
  10. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +226 -146
  11. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +7 -0
  12. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  13. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +39 -0
  14. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  15. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  16. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +14 -2
  17. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  18. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  19. package/ios/Engine/DeviceRegistrar.swift +13 -3
  20. package/ios/Engine/RejourneyImpl.swift +204 -115
  21. package/ios/Recording/AnrSentinel.swift +58 -25
  22. package/ios/Recording/InteractionRecorder.swift +1 -0
  23. package/ios/Recording/RejourneyURLProtocol.swift +216 -0
  24. package/ios/Recording/ReplayOrchestrator.swift +207 -144
  25. package/ios/Recording/SegmentDispatcher.swift +8 -0
  26. package/ios/Recording/StabilityMonitor.swift +40 -32
  27. package/ios/Recording/TelemetryPipeline.swift +45 -2
  28. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  29. package/ios/Recording/VisualCapture.swift +79 -29
  30. package/ios/Rejourney.mm +27 -8
  31. package/ios/Utility/DataCompression.swift +2 -2
  32. package/ios/Utility/ImageBlur.swift +0 -1
  33. package/lib/commonjs/expoRouterTracking.js +137 -0
  34. package/lib/commonjs/index.js +204 -34
  35. package/lib/commonjs/sdk/autoTracking.js +262 -100
  36. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  37. package/lib/module/expoRouterTracking.js +135 -0
  38. package/lib/module/index.js +203 -28
  39. package/lib/module/sdk/autoTracking.js +260 -100
  40. package/lib/module/sdk/networkInterceptor.js +84 -4
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/expoRouterTracking.d.ts +14 -0
  43. package/lib/typescript/index.d.ts +2 -2
  44. package/lib/typescript/sdk/autoTracking.d.ts +14 -1
  45. package/lib/typescript/types/index.d.ts +56 -5
  46. package/package.json +23 -3
  47. package/src/NativeRejourney.ts +8 -5
  48. package/src/expoRouterTracking.ts +167 -0
  49. package/src/index.ts +221 -35
  50. package/src/sdk/autoTracking.ts +286 -114
  51. package/src/sdk/networkInterceptor.ts +110 -1
  52. 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
- // 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
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
- // Start a new session with preserved config and user identity
145
- DispatchQueue.main.async {
146
- self?._startNewSessionAfterTimeout()
147
- }
180
+ triggerRestart("end_replay_callback")
148
181
  }
149
182
  }
150
183
  } else {
151
- // Resume existing session
152
- state = .active(sessionId: sid, startTime: start)
153
- stateLock.unlock()
154
-
155
- DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '\(sid)'")
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.endReplay { success, uploaded in
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
  }