@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
|
@@ -40,20 +40,20 @@ import java.util.*
|
|
|
40
40
|
* Android implementation aligned with iOS ReplayOrchestrator.swift
|
|
41
41
|
*/
|
|
42
42
|
class ReplayOrchestrator private constructor(private val context: Context) {
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
companion object {
|
|
45
45
|
@Volatile
|
|
46
46
|
private var instance: ReplayOrchestrator? = null
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
fun getInstance(context: Context): ReplayOrchestrator {
|
|
49
49
|
return instance ?: synchronized(this) {
|
|
50
50
|
instance ?: ReplayOrchestrator(context.applicationContext).also { instance = it }
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
|
|
53
|
+
|
|
54
54
|
val shared: ReplayOrchestrator?
|
|
55
55
|
get() = instance
|
|
56
|
-
|
|
56
|
+
|
|
57
57
|
// Process start time for app startup tracking
|
|
58
58
|
private val processStartTime: Long by lazy {
|
|
59
59
|
try {
|
|
@@ -72,13 +72,13 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
var apiToken: String? = null
|
|
77
77
|
var replayId: String? = null
|
|
78
78
|
var replayStartMs: Long = 0
|
|
79
79
|
var deferredUploadMode = false
|
|
80
80
|
var frameBundleSize: Int = 5
|
|
81
|
-
|
|
81
|
+
|
|
82
82
|
var serverEndpoint: String
|
|
83
83
|
get() = TelemetryPipeline.shared?.endpoint ?: "https://api.rejourney.co"
|
|
84
84
|
set(value) {
|
|
@@ -86,7 +86,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
86
86
|
SegmentDispatcher.shared.endpoint = value
|
|
87
87
|
DeviceRegistrar.shared?.endpoint = value
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
|
|
90
90
|
var snapshotInterval: Double = 1.0
|
|
91
91
|
var compressionLevel: Double = 0.5
|
|
92
92
|
var visualCaptureEnabled: Boolean = true
|
|
@@ -99,7 +99,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
99
99
|
var hierarchyCaptureInterval: Double = 2.0
|
|
100
100
|
var currentScreenName: String? = null
|
|
101
101
|
private set
|
|
102
|
-
|
|
102
|
+
|
|
103
103
|
// Remote config from backend (set via setRemoteConfig before session start)
|
|
104
104
|
var remoteRejourneyEnabled: Boolean = true
|
|
105
105
|
private set
|
|
@@ -109,7 +109,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
109
109
|
private set
|
|
110
110
|
var remoteMaxRecordingMinutes: Int = 10
|
|
111
111
|
private set
|
|
112
|
-
|
|
112
|
+
|
|
113
113
|
// Network state tracking
|
|
114
114
|
var currentNetworkType: String = "unknown"
|
|
115
115
|
private set
|
|
@@ -119,11 +119,11 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
119
119
|
private set
|
|
120
120
|
var networkIsExpensive: Boolean = false
|
|
121
121
|
private set
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
|
124
124
|
private var netReady = false
|
|
125
125
|
private var live = false
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
private var crashCount = 0
|
|
128
128
|
private var freezeCount = 0
|
|
129
129
|
private var errorCount = 0
|
|
@@ -140,20 +140,21 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
140
140
|
private var hierarchyRunnable: Runnable? = null
|
|
141
141
|
private var lastHierarchyHash: String? = null
|
|
142
142
|
private var durationLimitRunnable: Runnable? = null
|
|
143
|
-
|
|
143
|
+
private val lifecycleContractVersion = 2
|
|
144
|
+
|
|
144
145
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
145
|
-
|
|
146
|
+
|
|
146
147
|
/**
|
|
147
148
|
* Fast session start using existing credentials - skips credential fetch for faster restart
|
|
148
149
|
*/
|
|
149
150
|
fun beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: Map<String, Any>? = null) {
|
|
150
151
|
val perf = PerformanceSnapshot.capture()
|
|
151
152
|
DiagnosticLog.debugSessionCreate("ORCHESTRATOR_FAST_INIT", "beginReplayFast with existing credential", perf)
|
|
152
|
-
|
|
153
|
+
|
|
153
154
|
this.apiToken = apiToken
|
|
154
155
|
this.serverEndpoint = serverEndpoint
|
|
155
156
|
applySettings(captureSettings)
|
|
156
|
-
|
|
157
|
+
|
|
157
158
|
// Set credentials AND endpoint directly without network fetch
|
|
158
159
|
TelemetryPipeline.shared?.apiToken = apiToken
|
|
159
160
|
TelemetryPipeline.shared?.credential = credential
|
|
@@ -161,26 +162,26 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
161
162
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
162
163
|
SegmentDispatcher.shared.credential = credential
|
|
163
164
|
SegmentDispatcher.shared.endpoint = serverEndpoint
|
|
164
|
-
|
|
165
|
+
|
|
165
166
|
// Skip network monitoring, assume network is available since we just came from background
|
|
166
167
|
mainHandler.post {
|
|
167
168
|
beginRecording(apiToken)
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
+
|
|
171
172
|
fun beginReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
|
|
172
173
|
DiagnosticLog.trace("[ReplayOrchestrator] beginReplay v2")
|
|
173
174
|
val perf = PerformanceSnapshot.capture()
|
|
174
175
|
DiagnosticLog.debugSessionCreate("ORCHESTRATOR_INIT", "beginReplay", perf)
|
|
175
176
|
DiagnosticLog.trace("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
|
|
176
|
-
|
|
177
|
+
|
|
177
178
|
this.apiToken = apiToken
|
|
178
179
|
this.serverEndpoint = serverEndpoint
|
|
179
180
|
applySettings(captureSettings)
|
|
180
|
-
|
|
181
|
+
|
|
181
182
|
DiagnosticLog.debugSessionCreate("CREDENTIAL_START", "Requesting device credential")
|
|
182
183
|
DiagnosticLog.trace("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
|
|
183
|
-
|
|
184
|
+
|
|
184
185
|
DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
|
|
185
186
|
DiagnosticLog.trace("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
|
|
186
187
|
if (!ok) {
|
|
@@ -188,24 +189,24 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
188
189
|
DiagnosticLog.caution("[ReplayOrchestrator] Credential fetch FAILED - recording cannot start")
|
|
189
190
|
return@obtainCredential
|
|
190
191
|
}
|
|
191
|
-
|
|
192
|
+
|
|
192
193
|
TelemetryPipeline.shared?.apiToken = apiToken
|
|
193
194
|
TelemetryPipeline.shared?.credential = cred
|
|
194
195
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
195
196
|
SegmentDispatcher.shared.credential = cred
|
|
196
|
-
|
|
197
|
+
|
|
197
198
|
DiagnosticLog.trace("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
|
|
198
199
|
monitorNetwork(apiToken)
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
|
-
|
|
202
|
+
|
|
202
203
|
fun beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
|
|
203
204
|
this.apiToken = apiToken
|
|
204
205
|
this.serverEndpoint = serverEndpoint
|
|
205
206
|
deferredUploadMode = true
|
|
206
|
-
|
|
207
|
+
|
|
207
208
|
applySettings(captureSettings)
|
|
208
|
-
|
|
209
|
+
|
|
209
210
|
DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
|
|
210
211
|
if (!ok) return@obtainCredential
|
|
211
212
|
TelemetryPipeline.shared?.apiToken = apiToken
|
|
@@ -213,47 +214,55 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
213
214
|
SegmentDispatcher.shared.apiToken = apiToken
|
|
214
215
|
SegmentDispatcher.shared.credential = cred
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
+
|
|
217
218
|
initSession()
|
|
218
219
|
TelemetryPipeline.shared?.activateDeferredMode()
|
|
219
|
-
|
|
220
|
+
|
|
220
221
|
val renderCfg = computeRender(1, "standard")
|
|
221
|
-
|
|
222
|
+
|
|
222
223
|
if (visualCaptureEnabled) {
|
|
223
224
|
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
|
|
224
225
|
VisualCapture.shared?.beginCapture(replayStartMs)
|
|
225
226
|
VisualCapture.shared?.activateDeferredMode()
|
|
226
227
|
}
|
|
227
|
-
|
|
228
|
+
|
|
228
229
|
if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
|
|
229
230
|
if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
|
|
230
|
-
|
|
231
|
+
|
|
231
232
|
live = true
|
|
232
233
|
}
|
|
233
|
-
|
|
234
|
+
|
|
234
235
|
fun commitDeferredReplay() {
|
|
235
236
|
deferredUploadMode = false
|
|
236
237
|
TelemetryPipeline.shared?.commitDeferredData()
|
|
237
238
|
VisualCapture.shared?.commitDeferredData()
|
|
238
239
|
TelemetryPipeline.shared?.activate()
|
|
239
240
|
}
|
|
240
|
-
|
|
241
|
+
|
|
241
242
|
fun endReplay(completion: ((Boolean, Boolean) -> Unit)? = null) {
|
|
243
|
+
endReplayInternal("unspecified", completion)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fun endReplayWithReason(endReason: String, completion: ((Boolean, Boolean) -> Unit)? = null) {
|
|
247
|
+
endReplayInternal(endReason, completion)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun endReplayInternal(endReason: String, completion: ((Boolean, Boolean) -> Unit)? = null) {
|
|
242
251
|
if (!live) {
|
|
243
252
|
completion?.invoke(false, false)
|
|
244
253
|
return
|
|
245
254
|
}
|
|
246
255
|
live = false
|
|
247
|
-
|
|
256
|
+
|
|
248
257
|
val sid = replayId ?: ""
|
|
249
258
|
val termMs = System.currentTimeMillis()
|
|
250
259
|
val elapsed = ((termMs - replayStartMs) / 1000).toInt()
|
|
251
|
-
|
|
260
|
+
|
|
252
261
|
unregisterNetworkCallback()
|
|
253
262
|
stopHierarchyCapture()
|
|
254
263
|
stopDurationLimitTimer()
|
|
255
264
|
detachLifecycle()
|
|
256
|
-
|
|
265
|
+
|
|
257
266
|
val metrics = mapOf(
|
|
258
267
|
"crashCount" to crashCount,
|
|
259
268
|
"anrCount" to freezeCount,
|
|
@@ -268,40 +277,49 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
268
277
|
"screenCount" to visitedScreens.toSet().size
|
|
269
278
|
)
|
|
270
279
|
val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
280
|
+
|
|
281
|
+
// Do local teardown immediately so lifecycle rollover never depends on network latency.
|
|
282
|
+
mainHandler.post {
|
|
283
|
+
TelemetryPipeline.shared?.shutdown()
|
|
284
|
+
VisualCapture.shared?.halt()
|
|
285
|
+
InteractionRecorder.shared?.deactivate()
|
|
286
|
+
StabilityMonitor.shared?.deactivate()
|
|
287
|
+
AnrSentinel.shared?.deactivate()
|
|
288
|
+
}
|
|
289
|
+
SegmentDispatcher.shared.shipPending()
|
|
290
|
+
|
|
291
|
+
if (finalized) {
|
|
292
|
+
clearRecovery()
|
|
293
|
+
completion?.invoke(true, true)
|
|
294
|
+
replayId = null
|
|
295
|
+
replayStartMs = 0
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
finalized = true
|
|
299
|
+
|
|
300
|
+
SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { _, _ ->
|
|
301
|
+
SegmentDispatcher.shared.concludeReplay(
|
|
302
|
+
sid,
|
|
303
|
+
termMs,
|
|
304
|
+
bgTimeMs,
|
|
305
|
+
metrics,
|
|
306
|
+
queueDepthAtFinalize,
|
|
307
|
+
endReason = endReason,
|
|
308
|
+
lifecycleVersion = lifecycleContractVersion
|
|
309
|
+
) { ok ->
|
|
292
310
|
if (ok) clearRecovery()
|
|
293
311
|
completion?.invoke(true, ok)
|
|
294
312
|
}
|
|
295
313
|
}
|
|
296
|
-
|
|
314
|
+
|
|
297
315
|
replayId = null
|
|
298
316
|
replayStartMs = 0
|
|
299
317
|
}
|
|
300
|
-
|
|
318
|
+
|
|
301
319
|
fun redactView(view: View) {
|
|
302
320
|
VisualCapture.shared?.registerRedaction(view)
|
|
303
321
|
}
|
|
304
|
-
|
|
322
|
+
|
|
305
323
|
/**
|
|
306
324
|
* Set remote configuration from backend
|
|
307
325
|
* Called by JS side before startSession to apply server-side settings
|
|
@@ -316,89 +334,142 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
316
334
|
this.remoteRecordingEnabled = recordingEnabled
|
|
317
335
|
this.remoteSampleRate = sampleRate
|
|
318
336
|
this.remoteMaxRecordingMinutes = maxRecordingMinutes
|
|
319
|
-
|
|
337
|
+
|
|
320
338
|
// Set isSampledIn for server-side enforcement
|
|
321
339
|
// recordingEnabled=false means either dashboard disabled OR session sampled out by JS
|
|
322
340
|
TelemetryPipeline.shared?.isSampledIn = recordingEnabled
|
|
323
|
-
|
|
341
|
+
|
|
324
342
|
// Apply recording settings immediately
|
|
325
343
|
// If recording is disabled, disable visual capture
|
|
326
344
|
if (!recordingEnabled) {
|
|
327
345
|
visualCaptureEnabled = false
|
|
328
346
|
DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
|
|
329
347
|
}
|
|
330
|
-
|
|
348
|
+
|
|
331
349
|
// If already recording, restart the duration limit timer with updated config
|
|
332
350
|
if (live) {
|
|
333
351
|
startDurationLimitTimer()
|
|
334
352
|
}
|
|
335
|
-
|
|
353
|
+
|
|
336
354
|
DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
|
|
337
355
|
}
|
|
338
|
-
|
|
356
|
+
|
|
339
357
|
fun unredactView(view: View) {
|
|
340
358
|
VisualCapture.shared?.unregisterRedaction(view)
|
|
341
359
|
}
|
|
342
|
-
|
|
360
|
+
|
|
343
361
|
fun attachAttribute(key: String, value: String) {
|
|
344
362
|
TelemetryPipeline.shared?.recordAttribute(key, value)
|
|
345
363
|
}
|
|
346
|
-
|
|
364
|
+
|
|
347
365
|
fun recordCustomEvent(name: String, payload: String?) {
|
|
348
366
|
TelemetryPipeline.shared?.recordCustomEvent(name, payload ?: "")
|
|
349
367
|
}
|
|
350
|
-
|
|
368
|
+
|
|
351
369
|
fun associateUser(userId: String) {
|
|
352
370
|
TelemetryPipeline.shared?.recordUserAssociation(userId)
|
|
353
371
|
}
|
|
354
|
-
|
|
372
|
+
|
|
355
373
|
fun currentReplayId(): String {
|
|
356
374
|
return replayId ?: ""
|
|
357
375
|
}
|
|
358
|
-
|
|
376
|
+
|
|
359
377
|
fun activateGestureRecording() {
|
|
360
378
|
// Gesture recording activation - handled by InteractionRecorder
|
|
361
379
|
}
|
|
362
|
-
|
|
380
|
+
|
|
363
381
|
fun recoverInterruptedReplay(completion: (String?) -> Unit) {
|
|
364
382
|
val recoveryFile = File(context.filesDir, "rejourney_recovery.json")
|
|
365
|
-
|
|
383
|
+
|
|
366
384
|
if (!recoveryFile.exists()) {
|
|
367
385
|
completion(null)
|
|
368
386
|
return
|
|
369
387
|
}
|
|
370
|
-
|
|
388
|
+
|
|
371
389
|
try {
|
|
372
390
|
val data = recoveryFile.readText()
|
|
373
391
|
val checkpoint = JSONObject(data)
|
|
374
392
|
val recId = checkpoint.optString("replayId", null)
|
|
375
|
-
|
|
393
|
+
|
|
376
394
|
if (recId == null) {
|
|
395
|
+
clearRecovery()
|
|
377
396
|
completion(null)
|
|
378
397
|
return
|
|
379
398
|
}
|
|
380
|
-
|
|
399
|
+
|
|
381
400
|
val origStart = checkpoint.optLong("startMs", 0)
|
|
382
401
|
val nowMs = System.currentTimeMillis()
|
|
383
|
-
|
|
402
|
+
|
|
403
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Recovering interrupted session: $recId")
|
|
404
|
+
|
|
384
405
|
checkpoint.optString("apiToken", null)?.let { SegmentDispatcher.shared.apiToken = it }
|
|
385
406
|
checkpoint.optString("endpoint", null)?.let { SegmentDispatcher.shared.endpoint = it }
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
407
|
+
checkpoint.optString("credential", null)?.let { SegmentDispatcher.shared.credential = it }
|
|
408
|
+
SegmentDispatcher.shared.currentReplayId = recId
|
|
409
|
+
SegmentDispatcher.shared.activate()
|
|
410
|
+
TelemetryPipeline.shared?.currentReplayId = recId
|
|
411
|
+
val hasCrashIncident = hasStoredCrashIncidentForSession(recId)
|
|
412
|
+
|
|
413
|
+
val finalizeRecoveredSession = {
|
|
414
|
+
val crashMetrics = mapOf(
|
|
415
|
+
"crashCount" to if (hasCrashIncident) 1 else 0,
|
|
416
|
+
"durationSeconds" to ((nowMs - origStart) / 1000).toInt()
|
|
417
|
+
)
|
|
418
|
+
val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
|
|
419
|
+
|
|
420
|
+
SegmentDispatcher.shared.concludeReplay(
|
|
421
|
+
recId,
|
|
422
|
+
nowMs,
|
|
423
|
+
0,
|
|
424
|
+
crashMetrics,
|
|
425
|
+
queueDepthAtFinalize,
|
|
426
|
+
endReason = "recovery_finalize",
|
|
427
|
+
lifecycleVersion = lifecycleContractVersion
|
|
428
|
+
) { ok ->
|
|
429
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Crash recovery finalize: success=$ok, sessionId=$recId")
|
|
430
|
+
if (ok) {
|
|
431
|
+
clearRecovery()
|
|
432
|
+
}
|
|
433
|
+
completion(if (ok) recId else null)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
val visualCapture = VisualCapture.shared
|
|
438
|
+
if (visualCapture == null) {
|
|
439
|
+
finalizeRecoveredSession()
|
|
440
|
+
} else {
|
|
441
|
+
visualCapture.uploadPendingFrames(recId) { framesUploaded ->
|
|
442
|
+
if (!framesUploaded) {
|
|
443
|
+
DiagnosticLog.caution("[ReplayOrchestrator] Crash recovery postponed: pending frame upload failed for session $recId")
|
|
444
|
+
completion(null)
|
|
445
|
+
return@uploadPendingFrames
|
|
446
|
+
}
|
|
447
|
+
finalizeRecoveredSession()
|
|
448
|
+
}
|
|
396
449
|
}
|
|
397
450
|
} catch (e: Exception) {
|
|
451
|
+
DiagnosticLog.fault("[ReplayOrchestrator] Crash recovery failed: ${e.message}")
|
|
398
452
|
completion(null)
|
|
399
453
|
}
|
|
400
454
|
}
|
|
401
|
-
|
|
455
|
+
|
|
456
|
+
private fun hasStoredCrashIncidentForSession(sessionId: String): Boolean {
|
|
457
|
+
val incidentFile = File(context.cacheDir, "rj_incidents.json")
|
|
458
|
+
if (!incidentFile.exists()) return false
|
|
459
|
+
|
|
460
|
+
return try {
|
|
461
|
+
val incident = JSONObject(incidentFile.readText())
|
|
462
|
+
val incidentSessionId = incident.optString("sessionId", "")
|
|
463
|
+
val category = incident.optString("category", "").lowercase()
|
|
464
|
+
val crashLikeCategory = category == "signal" || category == "exception" || category == "crash"
|
|
465
|
+
val hasSignalDetail = incident.optString("identifier", "").isNotBlank()
|
|
466
|
+
|| incident.optString("detail", "").isNotBlank()
|
|
467
|
+
crashLikeCategory && incidentSessionId == sessionId && hasSignalDetail
|
|
468
|
+
} catch (_: Exception) {
|
|
469
|
+
false
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
402
473
|
// Tally methods
|
|
403
474
|
fun incrementFaultTally() { crashCount++ }
|
|
404
475
|
fun incrementStalledTally() { freezeCount++ }
|
|
@@ -408,21 +479,21 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
408
479
|
fun incrementGestureTally() { gestureCount++ }
|
|
409
480
|
fun incrementRageTapTally() { rageCount++ }
|
|
410
481
|
fun incrementDeadTapTally() { deadTapCount++ }
|
|
411
|
-
|
|
482
|
+
|
|
412
483
|
fun logScreenView(screenId: String) {
|
|
413
484
|
if (screenId.isEmpty()) return
|
|
414
485
|
visitedScreens.add(screenId)
|
|
415
486
|
currentScreenName = screenId
|
|
416
487
|
if (hierarchyCaptureEnabled) captureHierarchy()
|
|
417
488
|
}
|
|
418
|
-
|
|
489
|
+
|
|
419
490
|
private fun initSession() {
|
|
420
491
|
replayStartMs = System.currentTimeMillis()
|
|
421
492
|
// Always generate a fresh session ID - never reuse stale IDs
|
|
422
493
|
val uuidPart = UUID.randomUUID().toString().replace("-", "").lowercase()
|
|
423
494
|
replayId = "session_${replayStartMs}_$uuidPart"
|
|
424
495
|
finalized = false
|
|
425
|
-
|
|
496
|
+
|
|
426
497
|
crashCount = 0
|
|
427
498
|
freezeCount = 0
|
|
428
499
|
errorCount = 0
|
|
@@ -434,27 +505,27 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
434
505
|
visitedScreens.clear()
|
|
435
506
|
bgTimeMs = 0
|
|
436
507
|
bgStartMs = null
|
|
437
|
-
|
|
508
|
+
|
|
438
509
|
TelemetryPipeline.shared?.currentReplayId = replayId
|
|
439
510
|
SegmentDispatcher.shared.currentReplayId = replayId
|
|
440
511
|
StabilityMonitor.shared?.currentSessionId = replayId
|
|
441
|
-
|
|
512
|
+
|
|
442
513
|
attachLifecycle()
|
|
443
514
|
saveRecovery()
|
|
444
|
-
|
|
515
|
+
|
|
445
516
|
recordAppStartup()
|
|
446
517
|
}
|
|
447
|
-
|
|
518
|
+
|
|
448
519
|
private fun recordAppStartup() {
|
|
449
520
|
val nowMs = System.currentTimeMillis()
|
|
450
521
|
val startupDurationMs = nowMs - processStartTime
|
|
451
|
-
|
|
522
|
+
|
|
452
523
|
// Only record if it's a reasonable startup time (> 0 and < 60 seconds)
|
|
453
524
|
if (startupDurationMs > 0 && startupDurationMs < 60000) {
|
|
454
525
|
TelemetryPipeline.shared?.recordAppStartup(startupDurationMs)
|
|
455
526
|
}
|
|
456
527
|
}
|
|
457
|
-
|
|
528
|
+
|
|
458
529
|
private fun applySettings(cfg: Map<String, Any>?) {
|
|
459
530
|
if (cfg == null) return
|
|
460
531
|
snapshotInterval = (cfg["captureRate"] as? Double) ?: 0.33
|
|
@@ -467,7 +538,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
467
538
|
wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
|
|
468
539
|
frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 5
|
|
469
540
|
}
|
|
470
|
-
|
|
541
|
+
|
|
471
542
|
private fun monitorNetwork(token: String) {
|
|
472
543
|
DiagnosticLog.trace("[ReplayOrchestrator] monitorNetwork called")
|
|
473
544
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
|
@@ -476,25 +547,25 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
476
547
|
beginRecording(token)
|
|
477
548
|
return
|
|
478
549
|
}
|
|
479
|
-
|
|
550
|
+
|
|
480
551
|
networkCallback = object : ConnectivityManager.NetworkCallback() {
|
|
481
552
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
|
482
553
|
handleNetworkChange(capabilities, token)
|
|
483
554
|
}
|
|
484
|
-
|
|
555
|
+
|
|
485
556
|
override fun onLost(network: Network) {
|
|
486
557
|
currentNetworkType = "none"
|
|
487
558
|
netReady = false
|
|
488
559
|
}
|
|
489
560
|
}
|
|
490
|
-
|
|
561
|
+
|
|
491
562
|
val request = NetworkRequest.Builder()
|
|
492
563
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
493
564
|
.build()
|
|
494
|
-
|
|
565
|
+
|
|
495
566
|
try {
|
|
496
567
|
connectivityManager.registerNetworkCallback(request, networkCallback!!)
|
|
497
|
-
|
|
568
|
+
|
|
498
569
|
// Check current network state immediately (callback only fires on CHANGES)
|
|
499
570
|
val activeNetwork = connectivityManager.activeNetwork
|
|
500
571
|
val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
|
|
@@ -511,31 +582,31 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
511
582
|
beginRecording(token)
|
|
512
583
|
}
|
|
513
584
|
}
|
|
514
|
-
|
|
585
|
+
|
|
515
586
|
private fun handleNetworkChange(capabilities: NetworkCapabilities, token: String) {
|
|
516
587
|
val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
517
588
|
val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
|
518
589
|
val isEthernet = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
|
519
|
-
|
|
590
|
+
|
|
520
591
|
networkIsExpensive = !isWifi && !isEthernet
|
|
521
592
|
networkIsConstrained = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
522
593
|
!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
|
523
594
|
} else {
|
|
524
595
|
networkIsExpensive
|
|
525
596
|
}
|
|
526
|
-
|
|
597
|
+
|
|
527
598
|
currentNetworkType = when {
|
|
528
599
|
isWifi -> "wifi"
|
|
529
600
|
isCellular -> "cellular"
|
|
530
601
|
isEthernet -> "wired"
|
|
531
602
|
else -> "other"
|
|
532
603
|
}
|
|
533
|
-
|
|
604
|
+
|
|
534
605
|
val canProceed = when {
|
|
535
606
|
wifiRequired && !isWifi -> false
|
|
536
607
|
else -> true
|
|
537
608
|
}
|
|
538
|
-
|
|
609
|
+
|
|
539
610
|
mainHandler.post {
|
|
540
611
|
netReady = canProceed
|
|
541
612
|
if (canProceed && !live) {
|
|
@@ -543,7 +614,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
543
614
|
}
|
|
544
615
|
}
|
|
545
616
|
}
|
|
546
|
-
|
|
617
|
+
|
|
547
618
|
private fun beginRecording(token: String) {
|
|
548
619
|
DiagnosticLog.trace("[ReplayOrchestrator] beginRecording called, live=$live")
|
|
549
620
|
if (live) {
|
|
@@ -551,19 +622,19 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
551
622
|
return
|
|
552
623
|
}
|
|
553
624
|
live = true
|
|
554
|
-
|
|
625
|
+
|
|
555
626
|
this.apiToken = token
|
|
556
627
|
initSession()
|
|
557
628
|
DiagnosticLog.trace("[ReplayOrchestrator] Session initialized: replayId=$replayId")
|
|
558
|
-
|
|
629
|
+
|
|
559
630
|
// Reactivate the dispatcher in case it was halted from a previous session
|
|
560
631
|
SegmentDispatcher.shared.activate()
|
|
561
632
|
TelemetryPipeline.shared?.activate()
|
|
562
|
-
|
|
633
|
+
|
|
563
634
|
val renderCfg = computeRender(1, "standard")
|
|
564
635
|
DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
|
|
565
636
|
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
|
|
566
|
-
|
|
637
|
+
|
|
567
638
|
if (visualCaptureEnabled) {
|
|
568
639
|
DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
|
|
569
640
|
VisualCapture.shared?.beginCapture(replayStartMs)
|
|
@@ -572,79 +643,80 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
572
643
|
if (faultTrackingEnabled) StabilityMonitor.shared?.activate()
|
|
573
644
|
if (responsivenessCaptureEnabled) AnrSentinel.shared?.activate()
|
|
574
645
|
if (hierarchyCaptureEnabled) startHierarchyCapture()
|
|
575
|
-
|
|
646
|
+
|
|
576
647
|
// Start duration limit timer based on remote config
|
|
577
648
|
startDurationLimitTimer()
|
|
578
|
-
|
|
649
|
+
|
|
579
650
|
DiagnosticLog.trace("[ReplayOrchestrator] beginRecording completed")
|
|
580
651
|
}
|
|
581
|
-
|
|
652
|
+
|
|
582
653
|
// MARK: - Duration Limit Timer
|
|
583
|
-
|
|
654
|
+
|
|
584
655
|
private fun startDurationLimitTimer() {
|
|
585
656
|
stopDurationLimitTimer()
|
|
586
|
-
|
|
657
|
+
|
|
587
658
|
val maxMinutes = remoteMaxRecordingMinutes
|
|
588
659
|
if (maxMinutes <= 0) return
|
|
589
|
-
|
|
660
|
+
|
|
590
661
|
val maxMs = maxMinutes.toLong() * 60 * 1000
|
|
591
662
|
val now = System.currentTimeMillis()
|
|
592
663
|
val elapsed = now - replayStartMs
|
|
593
664
|
val remaining = if (maxMs > elapsed) maxMs - elapsed else 0L
|
|
594
|
-
|
|
665
|
+
|
|
595
666
|
if (remaining <= 0) {
|
|
596
667
|
DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
|
|
597
|
-
|
|
668
|
+
endReplayWithReason("duration_limit")
|
|
598
669
|
return
|
|
599
670
|
}
|
|
600
|
-
|
|
671
|
+
|
|
601
672
|
durationLimitRunnable = Runnable {
|
|
602
673
|
if (!live) return@Runnable
|
|
603
674
|
DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
|
|
604
|
-
|
|
675
|
+
endReplayWithReason("duration_limit")
|
|
605
676
|
}
|
|
606
677
|
mainHandler.postDelayed(durationLimitRunnable!!, remaining)
|
|
607
|
-
|
|
678
|
+
|
|
608
679
|
DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
|
|
609
680
|
}
|
|
610
|
-
|
|
681
|
+
|
|
611
682
|
private fun stopDurationLimitTimer() {
|
|
612
683
|
durationLimitRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
613
684
|
durationLimitRunnable = null
|
|
614
685
|
}
|
|
615
|
-
|
|
686
|
+
|
|
616
687
|
private fun saveRecovery() {
|
|
617
688
|
val sid = replayId ?: return
|
|
618
689
|
val token = apiToken ?: return
|
|
619
|
-
|
|
690
|
+
|
|
620
691
|
val checkpoint = JSONObject().apply {
|
|
621
692
|
put("replayId", sid)
|
|
622
693
|
put("apiToken", token)
|
|
623
694
|
put("startMs", replayStartMs)
|
|
624
695
|
put("endpoint", serverEndpoint)
|
|
696
|
+
SegmentDispatcher.shared.credential?.let { put("credential", it) }
|
|
625
697
|
}
|
|
626
|
-
|
|
698
|
+
|
|
627
699
|
try {
|
|
628
700
|
File(context.filesDir, "rejourney_recovery.json").writeText(checkpoint.toString())
|
|
629
701
|
} catch (_: Exception) { }
|
|
630
702
|
}
|
|
631
|
-
|
|
703
|
+
|
|
632
704
|
private fun clearRecovery() {
|
|
633
705
|
try {
|
|
634
706
|
File(context.filesDir, "rejourney_recovery.json").delete()
|
|
635
707
|
} catch (_: Exception) { }
|
|
636
708
|
}
|
|
637
|
-
|
|
709
|
+
|
|
638
710
|
private fun attachLifecycle() {
|
|
639
711
|
val app = context as? Application ?: return
|
|
640
712
|
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
|
641
713
|
}
|
|
642
|
-
|
|
714
|
+
|
|
643
715
|
private fun detachLifecycle() {
|
|
644
716
|
val app = context as? Application ?: return
|
|
645
717
|
app.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
|
|
646
718
|
}
|
|
647
|
-
|
|
719
|
+
|
|
648
720
|
private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
|
649
721
|
override fun onActivityResumed(activity: Activity) {
|
|
650
722
|
bgStartMs?.let { start ->
|
|
@@ -652,19 +724,24 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
652
724
|
bgTimeMs += (now - start)
|
|
653
725
|
}
|
|
654
726
|
bgStartMs = null
|
|
727
|
+
|
|
728
|
+
if (responsivenessCaptureEnabled) {
|
|
729
|
+
AnrSentinel.shared.activate()
|
|
730
|
+
}
|
|
655
731
|
}
|
|
656
|
-
|
|
732
|
+
|
|
657
733
|
override fun onActivityPaused(activity: Activity) {
|
|
658
734
|
bgStartMs = System.currentTimeMillis()
|
|
735
|
+
AnrSentinel.shared.deactivate()
|
|
659
736
|
}
|
|
660
|
-
|
|
737
|
+
|
|
661
738
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
662
739
|
override fun onActivityStarted(activity: Activity) {}
|
|
663
740
|
override fun onActivityStopped(activity: Activity) {}
|
|
664
741
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
665
742
|
override fun onActivityDestroyed(activity: Activity) {}
|
|
666
743
|
}
|
|
667
|
-
|
|
744
|
+
|
|
668
745
|
private fun unregisterNetworkCallback() {
|
|
669
746
|
networkCallback?.let { callback ->
|
|
670
747
|
try {
|
|
@@ -674,10 +751,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
674
751
|
}
|
|
675
752
|
networkCallback = null
|
|
676
753
|
}
|
|
677
|
-
|
|
754
|
+
|
|
678
755
|
private fun startHierarchyCapture() {
|
|
679
756
|
stopHierarchyCapture()
|
|
680
|
-
|
|
757
|
+
|
|
681
758
|
hierarchyHandler = Handler(Looper.getMainLooper())
|
|
682
759
|
hierarchyRunnable = object : Runnable {
|
|
683
760
|
override fun run() {
|
|
@@ -686,45 +763,45 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
686
763
|
}
|
|
687
764
|
}
|
|
688
765
|
hierarchyHandler?.postDelayed(hierarchyRunnable!!, (hierarchyCaptureInterval * 1000).toLong())
|
|
689
|
-
|
|
766
|
+
|
|
690
767
|
// Initial capture after 500ms
|
|
691
768
|
hierarchyHandler?.postDelayed({ captureHierarchy() }, 500)
|
|
692
769
|
}
|
|
693
|
-
|
|
770
|
+
|
|
694
771
|
private fun stopHierarchyCapture() {
|
|
695
772
|
hierarchyRunnable?.let { hierarchyHandler?.removeCallbacks(it) }
|
|
696
773
|
hierarchyHandler = null
|
|
697
774
|
hierarchyRunnable = null
|
|
698
775
|
}
|
|
699
|
-
|
|
776
|
+
|
|
700
777
|
private fun captureHierarchy() {
|
|
701
778
|
if (!live) return
|
|
702
779
|
val sid = replayId ?: return
|
|
703
|
-
|
|
780
|
+
|
|
704
781
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
|
705
782
|
mainHandler.post { captureHierarchy() }
|
|
706
783
|
return
|
|
707
784
|
}
|
|
708
|
-
|
|
785
|
+
|
|
709
786
|
// Throttle hierarchy capture when map is visible and animating —
|
|
710
787
|
// ViewHierarchyScanner traverses the full view tree including map's
|
|
711
788
|
// deep SurfaceView/TextureView children, adding main-thread pressure.
|
|
712
789
|
if (SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle) {
|
|
713
790
|
return
|
|
714
791
|
}
|
|
715
|
-
|
|
792
|
+
|
|
716
793
|
val hierarchy = ViewHierarchyScanner.shared?.captureHierarchy() ?: return
|
|
717
|
-
|
|
794
|
+
|
|
718
795
|
val hash = hierarchyHash(hierarchy)
|
|
719
796
|
if (hash == lastHierarchyHash) return
|
|
720
797
|
lastHierarchyHash = hash
|
|
721
|
-
|
|
798
|
+
|
|
722
799
|
val json = JSONObject(hierarchy).toString().toByteArray(Charsets.UTF_8)
|
|
723
800
|
val ts = System.currentTimeMillis()
|
|
724
|
-
|
|
801
|
+
|
|
725
802
|
SegmentDispatcher.shared.transmitHierarchy(sid, json, ts, null)
|
|
726
803
|
}
|
|
727
|
-
|
|
804
|
+
|
|
728
805
|
private fun hierarchyHash(h: Map<String, Any>): String {
|
|
729
806
|
val screen = currentScreenName ?: "unknown"
|
|
730
807
|
var childCount = 0
|