@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
@@ -20,15 +20,15 @@ import QuartzCore
20
20
 
21
21
  @objc(ReplayOrchestrator)
22
22
  public final class ReplayOrchestrator: NSObject {
23
-
23
+
24
24
  @objc public static let shared = ReplayOrchestrator()
25
-
25
+
26
26
  @objc public var apiToken: String?
27
27
  @objc public var replayId: String?
28
28
  @objc public var replayStartMs: UInt64 = 0
29
29
  @objc public var deferredUploadMode = false
30
30
  @objc public var frameBundleSize: Int = 5
31
-
31
+
32
32
  public var serverEndpoint: String {
33
33
  get { TelemetryPipeline.shared.endpoint }
34
34
  set {
@@ -37,7 +37,7 @@ public final class ReplayOrchestrator: NSObject {
37
37
  DeviceRegistrar.shared.endpoint = newValue
38
38
  }
39
39
  }
40
-
40
+
41
41
  @objc public var snapshotInterval: Double = 1.0
42
42
  @objc public var compressionLevel: Double = 0.5
43
43
  @objc public var visualCaptureEnabled: Bool = true
@@ -49,30 +49,30 @@ public final class ReplayOrchestrator: NSObject {
49
49
  @objc public var hierarchyCaptureEnabled: Bool = true
50
50
  @objc public var hierarchyCaptureInterval: Double = 2.0
51
51
  @objc public private(set) var currentScreenName: String?
52
-
52
+
53
53
  // Remote config from backend (set via setRemoteConfig before session start)
54
54
  @objc public private(set) var remoteRejourneyEnabled: Bool = true
55
55
  @objc public private(set) var remoteRecordingEnabled: Bool = true
56
56
  @objc public private(set) var remoteSampleRate: Int = 100
57
57
  @objc public private(set) var remoteMaxRecordingMinutes: Int = 10
58
-
58
+
59
59
  private var _netMonitor: NWPathMonitor?
60
60
  private var _netReady = false
61
61
  private var _live = false
62
-
62
+
63
63
  // Network state tracking
64
64
  @objc public private(set) var currentNetworkType: String = "unknown"
65
65
  @objc public private(set) var currentCellularGeneration: String = "unknown"
66
66
  @objc public private(set) var networkIsConstrained: Bool = false
67
67
  @objc public private(set) var networkIsExpensive: Bool = false
68
-
68
+
69
69
  // App startup tracking - use actual process start time from kernel
70
70
  private static var processStartTime: TimeInterval = {
71
71
  // Get the actual process start time from the kernel
72
72
  var kinfo = kinfo_proc()
73
73
  var size = MemoryLayout<kinfo_proc>.stride
74
74
  var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
75
-
75
+
76
76
  if sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0 {
77
77
  let startSec = kinfo.kp_proc.p_starttime.tv_sec
78
78
  let startUsec = kinfo.kp_proc.p_starttime.tv_usec
@@ -81,7 +81,7 @@ public final class ReplayOrchestrator: NSObject {
81
81
  // Fallback to current time if sysctl fails
82
82
  return Date().timeIntervalSince1970
83
83
  }()
84
-
84
+
85
85
  private var _crashCount = 0
86
86
  private var _freezeCount = 0
87
87
  private var _errorCount = 0
@@ -97,20 +97,21 @@ public final class ReplayOrchestrator: NSObject {
97
97
  private var _hierarchyTimer: Timer?
98
98
  private var _lastHierarchyHash: String?
99
99
  private var _durationLimitTimer: DispatchWorkItem?
100
-
100
+ private let lifecycleContractVersion = 2
101
+
101
102
  private override init() {
102
103
  super.init()
103
104
  }
104
-
105
+
105
106
  /// Fast session start using existing credentials - skips credential fetch for faster restart
106
107
  @objc public func beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: [String: Any]? = nil) {
107
108
  let perf = PerformanceSnapshot.capture()
108
109
  DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_FAST_INIT", details: "beginReplayFast with existing credential", perf: perf)
109
-
110
+
110
111
  self.apiToken = apiToken
111
112
  self.serverEndpoint = serverEndpoint
112
113
  _applySettings(captureSettings)
113
-
114
+
114
115
  // Set credentials AND endpoint directly without network fetch
115
116
  TelemetryPipeline.shared.apiToken = apiToken
116
117
  TelemetryPipeline.shared.credential = credential
@@ -118,45 +119,45 @@ public final class ReplayOrchestrator: NSObject {
118
119
  SegmentDispatcher.shared.apiToken = apiToken
119
120
  SegmentDispatcher.shared.credential = credential
120
121
  SegmentDispatcher.shared.endpoint = serverEndpoint
121
-
122
+
122
123
  // Skip network monitoring, assume network is available since we just came from background
123
124
  DispatchQueue.main.async { [weak self] in
124
125
  self?._beginRecording(token: apiToken)
125
126
  }
126
127
  }
127
-
128
+
128
129
  @objc public func beginReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
129
130
  let perf = PerformanceSnapshot.capture()
130
131
  DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_INIT", details: "beginReplay", perf: perf)
131
-
132
+
132
133
  self.apiToken = apiToken
133
134
  self.serverEndpoint = serverEndpoint
134
135
  _applySettings(captureSettings)
135
-
136
+
136
137
  DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_START", details: "Requesting device credential")
137
-
138
+
138
139
  DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
139
140
  guard let self, ok else {
140
141
  DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_FAIL", details: "Failed")
141
142
  return
142
143
  }
143
-
144
+
144
145
  TelemetryPipeline.shared.apiToken = apiToken
145
146
  TelemetryPipeline.shared.credential = cred
146
147
  SegmentDispatcher.shared.apiToken = apiToken
147
148
  SegmentDispatcher.shared.credential = cred
148
-
149
+
149
150
  self._monitorNetwork(token: apiToken)
150
151
  }
151
152
  }
152
-
153
+
153
154
  @objc public func beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
154
155
  self.apiToken = apiToken
155
156
  self.serverEndpoint = serverEndpoint
156
157
  deferredUploadMode = true
157
-
158
+
158
159
  _applySettings(captureSettings)
159
-
160
+
160
161
  DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
161
162
  guard let self, ok else { return }
162
163
  TelemetryPipeline.shared.apiToken = apiToken
@@ -164,53 +165,61 @@ public final class ReplayOrchestrator: NSObject {
164
165
  SegmentDispatcher.shared.apiToken = apiToken
165
166
  SegmentDispatcher.shared.credential = cred
166
167
  }
167
-
168
+
168
169
  _initSession()
169
170
  TelemetryPipeline.shared.activateDeferredMode()
170
-
171
+
171
172
  let renderCfg = _computeRender(fps: 1, tier: "standard")
172
-
173
+
173
174
  if visualCaptureEnabled {
174
175
  VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
175
176
  VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs)
176
177
  VisualCapture.shared.activateDeferredMode()
177
178
  }
178
-
179
+
179
180
  if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
180
181
  if faultTrackingEnabled { FaultTracker.shared.activate() }
181
-
182
+
182
183
  _live = true
183
184
  }
184
-
185
+
185
186
  @objc public func commitDeferredReplay() {
186
187
  deferredUploadMode = false
187
188
  TelemetryPipeline.shared.commitDeferredData()
188
189
  VisualCapture.shared.commitDeferredData()
189
190
  TelemetryPipeline.shared.activate()
190
191
  }
191
-
192
+
192
193
  @objc public func endReplay() {
193
194
  endReplay(completion: nil)
194
195
  }
195
-
196
+
196
197
  @objc public func endReplay(completion: ((Bool, Bool) -> Void)?) {
198
+ endReplayInternal(reason: "unspecified", completion: completion)
199
+ }
200
+
201
+ @objc public func endReplayWithReason(_ endReason: String, completion: ((Bool, Bool) -> Void)? = nil) {
202
+ endReplayInternal(reason: endReason, completion: completion)
203
+ }
204
+
205
+ private func endReplayInternal(reason endReason: String, completion: ((Bool, Bool) -> Void)?) {
197
206
  guard _live else {
198
207
  completion?(false, false)
199
208
  return
200
209
  }
201
210
  _live = false
202
-
211
+
203
212
  let sid = replayId ?? ""
204
213
  let termMs = UInt64(Date().timeIntervalSince1970 * 1000)
205
214
  let elapsed = Int((termMs - replayStartMs) / 1000)
206
-
215
+
207
216
  _netMonitor?.cancel()
208
217
  _netMonitor = nil
209
218
  _hierarchyTimer?.invalidate()
210
219
  _hierarchyTimer = nil
211
220
  _stopDurationLimitTimer()
212
221
  _detachLifecycle()
213
-
222
+
214
223
  let metrics: [String: Any] = [
215
224
  "crashCount": _crashCount,
216
225
  "anrCount": _freezeCount,
@@ -225,52 +234,54 @@ public final class ReplayOrchestrator: NSObject {
225
234
  "screenCount": Set(_visitedScreens).count
226
235
  ]
227
236
  let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
228
-
229
- SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] retain, reason in
237
+
238
+ // Do local teardown immediately so lifecycle rollover never depends on network latency.
239
+ DispatchQueue.main.async {
240
+ TelemetryPipeline.shared.shutdown()
241
+ VisualCapture.shared.halt()
242
+ InteractionRecorder.shared.deactivate()
243
+ FaultTracker.shared.deactivate()
244
+ ResponsivenessWatcher.shared.halt()
245
+ }
246
+ SegmentDispatcher.shared.shipPending()
247
+
248
+ guard !_finalized else {
249
+ _clearRecovery()
250
+ completion?(true, true)
251
+ replayId = nil
252
+ replayStartMs = 0
253
+ return
254
+ }
255
+ _finalized = true
256
+
257
+ SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] _, _ in
230
258
  guard let self else { return }
231
-
232
- // UI operations MUST run on main thread
233
- DispatchQueue.main.async {
234
- TelemetryPipeline.shared.shutdown()
235
- VisualCapture.shared.halt()
236
- InteractionRecorder.shared.deactivate()
237
- FaultTracker.shared.deactivate()
238
- ResponsivenessWatcher.shared.halt()
239
- }
240
-
241
- SegmentDispatcher.shared.shipPending()
242
-
243
- guard !self._finalized else {
244
- self._clearRecovery()
245
- completion?(true, true)
246
- return
247
- }
248
- self._finalized = true
249
-
250
259
  SegmentDispatcher.shared.concludeReplay(
251
260
  replayId: sid,
252
261
  concludedAt: termMs,
253
262
  backgroundDurationMs: self._bgTimeMs,
254
263
  metrics: metrics,
255
- currentQueueDepth: queueDepthAtFinalize
264
+ currentQueueDepth: queueDepthAtFinalize,
265
+ endReason: endReason,
266
+ lifecycleVersion: self.lifecycleContractVersion
256
267
  ) { [weak self] ok in
257
268
  if ok { self?._clearRecovery() }
258
269
  completion?(true, ok)
259
270
  }
260
271
  }
261
-
272
+
262
273
  replayId = nil
263
274
  replayStartMs = 0
264
275
  }
