@rejourneyco/react-native 1.0.14 → 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 +31 -19
- 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 +35 -21
- 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
|
@@ -400,8 +400,13 @@ class RejourneyModuleImpl(
|
|
|
400
400
|
waitForSessionReady(savedUserId, 0)
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
private fun waitForSessionReady(
|
|
404
|
-
|
|
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
|
|
405
410
|
|
|
406
411
|
mainHandler.postDelayed({
|
|
407
412
|
val newSid = ReplayOrchestrator.shared?.replayId
|
|
@@ -420,10 +425,12 @@ class RejourneyModuleImpl(
|
|
|
420
425
|
|
|
421
426
|
DiagnosticLog.replayBegan(newSid)
|
|
422
427
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
428
|
+
onReady?.invoke(newSid)
|
|
423
429
|
} else if (attempts < maxAttempts) {
|
|
424
|
-
waitForSessionReady(savedUserId, attempts + 1)
|
|
430
|
+
waitForSessionReady(savedUserId, attempts + 1, onReady, onTimeout)
|
|
425
431
|
} else {
|
|
426
432
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
433
|
+
onTimeout?.invoke()
|
|
427
434
|
}
|
|
428
435
|
}, 100)
|
|
429
436
|
}
|
|
@@ -495,7 +502,12 @@ class RejourneyModuleImpl(
|
|
|
495
502
|
return@post
|
|
496
503
|
}
|
|
497
504
|
is SessionState.Starting -> {
|
|
498
|
-
|
|
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
|
+
}
|
|
499
511
|
return@post
|
|
500
512
|
}
|
|
501
513
|
else -> Unit
|
|
@@ -545,22 +557,22 @@ class RejourneyModuleImpl(
|
|
|
545
557
|
// Android-specific: Start SessionLifecycleService for task removal detection
|
|
546
558
|
startSessionLifecycleService()
|
|
547
559
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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"))
|
|
559
574
|
}
|
|
560
|
-
|
|
561
|
-
DiagnosticLog.replayBegan(sid)
|
|
562
|
-
promise.resolve(createResultMap(true, sid))
|
|
563
|
-
}, 300)
|
|
575
|
+
)
|
|
564
576
|
}
|
|
565
577
|
}
|
|
566
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()
|
|
@@ -251,8 +251,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
private func _waitForSessionReady(
|
|
255
|
-
|
|
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)
|
|
256
261
|
|
|
257
262
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
258
263
|
guard let self else { return }
|
|
@@ -275,11 +280,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
275
280
|
|
|
276
281
|
DiagnosticLog.replayBegan(newSid)
|
|
277
282
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
|
|
283
|
+
onReady?(newSid)
|
|
278
284
|
} else if attempts < maxAttempts {
|
|
279
285
|
// Keep polling
|
|
280
|
-
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1)
|
|
286
|
+
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1, onReady: onReady, onTimeout: onTimeout)
|
|
281
287
|
} else {
|
|
282
288
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
289
|
+
onTimeout?()
|
|
283
290
|
}
|
|
284
291
|
}
|
|
285
292
|
}
|
|
@@ -355,10 +362,19 @@ public final class RejourneyImpl: NSObject {
|
|
|
355
362
|
|
|
356
363
|
self.stateLock.lock()
|
|
357
364
|
switch self.state {
|
|
358
|
-
case .active(let sid, _)
|
|
365
|
+
case .active(let sid, _):
|
|
359
366
|
self.stateLock.unlock()
|
|
360
367
|
resolve(["success": true, "sessionId": sid])
|
|
361
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
|
|
362
378
|
default:
|
|
363
379
|
self.stateLock.unlock()
|
|
364
380
|
}
|
|
@@ -387,24 +403,22 @@ public final class RejourneyImpl: NSObject {
|
|
|
387
403
|
|
|
388
404
|
ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
|
|
389
405
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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"])
|
|
403
420
|
}
|
|
404
|
-
|
|
405
|
-
DiagnosticLog.replayBegan(sid)
|
|
406
|
-
resolve(["success": true, "sessionId": sid])
|
|
407
|
-
}
|
|
421
|
+
)
|
|
408
422
|
}
|
|
409
423
|
}
|
|
410
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 {
|