@rejourneyco/react-native 1.0.7 → 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 (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -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,8 +37,8 @@ public final class ReplayOrchestrator: NSObject {
37
37
  DeviceRegistrar.shared.endpoint = newValue
38
38
  }
39
39
  }
40
-
41
- @objc public var snapshotInterval: Double = 0.33
40
+
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
44
44
  @objc public var interactionCaptureEnabled: 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
- let renderCfg = _computeRender(fps: 3, tier: "standard")
172
-
171
+
172
+ let renderCfg = _computeRender(fps: 1, tier: "standard")
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,
@@ -224,46 +233,55 @@ public final class ReplayOrchestrator: NSObject {
224
233
  "screensVisited": _visitedScreens,
225
234
  "screenCount": Set(_visitedScreens).count
226
235
  ]
227
-
228
- SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] retain, reason in
236
+ let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
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
229
258
  guard let self else { return }
230
-
231
- // UI operations MUST run on main thread
232
- DispatchQueue.main.async {
233
- TelemetryPipeline.shared.shutdown()
234
- VisualCapture.shared.halt()
235
- InteractionRecorder.shared.deactivate()
236
- FaultTracker.shared.deactivate()
237
- ResponsivenessWatcher.shared.halt()
238
- }
239
-
240
- SegmentDispatcher.shared.shipPending()
241
-
242
- guard !self._finalized else {
243
- self._clearRecovery()
244
- completion?(true, true)
245
- return
246
- }
247
- self._finalized = true
248
-
249
- SegmentDispatcher.shared.concludeReplay(replayId: sid, concludedAt: termMs, backgroundDurationMs: self._bgTimeMs, metrics: metrics) { [weak self] ok in
259
+ SegmentDispatcher.shared.concludeReplay(
260
+ replayId: sid,
261
+ concludedAt: termMs,
262
+ backgroundDurationMs: self._bgTimeMs,
263
+ metrics: metrics,
264
+ currentQueueDepth: queueDepthAtFinalize,
265
+ endReason: endReason,
266
+ lifecycleVersion: self.lifecycleContractVersion
267
+ ) { [weak self] ok in
250
268
  if ok { self?._clearRecovery() }
251
269
  completion?(true, ok)
252
270
  }
253
271
  }
254
-
272
+
255
273
  replayId = nil
256
274
  replayStartMs = 0
257
275
  }
258
-
276
+
259
277
  @objc public func redactView(_ view: UIView) {
260
278
  VisualCapture.shared.registerRedaction(view)
261
279
  }
262
-
280
+
263
281
  @objc public func unredactView(_ view: UIView) {
264
282
  VisualCapture.shared.unregisterRedaction(view)
265
283
  }
266
-
284
+
267
285
  /// Set remote configuration from backend
268
286
  /// Called by JS side before startSession to apply server-side settings
269
287
  @objc public func setRemoteConfig(
@@ -276,81 +294,130 @@ public final class ReplayOrchestrator: NSObject {
276
294
  self.remoteRecordingEnabled = recordingEnabled
277
295
  self.remoteSampleRate = sampleRate
278
296
  self.remoteMaxRecordingMinutes = maxRecordingMinutes
279
-
297
+
280
298
  // Set isSampledIn for server-side enforcement
281
299
  // recordingEnabled=false means either dashboard disabled OR session sampled out by JS
282
300
  TelemetryPipeline.shared.isSampledIn = recordingEnabled
283
-
301
+
284
302
  // Apply recording settings immediately
285
303
  // If recording is disabled, disable visual capture
286
304
  if !recordingEnabled {
287
305
  visualCaptureEnabled = false
288
- DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
306
+ DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
289
307
  }
290
-
308
+
291
309
  // If already recording, restart the duration limit timer with updated config
292
310
  if _live {
293
311
  _startDurationLimitTimer()
294
312
  }
295
-
296
- DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate)%, maxRecording=\(maxRecordingMinutes)min, isSampledIn=\(recordingEnabled)")
313
+
314
+ DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate)%, maxRecording=\(maxRecordingMinutes)min, isSampledIn=\(recordingEnabled)")
297
315
  }
298
-
316
+
299
317
  @objc public func attachAttribute(key: String, value: String) {
300
318
  TelemetryPipeline.shared.recordAttribute(key: key, value: value)
301
319
  }