265
-
276
+
266
277
  @objc public func redactView(_ view: UIView) {
267
278
  VisualCapture.shared.registerRedaction(view)
268
279
  }
269
-
280
+
270
281
  @objc public func unredactView(_ view: UIView) {
271
282
  VisualCapture.shared.unregisterRedaction(view)
272
283
  }
273
-
284
+
274
285
  /// Set remote configuration from backend
275
286
  /// Called by JS side before startSession to apply server-side settings
276
287
  @objc public func setRemoteConfig(
@@ -283,88 +294,130 @@ public final class ReplayOrchestrator: NSObject {
283
294
  self.remoteRecordingEnabled = recordingEnabled
284
295
  self.remoteSampleRate = sampleRate
285
296
  self.remoteMaxRecordingMinutes = maxRecordingMinutes
286
-
297
+
287
298
  // Set isSampledIn for server-side enforcement
288
299
  // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
289
300
  TelemetryPipeline.shared.isSampledIn = recordingEnabled
290
-
301
+
291
302
  // Apply recording settings immediately
292
303
  // If recording is disabled, disable visual capture
293
304
  if !recordingEnabled {
294
305
  visualCaptureEnabled = false
295
306
  DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
296
307
  }
297
-
308
+
298
309
  // If already recording, restart the duration limit timer with updated config
299
310
  if _live {
300
311
  _startDurationLimitTimer()
301
312
  }
302
-
313
+
303
314
  DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate)%, maxRecording=\(maxRecordingMinutes)min, isSampledIn=\(recordingEnabled)")
304
315
  }
