@rejourneyco/react-native 1.0.13 → 1.0.15
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 +45 -24
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +40 -23
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +5 -5
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +22 -4
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +32 -12
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +5 -4
- package/ios/Engine/RejourneyImpl.swift +45 -22
- package/ios/Recording/ReplayOrchestrator.swift +5 -5
- package/ios/Recording/SegmentDispatcher.swift +14 -4
- package/ios/Recording/TelemetryPipeline.swift +34 -10
- package/ios/Recording/VisualCapture.swift +5 -4
- package/package.json +1 -1
|
@@ -60,6 +60,7 @@ import kotlin.concurrent.withLock
|
|
|
60
60
|
*/
|
|
61
61
|
sealed class SessionState {
|
|
62
62
|
object Idle : SessionState()
|
|
63
|
+
data class Starting(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
63
64
|
data class Active(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
64
65
|
data class Paused(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
65
66
|
object Terminated : SessionState()
|
|
@@ -399,8 +400,13 @@ class RejourneyModuleImpl(
|
|
|
399
400
|
waitForSessionReady(savedUserId, 0)
|
|
400
401
|
}
|
|
401
402
|
|
|
402
|
-
private fun waitForSessionReady(
|
|
403
|
-
|
|
403
|
+
private fun waitForSessionReady(
|
|
404
|
+
savedUserId: String?,
|
|
405
|
+
attempts: Int,
|
|
406
|
+
onReady: ((String) -> Unit)? = null,
|
|
407
|
+
onTimeout: (() -> Unit)? = null
|
|
408
|
+
) {
|
|
409
|
+
val maxAttempts = 50 // 5 seconds max
|
|
404
410
|
|
|
405
411
|
mainHandler.postDelayed({
|
|
406
412
|
val newSid = ReplayOrchestrator.shared?.replayId
|
|
@@ -419,10 +425,12 @@ class RejourneyModuleImpl(
|
|
|
419
425
|
|
|
420
426
|
DiagnosticLog.replayBegan(newSid)
|
|
421
427
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
428
|
+
onReady?.invoke(newSid)
|
|
422
429
|
} else if (attempts < maxAttempts) {
|
|
423
|
-
waitForSessionReady(savedUserId, attempts + 1)
|
|
430
|
+
waitForSessionReady(savedUserId, attempts + 1, onReady, onTimeout)
|
|
424
431
|
} else {
|
|
425
432
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
433
|
+
onTimeout?.invoke()
|
|
426
434
|
}
|
|
427
435
|
}, 100)
|
|
428
436
|
}
|
|
@@ -488,9 +496,21 @@ class RejourneyModuleImpl(
|
|
|
488
496
|
// Check if already active
|
|
489
497
|
stateLock.withLock {
|
|
490
498
|
val currentState = state
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
499
|
+
when (currentState) {
|
|
500
|
+
is SessionState.Active -> {
|
|
501
|
+
promise.resolve(createResultMap(true, currentState.sessionId))
|
|
502
|
+
return@post
|
|
503
|
+
}
|
|
504
|
+
is SessionState.Starting -> {
|
|
505
|
+
val activeSid = ReplayOrchestrator.shared?.replayId
|
|
506
|
+
if (!activeSid.isNullOrEmpty()) {
|
|
507
|
+
promise.resolve(createResultMap(true, activeSid))
|
|
508
|
+
} else {
|
|
509
|
+
promise.resolve(createResultMap(false, "", "Session is still starting"))
|
|
510
|
+
}
|
|
511
|
+
return@post
|
|
512
|
+
}
|
|
513
|
+
else -> Unit
|
|
494
514
|
}
|
|
495
515
|
}
|
|
496
516
|
|
|
@@ -522,10 +542,10 @@ class RejourneyModuleImpl(
|
|
|
522
542
|
DiagnosticLog.fault("[Rejourney] CRITICAL: No current activity available for capture!")
|
|
523
543
|
}
|
|
524
544
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
545
|
+
val pendingSessionId = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
|
|
546
|
+
stateLock.withLock {
|
|
547
|
+
state = SessionState.Starting(pendingSessionId, System.currentTimeMillis())
|
|
548
|
+
}
|
|
529
549
|
|
|
530
550
|
// Begin replay
|
|
531
551
|
ReplayOrchestrator.shared?.beginReplay(
|
|
@@ -537,21 +557,22 @@ class RejourneyModuleImpl(
|
|
|
537
557
|
// Android-specific: Start SessionLifecycleService for task removal detection
|
|
538
558
|
startSessionLifecycleService()
|
|
539
559
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
560
|
+
waitForSessionReady(
|
|
561
|
+
savedUserId = userId,
|
|
562
|
+
attempts = 0,
|
|
563
|
+
onReady = { sid ->
|
|
564
|
+
promise.resolve(createResultMap(true, sid))
|
|
565
|
+
},
|
|
566
|
+
onTimeout = {
|
|
567
|
+
stateLock.withLock {
|
|
568
|
+
if (state is SessionState.Starting) {
|
|
569
|
+
state = SessionState.Idle
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
stopSessionLifecycleService()
|
|
573
|
+
promise.resolve(createResultMap(false, "", "Timed out waiting for replay session to initialize"))
|
|
550
574
|
}
|
|
551
|
-
|
|
552
|
-
DiagnosticLog.replayBegan(sid)
|
|
553
|
-
promise.resolve(createResultMap(true, sid))
|
|
554
|
-
}, 300)
|
|
575
|
+
)
|
|
555
576
|
}
|
|
556
577
|
}
|
|
557
578
|
|
|
@@ -39,6 +39,7 @@ import kotlin.concurrent.withLock
|
|
|
39
39
|
*/
|
|
40
40
|
sealed class SessionState {
|
|
41
41
|
object Idle : SessionState()
|
|
42
|
+
data class Starting(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
42
43
|
data class Active(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
43
44
|
data class Paused(val sessionId: String, val startTimeMs: Long) : SessionState()
|
|
44
45
|
object Terminated : SessionState()
|
|
@@ -250,8 +251,13 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
private fun waitForSessionReady(
|
|
254
|
-
|
|
254
|
+
private fun waitForSessionReady(
|
|
255
|
+
savedUserId: String?,
|
|
256
|
+
attempts: Int,
|
|
257
|
+
onReady: ((String) -> Unit)? = null,
|
|
258
|
+
onTimeout: (() -> Unit)? = null
|
|
259
|
+
) {
|
|
260
|
+
val maxAttempts = 50 // 5 seconds max
|
|
255
261
|
|
|
256
262
|
mainHandler.postDelayed({
|
|
257
263
|
val newSid = ReplayOrchestrator.shared?.replayId
|
|
@@ -270,10 +276,12 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
270
276
|
|
|
271
277
|
DiagnosticLog.replayBegan(newSid)
|
|
272
278
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
279
|
+
onReady?.invoke(newSid)
|
|
273
280
|
} else if (attempts < maxAttempts) {
|
|
274
|
-
waitForSessionReady(savedUserId, attempts + 1)
|
|
281
|
+
waitForSessionReady(savedUserId, attempts + 1, onReady, onTimeout)
|
|
275
282
|
} else {
|
|
276
283
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
284
|
+
onTimeout?.invoke()
|
|
277
285
|
}
|
|
278
286
|
}, 100)
|
|
279
287
|
}
|
|
@@ -299,9 +307,17 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
299
307
|
// Check if already active
|
|
300
308
|
stateLock.withLock {
|
|
301
309
|
val currentState = state
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
when (currentState) {
|
|
311
|
+
is SessionState.Active -> {
|
|
312
|
+
callback?.invoke(true, currentState.sessionId)
|
|
313
|
+
return@post
|
|
314
|
+
}
|
|
315
|
+
is SessionState.Starting -> {
|
|
316
|
+
val activeSid = ReplayOrchestrator.shared?.replayId
|
|
317
|
+
callback?.invoke(!activeSid.isNullOrEmpty(), activeSid ?: "")
|
|
318
|
+
return@post
|
|
319
|
+
}
|
|
320
|
+
else -> Unit
|
|
305
321
|
}
|
|
306
322
|
}
|
|
307
323
|
|
|
@@ -317,9 +333,10 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
317
333
|
SegmentDispatcher.shared.endpoint = apiUrl
|
|
318
334
|
DeviceRegistrar.shared?.endpoint = apiUrl
|
|
319
335
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
336
|
+
val pendingSessionId = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
|
|
337
|
+
stateLock.withLock {
|
|
338
|
+
state = SessionState.Starting(pendingSessionId, System.currentTimeMillis())
|
|
339
|
+
}
|
|
323
340
|
|
|
324
341
|
// Begin replay
|
|
325
342
|
ReplayOrchestrator.shared?.beginReplay(
|
|
@@ -328,21 +345,21 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
328
345
|
captureSettings = config
|
|
329
346
|
)
|
|
330
347
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
348
|
+
waitForSessionReady(
|
|
349
|
+
savedUserId = userId,
|
|
350
|
+
attempts = 0,
|
|
351
|
+
onReady = { sid ->
|
|
352
|
+
callback?.invoke(true, sid)
|
|
353
|
+
},
|
|
354
|
+
onTimeout = {
|
|
355
|
+
stateLock.withLock {
|
|
356
|
+
if (state is SessionState.Starting) {
|
|
357
|
+
state = SessionState.Idle
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
callback?.invoke(false, "")
|
|
341
361
|
}
|
|
342
|
-
|
|
343
|
-
DiagnosticLog.replayBegan(sid)
|
|
344
|
-
callback?.invoke(true, sid)
|
|
345
|
-
}, 300)
|
|
362
|
+
)
|
|
346
363
|
}
|
|
347
364
|
}
|
|
348
365
|
|
|
@@ -78,7 +78,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
78
78
|
var replayId: String? = null
|
|
79
79
|
var replayStartMs: Long = 0
|
|
80
80
|
var deferredUploadMode = false
|
|
81
|
-
var frameBundleSize: Int =
|
|
81
|
+
var frameBundleSize: Int = 3
|
|
82
82
|
|
|
83
83
|
var serverEndpoint: String
|
|
84
84
|
get() = TelemetryPipeline.shared?.endpoint ?: "https://api.rejourney.co"
|
|
@@ -222,7 +222,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
222
222
|
val renderCfg = computeRender(1, "standard")
|
|
223
223
|
|
|
224
224
|
if (visualCaptureEnabled) {
|
|
225
|
-
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
|
|
225
|
+
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second, frameBundleSize)
|
|
226
226
|
VisualCapture.shared?.beginCapture(replayStartMs)
|
|
227
227
|
VisualCapture.shared?.activateDeferredMode()
|
|
228
228
|
}
|
|
@@ -285,8 +285,8 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
285
285
|
|
|
286
286
|
// Do local teardown immediately so lifecycle rollover never depends on network latency.
|
|
287
287
|
mainHandler.post {
|
|
288
|
-
TelemetryPipeline.shared?.shutdown()
|
|
289
288
|
VisualCapture.shared?.halt(haltGeneration)
|
|
289
|
+
TelemetryPipeline.shared?.shutdown()
|
|
290
290
|
InteractionRecorder.shared?.deactivate()
|
|
291
291
|
StabilityMonitor.shared?.deactivate()
|
|
292
292
|
AnrSentinel.shared?.deactivate()
|
|
@@ -546,7 +546,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
546
546
|
responsivenessCaptureEnabled = (cfg["captureANR"] as? Boolean) ?: true
|
|
547
547
|
consoleCaptureEnabled = (cfg["captureLogs"] as? Boolean) ?: true
|
|
548
548
|
wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
|
|
549
|
-
frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?:
|
|
549
|
+
frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 3
|
|
550
550
|
}
|
|
551
551
|
|
|
552
552
|
private fun monitorNetwork(token: String) {
|
|
@@ -643,7 +643,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
643
643
|
|
|
644
644
|
val renderCfg = computeRender(1, "standard")
|
|
645
645
|
DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
|
|
646
|
-
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
|
|
646
|
+
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second, frameBundleSize)
|
|
647
647
|
|
|
648
648
|
if (visualCaptureEnabled) {
|
|
649
649
|
DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
|
|
@@ -173,7 +173,18 @@ class SegmentDispatcher private constructor() {
|
|
|
173
173
|
frameCount: Int,
|
|
174
174
|
completion: ((Boolean) -> Unit)? = null
|
|
175
175
|
) {
|
|
176
|
-
|
|
176
|
+
transmitFrameBundleForSession(currentReplayId, payload, startMs, endMs, frameCount, completion)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fun transmitFrameBundleForSession(
|
|
180
|
+
sessionId: String?,
|
|
181
|
+
payload: ByteArray,
|
|
182
|
+
startMs: Long,
|
|
183
|
+
endMs: Long,
|
|
184
|
+
frameCount: Int,
|
|
185
|
+
completion: ((Boolean) -> Unit)? = null
|
|
186
|
+
) {
|
|
187
|
+
val sid = sessionId
|
|
177
188
|
val canUpload = canUploadNow()
|
|
178
189
|
DiagnosticLog.trace("[SegmentDispatcher] transmitFrameBundle: sid=${sid?.take(12) ?: "null"}, canUpload=$canUpload, frames=$frameCount, bytes=${payload.size}")
|
|
179
190
|
|
|
@@ -390,6 +401,12 @@ class SegmentDispatcher private constructor() {
|
|
|
390
401
|
scheduleRetryIfNeeded(upload, completion)
|
|
391
402
|
return
|
|
392
403
|
}
|
|
404
|
+
|
|
405
|
+
if (presignResponse.skipUpload) {
|
|
406
|
+
registerSuccess()
|
|
407
|
+
completion?.invoke(true)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
393
410
|
|
|
394
411
|
val s3ok = uploadToS3(presignResponse.presignedUrl, upload.payload)
|
|
395
412
|
if (!s3ok) {
|
|
@@ -483,7 +500,7 @@ class SegmentDispatcher private constructor() {
|
|
|
483
500
|
val json = JSONObject(responseBody)
|
|
484
501
|
|
|
485
502
|
if (json.optBoolean("skipUpload", false)) {
|
|
486
|
-
return
|
|
503
|
+
return PresignResponse(skipUpload = true)
|
|
487
504
|
}
|
|
488
505
|
|
|
489
506
|
val presignedUrl = json.optString("presignedUrl", null) ?: return null
|
|
@@ -758,8 +775,9 @@ private data class PendingUpload(
|
|
|
758
775
|
)
|
|
759
776
|
|
|
760
777
|
private data class PresignResponse(
|
|
761
|
-
val presignedUrl: String,
|
|
762
|
-
val batchId: String
|
|
778
|
+
val presignedUrl: String = "",
|
|
779
|
+
val batchId: String = "",
|
|
780
|
+
val skipUpload: Boolean = false
|
|
763
781
|
)
|
|
764
782
|
|
|
765
783
|
private data class TelemetrySnapshot(
|
|
@@ -26,8 +26,10 @@ import com.rejourney.utility.gzipCompress
|
|
|
26
26
|
import org.json.JSONArray
|
|
27
27
|
import org.json.JSONObject
|
|
28
28
|
import java.util.*
|
|
29
|
+
import java.util.concurrent.CountDownLatch
|
|
29
30
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
30
31
|
import java.util.concurrent.Executors
|
|
32
|
+
import java.util.concurrent.TimeUnit
|
|
31
33
|
import java.util.concurrent.locks.ReentrantLock
|
|
32
34
|
import kotlin.concurrent.withLock
|
|
33
35
|
|
|
@@ -160,11 +162,7 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
160
162
|
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
161
163
|
heartbeatRunnable = null
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
appSuspending()
|
|
165
|
-
|
|
166
|
-
// Clear pending frame bundles so they don't leak into the next session
|
|
167
|
-
frameQueue.clear()
|
|
165
|
+
drainPendingDataForShutdown()
|
|
168
166
|
}
|
|
169
167
|
|
|
170
168
|
fun finalizeAndShip() {
|
|
@@ -205,6 +203,31 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
205
203
|
fun getQueueDepth(): Int {
|
|
206
204
|
return eventRing.size() + frameQueue.size()
|
|
207
205
|
}
|
|
206
|
+
|
|
207
|
+
private fun drainPendingDataForShutdown() {
|
|
208
|
+
if (draining) return
|
|
209
|
+
draining = true
|
|
210
|
+
|
|
211
|
+
// Force any in-memory frames into the upload pipeline before session
|
|
212
|
+
// teardown clears the active replay ID.
|
|
213
|
+
VisualCapture.shared?.flushToDisk()
|
|
214
|
+
VisualCapture.shared?.flushBufferToNetwork()
|
|
215
|
+
|
|
216
|
+
val latch = CountDownLatch(1)
|
|
217
|
+
serialWorker.execute {
|
|
218
|
+
try {
|
|
219
|
+
shipPendingEvents()
|
|
220
|
+
shipPendingFrames()
|
|
221
|
+
} finally {
|
|
222
|
+
draining = false
|
|
223
|
+
latch.countDown()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!latch.await(1, TimeUnit.SECONDS)) {
|
|
228
|
+
DiagnosticLog.trace("[TelemetryPipeline] shutdown drain timed out before upload queue kick-off completed")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
208
231
|
|
|
209
232
|
private fun appSuspending() {
|
|
210
233
|
if (draining) return
|
|
@@ -249,17 +272,14 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
249
272
|
return
|
|
250
273
|
}
|
|
251
274
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (next.sessionId != null && next.sessionId != currentReplayId && currentReplayId != null) {
|
|
255
|
-
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: dropping ${next.count} stale frames from session ${next.sessionId} (current=${currentReplayId})")
|
|
256
|
-
serialWorker.execute { shipPendingFrames() }
|
|
257
|
-
return
|
|
275
|
+
if (next.sessionId != null && next.sessionId != currentReplayId) {
|
|
276
|
+
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: routing ${next.count} frames to captured session ${next.sessionId} (current=${currentReplayId})")
|
|
258
277
|
}
|
|
259
278
|
|
|
260
279
|
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
|
|
261
280
|
|
|
262
|
-
SegmentDispatcher.shared.
|
|
281
|
+
SegmentDispatcher.shared.transmitFrameBundleForSession(
|
|
282
|
+
sessionId = targetSession,
|
|
263
283
|
payload = next.payload,
|
|
264
284
|
startMs = next.rangeStart,
|
|
265
285
|
endMs = next.rangeEnd,
|
|
@@ -91,8 +91,8 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
91
91
|
private val maxPendingBatches = 50
|
|
92
92
|
private val maxBufferedScreenshots = 500
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
private
|
|
94
|
+
/** Flush to the network after this many frames (smaller = more frequent uploads). */
|
|
95
|
+
private var uploadBatchSize = 3
|
|
96
96
|
|
|
97
97
|
// Current activity reference
|
|
98
98
|
private var currentActivity: WeakReference<Activity>? = null
|
|
@@ -208,9 +208,10 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
208
208
|
redactionMask.invalidateCache()
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
fun configure(snapshotInterval: Double, jpegQuality: Double) {
|
|
211
|
+
fun configure(snapshotInterval: Double, jpegQuality: Double, uploadBatchSize: Int = 3) {
|
|
212
212
|
this.snapshotInterval = snapshotInterval
|
|
213
213
|
this.quality = jpegQuality.toFloat()
|
|
214
|
+
this.uploadBatchSize = uploadBatchSize.coerceIn(1, 100)
|
|
214
215
|
if (stateMachine.currentState == CaptureState.CAPTURING) {
|
|
215
216
|
stopCaptureTimer()
|
|
216
217
|
startCaptureTimer()
|
|
@@ -404,7 +405,7 @@ class VisualCapture private constructor(private val context: Context) {
|
|
|
404
405
|
stateLock.withLock {
|
|
405
406
|
screenshots.add(Pair(data, captureTs))
|
|
406
407
|
enforceScreenshotCaps()
|
|
407
|
-
val shouldSend = !deferredUntilCommit && screenshots.size >=
|
|
408
|
+
val shouldSend = !deferredUntilCommit && screenshots.size >= uploadBatchSize
|
|
408
409
|
|
|
409
410
|
if (shouldSend) {
|
|
410
411
|
sendScreenshots()
|
|
@@ -27,6 +27,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
27
27
|
|
|
28
28
|
private enum SessionState {
|
|
29
29
|
case idle
|
|
30
|
+
case starting(sessionId: String, startTime: TimeInterval)
|
|
30
31
|
case active(sessionId: String, startTime: TimeInterval)
|
|
31
32
|
case paused(sessionId: String, startTime: TimeInterval)
|
|
32
33
|
case terminated
|
|
@@ -250,8 +251,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
private func _waitForSessionReady(
|
|
254
|
-
|
|
254
|
+
private func _waitForSessionReady(
|
|
255
|
+
savedUserId: String?,
|
|
256
|
+
attempts: Int,
|
|
257
|
+
onReady: ((String) -> Void)? = nil,
|
|
258
|
+
onTimeout: (() -> Void)? = nil
|
|
259
|
+
) {
|
|
260
|
+
let maxAttempts = 50 // 5 seconds max (50 * 100ms)
|
|
255
261
|
|
|
256
262
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
257
263
|
guard let self else { return }
|
|
@@ -274,11 +280,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
274
280
|
|
|
275
281
|
DiagnosticLog.replayBegan(newSid)
|
|
276
282
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
|
|
283
|
+
onReady?(newSid)
|
|
277
284
|
} else if attempts < maxAttempts {
|
|
278
285
|
// Keep polling
|
|
279
|
-
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1)
|
|
286
|
+
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1, onReady: onReady, onTimeout: onTimeout)
|
|
280
287
|
} else {
|
|
281
288
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
289
|
+
onTimeout?()
|
|
282
290
|
}
|
|
283
291
|
}
|
|
284
292
|
}
|
|
@@ -353,12 +361,23 @@ public final class RejourneyImpl: NSObject {
|
|
|
353
361
|
}
|
|
354
362
|
|
|
355
363
|
self.stateLock.lock()
|
|
356
|
-
|
|
364
|
+
switch self.state {
|
|
365
|
+
case .active(let sid, _):
|
|
357
366
|
self.stateLock.unlock()
|
|
358
367
|
resolve(["success": true, "sessionId": sid])
|
|
359
368
|
return
|
|
369
|
+
case .starting(_, _):
|
|
370
|
+
let activeSid = ReplayOrchestrator.shared.replayId
|
|
371
|
+
self.stateLock.unlock()
|
|
372
|
+
if let activeSid, !activeSid.isEmpty {
|
|
373
|
+
resolve(["success": true, "sessionId": activeSid])
|
|
374
|
+
} else {
|
|
375
|
+
resolve(["success": false, "sessionId": "", "error": "Session is still starting"])
|
|
376
|
+
}
|
|
377
|
+
return
|
|
378
|
+
default:
|
|
379
|
+
self.stateLock.unlock()
|
|
360
380
|
}
|
|
361
|
-
self.stateLock.unlock()
|
|
362
381
|
|
|
363
382
|
if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
364
383
|
self.currentUserIdentity = userId
|
|
@@ -376,26 +395,30 @@ public final class RejourneyImpl: NSObject {
|
|
|
376
395
|
// Activate native network interception
|
|
377
396
|
RejourneyURLProtocol.enable()
|
|
378
397
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
let start = Date().timeIntervalSince1970
|
|
385
|
-
|
|
386
|
-
self.stateLock.lock()
|
|
387
|
-
self.state = .active(sessionId: sid, startTime: start)
|
|
388
|
-
self.stateLock.unlock()
|
|
398
|
+
let pendingSessionId = "session_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())"
|
|
399
|
+
let pendingStart = Date().timeIntervalSince1970
|
|
400
|
+
self.stateLock.lock()
|
|
401
|
+
self.state = .starting(sessionId: pendingSessionId, startTime: pendingStart)
|
|
402
|
+
self.stateLock.unlock()
|
|
389
403
|
|
|
390
|
-
|
|
404
|
+
ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
|
|
391
405
|
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
self._waitForSessionReady(
|
|
407
|
+
savedUserId: userId,
|
|
408
|
+
attempts: 0,
|
|
409
|
+
onReady: { sid in
|
|
410
|
+
resolve(["success": true, "sessionId": sid])
|
|
411
|
+
},
|
|
412
|
+
onTimeout: { [weak self] in
|
|
413
|
+
guard let self else { return }
|
|
414
|
+
self.stateLock.lock()
|
|
415
|
+
if case .starting(_, _) = self.state {
|
|
416
|
+
self.state = .idle
|
|
417
|
+
}
|
|
418
|
+
self.stateLock.unlock()
|
|
419
|
+
resolve(["success": false, "sessionId": "", "error": "Timed out waiting for replay session to initialize"])
|
|
394
420
|
}
|
|
395
|
-
|
|
396
|
-
DiagnosticLog.replayBegan(sid)
|
|
397
|
-
resolve(["success": true, "sessionId": sid])
|
|
398
|
-
}
|
|
421
|
+
)
|
|
399
422
|
}
|
|
400
423
|
}
|
|
401
424
|
|
|
@@ -27,7 +27,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
27
27
|
@objc public var replayId: String?
|
|
28
28
|
@objc public var replayStartMs: UInt64 = 0
|
|
29
29
|
@objc public var deferredUploadMode = false
|
|
30
|
-
@objc public var frameBundleSize: Int =
|
|
30
|
+
@objc public var frameBundleSize: Int = 3
|
|
31
31
|
|
|
32
32
|
public var serverEndpoint: String {
|
|
33
33
|
get { TelemetryPipeline.shared.endpoint }
|
|
@@ -172,7 +172,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
172
172
|
let renderCfg = _computeRender(fps: 1, tier: "standard")
|
|
173
173
|
|
|
174
174
|
if visualCaptureEnabled {
|
|
175
|
-
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
175
|
+
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality, uploadBatchSize: frameBundleSize)
|
|
176
176
|
VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs)
|
|
177
177
|
VisualCapture.shared.activateDeferredMode()
|
|
178
178
|
}
|
|
@@ -241,8 +241,8 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
241
241
|
|
|
242
242
|
// Do local teardown immediately so lifecycle rollover never depends on network latency.
|
|
243
243
|
DispatchQueue.main.async {
|
|
244
|
-
TelemetryPipeline.shared.shutdown()
|
|
245
244
|
VisualCapture.shared.halt(expectedGeneration: haltGeneration)
|
|
245
|
+
TelemetryPipeline.shared.shutdown()
|
|
246
246
|
InteractionRecorder.shared.deactivate()
|
|
247
247
|
FaultTracker.shared.deactivate()
|
|
248
248
|
ResponsivenessWatcher.shared.halt()
|
|
@@ -491,7 +491,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
491
491
|
responsivenessCaptureEnabled = cfg["captureANR"] as? Bool ?? true
|
|
492
492
|
consoleCaptureEnabled = cfg["captureLogs"] as? Bool ?? true
|
|
493
493
|
wifiRequired = cfg["wifiOnly"] as? Bool ?? false
|
|
494
|
-
frameBundleSize = cfg["screenshotBatchSize"] as? Int ??
|
|
494
|
+
frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 3
|
|
495
495
|
}
|
|
496
496
|
|
|
497
497
|
private func _monitorNetwork(token: String) {
|
|
@@ -559,7 +559,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
559
559
|
TelemetryPipeline.shared.activate()
|
|
560
560
|
|
|
561
561
|
let renderCfg = _computeRender(fps: 1, tier: "standard")
|
|
562
|
-
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
562
|
+
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality, uploadBatchSize: frameBundleSize)
|
|
563
563
|
|
|
564
564
|
if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
|
|
565
565
|
if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
|
|
@@ -100,7 +100,6 @@ final class SegmentDispatcher {
|
|
|
100
100
|
|
|
101
101
|
func halt() {
|
|
102
102
|
active = false
|
|
103
|
-
workerQueue.cancelAllOperations()
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
func shipPending() {
|
|
@@ -109,7 +108,11 @@ final class SegmentDispatcher {
|
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
func transmitFrameBundle(payload: Data, startMs: UInt64, endMs: UInt64, frameCount: Int, completion: ((Bool) -> Void)? = nil) {
|
|
112
|
-
|
|
111
|
+
transmitFrameBundle(for: currentReplayId, payload: payload, startMs: startMs, endMs: endMs, frameCount: frameCount, completion: completion)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func transmitFrameBundle(for sessionId: String?, payload: Data, startMs: UInt64, endMs: UInt64, frameCount: Int, completion: ((Bool) -> Void)? = nil) {
|
|
115
|
+
guard let sid = sessionId, canUploadNow() else {
|
|
113
116
|
completion?(false)
|
|
114
117
|
return
|
|
115
118
|
}
|
|
@@ -313,6 +316,12 @@ final class SegmentDispatcher {
|
|
|
313
316
|
self.scheduleRetryIfNeeded(upload, completion: completion)
|
|
314
317
|
return
|
|
315
318
|
}
|
|
319
|
+
|
|
320
|
+
if presign.skipUpload {
|
|
321
|
+
self.registerSuccess()
|
|
322
|
+
completion?(true)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
316
325
|
|
|
317
326
|
self.uploadToS3(url: presign.presignedUrl, payload: upload.payload) { s3ok in
|
|
318
327
|
guard s3ok else {
|
|
@@ -415,7 +424,7 @@ final class SegmentDispatcher {
|
|
|
415
424
|
}
|
|
416
425
|
|
|
417
426
|
if json["skipUpload"] as? Bool == true {
|
|
418
|
-
completion(
|
|
427
|
+
completion(PresignResponse(presignedUrl: "", batchId: "", skipUpload: true))
|
|
419
428
|
return
|
|
420
429
|
}
|
|
421
430
|
|
|
@@ -426,7 +435,7 @@ final class SegmentDispatcher {
|
|
|
426
435
|
|
|
427
436
|
let batchId = json["batchId"] as? String ?? json["segmentId"] as? String ?? ""
|
|
428
437
|
|
|
429
|
-
completion(PresignResponse(presignedUrl: presignedUrl, batchId: batchId))
|
|
438
|
+
completion(PresignResponse(presignedUrl: presignedUrl, batchId: batchId, skipUpload: false))
|
|
430
439
|
}.resume()
|
|
431
440
|
}
|
|
432
441
|
|
|
@@ -638,4 +647,5 @@ private struct PendingUpload {
|
|
|
638
647
|
private struct PresignResponse {
|
|
639
648
|
let presignedUrl: String
|
|
640
649
|
let batchId: String
|
|
650
|
+
let skipUpload: Bool
|
|
641
651
|
}
|
|
@@ -125,11 +125,7 @@ public final class TelemetryPipeline: NSObject {
|
|
|
125
125
|
_heartbeat = nil
|
|
126
126
|
NotificationCenter.default.removeObserver(self)
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
_appSuspending()
|
|
130
|
-
|
|
131
|
-
// Clear pending frame bundles so they don't leak into the next session
|
|
132
|
-
_frameQueue.clear()
|
|
128
|
+
_drainPendingDataForShutdown()
|
|
133
129
|
}
|
|
134
130
|
|
|
135
131
|
@objc public func finalizeAndShip() {
|
|
@@ -169,6 +165,35 @@ public final class TelemetryPipeline: NSObject {
|
|
|
169
165
|
@objc public func getQueueDepth() -> Int {
|
|
170
166
|
_eventRing.count + _frameQueue.count
|
|
171
167
|
}
|
|
168
|
+
|
|
169
|
+
private func _drainPendingDataForShutdown() {
|
|
170
|
+
guard !_draining else { return }
|
|
171
|
+
_draining = true
|
|
172
|
+
|
|
173
|
+
_backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "RejourneyShutdownFlush") { [weak self] in
|
|
174
|
+
self?._endBackgroundTask()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Force any in-memory frames into the upload pipeline before session
|
|
178
|
+
// teardown clears the active replay ID.
|
|
179
|
+
VisualCapture.shared.flushToDisk()
|
|
180
|
+
VisualCapture.shared.flushBufferToNetwork()
|
|
181
|
+
|
|
182
|
+
let group = DispatchGroup()
|
|
183
|
+
group.enter()
|
|
184
|
+
_serialWorker.async { [weak self] in
|
|
185
|
+
defer { group.leave() }
|
|
186
|
+
self?._shipPendingEvents()
|
|
187
|
+
self?._shipPendingFrames()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if group.wait(timeout: .now() + 1.0) == .timedOut {
|
|
191
|
+
DiagnosticLog.trace("[TelemetryPipeline] shutdown drain timed out before upload queue kick-off completed")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_endBackgroundTask()
|
|
195
|
+
_draining = false
|
|
196
|
+
}
|
|
172
197
|
|
|
173
198
|
@objc private func _appSuspending() {
|
|
174
199
|
guard !_draining else { return }
|
|
@@ -254,19 +279,18 @@ public final class TelemetryPipeline: NSObject {
|
|
|
254
279
|
private func _shipPendingFrames() {
|
|
255
280
|
guard !_deferredMode, let next = _frameQueue.dequeue() else { return }
|
|
256
281
|
|
|
257
|
-
|
|
282
|
+
let targetSession = next.sessionId ?? currentReplayId
|
|
283
|
+
guard let targetSession else {
|
|
258
284
|
_frameQueue.requeue(next)
|
|
259
285
|
return
|
|
260
286
|
}
|
|
261
287
|
|
|
262
|
-
// Drop frames that belong to a session that is no longer active
|
|
263
288
|
if let bundleSession = next.sessionId, bundleSession != currentReplayId {
|
|
264
|
-
DiagnosticLog.trace("[TelemetryPipeline]
|
|
265
|
-
_shipPendingFrames()
|
|
266
|
-
return
|
|
289
|
+
DiagnosticLog.trace("[TelemetryPipeline] Routing \(next.count) frames to captured session \(bundleSession) (current=\(currentReplayId ?? "nil"))")
|
|
267
290
|
}
|
|
268
291
|
|
|
269
292
|
SegmentDispatcher.shared.transmitFrameBundle(
|
|
293
|
+
for: targetSession,
|
|
270
294
|
payload: next.payload,
|
|
271
295
|
startMs: next.rangeStart,
|
|
272
296
|
endMs: next.rangeEnd,
|
|
@@ -59,8 +59,8 @@ public final class VisualCapture: NSObject {
|
|
|
59
59
|
private let _maxPendingBatches = 50
|
|
60
60
|
private let _maxBufferedScreenshots = 500
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
private
|
|
62
|
+
/// Flush to the network after this many frames (smaller = more frequent uploads).
|
|
63
|
+
private var _uploadBatchSize = 3
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
private override init() {
|
|
@@ -195,10 +195,11 @@ public final class VisualCapture: NSObject {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
|
|
198
|
-
@objc public func configure(snapshotInterval: Double, jpegQuality: Double, captureScale: CGFloat = 1.25) {
|
|
198
|
+
@objc public func configure(snapshotInterval: Double, jpegQuality: Double, captureScale: CGFloat = 1.25, uploadBatchSize: Int = 3) {
|
|
199
199
|
self.snapshotInterval = snapshotInterval
|
|
200
200
|
self.quality = CGFloat(jpegQuality)
|
|
201
201
|
self.captureScale = max(1.0, captureScale)
|
|
202
|
+
_uploadBatchSize = max(1, min(uploadBatchSize, 100))
|
|
202
203
|
if _stateMachine.currentState == .capturing {
|
|
203
204
|
_stopCaptureTimer()
|
|
204
205
|
_startCaptureTimer()
|
|
@@ -325,7 +326,7 @@ public final class VisualCapture: NSObject {
|
|
|
325
326
|
self._stateLock.lock()
|
|
326
327
|
self._screenshots.append((data, captureTs))
|
|
327
328
|
self._enforceScreenshotCaps()
|
|
328
|
-
let shouldSend = !self._deferredUntilCommit && self._screenshots.count >= self.
|
|
329
|
+
let shouldSend = !self._deferredUntilCommit && self._screenshots.count >= self._uploadBatchSize
|
|
329
330
|
self._stateLock.unlock()
|
|
330
331
|
|
|
331
332
|
if shouldSend {
|