302
-
320
+
303
321
  @objc public func recordCustomEvent(name: String, payload: String?) {
304
322
  TelemetryPipeline.shared.recordCustomEvent(name: name, payload: payload ?? "")
305
323
  }
306
-
324
+
307
325
  @objc public func associateUser(_ userId: String) {
308
326
  TelemetryPipeline.shared.recordUserAssociation(userId)
309
327
  }
310
-
328
+
311
329
  @objc public func currentReplayId() -> String {
312
330
  replayId ?? ""
313
331
  }
314
-
332
+
315
333
  @objc public func activateGestureRecording() {
316
334
  }
317
-
335
+
318
336
  @objc public func recoverInterruptedReplay(completion: @escaping (String?) -> Void) {
319
337
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
320
338
  completion(nil)
321
339
  return
322
340
  }
323
-
341
+
324
342
  let path = docs.appendingPathComponent("rejourney_recovery.json")
325
-
343
+
326
344
  guard let data = try? Data(contentsOf: path),
327
345
  let checkpoint = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
328
346
  let recId = checkpoint["replayId"] as? String else {
329
347
  completion(nil)
330
348
  return
331
349
  }
332
-
350
+
333
351
  let origStart = checkpoint["startMs"] as? UInt64 ?? 0
334
352
  let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
335
-
353
+
354
+ DiagnosticLog.notice("[ReplayOrchestrator] Recovering interrupted session: \(recId)")
355
+
336
356
  if let token = checkpoint["apiToken"] as? String {
337
357
  SegmentDispatcher.shared.apiToken = token
338
358
  }
339
359
  if let endpoint = checkpoint["endpoint"] as? String {
340
360
  SegmentDispatcher.shared.endpoint = endpoint
341
361
  }
342
-
343
- let crashMetrics: [String: Any] = [
344
- "crashCount": 1,
345
- "durationSeconds": Int((nowMs - origStart) / 1000)
346
- ]
347
-
348
- SegmentDispatcher.shared.concludeReplay(replayId: recId, concludedAt: nowMs, backgroundDurationMs: 0, metrics: crashMetrics) { [weak self] ok in
349
- self?._clearRecovery()
350
- completion(ok ? recId : nil)
362
+ if let credential = checkpoint["credential"] as? String {
363
+ SegmentDispatcher.shared.credential = credential
351
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)
352
419
  }
353
-
420
+
354
421
  @objc public func incrementFaultTally() { _crashCount += 1 }
355
422
  @objc public func incrementStalledTally() { _freezeCount += 1 }
356
423
  @objc public func incrementExceptionTally() { _errorCount += 1 }