305
-
316
+
306
317
  @objc public func attachAttribute(key: String, value: String) {
307
318
  TelemetryPipeline.shared.recordAttribute(key: key, value: value)
308
319
  }
309
-
320
+
310
321
  @objc public func recordCustomEvent(name: String, payload: String?) {
311
322
  TelemetryPipeline.shared.recordCustomEvent(name: name, payload: payload ?? "")
312
323
  }
313
-
324
+
314
325
  @objc public func associateUser(_ userId: String) {
315
326
  TelemetryPipeline.shared.recordUserAssociation(userId)
316
327
  }
317
-
328
+
318
329
  @objc public func currentReplayId() -> String {
319
330
  replayId ?? ""
320
331
  }
321
-
332
+
322
333
  @objc public func activateGestureRecording() {
323
334
  }
324
-
335
+
325
336
  @objc public func recoverInterruptedReplay(completion: @escaping (String?) -> Void) {
326
337
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
327
338
  completion(nil)
328
339
  return
329
340
  }
330
-
341
+
331
342
  let path = docs.appendingPathComponent("rejourney_recovery.json")
332
-
343
+
333
344
  guard let data = try? Data(contentsOf: path),
334
345
  let checkpoint = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
335
346
  let recId = checkpoint["replayId"] as? String else {
336
347
  completion(nil)
337
348
  return
338
349
  }
