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