@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.
- package/README.md +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- 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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
514
|
-
|
|
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.
|
|
521
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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
|
|
693
|
+
let tierLower = tier.lowercased()
|
|
694
|
+
let interval: Double
|
|
612
695
|
let quality: Double
|
|
613
|
-
switch
|
|
614
|
-
case "
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
}
|