@rejourneyco/react-native 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
- 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 +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- 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 +199 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +204 -143
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +17 -0
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +54 -8
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +28 -15
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/index.js +28 -15
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/types/index.d.ts +14 -2
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +37 -19
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/types/index.ts +15 -3
|
@@ -20,15 +20,15 @@ import QuartzCore
|
|
|
20
20
|
|
|
21
21
|
@objc(ReplayOrchestrator)
|
|
22
22
|
public final class ReplayOrchestrator: NSObject {
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
@objc public static let shared = ReplayOrchestrator()
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
@objc public var apiToken: String?
|
|
27
27
|
@objc public var replayId: String?
|
|
28
28
|
@objc public var replayStartMs: UInt64 = 0
|
|
29
29
|
@objc public var deferredUploadMode = false
|
|
30
30
|
@objc public var frameBundleSize: Int = 5
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
public var serverEndpoint: String {
|
|
33
33
|
get { TelemetryPipeline.shared.endpoint }
|
|
34
34
|
set {
|
|
@@ -37,7 +37,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
37
37
|
DeviceRegistrar.shared.endpoint = newValue
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
@objc public var snapshotInterval: Double = 1.0
|
|
42
42
|
@objc public var compressionLevel: Double = 0.5
|
|
43
43
|
@objc public var visualCaptureEnabled: Bool = true
|
|
@@ -49,30 +49,30 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
49
49
|
@objc public var hierarchyCaptureEnabled: Bool = true
|
|
50
50
|
@objc public var hierarchyCaptureInterval: Double = 2.0
|
|
51
51
|
@objc public private(set) var currentScreenName: String?
|
|
52
|
-
|
|
52
|
+
|
|
53
53
|
// Remote config from backend (set via setRemoteConfig before session start)
|
|
54
54
|
@objc public private(set) var remoteRejourneyEnabled: Bool = true
|
|
55
55
|
@objc public private(set) var remoteRecordingEnabled: Bool = true
|
|
56
56
|
@objc public private(set) var remoteSampleRate: Int = 100
|
|
57
57
|
@objc public private(set) var remoteMaxRecordingMinutes: Int = 10
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
private var _netMonitor: NWPathMonitor?
|
|
60
60
|
private var _netReady = false
|
|
61
61
|
private var _live = false
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
// Network state tracking
|
|
64
64
|
@objc public private(set) var currentNetworkType: String = "unknown"
|
|
65
65
|
@objc public private(set) var currentCellularGeneration: String = "unknown"
|
|
66
66
|
@objc public private(set) var networkIsConstrained: Bool = false
|
|
67
67
|
@objc public private(set) var networkIsExpensive: Bool = false
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
// App startup tracking - use actual process start time from kernel
|
|
70
70
|
private static var processStartTime: TimeInterval = {
|
|
71
71
|
// Get the actual process start time from the kernel
|
|
72
72
|
var kinfo = kinfo_proc()
|
|
73
73
|
var size = MemoryLayout<kinfo_proc>.stride
|
|
74
74
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
if sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0 {
|
|
77
77
|
let startSec = kinfo.kp_proc.p_starttime.tv_sec
|
|
78
78
|
let startUsec = kinfo.kp_proc.p_starttime.tv_usec
|
|
@@ -81,7 +81,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
81
81
|
// Fallback to current time if sysctl fails
|
|
82
82
|
return Date().timeIntervalSince1970
|
|
83
83
|
}()
|
|
84
|
-
|
|
84
|
+
|
|
85
85
|
private var _crashCount = 0
|
|
86
86
|
private var _freezeCount = 0
|
|
87
87
|
private var _errorCount = 0
|
|
@@ -97,20 +97,21 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
97
97
|
private var _hierarchyTimer: Timer?
|
|
98
98
|
private var _lastHierarchyHash: String?
|
|
99
99
|
private var _durationLimitTimer: DispatchWorkItem?
|
|
100
|
-
|
|
100
|
+
private let lifecycleContractVersion = 2
|
|
101
|
+
|
|
101
102
|
private override init() {
|
|
102
103
|
super.init()
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
|
|
105
106
|
/// Fast session start using existing credentials - skips credential fetch for faster restart
|
|
106
107
|
@objc public func beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: [String: Any]? = nil) {
|
|
107
108
|
let perf = PerformanceSnapshot.capture()
|
|
108
109
|
DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_FAST_INIT", details: "beginReplayFast with existing credential", perf: perf)
|
|
109
|
-
|
|
110
|
+
|
|
110
111
|
self.apiToken = apiToken
|
|
111
112
|
self.serverEndpoint = serverEndpoint
|
|
112
113
|
_applySettings(captureSettings)
|
|
113
|
-
|
|
114
|
+
|
|
114
115
|
// Set credentials AND endpoint directly without network fetch
|
|
115
116
|
TelemetryPipeline.shared.apiToken = apiToken
|
|
116
117
|
TelemetryPipeline.shared.credential = credential
|
|
@@ -118,45 +119,45 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
118
119
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
119
120
|
SegmentDispatcher.shared.credential = credential
|
|
120
121
|
SegmentDispatcher.shared.endpoint = serverEndpoint
|
|
121
|
-
|
|
122
|
+
|
|
122
123
|
// Skip network monitoring, assume network is available since we just came from background
|
|
123
124
|
DispatchQueue.main.async { [weak self] in
|
|
124
125
|
self?._beginRecording(token: apiToken)
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
+
|
|
128
129
|
@objc public func beginReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
|
|
129
130
|
let perf = PerformanceSnapshot.capture()
|
|
130
131
|
DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_INIT", details: "beginReplay", perf: perf)
|
|
131
|
-
|
|
132
|
+
|
|
132
133
|
self.apiToken = apiToken
|
|
133
134
|
self.serverEndpoint = serverEndpoint
|
|
134
135
|
_applySettings(captureSettings)
|
|
135
|
-
|
|
136
|
+
|
|
136
137
|
DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_START", details: "Requesting device credential")
|
|
137
|
-
|
|
138
|
+
|
|
138
139
|
DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
|
|
139
140
|
guard let self, ok else {
|
|
140
141
|
DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_FAIL", details: "Failed")
|
|
141
142
|
return
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
|
|
144
145
|
TelemetryPipeline.shared.apiToken = apiToken
|
|
145
146
|
TelemetryPipeline.shared.credential = cred
|
|
146
147
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
147
148
|
SegmentDispatcher.shared.credential = cred
|
|
148
|
-
|
|
149
|
+
|
|
149
150
|
self._monitorNetwork(token: apiToken)
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
|
|
153
154
|
@objc public func beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
|
|
154
155
|
self.apiToken = apiToken
|
|
155
156
|
self.serverEndpoint = serverEndpoint
|
|
156
157
|
deferredUploadMode = true
|
|
157
|
-
|
|
158
|
+
|
|
158
159
|
_applySettings(captureSettings)
|
|
159
|
-
|
|
160
|
+
|
|
160
161
|
DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
|
|
161
162
|
guard let self, ok else { return }
|
|
162
163
|
TelemetryPipeline.shared.apiToken = apiToken
|
|
@@ -164,53 +165,61 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
164
165
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
165
166
|
SegmentDispatcher.shared.credential = cred
|
|
166
167
|
}
|
|
167
|
-
|
|
168
|
+
|
|
168
169
|
_initSession()
|
|
169
170
|
TelemetryPipeline.shared.activateDeferredMode()
|
|
170
|
-
|
|
171
|
+
|
|
171
172
|
let renderCfg = _computeRender(fps: 1, tier: "standard")
|
|
172
|
-
|
|
173
|
+
|
|
173
174
|
if visualCaptureEnabled {
|
|
174
175
|
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
175
176
|
VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs)
|
|
176
177
|
VisualCapture.shared.activateDeferredMode()
|
|
177
178
|
}
|
|
178
|
-
|
|
179
|
+
|
|
179
180
|
if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
|
|
180
181
|
if faultTrackingEnabled { FaultTracker.shared.activate() }
|
|
181
|
-
|
|
182
|
+
|
|
182
183
|
_live = true
|
|
183
184
|
}
|
|
184
|
-
|
|
185
|
+
|
|
185
186
|
@objc public func commitDeferredReplay() {
|
|
186
187
|
deferredUploadMode = false
|
|
187
188
|
TelemetryPipeline.shared.commitDeferredData()
|
|
188
189
|
VisualCapture.shared.commitDeferredData()
|
|
189
190
|
TelemetryPipeline.shared.activate()
|
|
190
191
|
}
|
|
191
|
-
|
|
192
|
+
|
|
192
193
|
@objc public func endReplay() {
|
|
193
194
|
endReplay(completion: nil)
|
|
194
195
|
}
|
|
195
|
-
|
|
196
|
+
|
|
196
197
|
@objc public func endReplay(completion: ((Bool, Bool) -> Void)?) {
|
|
198
|
+
endReplayInternal(reason: "unspecified", completion: completion)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@objc public func endReplayWithReason(_ endReason: String, completion: ((Bool, Bool) -> Void)? = nil) {
|
|
202
|
+
endReplayInternal(reason: endReason, completion: completion)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private func endReplayInternal(reason endReason: String, completion: ((Bool, Bool) -> Void)?) {
|
|
197
206
|
guard _live else {
|
|
198
207
|
completion?(false, false)
|
|
199
208
|
return
|
|
200
209
|
}
|
|
201
210
|
_live = false
|
|
202
|
-
|
|
211
|
+
|
|
203
212
|
let sid = replayId ?? ""
|
|
204
213
|
let termMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
205
214
|
let elapsed = Int((termMs - replayStartMs) / 1000)
|
|
206
|
-
|
|
215
|
+
|
|
207
216
|
_netMonitor?.cancel()
|
|
208
217
|
_netMonitor = nil
|
|
209
218
|
_hierarchyTimer?.invalidate()
|
|
210
219
|
_hierarchyTimer = nil
|
|
211
220
|
_stopDurationLimitTimer()
|
|
212
221
|
_detachLifecycle()
|
|
213
|
-
|
|
222
|
+
|
|
214
223
|
let metrics: [String: Any] = [
|
|
215
224
|
"crashCount": _crashCount,
|
|
216
225
|
"anrCount": _freezeCount,
|
|
@@ -225,52 +234,54 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
225
234
|
"screenCount": Set(_visitedScreens).count
|
|
226
235
|
]
|
|
227
236
|
let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
362
|
+
if let credential = checkpoint["credential"] as? String {
|
|
363
|
+
SegmentDispatcher.shared.credential = credential
|
|
364
|
+
}
|
|
365
|
+
SegmentDispatcher.shared.currentReplayId = recId
|
|
366
|
+
SegmentDispatcher.shared.activate()
|
|
367
|
+
TelemetryPipeline.shared.currentReplayId = recId
|
|
368
|
+
let hasCrashIncident = _hasStoredCrashIncident(for: recId)
|
|
369
|
+
|
|
370
|
+
let finalizeRecoveredSession = { [weak self] in
|
|
371
|
+
let crashMetrics: [String: Any] = [
|
|
372
|
+
"crashCount": hasCrashIncident ? 1 : 0,
|
|
373
|
+
"durationSeconds": Int((nowMs - origStart) / 1000)
|
|
374
|
+
]
|
|
375
|
+
let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
|
|
376
|
+
|
|
377
|
+
SegmentDispatcher.shared.concludeReplay(
|
|
378
|
+
replayId: recId,
|
|
379
|
+
concludedAt: nowMs,
|
|
380
|
+
backgroundDurationMs: 0,
|
|
381
|
+
metrics: crashMetrics,
|
|
382
|
+
currentQueueDepth: queueDepthAtFinalize,
|
|
383
|
+
endReason: "recovery_finalize",
|
|
384
|
+
lifecycleVersion: self?.lifecycleContractVersion
|
|
385
|
+
) { ok in
|
|
386
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Crash recovery finalize: success=\(ok), sessionId=\(recId)")
|
|
387
|
+
if ok { self?._clearRecovery() }
|
|
388
|
+
completion(ok ? recId : nil)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
VisualCapture.shared.uploadPendingFrames(sessionId: recId) { uploaded in
|
|
393
|
+
guard uploaded else {
|
|
394
|
+
DiagnosticLog.caution("[ReplayOrchestrator] Crash recovery postponed: pending frame upload failed for session \(recId)")
|
|
395
|
+
completion(nil)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
finalizeRecoveredSession()
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private func _hasStoredCrashIncident(for sessionId: String) -> Bool {
|
|
403
|
+
guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let incidentPath = cacheDir.appendingPathComponent("rj_incidents.json")
|
|
408
|
+
guard let data = try? Data(contentsOf: incidentPath),
|
|
409
|
+
let incident = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let incidentSessionId = incident["sessionId"] as? String ?? ""
|
|
414
|
+
let category = (incident["category"] as? String ?? "").lowercased()
|
|
415
|
+
let crashLikeCategory = category == "signal" || category == "exception" || category == "crash"
|
|
416
|
+
let identifier = incident["identifier"] as? String ?? ""
|
|
417
|
+
let detail = incident["detail"] as? String ?? ""
|
|
418
|
+
return crashLikeCategory && incidentSessionId == sessionId && (!identifier.isEmpty || !detail.isEmpty)
|
|
419
|
+
}
|
|
420
|
+
|
|
368
421
|
@objc public func incrementFaultTally() { _crashCount += 1 }
|
|
369
422
|
@objc public func incrementStalledTally() { _freezeCount += 1 }
|
|
370
423
|
@objc public func incrementExceptionTally() { _errorCount += 1 }
|
|
@@ -373,20 +426,20 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
373
426
|
@objc public func incrementGestureTally() { _gestureCount += 1 }
|
|
374
427
|
@objc public func incrementRageTapTally() { _rageCount += 1 }
|
|
375
428
|
@objc public func incrementDeadTapTally() { _deadTapCount += 1 }
|
|
376
|
-
|
|
429
|
+
|
|
377
430
|
@objc public func logScreenView(_ screenId: String) {
|
|
378
431
|
guard !screenId.isEmpty else { return }
|
|
379
432
|
_visitedScreens.append(screenId)
|
|
380
433
|
currentScreenName = screenId
|
|
381
434
|
if hierarchyCaptureEnabled { _captureHierarchy() }
|
|
382
435
|
}
|
|
383
|
-
|
|
436
|
+
|
|
384
437
|
private func _initSession() {
|
|
385
438
|
replayStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
386
439
|
let uuidPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
|
387
440
|
replayId = "session_\(replayStartMs)_\(uuidPart)"
|
|
388
441
|
_finalized = false
|
|
389
|
-
|
|
442
|
+
|
|
390
443
|
_crashCount = 0
|
|
391
444
|
_freezeCount = 0
|
|
392
445
|
_errorCount = 0
|
|
@@ -398,28 +451,28 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
398
451
|
_visitedScreens.removeAll()
|
|
399
452
|
_bgTimeMs = 0
|
|
400
453
|
_bgStartMs = nil
|
|
401
|
-
|
|
454
|
+
|
|
402
455
|
TelemetryPipeline.shared.currentReplayId = replayId
|
|
403
456
|
SegmentDispatcher.shared.currentReplayId = replayId
|
|
404
457
|
StabilityMonitor.shared.currentSessionId = replayId
|
|
405
|
-
|
|
458
|
+
|
|
406
459
|
_attachLifecycle()
|
|
407
460
|
_saveRecovery()
|
|
408
|
-
|
|
461
|
+
|
|
409
462
|
// Record app startup time
|
|
410
463
|
_recordAppStartup()
|
|
411
464
|
}
|
|
412
|
-
|
|
465
|
+
|
|
413
466
|
private func _recordAppStartup() {
|
|
414
467
|
let nowSec = Date().timeIntervalSince1970
|
|
415
468
|
let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
|
|
416
|
-
|
|
469
|
+
|
|
417
470
|
// Only record if it's a reasonable startup time (> 0 and < 60 seconds)
|
|
418
471
|
guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
|
|
419
|
-
|
|
472
|
+
|
|
420
473
|
TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
|
|
421
474
|
}
|
|
422
|
-
|
|
475
|
+
|
|
423
476
|
private func _applySettings(_ cfg: [String: Any]?) {
|
|
424
477
|
guard let cfg else { return }
|
|
425
478
|
snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
|
|
@@ -432,7 +485,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
432
485
|
wifiRequired = cfg["wifiOnly"] as? Bool ?? false
|
|
433
486
|
frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
|
|
434
487
|
}
|
|
435
|
-
|
|
488
|
+
|
|
436
489
|
private func _monitorNetwork(token: String) {
|
|
437
490
|
_netMonitor = NWPathMonitor()
|
|
438
491
|
_netMonitor?.pathUpdateHandler = { [weak self] path in
|
|
@@ -440,10 +493,10 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
440
493
|
}
|
|
441
494
|
_netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
|
|
442
495
|
}
|
|
443
|
-
|
|
496
|
+
|
|
444
497
|
private func handlePathChange(path: NWPath, token: String) {
|
|
445
498
|
let canProceed: Bool
|
|
446
|
-
|
|
499
|
+
|
|
447
500
|
if path.status != .satisfied {
|
|
448
501
|
canProceed = false
|
|
449
502
|
} else if wifiRequired && !path.isExpensive {
|
|
@@ -453,12 +506,12 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
453
506
|
} else {
|
|
454
507
|
canProceed = true
|
|
455
508
|
}
|
|
456
|
-
|
|
509
|
+
|
|
457
510
|
// Extract network interface type
|
|
458
511
|
let networkType: String
|
|
459
512
|
let isExpensive = path.isExpensive
|
|
460
513
|
let isConstrained = path.isConstrained
|
|
461
|
-
|
|
514
|
+
|
|
462
515
|
if path.status != .satisfied {
|
|
463
516
|
networkType = "none"
|
|
464
517
|
} else if path.usesInterfaceType(.wifi) {
|
|
@@ -472,113 +525,121 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
472
525
|
} else {
|
|
473
526
|
networkType = "other"
|
|
474
527
|
}
|
|
475
|
-
|
|
528
|
+
|
|
476
529
|
DispatchQueue.main.async { [weak self] in
|
|
477
530
|
guard let self else { return }
|
|
478
531
|
self._netReady = canProceed
|
|
479
532
|
self.currentNetworkType = networkType
|
|
480
533
|
self.networkIsExpensive = isExpensive
|
|
481
534
|
self.networkIsConstrained = isConstrained
|
|
482
|
-
|
|
535
|
+
|
|
483
536
|
if canProceed && !self._live {
|
|
484
537
|
self._beginRecording(token: token)
|
|
485
538
|
}
|
|
486
539
|
}
|
|
487
540
|
}
|
|
488
|
-
|
|
541
|
+
|
|
489
542
|
private func _beginRecording(token: String) {
|
|
490
543
|
guard !_live else { return }
|
|
491
544
|
_live = true
|
|
492
|
-
|
|
545
|
+
|
|
493
546
|
self.apiToken = token
|
|
494
547
|
_initSession()
|
|
495
|
-
|
|
548
|
+
|
|
496
549
|
// Reactivate the dispatcher in case it was halted from a previous session
|
|
497
550
|
SegmentDispatcher.shared.activate()
|
|
498
551
|
TelemetryPipeline.shared.activate()
|
|
499
|
-
|
|
552
|
+
|
|
500
553
|
let renderCfg = _computeRender(fps: 1, tier: "standard")
|
|
501
554
|
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
502
|
-
|
|
555
|
+
|
|
503
556
|
if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
|
|
504
557
|
if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
|
|
505
558
|
if faultTrackingEnabled { FaultTracker.shared.activate() }
|
|
506
559
|
if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
|
|
507
560
|
if hierarchyCaptureEnabled { _startHierarchyCapture() }
|
|
508
|
-
|
|
561
|
+
|
|
509
562
|
// Start duration limit timer based on remote config
|
|
510
563
|
_startDurationLimitTimer()
|
|
511
564
|
}
|
|
512
|
-
|
|
565
|
+
|
|
513
566
|
// MARK: - Duration Limit Timer
|
|
514
|
-
|
|
567
|
+
|
|
515
568
|
private func _startDurationLimitTimer() {
|
|
516
569
|
_stopDurationLimitTimer()
|
|
517
|
-
|
|
570
|
+
|
|
518
571
|
let maxMinutes = remoteMaxRecordingMinutes
|
|
519
572
|
guard maxMinutes > 0 else { return }
|
|
520
|
-
|
|
573
|
+
|
|
521
574
|
let maxMs = UInt64(maxMinutes) * 60 * 1000
|
|
522
575
|
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
523
576
|
let elapsed = now - replayStartMs
|
|
524
577
|
let remaining = maxMs > elapsed ? maxMs - elapsed : 0
|
|
525
|
-
|
|
578
|
+
|
|
526
579
|
guard remaining > 0 else {
|
|
527
580
|
DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
|
|
528
|
-
|
|
581
|
+
endReplayWithReason("duration_limit")
|
|
529
582
|
return
|
|
530
583
|
}
|
|
531
|
-
|
|
584
|
+
|
|
532
585
|
let workItem = DispatchWorkItem { [weak self] in
|
|
533
586
|
guard let self, self._live else { return }
|
|
534
587
|
DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
|
|
535
|
-
self.
|
|
588
|
+
self.endReplayWithReason("duration_limit")
|
|
536
589
|
}
|
|
537
590
|
_durationLimitTimer = workItem
|
|
538
591
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
|
|
539
|
-
|
|
592
|
+
|
|
540
593
|
DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
|
|
541
594
|
}
|
|
542
|
-
|
|
595
|
+
|
|
543
596
|
private func _stopDurationLimitTimer() {
|
|
544
597
|
_durationLimitTimer?.cancel()
|
|
545
598
|
_durationLimitTimer = nil
|
|
546
599
|
}
|
|
547
|
-
|
|
600
|
+
|
|
548
601
|
private func _saveRecovery() {
|
|
549
602
|
guard let sid = replayId, let token = apiToken else { return }
|
|
550
|
-
|
|
603
|
+
var checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
|
|
604
|
+
if let cred = SegmentDispatcher.shared.credential {
|
|
605
|
+
checkpoint["credential"] = cred
|
|
606
|
+
}
|
|
551
607
|
guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
|
|
552
608
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
|
553
609
|
try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
|
|
554
610
|
}
|
|
555
|
-
|
|
611
|
+
|
|
556
612
|
private func _clearRecovery() {
|
|
557
613
|
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
|
558
614
|
try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
|
|
559
615
|
}
|
|
560
|
-
|
|
616
|
+
|
|
561
617
|
private func _attachLifecycle() {
|
|
562
618
|
NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
563
619
|
NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
564
620
|
}
|
|
565
|
-
|
|
621
|
+
|
|
566
622
|
private func _detachLifecycle() {
|
|
567
623
|
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
568
624
|
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
569
625
|
}
|
|
570
|
-
|
|
626
|
+
|
|
571
627
|
@objc private func _onBackground() {
|
|
572
628
|
_bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
629
|
+
ResponsivenessWatcher.shared.halt()
|
|
573
630
|
}
|
|
574
|
-
|
|
631
|
+
|
|
575
632
|
@objc private func _onForeground() {
|
|
576
633
|
guard let start = _bgStartMs else { return }
|
|
577
634
|
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
578
635
|
_bgTimeMs += (now - start)
|
|
579
636
|
_bgStartMs = nil
|
|
637
|
+
|
|
638
|
+
if responsivenessCaptureEnabled {
|
|
639
|
+
ResponsivenessWatcher.shared.activate()
|
|
640
|
+
}
|
|
580
641
|
}
|
|
581
|
-
|
|
642
|
+
|
|
582
643
|
private func _startHierarchyCapture() {
|
|
583
644
|
_hierarchyTimer?.invalidate()
|
|
584
645
|
// Industry standard: Use default run loop mode (NOT .common)
|
|
@@ -586,38 +647,38 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
586
647
|
_hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
|
|
587
648
|
self?._captureHierarchy()
|
|
588
649
|
}
|
|
589
|
-
|
|
650
|
+
|
|
590
651
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
591
652
|
self?._captureHierarchy()
|
|
592
653
|
}
|
|
593
654
|
}
|
|
594
|
-
|
|
655
|
+
|
|
595
656
|
private func _captureHierarchy() {
|
|
596
657
|
guard _live, let sid = replayId else { return }
|
|
597
658
|
if !Thread.isMainThread {
|
|
598
659
|
DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
|
|
599
660
|
return
|
|
600
661
|
}
|
|
601
|
-
|
|
662
|
+
|
|
602
663
|
// Throttle hierarchy capture when map is visible and animating —
|
|
603
664
|
// hierarchy scanning traverses the full view tree including the
|
|
604
665
|
// map's deep Metal/GL subviews, adding main-thread pressure.
|
|
605
666
|
if SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle {
|
|
606
667
|
return
|
|
607
668
|
}
|
|
608
|
-
|
|
669
|
+
|
|
609
670
|
guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
|
|
610
|
-
|
|
671
|
+
|
|
611
672
|
let hash = _hierarchyHash(hierarchy)
|
|
612
673
|
if hash == _lastHierarchyHash { return }
|
|
613
674
|
_lastHierarchyHash = hash
|
|
614
|
-
|
|
675
|
+
|
|
615
676
|
guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
|
|
616
677
|
let ts = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
617
|
-
|
|
678
|
+
|
|
618
679
|
SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
|
|
619
680
|
}
|
|
620
|
-
|
|
681
|
+
|
|
621
682
|
private func _hierarchyHash(_ h: [String: Any]) -> String {
|
|
622
683
|
let screen = currentScreenName ?? "unknown"
|
|
623
684
|
var childCount = 0
|