339
-
350
+
340
351
  let origStart = checkpoint["startMs"] as? UInt64 ?? 0
341
352
  let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
342
-
353
+
354
+ DiagnosticLog.notice("[ReplayOrchestrator] Recovering interrupted session: \(recId)")
355
+
343
356
  if let token = checkpoint["apiToken"] as? String {
344
357
  SegmentDispatcher.shared.apiToken = token
345
358
  }
346
359
  if let endpoint = checkpoint["endpoint"] as? String {
347
360
  SegmentDispatcher.shared.endpoint = endpoint
348
361
  }
349
-
350
- let crashMetrics: [String: Any] = [
351
- "crashCount": 1,
352
- "durationSeconds": Int((nowMs - origStart) / 1000)
353
- ]
354
- let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
355
-
356
- SegmentDispatcher.shared.concludeReplay(
357
- replayId: recId,
358
- concludedAt: nowMs,
359
- backgroundDurationMs: 0,
360
- metrics: crashMetrics,
361
- currentQueueDepth: queueDepthAtFinalize
362
- ) { [weak self] ok in
363
- self?._clearRecovery()
364
- completion(ok ? recId : nil)
365
- }
366
- }
367
-
362
+ if let credential = checkpoint["credential"] as? String {
363
+ SegmentDispatcher.shared.credential = credential
364
+ }
365
+ SegmentDispatcher.shared.currentReplayId = recId
366
+ SegmentDispatcher.shared.activate()
367
+ TelemetryPipeline.shared.currentReplayId = recId
368
+ let hasCrashIncident = _hasStoredCrashIncident(for: recId)
369
+
370
+ let finalizeRecoveredSession = { [weak self] in
371
+ let crashMetrics: [String: Any] = [
372
+ "crashCount": hasCrashIncident ? 1 : 0,
373
+ "durationSeconds": Int((nowMs - origStart) / 1000)
374
+ ]
375
+ let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
376
+
377
+ SegmentDispatcher.shared.concludeReplay(
378
+ replayId: recId,
379
+ concludedAt: nowMs,
380
+ backgroundDurationMs: 0,
381
+ metrics: crashMetrics,
382
+ currentQueueDepth: queueDepthAtFinalize,
383
+ endReason: "recovery_finalize",
384
+ lifecycleVersion: self?.lifecycleContractVersion
385
+ ) { ok in
386
+ DiagnosticLog.notice("[ReplayOrchestrator] Crash recovery finalize: success=\(ok), sessionId=\(recId)")
387
+ if ok { self?._clearRecovery() }
388
+ completion(ok ? recId : nil)
389
+ }
390
+ }
391
+
392
+ VisualCapture.shared.uploadPendingFrames(sessionId: recId) { uploaded in
393
+ guard uploaded else {
394
+ DiagnosticLog.caution("[ReplayOrchestrator] Crash recovery postponed: pending frame upload failed for session \(recId)")
395
+ completion(nil)
396
+ return
397
+ }
398
+ finalizeRecoveredSession()
399
+ }
400
+ }
401
+
402
+ private func _hasStoredCrashIncident(for sessionId: String) -> Bool {
403
+ guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
404
+ return false
405
+ }
406
+
407
+ let incidentPath = cacheDir.appendingPathComponent("rj_incidents.json")
408
+ guard let data = try? Data(contentsOf: incidentPath),
409
+ let incident = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
410
+ return false
411
+ }
412
+
413
+ let incidentSessionId = incident["sessionId"] as? String ?? ""
414
+ let category = (incident["category"] as? String ?? "").lowercased()
415
+ let crashLikeCategory = category == "signal" || category == "exception" || category == "crash"
416
+ let identifier = incident["identifier"] as? String ?? ""
417
+ let detail = incident["detail"] as? String ?? ""
418
+ return crashLikeCategory && incidentSessionId == sessionId && (!identifier.isEmpty || !detail.isEmpty)
419
+ }
420
+
368
421
  @objc public func incrementFaultTally() { _crashCount += 1 }
