@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.
@@ -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(savedUserId: String?, attempts: Int) {
404
- val maxAttempts = 30 // 3 seconds max
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
- promise.resolve(createResultMap(true, currentState.sessionId))
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
- // Allow orchestrator time to spin up
549
- mainHandler.postDelayed({
550
- val sid = ReplayOrchestrator.shared?.replayId ?: pendingSessionId
551
- stateLock.withLock {
552
- state = SessionState.Active(sid, System.currentTimeMillis())
553
- }
554
-
555
- ReplayOrchestrator.shared?.activateGestureRecording()
556
-
557
- if (!userId.isNullOrBlank() && userId != "anonymous" && !userId.startsWith("anon_")) {
558
- ReplayOrchestrator.shared?.associateUser(userId)
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(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()
@@ -302,19 +302,17 @@ class ReplayOrchestrator private constructor(private val context: Context) {
302
302
  }
303
303
  finalized = true
304
304
 
305
- SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { _, _ ->
306
- SegmentDispatcher.shared.concludeReplay(
307
- sid,
308
- termMs,
309
- bgTimeMs,
310
- metrics,
311
- queueDepthAtFinalize,
312
- endReason = endReason,
313
- lifecycleVersion = lifecycleContractVersion
314
- ) { ok ->
315
- if (ok) clearRecovery()
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) ?: 5
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
- val sid = currentReplayId
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 null
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
- SegmentDispatcher.shared.halt()
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
- // 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
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.transmitFrameBundle(
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
- // 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()
@@ -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(savedUserId: String?, attempts: Int) {
255
- let maxAttempts = 30 // 3 seconds max (30 * 100ms)
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, _), .starting(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
- // Allow orchestrator time to spin up
391
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
392
- let sid = ReplayOrchestrator.shared.replayId ?? pendingSessionId
393
- let start = Date().timeIntervalSince1970
394
-
395
- self.stateLock.lock()
396
- self.state = .active(sessionId: sid, startTime: start)
397
- self.stateLock.unlock()
398
-
399
- ReplayOrchestrator.shared.activateGestureRecording()
400
-
401
- if userId != "anonymous" && !userId.hasPrefix("anon_") {
402
- ReplayOrchestrator.shared.associateUser(userId)
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 = 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()
@@ -258,20 +258,17 @@ public final class ReplayOrchestrator: NSObject {
258
258
  }
259
259
  _finalized = true
260
260
 
261
- SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] _, _ in
262
- guard let self else { return }
263
- SegmentDispatcher.shared.concludeReplay(
264
- replayId: sid,
265
- concludedAt: termMs,
266
- backgroundDurationMs: self._bgTimeMs,
267
- metrics: metrics,
268
- currentQueueDepth: queueDepthAtFinalize,
269
- endReason: endReason,
270
- lifecycleVersion: self.lifecycleContractVersion
271
- ) { [weak self] ok in
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 ?? 5
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
- 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
  }
@@ -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] = ["sessionId": replayId, "endedAt": concludedAt]
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(nil)
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
- 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,
@@ -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
- // 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.14",
3
+ "version": "1.0.16",
4
4
  "description": "Rejourney Session Recording SDK for React Native",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",