@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
@@ -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,29 @@ public final class ReplayOrchestrator: NSObject {
398
451
  _visitedScreens.removeAll()
399
452
  _bgTimeMs = 0
400
453
  _bgStartMs = nil
401
-
454
+ _lastHierarchyHash = nil
455
+
402
456
  TelemetryPipeline.shared.currentReplayId = replayId
403
457
  SegmentDispatcher.shared.currentReplayId = replayId
404
458
  StabilityMonitor.shared.currentSessionId = replayId
405
-
459
+
406
460
  _attachLifecycle()
407
461
  _saveRecovery()
408
-
462
+
409
463
  // Record app startup time
410
464
  _recordAppStartup()
411
465
  }
412
-
466
+
413
467
  private func _recordAppStartup() {
414
468
  let nowSec = Date().timeIntervalSince1970
415
469
  let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
416
-
470
+
417
471
  // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
418
472
  guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
419
-
473
+
420
474
  TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
421
475
  }
422
-
476
+
423
477
  private func _applySettings(_ cfg: [String: Any]?) {
424
478
  guard let cfg else { return }
425
479
  snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
@@ -432,7 +486,7 @@ public final class ReplayOrchestrator: NSObject {
432
486
  wifiRequired = cfg["wifiOnly"] as? Bool ?? false
433
487
  frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
434
488
  }
435
-
489
+
436
490
  private func _monitorNetwork(token: String) {
437
491
  _netMonitor = NWPathMonitor()
438
492
  _netMonitor?.pathUpdateHandler = { [weak self] path in
@@ -440,10 +494,10 @@ public final class ReplayOrchestrator: NSObject {
440
494
  }
441
495
  _netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
442
496
  }
443
-
497
+
444
498
  private func handlePathChange(path: NWPath, token: String) {
445
499
  let canProceed: Bool
446
-
500
+
447
501
  if path.status != .satisfied {
448
502
  canProceed = false
449
503
  } else if wifiRequired && !path.isExpensive {
@@ -453,12 +507,12 @@ public final class ReplayOrchestrator: NSObject {
453
507
  } else {
454
508
  canProceed = true
455
509
  }
456
-
510
+
457
511
  // Extract network interface type
458
512
  let networkType: String
459
513
  let isExpensive = path.isExpensive
460
514
  let isConstrained = path.isConstrained
461
-
515
+
462
516
  if path.status != .satisfied {
463
517
  networkType = "none"
464
518
  } else if path.usesInterfaceType(.wifi) {
@@ -472,113 +526,121 @@ public final class ReplayOrchestrator: NSObject {
472
526
  } else {
473
527
  networkType = "other"
474
528
  }
475
-
529
+
476
530
  DispatchQueue.main.async { [weak self] in
477
531
  guard let self else { return }
478
532
  self._netReady = canProceed
479
533
  self.currentNetworkType = networkType
480
534
  self.networkIsExpensive = isExpensive
481
535
  self.networkIsConstrained = isConstrained
482
-
536
+
483
537
  if canProceed && !self._live {
484
538
  self._beginRecording(token: token)
485
539
  }
486
540
  }
487
541
  }
488
-
542
+
489
543
  private func _beginRecording(token: String) {
490
544
  guard !_live else { return }
491
545
  _live = true
492
-
546
+
493
547
  self.apiToken = token
494
548
  _initSession()
495
-
549
+
496
550
  // Reactivate the dispatcher in case it was halted from a previous session
497
551
  SegmentDispatcher.shared.activate()
498
552
  TelemetryPipeline.shared.activate()
499
-
553
+
500
554
  let renderCfg = _computeRender(fps: 1, tier: "standard")
501
555
  VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
502
-
556
+
503
557
  if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
504
558
  if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
505
559
  if faultTrackingEnabled { FaultTracker.shared.activate() }
506
560
  if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
507
561
  if hierarchyCaptureEnabled { _startHierarchyCapture() }
508
-
562
+
509
563
  // Start duration limit timer based on remote config
510
564
  _startDurationLimitTimer()
511
565
  }
512
-
566
+
513
567
  // MARK: - Duration Limit Timer
514
-
568
+
515
569
  private func _startDurationLimitTimer() {
516
570
  _stopDurationLimitTimer()
517
-
571
+
518
572
  let maxMinutes = remoteMaxRecordingMinutes
519
573
  guard maxMinutes > 0 else { return }
520
-
574
+
521
575
  let maxMs = UInt64(maxMinutes) * 60 * 1000
522
576
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
523
577
  let elapsed = now - replayStartMs
524
578
  let remaining = maxMs > elapsed ? maxMs - elapsed : 0
525
-
579
+
526
580
  guard remaining > 0 else {
527
581
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
528
- endReplay()
582
+ endReplayWithReason("duration_limit")
529
583
  return
530
584
  }
531
-
585
+
532
586
  let workItem = DispatchWorkItem { [weak self] in
533
587
  guard let self, self._live else { return }
534
588
  DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
535
- self.endReplay()
589
+ self.endReplayWithReason("duration_limit")
536
590
  }
537
591
  _durationLimitTimer = workItem
538
592
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
539
-
593
+
540
594
  DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
541
595
  }
542
-
596
+
543
597
  private func _stopDurationLimitTimer() {
544
598
  _durationLimitTimer?.cancel()
545
599
  _durationLimitTimer = nil
546
600
  }
547
-
601
+
548
602
  private func _saveRecovery() {
549
603
  guard let sid = replayId, let token = apiToken else { return }
550
- let checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
604
+ var checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
605
+ if let cred = SegmentDispatcher.shared.credential {
606
+ checkpoint["credential"] = cred
607
+ }
551
608
  guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
552
609
  let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
553
610
  try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
554
611
  }
555
-
612
+
556
613
  private func _clearRecovery() {
557
614
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
558
615
  try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
559
616
  }
560
-
617
+
561
618
  private func _attachLifecycle() {
562
619
  NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
563
620
  NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
564
621
  }
565
-
622
+
566
623
  private func _detachLifecycle() {
567
624
  NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
568
625
  NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
569
626
  }
570
-
627
+
571
628
  @objc private func _onBackground() {
572
629
  _bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
630
+ ResponsivenessWatcher.shared.halt()
573
631
  }
574
-
632
+
575
633
  @objc private func _onForeground() {
576
634
  guard let start = _bgStartMs else { return }
577
635
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
578
636
  _bgTimeMs += (now - start)
579
637
  _bgStartMs = nil
638
+
639
+ if responsivenessCaptureEnabled {
640
+ ResponsivenessWatcher.shared.activate()
641
+ }
580
642
  }
581
-
643
+
582
644
  private func _startHierarchyCapture() {
583
645
  _hierarchyTimer?.invalidate()
584
646
  // Industry standard: Use default run loop mode (NOT .common)
@@ -586,38 +648,39 @@ public final class ReplayOrchestrator: NSObject {
586
648
  _hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
587
649
  self?._captureHierarchy()
588
650
  }
589
-
651
+
590
652
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
591
653
  self?._captureHierarchy()
592
654
  }
593
655
  }
594
-
656
+
595
657
  private func _captureHierarchy() {
596
658
  guard _live, let sid = replayId else { return }
597
659
  if !Thread.isMainThread {
598
660
  DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
599
661
  return
600
662
  }
601
-
663
+
602
664
  // Throttle hierarchy capture when map is visible and animating —
603
665
  // hierarchy scanning traverses the full view tree including the
604
666
  // map's deep Metal/GL subviews, adding main-thread pressure.
605
667
  if SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle {
606
668
  return
607
669
  }
608
-
670
+
609
671
  guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
610
-
672
+
611
673
  let hash = _hierarchyHash(hierarchy)
612
674
  if hash == _lastHierarchyHash { return }
613
675
  _lastHierarchyHash = hash
614
-
676
+
615
677
  guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
678
+ guard let compressed = json.gzipCompress() else { return }
616
679
  let ts = UInt64(Date().timeIntervalSince1970 * 1000)
617
-
618
- SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
680
+
681
+ SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: compressed, timestampMs: ts, completion: nil)
619
682
  }
620
-
683
+
621
684
  private func _hierarchyHash(_ h: [String: Any]) -> String {
622
685
  let screen = currentScreenName ?? "unknown"
623
686
  var childCount = 0