@@ -359,20 +426,20 @@ public final class ReplayOrchestrator: NSObject {
359
426
  @objc public func incrementGestureTally() { _gestureCount += 1 }
360
427
  @objc public func incrementRageTapTally() { _rageCount += 1 }
361
428
  @objc public func incrementDeadTapTally() { _deadTapCount += 1 }
362
-
429
+
363
430
  @objc public func logScreenView(_ screenId: String) {
364
431
  guard !screenId.isEmpty else { return }
365
432
  _visitedScreens.append(screenId)
366
433
  currentScreenName = screenId
367
434
  if hierarchyCaptureEnabled { _captureHierarchy() }
368
435
  }
369
-
436
+
370
437
  private func _initSession() {
371
438
  replayStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
372
439
  let uuidPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
373
440
  replayId = "session_\(replayStartMs)_\(uuidPart)"
374
441
  _finalized = false
375
-
442
+
376
443
  _crashCount = 0
377
444
  _freezeCount = 0
378
445
  _errorCount = 0
@@ -384,28 +451,28 @@ public final class ReplayOrchestrator: NSObject {
384
451
  _visitedScreens.removeAll()
385
452
  _bgTimeMs = 0
386
453
  _bgStartMs = nil
387
-
454
+
388
455
  TelemetryPipeline.shared.currentReplayId = replayId
389
456
  SegmentDispatcher.shared.currentReplayId = replayId
390
457
  StabilityMonitor.shared.currentSessionId = replayId
391
-
458
+
392
459
  _attachLifecycle()
393
460
  _saveRecovery()
394
-
461
+
395
462
  // Record app startup time
396
463
  _recordAppStartup()
397
464
  }
398
-
465
+
399
466
  private func _recordAppStartup() {
400
467
  let nowSec = Date().timeIntervalSince1970
401
468
  let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
402
-
469
+
403
470
  // Only record if it's a reasonable startup time (> 0 and < 60 seconds)
404
471
  guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
405
-
472
+
406
473
  TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
407
474
  }
408
-
475
+
409
476
  private func _applySettings(_ cfg: [String: Any]?) {
410
477
  guard let cfg else { return }
411
478
  snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
@@ -418,7 +485,7 @@ public final class ReplayOrchestrator: NSObject {
418
485
  wifiRequired = cfg["wifiOnly"] as? Bool ?? false
419
486
  frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
420
487
  }
421
-
488
+
422
489
  private func _monitorNetwork(token: String) {
423
490
  _netMonitor = NWPathMonitor()
424
491
  _netMonitor?.pathUpdateHandler = { [weak self] path in
@@ -426,10 +493,10 @@ public final class ReplayOrchestrator: NSObject {
426
493
  }
427
494
  _netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
428
495
  }
429
-
496
+
430
497
  private func handlePathChange(path: NWPath, token: String) {
431
498
  let canProceed: Bool
432
-
499
+
433
500
  if path.status != .satisfied {
434
501
  canProceed = false
435
502
  } else if wifiRequired && !path.isExpensive {
@@ -439,12 +506,12 @@ public final class ReplayOrchestrator: NSObject {
439
506
  } else {
440
507
  canProceed = true
441
508
  }
442
-
509
+
443
510
  // Extract network interface type
444
511
  let networkType: String
445
512
  let isExpensive = path.isExpensive
446
513
  let isConstrained = path.isConstrained
447
-
514
+
448
515
  if path.status != .satisfied {
449
516
  networkType = "none"
450
517
  } else if path.usesInterfaceType(.wifi) {
@@ -458,113 +525,121 @@ public final class ReplayOrchestrator: NSObject {
458
525
  } else {
459
526
  networkType = "other"
460
527
  }
461
-
528
+
462
529
  DispatchQueue.main.async { [weak self] in
463
530
  guard let self else { return }
464
531
  self._netReady = canProceed
465
532
  self.currentNetworkType = networkType
466
533
  self.networkIsExpensive = isExpensive
467
534
  self.networkIsConstrained = isConstrained
468
-
535
+
469
536
  if canProceed && !self._live {
470
537
  self._beginRecording(token: token)
471
538
  }
472
539
  }
473
540
  }
474
-
541
+
475
542
  private func _beginRecording(token: String) {
476
543
  guard !_live else { return }
477
544
  _live = true
478
-
545
+
479
546
  self.apiToken = token
480
547
  _initSession()
481
-
548
+
482
549
  // Reactivate the dispatcher in case it was halted from a previous session
483
550
  SegmentDispatcher.shared.activate()
484
551
  TelemetryPipeline.shared.activate()
485
-
486
- let renderCfg = _computeRender(fps: 3, tier: "high")
552
+
553
+ let renderCfg = _computeRender(fps: 1, tier: "standard")
487
554
  VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
488
-
555
+
489
556
  if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
490
557
  if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
491
558
  if faultTrackingEnabled { FaultTracker.shared.activate() }
492
559
  if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
493
560
  if hierarchyCaptureEnabled { _startHierarchyCapture() }
494
-
561
+
495
562
  // Start duration limit timer based on remote config
496
563
  _startDurationLimitTimer()
497
564
  }
498
-
565
+
499
566
  // MARK: - Duration Limit Timer
500
-
567
+
501
568
  private func _startDurationLimitTimer() {
502
569
  _stopDurationLimitTimer()
503
-
570
+
504
571
  let maxMinutes = remoteMaxRecordingMinutes
505
572
  guard maxMinutes > 0 else { return }
506
-
573
+
507
574
  let maxMs = UInt64(maxMinutes) * 60 * 1000
508
575
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
509
576
  let elapsed = now - replayStartMs
510
577
  let remaining = maxMs > elapsed ? maxMs - elapsed : 0
511
-
578
+
512
579
  guard remaining > 0 else {
513
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
514
- endReplay()
580
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
581
+ endReplayWithReason("duration_limit")
515
582
  return
516
583
  }
517
-
584
+
518
585
  let workItem = DispatchWorkItem { [weak self] in
519
586
  guard let self, self._live else { return }
520
- DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
521
- self.endReplay()
587
+ DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
588
+ self.endReplayWithReason("duration_limit")
522
589
  }
523
590
  _durationLimitTimer = workItem
524
591
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
525
-
526
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
592
+
593
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
527
594
  }
528
-
595
+
529
596
  private func _stopDurationLimitTimer() {
530
597
  _durationLimitTimer?.cancel()
531
598
  _durationLimitTimer = nil
532
599
  }
533
-
600
+
534
601
  private func _saveRecovery() {
535
602
  guard let sid = replayId, let token = apiToken else { return }
536
- 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
+ }
537
607
  guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
538
608
  let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
539
609
  try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
540
610
  }
541
-
611
+
542
612
  private func _clearRecovery() {
543
613
  guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
544
614
  try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
545
615
  }
546
-
616
+
547
617
  private func _attachLifecycle() {
548
618
  NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
549
619
  NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
550
620
  }
551
-
621
+
552
622
  private func _detachLifecycle() {
553
623
  NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
554
624
  NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
555
625
  }
556
-
626
+
557
627
  @objc private func _onBackground() {
558
628
  _bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
629
+ ResponsivenessWatcher.shared.halt()
559
630
  }
560
-
631
+
561
632
  @objc private func _onForeground() {
562
633
  guard let start = _bgStartMs else { return }
563
634
  let now = UInt64(Date().timeIntervalSince1970 * 1000)
564
635
  _bgTimeMs += (now - start)
565
636
  _bgStartMs = nil
637
+
638
+ if responsivenessCaptureEnabled {
639
+ ResponsivenessWatcher.shared.activate()
640
+ }
566
641
  }
567
-
642
+
568
643
  private func _startHierarchyCapture() {
569
644
  _hierarchyTimer?.invalidate()
570
645
  // Industry standard: Use default run loop mode (NOT .common)
@@ -572,31 +647,38 @@ public final class ReplayOrchestrator: NSObject {
572
647
  _hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
573
648
  self?._captureHierarchy()
574
649
  }
575
-
650
+
576
651
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
577
652
  self?._captureHierarchy()
578
653
  }
579
654
  }
580
-
655
+
581
656
  private func _captureHierarchy() {
582
657
  guard _live, let sid = replayId else { return }
583
658
  if !Thread.isMainThread {
584
659
  DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
585
660
  return
586
661
  }
587
-
662
+
663
+ // Throttle hierarchy capture when map is visible and animating —
664
+ // hierarchy scanning traverses the full view tree including the
665
+ // map's deep Metal/GL subviews, adding main-thread pressure.
666
+ if SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle {
667
+ return
668
+ }
669
+
588
670
  guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
589
-
671
+
590
672
  let hash = _hierarchyHash(hierarchy)
591
673
  if hash == _lastHierarchyHash { return }
592
674
  _lastHierarchyHash = hash
593
-
675
+
594
676
  guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
595
677
  let ts = UInt64(Date().timeIntervalSince1970 * 1000)
596
-
678
+
597
679
  SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
598
680
  }
599
-
681
+
600
682
  private func _hierarchyHash(_ h: [String: Any]) -> String {
601
683
  let screen = currentScreenName ?? "unknown"
602
684
  var childCount = 0
@@ -608,13 +690,25 @@ public final class ReplayOrchestrator: NSObject {
608
690
  }
609
691
 
610
692
  private func _computeRender(fps: Int, tier: String) -> (interval: Double, quality: Double) {
611
- let interval = 1.0 / Double(max(1, min(fps, 99)))
693
+ let tierLower = tier.lowercased()
694
+ let interval: Double
612
695
  let quality: Double
613
- switch tier.lowercased() {
614
- case "low": quality = 0.4
615
- case "standard": quality = 0.5
616
- case "high": quality = 0.6
617
- default: quality = 0.5
696
+ switch tierLower {
697
+ case "minimal":
698
+ interval = 2.0 // 0.5 fps for maximum size reduction
699
+ quality = 0.4
700
+ case "low":
701
+ interval = 1.0 / Double(max(1, min(fps, 99)))
702
+ quality = 0.4
703
+ case "standard":
704
+ interval = 1.0 / Double(max(1, min(fps, 99)))
705
+ quality = 0.5
706
+ case "high":
707
+ interval = 1.0 / Double(max(1, min(fps, 99)))
708
+ quality = 0.55
709
+ default:
710
+ interval = 1.0 / Double(max(1, min(fps, 99)))
711
+ quality = 0.5
618
712
  }
619
713
  return (interval, quality)
620
714
  }