@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.
@@ -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(savedUserId: String?, attempts: Int) {
403
- val maxAttempts = 30 // 3 seconds max
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
- if (currentState is SessionState.Active) {
492
- promise.resolve(createResultMap(true, currentState.sessionId))
493
- return@post
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
- // Pre-generate session ID to ensure consistency between JS and native
526
- val sid = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
527
- ReplayOrchestrator.shared?.replayId = sid
528
- TelemetryPipeline.shared?.currentReplayId = sid
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
- // Allow orchestrator time to spin up
541
- mainHandler.postDelayed({
542
- stateLock.withLock {
543
- state = SessionState.Active(sid, System.currentTimeMillis())
544
- }
545
-
546
- ReplayOrchestrator.shared?.activateGestureRecording()
547
-
548
- if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
549
- ReplayOrchestrator.shared?.associateUser(userId)
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(savedUserId: String?, attempts: Int) {
254
- val maxAttempts = 30 // 3 seconds max
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
- if (currentState is SessionState.Active) {
303
- callback?.invoke(true, currentState.sessionId)
304
- return@post
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
- // Pre-generate session ID
321
- val sid = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
322
- ReplayOrchestrator.shared?.replayId = sid
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
- // Allow orchestrator time to spin up
332
- mainHandler.postDelayed({
333
- stateLock.withLock {
334
- state = SessionState.Active(sid, System.currentTimeMillis())
335
- }
336
-
337
- ReplayOrchestrator.shared?.activateGestureRecording()
338
-
339
- if (userId != "anonymous") {
340
- ReplayOrchestrator.shared?.associateUser(userId)
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 = 5
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) ?: 5
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
- val sid = currentReplayId
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 null
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
- SegmentDispatcher.shared.halt()
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
- // Drop frames that belong to a session that is no longer active —
253
- // they would be uploaded under the wrong session and cause flickering.
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.transmitFrameBundle(
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
- // Industry standard batch size (20 frames per batch)
95
- private val batchSize = 20
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 >= batchSize
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(savedUserId: String?, attempts: Int) {
254
- let maxAttempts = 30 // 3 seconds max (30 * 100ms)
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
- if case .active(let sid, _) = self.state {
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
- ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
380
-
381
- // Allow orchestrator time to spin up
382
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
383
- let sid = ReplayOrchestrator.shared.replayId ?? UUID().uuidString
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
- ReplayOrchestrator.shared.activateGestureRecording()
404
+ ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
391
405
 
392
- if userId != "anonymous" && !userId.hasPrefix("anon_") {
393
- ReplayOrchestrator.shared.associateUser(userId)
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 = 5
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 ?? 5
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
- guard let sid = currentReplayId, canUploadNow() else {
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(nil)
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
- SegmentDispatcher.shared.halt()
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
- guard currentReplayId != nil else {
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] Dropping \(next.count) stale frames from session \(bundleSession)")
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
- // Industry standard batch size (20 frames per batch, not 5)
63
- private let _batchSize = 20
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._batchSize
329
+ let shouldSend = !self._deferredUntilCommit && self._screenshots.count >= self._uploadBatchSize
329
330
  self._stateLock.unlock()
330
331
 
331
332
  if shouldSend {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rejourneyco/react-native",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Rejourney Session Recording SDK for React Native",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",