369
422
  @objc public func incrementStalledTally() { _freezeCount += 1 }
370
423
  @objc public func incrementExceptionTally() { _errorCount += 1 }
@@ -373,20 +426,20 @@ public final class ReplayOrchestrator: NSObject {
373
426
  @objc public func incrementGestureTally() { _gestureCount += 1 }
374
427
  @objc public func incrementRageTapTally() { _rageCount += 1 }
375
428
  @objc public func incrementDeadTapTally() { _deadTapCount += 1 }
376
-
429
+
377
430
  @objc public func logScreenView(_ screenId: String) {
378
431
  guard !screenId.isEmpty else { return }
379
432
  _visitedScreens.append(screenId)
380
433
  currentScreenName = screenId
381
434
  if hierarchyCaptureEnabled { _captureHierarchy() }
382
435
  }
383
-
436
+
384
437
  private func _initSession() {
385
438
  replayStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
386
439
  let uuidPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
387
440
  replayId = "session_\(replayStartMs)_\(uuidPart)"
388
441
  _finalized = false
389
-
442
+
390
443
  _crashCount = 0
391
444
  _freezeCount = 0
392
445
  _errorCount = 0
@@ -398,28 +451,28 @@ public final class ReplayOrchestrator: NSObject {
398
451
  _visitedScreens.removeAll()
399
452
  _bgTimeMs = 0
400
453
  _bgStartMs = nil
401
-
454
+
402
455
  TelemetryPipeline.shared.currentReplayId = replayId
403
456
  SegmentDispatcher.shared.currentReplayId = replayId
404
457
  StabilityMonitor.shared.currentSessionId = replayId
405
-
458
+
406
459
  _attachLifecycle()
407
460
  _saveRecovery()
408
-
461
+
409
462
  // Record app startup time
410
463
  _recordAppStartup()
411
464
  }
412
-
465
+
413
466
  private func _recordAppStartup() {
414
467
  let nowSec = Date().timeIntervalSince1970
415
468
  let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
416
-
469
+
417
470
  // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
418
471
  guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
419
-
472
+
420
473
  TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
421
474
  }
422
-
475
+
423
476
  private func _applySettings(_ cfg: [String: Any]?) {
424
477
  guard let cfg else { return }
425
478
  snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
@@ -432,7 +485,7 @@ public final class ReplayOrchestrator: NSObject {
432
485
  wifiRequired = cfg["wifiOnly"] as? Bool ?? false
433
486
  frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
434
487
  }
435
-
488
+
436
489
  private func _monitorNetwork(token: String) {
437
490
  _netMonitor = NWPathMonitor()
438
491
  _netMonitor?.pathUpdateHandler = { [weak self] path in
@@ -440,10 +493,10 @@ public final class ReplayOrchestrator: NSObject {
440
493
  }
441
494
  _netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
442
495
  }
443
-
496
+
444
497
  private func handlePathChange(path: NWPath, token: String) {
445
498
  let canProceed: Bool
446
-
499
+
447
500
  if path.status != .satisfied {
448
501
  canProceed = false
449
502
  } else if wifiRequired && !path.isExpensive {
@@ -453,12 +506,12 @@ public final class ReplayOrchestrator: NSObject {
453
506
  } else {
454
507
  canProceed = true
455
508
  }
456
-
509
+
457
510
  // Extract network interface type
458
511
  let networkType: String
459
512
  let isExpensive = path.isExpensive
460
513
  let isConstrained = path.isConstrained
461
-
514
+
462
515
  if path.status != .satisfied {
463
516
  networkType = "none"
464
517
  } else if path.usesInterfaceType(.wifi) {
@@ -472,113 +525,121 @@ public final class ReplayOrchestrator: NSObject {
472
525
  } else {
473
526
  networkType = "other"
474
527
  }
475
-
528
+
476
529
  DispatchQueue.main.async { [weak self] in
477
530
  guard let self else { return }
478
531
  self._netReady = canProceed
479
532
  self.currentNetworkType = networkType
480
533
  self.networkIsExpensive = isExpensive
481
534
  self.networkIsConstrained = isConstrained
482
-
535
+
483
536
  if canProceed && !self._live {
484
537
  self._beginRecording(token: token)
485
538
  }
486
539
  }
487
540
  }
488
-
541
+
489
542
  private func _beginRecording(token: String) {
490
543
  guard !_live else { return }
491
544
  _live = true
492
-
545
+
493
546
  self.apiToken = token
494
547
  _initSession()
495
-
548
+
496
549
  // Reactivate the dispatcher in case it was halted from a previous session
497
550
  SegmentDispatcher.shared.activate()
498
551
  TelemetryPipeline.shared.activate()
499
-
552
+
500
553
  let renderCfg = _computeRender(fps: 1, tier: "standard")
501
554
  VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
502
-
555
+
503
556
  if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
504
557
  if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
505
558
  if faultTrackingEnabled { FaultTracker.shared.activate() }
506
559
  if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
507
560
  if hierarchyCaptureEnabled { _startHierarchyCapture() }
508
-
561
+
509
562
  // Start duration limit timer based on remote config
510
563
  _startDurationLimitTimer()
511
564
  }
512
-
565
+
513
566
  // MARK: - Duration Limit Timer
514
-
567
+
515
568
  private func _startDurationLimitTimer() {
516
569
  _stopDurationLimitTimer()
517
-
570
+
518
571
  let maxMinutes = remoteMaxRecordingMinutes
519
572
  guard maxMinutes > 0 else { return }
520
-
573
+
521
574
  let maxMs = UInt64(maxMinutes) * 60 * 1000
522
575
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
523
576
  let elapsed = now - replayStartMs
524
577
  let remaining = maxMs > elapsed ? maxMs - elapsed : 0
525
-
578
+
526
579
  guard remaining > 0 else {
527
580
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
528
- endReplay()
581
+ endReplayWithReason("duration_limit")
529
582
  return
530
583
  }
531
-
584
+
532
585
  let workItem = DispatchWorkItem { [weak self] in
533
586
  guard let self, self._live else { return }
534
587
  DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
535
- self.endReplay()
588
+ self.endReplayWithReason("duration_limit")
536
589
  }
537
590
  _durationLimitTimer = workItem
538
591
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
539
-
592
+
540
593
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
541
594
  }
542
-
595
+
543
596
  private func _stopDurationLimitTimer() {
544
597
  _durationLimitTimer?.cancel()
545
598
  _durationLimitTimer = nil
546
599
  }
547
-
600
+
548
601
  private func _saveRecovery() {
549
602
  guard let sid = replayId, let token = apiToken else { return }
550
- let checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
603
+ var checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
604
+ if let cred = SegmentDispatcher.shared.credential {
605
+ checkpoint["credential"] = cred
606
+ }
551
607
  guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
552
608
  let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
553
609
  try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
554
610
  }
555
-
611
+
556
612
  private func _clearRecovery() {
557
613
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
558
614
  try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
559
615
  }
560
-
616
+
561
617
  private func _attachLifecycle() {
562
618
  NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
563
619
  NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
564
620
  }
565
-
621
+
566
622
  private func _detachLifecycle() {
567
623
  NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
568
624
  NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
569
625
  }
570
-
626
+
571
627
  @objc private func _onBackground() {
572
628
  _bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
629
+ ResponsivenessWatcher.shared.halt()
573
630
  }
574
-
631
+
575
632
  @objc private func _onForeground() {
576
633
  guard let start = _bgStartMs else { return }
577
634
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
578
635
  _bgTimeMs += (now - start)
579
636
  _bgStartMs = nil
637
+
638
+ if responsivenessCaptureEnabled {
639
+ ResponsivenessWatcher.shared.activate()
640
+ }
580
641
  }
581
-
642
+
582
643
  private func _startHierarchyCapture() {
583
644
  _hierarchyTimer?.invalidate()
584
645
  // Industry standard: Use default run loop mode (NOT .common)
@@ -586,38 +647,38 @@ public final class ReplayOrchestrator: NSObject {
586
647
  _hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
587
648
  self?._captureHierarchy()
588
649
  }
589
-
650
+
590
651
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
591
652
  self?._captureHierarchy()
592
653
  }
593
654
  }
594
-
655
+
595
656
  private func _captureHierarchy() {
596
657
  guard _live, let sid = replayId else { return }
597
658
  if !Thread.isMainThread {
598
659
  DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
599
660
  return
600
661
  }
601
-
662
+
602
663
  // Throttle hierarchy capture when map is visible and animating —
603
664
  // hierarchy scanning traverses the full view tree including the
604
665
  // map's deep Metal/GL subviews, adding main-thread pressure.
605
666
  if SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle {
606
667
  return
607
668
  }
608
-
669
+
609
670
  guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
610
-
671
+
611
672
  let hash = _hierarchyHash(hierarchy)
612
673
  if hash == _lastHierarchyHash { return }
613
674
  _lastHierarchyHash = hash
614
-
675
+
615
676
  guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
616
677
  let ts = UInt64(Date().timeIntervalSince1970 * 1000)
617
-
678
+
618
679
  SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
619
680
  }
620
-
681
+
621
682
  private func _hierarchyHash(_ h: [String: Any]) -> String {
622
683
  let screen = currentScreenName ?? "unknown"
623
684
  var childCount = 0