@rejourneyco/react-native 1.0.14 → 1.0.16
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 +33 -19
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +40 -23
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +16 -18
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +25 -37
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +34 -12
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +5 -4
- package/ios/Engine/RejourneyImpl.swift +36 -21
- package/ios/Recording/ReplayOrchestrator.swift +16 -19
- package/ios/Recording/SegmentDispatcher.swift +21 -39
- package/ios/Recording/TelemetryPipeline.swift +35 -10
- package/ios/Recording/VisualCapture.swift +5 -4
- package/package.json +1 -1
|
@@ -44,6 +44,7 @@ import com.facebook.react.modules.network.NetworkingModule
|
|
|
44
44
|
import okhttp3.OkHttpClient
|
|
45
45
|
import com.rejourney.engine.DeviceRegistrar
|
|
46
46
|
import com.rejourney.engine.DiagnosticLog
|
|
47
|
+
import com.rejourney.engine.RejourneyImpl
|
|
47
48
|
|
|
48
49
|
import com.rejourney.platform.OEMDetector
|
|
49
50
|
import com.rejourney.platform.SessionLifecycleService
|
|
@@ -400,8 +401,13 @@ class RejourneyModuleImpl(
|
|
|
400
401
|
waitForSessionReady(savedUserId, 0)
|
|
401
402
|
}
|
|
402
403
|
|
|
403
|
-
private fun waitForSessionReady(
|
|
404
|
-
|
|
404
|
+
private fun waitForSessionReady(
|
|
405
|
+
savedUserId: String?,
|
|
406
|
+
attempts: Int,
|
|
407
|
+
onReady: ((String) -> Unit)? = null,
|
|
408
|
+
onTimeout: (() -> Unit)? = null
|
|
409
|
+
) {
|
|
410
|
+
val maxAttempts = 50 // 5 seconds max
|
|
405
411
|
|
|
406
412
|
mainHandler.postDelayed({
|
|
407
413
|
val newSid = ReplayOrchestrator.shared?.replayId
|
|
@@ -420,10 +426,12 @@ class RejourneyModuleImpl(
|
|
|
420
426
|
|
|
421
427
|
DiagnosticLog.replayBegan(newSid)
|
|
422
428
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: $newSid")
|
|
429
|
+
onReady?.invoke(newSid)
|
|
423
430
|
} else if (attempts < maxAttempts) {
|
|
424
|
-
waitForSessionReady(savedUserId, attempts + 1)
|
|
431
|
+
waitForSessionReady(savedUserId, attempts + 1, onReady, onTimeout)
|
|
425
432
|
} else {
|
|
426
433
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
434
|
+
onTimeout?.invoke()
|
|
427
435
|
}
|
|
428
436
|
}, 100)
|
|
429
437
|
}
|
|
@@ -495,7 +503,12 @@ class RejourneyModuleImpl(
|
|
|
495
503
|
return@post
|
|
496
504
|
}
|
|
497
505
|
is SessionState.Starting -> {
|
|
498
|
-
|
|
506
|
+
val activeSid = ReplayOrchestrator.shared?.replayId
|
|
507
|
+
if (!activeSid.isNullOrEmpty()) {
|
|
508
|
+
promise.resolve(createResultMap(true, activeSid))
|
|
509
|
+
} else {
|
|
510
|
+
promise.resolve(createResultMap(false, "", "Session is still starting"))
|
|
511
|
+
}
|
|
499
512
|
return@post
|
|
500
513
|
}
|
|
501
514
|
else -> Unit
|
|
@@ -545,22 +558,22 @@ class RejourneyModuleImpl(
|
|
|
545
558
|
// Android-specific: Start SessionLifecycleService for task removal detection
|
|
546
559
|
startSessionLifecycleService()
|
|
547
560
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
561
|
+
waitForSessionReady(
|
|
562
|
+
savedUserId = userId,
|
|
563
|
+
attempts = 0,
|
|
564
|
+
onReady = { sid ->
|
|
565
|
+
promise.resolve(createResultMap(true, sid))
|
|
566
|
+
},
|
|
567
|
+
onTimeout = {
|
|
568
|
+
stateLock.withLock {
|
|
569
|
+
if (state is SessionState.Starting) {
|
|
570
|
+
state = SessionState.Idle
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
stopSessionLifecycleService()
|
|
574
|
+
promise.resolve(createResultMap(false, "", "Timed out waiting for replay session to initialize"))
|
|
559
575
|
}
|
|
560
|
-
|
|
561
|
-
DiagnosticLog.replayBegan(sid)
|
|
562
|
-
promise.resolve(createResultMap(true, sid))
|
|
563
|
-
}, 300)
|
|
576
|
+
)
|
|
564
577
|
}
|
|
565
578
|
}
|
|
566
579
|
|
|
@@ -844,6 +857,7 @@ class RejourneyModuleImpl(
|
|
|
844
857
|
|
|
845
858
|
fun setSDKVersion(version: String) {
|
|
846
859
|
sdkVersion = version
|
|
860
|
+
RejourneyImpl.sdkVersion = version
|
|
847
861
|
}
|
|
848
862
|
|
|
849
863
|
fun getSDKVersion(promise: Promise) {
|
|
@@ -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()
|
|
@@ -302,19 +302,17 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
302
302
|
}
|
|
303
303
|
finalized = true
|
|
304
304
|
|
|
305
|
-
SegmentDispatcher.shared.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
completion?.invoke(true, ok)
|
|
317
|
-
}
|
|
305
|
+
SegmentDispatcher.shared.concludeReplay(
|
|
306
|
+
sid,
|
|
307
|
+
termMs,
|
|
308
|
+
bgTimeMs,
|
|
309
|
+
metrics,
|
|
310
|
+
queueDepthAtFinalize,
|
|
311
|
+
endReason = endReason,
|
|
312
|
+
lifecycleVersion = lifecycleContractVersion
|
|
313
|
+
) { ok ->
|
|
314
|
+
if (ok) clearRecovery()
|
|
315
|
+
completion?.invoke(true, ok)
|
|
318
316
|
}
|
|
319
317
|
|
|
320
318
|
replayId = null
|
|
@@ -546,7 +544,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
546
544
|
responsivenessCaptureEnabled = (cfg["captureANR"] as? Boolean) ?: true
|
|
547
545
|
consoleCaptureEnabled = (cfg["captureLogs"] as? Boolean) ?: true
|
|
548
546
|
wifiRequired = (cfg["wifiOnly"] as? Boolean) ?: false
|
|
549
|
-
frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?:
|
|
547
|
+
frameBundleSize = (cfg["screenshotBatchSize"] as? Int) ?: 3
|
|
550
548
|
}
|
|
551
549
|
|
|
552
550
|
private fun monitorNetwork(token: String) {
|
|
@@ -643,7 +641,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
|
|
|
643
641
|
|
|
644
642
|
val renderCfg = computeRender(1, "standard")
|
|
645
643
|
DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
|
|
646
|
-
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
|
|
644
|
+
VisualCapture.shared?.configure(renderCfg.first, renderCfg.second, frameBundleSize)
|
|
647
645
|
|
|
648
646
|
if (visualCaptureEnabled) {
|
|
649
647
|
DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
package com.rejourney.recording
|
|
18
18
|
|
|
19
19
|
import com.rejourney.engine.DiagnosticLog
|
|
20
|
+
import com.rejourney.engine.RejourneyImpl
|
|
20
21
|
import kotlinx.coroutines.*
|
|
21
22
|
import okhttp3.*
|
|
22
23
|
import com.rejourney.recording.RejourneyNetworkInterceptor
|
|
@@ -173,7 +174,18 @@ class SegmentDispatcher private constructor() {
|
|
|
173
174
|
frameCount: Int,
|
|
174
175
|
completion: ((Boolean) -> Unit)? = null
|
|
175
176
|
) {
|
|
176
|
-
|
|
177
|
+
transmitFrameBundleForSession(currentReplayId, payload, startMs, endMs, frameCount, completion)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fun transmitFrameBundleForSession(
|
|
181
|
+
sessionId: String?,
|
|
182
|
+
payload: ByteArray,
|
|
183
|
+
startMs: Long,
|
|
184
|
+
endMs: Long,
|
|
185
|
+
frameCount: Int,
|
|
186
|
+
completion: ((Boolean) -> Unit)? = null
|
|
187
|
+
) {
|
|
188
|
+
val sid = sessionId
|
|
177
189
|
val canUpload = canUploadNow()
|
|
178
190
|
DiagnosticLog.trace("[SegmentDispatcher] transmitFrameBundle: sid=${sid?.take(12) ?: "null"}, canUpload=$canUpload, frames=$frameCount, bytes=${payload.size}")
|
|
179
191
|
|
|
@@ -274,6 +286,7 @@ class SegmentDispatcher private constructor() {
|
|
|
274
286
|
val body = JSONObject().apply {
|
|
275
287
|
put("sessionId", replayId)
|
|
276
288
|
put("endedAt", concludedAt)
|
|
289
|
+
put("sdkVersion", RejourneyImpl.sdkVersion)
|
|
277
290
|
if (backgroundDurationMs > 0) put("totalBackgroundTimeMs", backgroundDurationMs)
|
|
278
291
|
metrics?.let { put("metrics", JSONObject(it)) }
|
|
279
292
|
put("sdkTelemetry", buildSdkTelemetry(currentQueueDepth))
|
|
@@ -293,39 +306,6 @@ class SegmentDispatcher private constructor() {
|
|
|
293
306
|
}
|
|
294
307
|
}
|
|
295
308
|
|
|
296
|
-
fun evaluateReplayRetention(
|
|
297
|
-
replayId: String,
|
|
298
|
-
metrics: Map<String, Any>,
|
|
299
|
-
completion: (Boolean, String) -> Unit
|
|
300
|
-
) {
|
|
301
|
-
val url = "$endpoint/api/ingest/replay/evaluate"
|
|
302
|
-
|
|
303
|
-
val body = JSONObject().apply {
|
|
304
|
-
put("sessionId", replayId)
|
|
305
|
-
metrics.forEach { (key, value) -> put(key, value) }
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
val request = buildRequest(url, body)
|
|
309
|
-
|
|
310
|
-
scope.launch {
|
|
311
|
-
try {
|
|
312
|
-
val response = httpClient.newCall(request).execute()
|
|
313
|
-
val responseBody = response.body?.string()
|
|
314
|
-
|
|
315
|
-
if (response.code == 200 && responseBody != null) {
|
|
316
|
-
val json = JSONObject(responseBody)
|
|
317
|
-
val retained = json.optBoolean("promoted", false)
|
|
318
|
-
val reason = json.optString("reason", "unknown")
|
|
319
|
-
completion(retained, reason)
|
|
320
|
-
} else {
|
|
321
|
-
completion(false, "request_failed")
|
|
322
|
-
}
|
|
323
|
-
} catch (e: Exception) {
|
|
324
|
-
completion(false, "request_failed")
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
309
|
@Synchronized
|
|
330
310
|
private fun canUploadNow(): Boolean {
|
|
331
311
|
if (billingBlocked) return false
|
|
@@ -390,6 +370,12 @@ class SegmentDispatcher private constructor() {
|
|
|
390
370
|
scheduleRetryIfNeeded(upload, completion)
|
|
391
371
|
return
|
|
392
372
|
}
|
|
373
|
+
|
|
374
|
+
if (presignResponse.skipUpload) {
|
|
375
|
+
registerSuccess()
|
|
376
|
+
completion?.invoke(true)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
393
379
|
|
|
394
380
|
val s3ok = uploadToS3(presignResponse.presignedUrl, upload.payload)
|
|
395
381
|
if (!s3ok) {
|
|
@@ -444,6 +430,7 @@ class SegmentDispatcher private constructor() {
|
|
|
444
430
|
val body = JSONObject().apply {
|
|
445
431
|
put("sessionId", upload.sessionId)
|
|
446
432
|
put("sizeBytes", upload.payload.size)
|
|
433
|
+
put("sdkVersion", RejourneyImpl.sdkVersion)
|
|
447
434
|
|
|
448
435
|
if (upload.contentType == "events") {
|
|
449
436
|
put("contentType", "events")
|
|
@@ -483,7 +470,7 @@ class SegmentDispatcher private constructor() {
|
|
|
483
470
|
val json = JSONObject(responseBody)
|
|
484
471
|
|
|
485
472
|
if (json.optBoolean("skipUpload", false)) {
|
|
486
|
-
return
|
|
473
|
+
return PresignResponse(skipUpload = true)
|
|
487
474
|
}
|
|
488
475
|
|
|
489
476
|
val presignedUrl = json.optString("presignedUrl", null) ?: return null
|
|
@@ -758,8 +745,9 @@ private data class PendingUpload(
|
|
|
758
745
|
)
|
|
759
746
|
|
|
760
747
|
private data class PresignResponse(
|
|
761
|
-
val presignedUrl: String,
|
|
762
|
-
val batchId: String
|
|
748
|
+
val presignedUrl: String = "",
|
|
749
|
+
val batchId: String = "",
|
|
750
|
+
val skipUpload: Boolean = false
|
|
763
751
|
)
|
|
764
752
|
|
|
765
753
|
private data class TelemetrySnapshot(
|
|
@@ -22,12 +22,15 @@ import android.os.Handler
|
|
|
22
22
|
import android.os.Looper
|
|
23
23
|
import com.rejourney.engine.DiagnosticLog
|
|
24
24
|
import com.rejourney.engine.DeviceRegistrar
|
|
25
|
+
import com.rejourney.engine.RejourneyImpl
|
|
25
26
|
import com.rejourney.utility.gzipCompress
|
|
26
27
|
import org.json.JSONArray
|
|
27
28
|
import org.json.JSONObject
|
|
28
29
|
import java.util.*
|
|
30
|
+
import java.util.concurrent.CountDownLatch
|
|
29
31
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
30
32
|
import java.util.concurrent.Executors
|
|
33
|
+
import java.util.concurrent.TimeUnit
|
|
31
34
|
import java.util.concurrent.locks.ReentrantLock
|
|
32
35
|
import kotlin.concurrent.withLock
|
|
33
36
|
|
|
@@ -160,11 +163,7 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
160
163
|
heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
161
164
|
heartbeatRunnable = null
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
appSuspending()
|
|
165
|
-
|
|
166
|
-
// Clear pending frame bundles so they don't leak into the next session
|
|
167
|
-
frameQueue.clear()
|
|
166
|
+
drainPendingDataForShutdown()
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
fun finalizeAndShip() {
|
|
@@ -205,6 +204,31 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
205
204
|
fun getQueueDepth(): Int {
|
|
206
205
|
return eventRing.size() + frameQueue.size()
|
|
207
206
|
}
|
|
207
|
+
|
|
208
|
+
private fun drainPendingDataForShutdown() {
|
|
209
|
+
if (draining) return
|
|
210
|
+
draining = true
|
|
211
|
+
|
|
212
|
+
// Force any in-memory frames into the upload pipeline before session
|
|
213
|
+
// teardown clears the active replay ID.
|
|
214
|
+
VisualCapture.shared?.flushToDisk()
|
|
215
|
+
VisualCapture.shared?.flushBufferToNetwork()
|
|
216
|
+
|
|
217
|
+
val latch = CountDownLatch(1)
|
|
218
|
+
serialWorker.execute {
|
|
219
|
+
try {
|
|
220
|
+
shipPendingEvents()
|
|
221
|
+
shipPendingFrames()
|
|
222
|
+
} finally {
|
|
223
|
+
draining = false
|
|
224
|
+
latch.countDown()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!latch.await(1, TimeUnit.SECONDS)) {
|
|
229
|
+
DiagnosticLog.trace("[TelemetryPipeline] shutdown drain timed out before upload queue kick-off completed")
|
|
230
|
+
}
|
|
231
|
+
}
|
|
208
232
|
|
|
209
233
|
private fun appSuspending() {
|
|
210
234
|
if (draining) return
|
|
@@ -249,17 +273,14 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
249
273
|
return
|
|
250
274
|
}
|
|
251
275
|
|
|
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
|
|
276
|
+
if (next.sessionId != null && next.sessionId != currentReplayId) {
|
|
277
|
+
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: routing ${next.count} frames to captured session ${next.sessionId} (current=${currentReplayId})")
|
|
258
278
|
}
|
|
259
279
|
|
|
260
280
|
DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
|
|
261
281
|
|
|
262
|
-
SegmentDispatcher.shared.
|
|
282
|
+
SegmentDispatcher.shared.transmitFrameBundleForSession(
|
|
283
|
+
sessionId = targetSession,
|
|
263
284
|
payload = next.payload,
|
|
264
285
|
startMs = next.rangeStart,
|
|
265
286
|
endMs = next.rangeEnd,
|
|
@@ -320,6 +341,7 @@ class TelemetryPipeline private constructor(private val context: Context) {
|
|
|
320
341
|
put("isConstrained", orchestrator?.networkIsConstrained ?: false)
|
|
321
342
|
put("isExpensive", orchestrator?.networkIsExpensive ?: false)
|
|
322
343
|
put("appVersion", getAppVersion())
|
|
344
|
+
put("sdkVersion", RejourneyImpl.sdkVersion)
|
|
323
345
|
put("appId", context.packageName)
|
|
324
346
|
put("screenWidth", displayMetrics.widthPixels)
|
|
325
347
|
put("screenHeight", displayMetrics.heightPixels)
|
|
@@ -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()
|
|
@@ -78,6 +78,7 @@ public final class RejourneyImpl: NSObject {
|
|
|
78
78
|
NotificationCenter.default.removeObserver(self)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
//NOTE: iOS cannot detect reliably app kill so we depend server side with the session reconciliation logic
|
|
81
82
|
private func setupLifecycleListeners() {
|
|
82
83
|
let center = NotificationCenter.default
|
|
83
84
|
center.addObserver(self, selector: #selector(handleTermination), name: UIApplication.willTerminateNotification, object: nil)
|
|
@@ -251,8 +252,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
251
252
|
}
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
private func _waitForSessionReady(
|
|
255
|
-
|
|
255
|
+
private func _waitForSessionReady(
|
|
256
|
+
savedUserId: String?,
|
|
257
|
+
attempts: Int,
|
|
258
|
+
onReady: ((String) -> Void)? = nil,
|
|
259
|
+
onTimeout: (() -> Void)? = nil
|
|
260
|
+
) {
|
|
261
|
+
let maxAttempts = 50 // 5 seconds max (50 * 100ms)
|
|
256
262
|
|
|
257
263
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
258
264
|
guard let self else { return }
|
|
@@ -275,11 +281,13 @@ public final class RejourneyImpl: NSObject {
|
|
|
275
281
|
|
|
276
282
|
DiagnosticLog.replayBegan(newSid)
|
|
277
283
|
DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
|
|
284
|
+
onReady?(newSid)
|
|
278
285
|
} else if attempts < maxAttempts {
|
|
279
286
|
// Keep polling
|
|
280
|
-
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1)
|
|
287
|
+
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1, onReady: onReady, onTimeout: onTimeout)
|
|
281
288
|
} else {
|
|
282
289
|
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
290
|
+
onTimeout?()
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
}
|
|
@@ -355,10 +363,19 @@ public final class RejourneyImpl: NSObject {
|
|
|
355
363
|
|
|
356
364
|
self.stateLock.lock()
|
|
357
365
|
switch self.state {
|
|
358
|
-
case .active(let sid, _)
|
|
366
|
+
case .active(let sid, _):
|
|
359
367
|
self.stateLock.unlock()
|
|
360
368
|
resolve(["success": true, "sessionId": sid])
|
|
361
369
|
return
|
|
370
|
+
case .starting(_, _):
|
|
371
|
+
let activeSid = ReplayOrchestrator.shared.replayId
|
|
372
|
+
self.stateLock.unlock()
|
|
373
|
+
if let activeSid, !activeSid.isEmpty {
|
|
374
|
+
resolve(["success": true, "sessionId": activeSid])
|
|
375
|
+
} else {
|
|
376
|
+
resolve(["success": false, "sessionId": "", "error": "Session is still starting"])
|
|
377
|
+
}
|
|
378
|
+
return
|
|
362
379
|
default:
|
|
363
380
|
self.stateLock.unlock()
|
|
364
381
|
}
|
|
@@ -387,24 +404,22 @@ public final class RejourneyImpl: NSObject {
|
|
|
387
404
|
|
|
388
405
|
ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
|
|
389
406
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
407
|
+
self._waitForSessionReady(
|
|
408
|
+
savedUserId: userId,
|
|
409
|
+
attempts: 0,
|
|
410
|
+
onReady: { sid in
|
|
411
|
+
resolve(["success": true, "sessionId": sid])
|
|
412
|
+
},
|
|
413
|
+
onTimeout: { [weak self] in
|
|
414
|
+
guard let self else { return }
|
|
415
|
+
self.stateLock.lock()
|
|
416
|
+
if case .starting(_, _) = self.state {
|
|
417
|
+
self.state = .idle
|
|
418
|
+
}
|
|
419
|
+
self.stateLock.unlock()
|
|
420
|
+
resolve(["success": false, "sessionId": "", "error": "Timed out waiting for replay session to initialize"])
|
|
403
421
|
}
|
|
404
|
-
|
|
405
|
-
DiagnosticLog.replayBegan(sid)
|
|
406
|
-
resolve(["success": true, "sessionId": sid])
|
|
407
|
-
}
|
|
422
|
+
)
|
|
408
423
|
}
|
|
409
424
|
}
|
|
410
425
|
|
|
@@ -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()
|
|
@@ -258,20 +258,17 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
258
258
|
}
|
|
259
259
|
_finalized = true
|
|
260
260
|
|
|
261
|
-
SegmentDispatcher.shared.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if ok { self?._clearRecovery() }
|
|
273
|
-
completion?(true, ok)
|
|
274
|
-
}
|
|
261
|
+
SegmentDispatcher.shared.concludeReplay(
|
|
262
|
+
replayId: sid,
|
|
263
|
+
concludedAt: termMs,
|
|
264
|
+
backgroundDurationMs: _bgTimeMs,
|
|
265
|
+
metrics: metrics,
|
|
266
|
+
currentQueueDepth: queueDepthAtFinalize,
|
|
267
|
+
endReason: endReason,
|
|
268
|
+
lifecycleVersion: lifecycleContractVersion
|
|
269
|
+
) { [weak self] ok in
|
|
270
|
+
if ok { self?._clearRecovery() }
|
|
271
|
+
completion?(true, ok)
|
|
275
272
|
}
|
|
276
273
|
|
|
277
274
|
replayId = nil
|
|
@@ -491,7 +488,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
491
488
|
responsivenessCaptureEnabled = cfg["captureANR"] as? Bool ?? true
|
|
492
489
|
consoleCaptureEnabled = cfg["captureLogs"] as? Bool ?? true
|
|
493
490
|
wifiRequired = cfg["wifiOnly"] as? Bool ?? false
|
|
494
|
-
frameBundleSize = cfg["screenshotBatchSize"] as? Int ??
|
|
491
|
+
frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 3
|
|
495
492
|
}
|
|
496
493
|
|
|
497
494
|
private func _monitorNetwork(token: String) {
|
|
@@ -559,7 +556,7 @@ public final class ReplayOrchestrator: NSObject {
|
|
|
559
556
|
TelemetryPipeline.shared.activate()
|
|
560
557
|
|
|
561
558
|
let renderCfg = _computeRender(fps: 1, tier: "standard")
|
|
562
|
-
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
559
|
+
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality, uploadBatchSize: frameBundleSize)
|
|
563
560
|
|
|
564
561
|
if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
|
|
565
562
|
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
|
}
|
|
@@ -192,7 +195,11 @@ final class SegmentDispatcher {
|
|
|
192
195
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
193
196
|
applyAuthHeaders(&req)
|
|
194
197
|
|
|
195
|
-
var body: [String: Any] = [
|
|
198
|
+
var body: [String: Any] = [
|
|
199
|
+
"sessionId": replayId,
|
|
200
|
+
"endedAt": concludedAt,
|
|
201
|
+
"sdkVersion": RejourneyImpl.sdkVersion
|
|
202
|
+
]
|
|
196
203
|
if backgroundDurationMs > 0 { body["totalBackgroundTimeMs"] = backgroundDurationMs }
|
|
197
204
|
if let m = metrics { body["metrics"] = m }
|
|
198
205
|
body["sdkTelemetry"] = sdkTelemetrySnapshot(currentQueueDepth: currentQueueDepth)
|
|
@@ -215,39 +222,6 @@ final class SegmentDispatcher {
|
|
|
215
222
|
}.resume()
|
|
216
223
|
}
|
|
217
224
|
|
|
218
|
-
func evaluateReplayRetention(replayId: String, metrics: [String: Any], completion: @escaping (Bool, String) -> Void) {
|
|
219
|
-
guard let url = URL(string: "\(endpoint)/api/ingest/replay/evaluate") else {
|
|
220
|
-
completion(false, "bad_url")
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
var req = URLRequest(url: url)
|
|
225
|
-
req.httpMethod = "POST"
|
|
226
|
-
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
227
|
-
applyAuthHeaders(&req)
|
|
228
|
-
|
|
229
|
-
var body: [String: Any] = ["sessionId": replayId]
|
|
230
|
-
metrics.forEach { body[$0.key] = $0.value }
|
|
231
|
-
|
|
232
|
-
do {
|
|
233
|
-
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
234
|
-
} catch {
|
|
235
|
-
completion(false, "serialize_error")
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
httpSession.dataTask(with: req) { data, resp, _ in
|
|
240
|
-
guard let data, (resp as? HTTPURLResponse)?.statusCode == 200,
|
|
241
|
-
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
242
|
-
completion(false, "request_failed")
|
|
243
|
-
return
|
|
244
|
-
}
|
|
245
|
-
let retained = json["promoted"] as? Bool ?? false
|
|
246
|
-
let reason = json["reason"] as? String ?? "unknown"
|
|
247
|
-
completion(retained, reason)
|
|
248
|
-
}.resume()
|
|
249
|
-
}
|
|
250
|
-
|
|
251
225
|
private func canUploadNow() -> Bool {
|
|
252
226
|
if billingBlocked { return false }
|
|
253
227
|
if circuitOpen {
|
|
@@ -313,6 +287,12 @@ final class SegmentDispatcher {
|
|
|
313
287
|
self.scheduleRetryIfNeeded(upload, completion: completion)
|
|
314
288
|
return
|
|
315
289
|
}
|
|
290
|
+
|
|
291
|
+
if presign.skipUpload {
|
|
292
|
+
self.registerSuccess()
|
|
293
|
+
completion?(true)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
316
296
|
|
|
317
297
|
self.uploadToS3(url: presign.presignedUrl, payload: upload.payload) { s3ok in
|
|
318
298
|
guard s3ok else {
|
|
@@ -373,7 +353,8 @@ final class SegmentDispatcher {
|
|
|
373
353
|
|
|
374
354
|
var body: [String: Any] = [
|
|
375
355
|
"sessionId": upload.sessionId,
|
|
376
|
-
"sizeBytes": upload.payload.count
|
|
356
|
+
"sizeBytes": upload.payload.count,
|
|
357
|
+
"sdkVersion": RejourneyImpl.sdkVersion
|
|
377
358
|
]
|
|
378
359
|
|
|
379
360
|
if upload.contentType == "events" {
|
|
@@ -415,7 +396,7 @@ final class SegmentDispatcher {
|
|
|
415
396
|
}
|
|
416
397
|
|
|
417
398
|
if json["skipUpload"] as? Bool == true {
|
|
418
|
-
completion(
|
|
399
|
+
completion(PresignResponse(presignedUrl: "", batchId: "", skipUpload: true))
|
|
419
400
|
return
|
|
420
401
|
}
|
|
421
402
|
|
|
@@ -426,7 +407,7 @@ final class SegmentDispatcher {
|
|
|
426
407
|
|
|
427
408
|
let batchId = json["batchId"] as? String ?? json["segmentId"] as? String ?? ""
|
|
428
409
|
|
|
429
|
-
completion(PresignResponse(presignedUrl: presignedUrl, batchId: batchId))
|
|
410
|
+
completion(PresignResponse(presignedUrl: presignedUrl, batchId: batchId, skipUpload: false))
|
|
430
411
|
}.resume()
|
|
431
412
|
}
|
|
432
413
|
|
|
@@ -638,4 +619,5 @@ private struct PendingUpload {
|
|
|
638
619
|
private struct PresignResponse {
|
|
639
620
|
let presignedUrl: String
|
|
640
621
|
let batchId: String
|
|
622
|
+
let skipUpload: Bool
|
|
641
623
|
}
|
|
@@ -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,
|
|
@@ -330,6 +354,7 @@ public final class TelemetryPipeline: NSObject {
|
|
|
330
354
|
"isConstrained": isConstrained,
|
|
331
355
|
"isExpensive": isExpensive,
|
|
332
356
|
"appVersion": appVersion,
|
|
357
|
+
"sdkVersion": RejourneyImpl.sdkVersion,
|
|
333
358
|
"appId": appId,
|
|
334
359
|
"screenWidth": Int(bounds.width),
|
|
335
360
|
"screenHeight": Int(bounds.height),
|
|
@@ -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 {
|