@rejourneyco/react-native 1.0.12 → 1.0.14

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()
@@ -488,9 +489,16 @@ class RejourneyModuleImpl(
488
489
  // Check if already active
489
490
  stateLock.withLock {
490
491
  val currentState = state
491
- if (currentState is SessionState.Active) {
492
- promise.resolve(createResultMap(true, currentState.sessionId))
493
- return@post
492
+ when (currentState) {
493
+ is SessionState.Active -> {
494
+ promise.resolve(createResultMap(true, currentState.sessionId))
495
+ return@post
496
+ }
497
+ is SessionState.Starting -> {
498
+ promise.resolve(createResultMap(true, currentState.sessionId))
499
+ return@post
500
+ }
501
+ else -> Unit
494
502
  }
495
503
  }
496
504
 
@@ -522,10 +530,10 @@ class RejourneyModuleImpl(
522
530
  DiagnosticLog.fault("[Rejourney] CRITICAL: No current activity available for capture!")
523
531
  }
524
532
 
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
533
+ val pendingSessionId = "session_${System.currentTimeMillis()}_${java.util.UUID.randomUUID().toString().replace("-", "").lowercase()}"
534
+ stateLock.withLock {
535
+ state = SessionState.Starting(pendingSessionId, System.currentTimeMillis())
536
+ }
529
537
 
530
538
  // Begin replay
531
539
  ReplayOrchestrator.shared?.beginReplay(
@@ -539,6 +547,7 @@ class RejourneyModuleImpl(
539
547
 
540
548
  // Allow orchestrator time to spin up
541
549
  mainHandler.postDelayed({
550
+ val sid = ReplayOrchestrator.shared?.replayId ?: pendingSessionId
542
551
  stateLock.withLock {
543
552
  state = SessionState.Active(sid, System.currentTimeMillis())
544
553
  }
@@ -279,10 +279,14 @@ class ReplayOrchestrator private constructor(private val context: Context) {
279
279
  )
280
280
  val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
281
281
 
282
+ // Capture the current generation so a stale halt posted here won't
283
+ // stop a new session's capture that starts before this block runs.
284
+ val haltGeneration = VisualCapture.shared?.captureGeneration ?: -1
285
+
282
286
  // Do local teardown immediately so lifecycle rollover never depends on network latency.
283
287
  mainHandler.post {
284
288
  TelemetryPipeline.shared?.shutdown()
285
- VisualCapture.shared?.halt()
289
+ VisualCapture.shared?.halt(haltGeneration)
286
290
  InteractionRecorder.shared?.deactivate()
287
291
  StabilityMonitor.shared?.deactivate()
288
292
  AnrSentinel.shared?.deactivate()
@@ -159,9 +159,12 @@ class TelemetryPipeline private constructor(private val context: Context) {
159
159
  fun shutdown() {
160
160
  heartbeatRunnable?.let { mainHandler.removeCallbacks(it) }
161
161
  heartbeatRunnable = null
162
-
162
+
163
163
  SegmentDispatcher.shared.halt()
164
164
  appSuspending()
165
+
166
+ // Clear pending frame bundles so they don't leak into the next session
167
+ frameQueue.clear()
165
168
  }
166
169
 
167
170
  fun finalizeAndShip() {
@@ -181,9 +184,12 @@ class TelemetryPipeline private constructor(private val context: Context) {
181
184
  }
182
185
 
183
186
  fun submitFrameBundle(payload: ByteArray, filename: String, startMs: Long, endMs: Long, frameCount: Int) {
184
- DiagnosticLog.trace("[TelemetryPipeline] submitFrameBundle: $frameCount frames, ${payload.size} bytes, deferredMode=$deferredMode")
187
+ // Capture the session ID now so frames are always attributed to the
188
+ // session that was active when they were captured, not when they ship.
189
+ val capturedSessionId = currentReplayId
190
+ DiagnosticLog.trace("[TelemetryPipeline] submitFrameBundle: $frameCount frames, ${payload.size} bytes, deferredMode=$deferredMode, session=$capturedSessionId")
185
191
  serialWorker.execute {
186
- val bundle = PendingFrameBundle(filename, payload, startMs, endMs, frameCount)
192
+ val bundle = PendingFrameBundle(filename, payload, startMs, endMs, frameCount, capturedSessionId)
187
193
  frameQueue.enqueue(bundle)
188
194
  if (!deferredMode) shipPendingFrames()
189
195
  }
@@ -233,11 +239,23 @@ class TelemetryPipeline private constructor(private val context: Context) {
233
239
  DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: no frames in queue")
234
240
  return
235
241
  }
236
- if (currentReplayId == null) {
237
- DiagnosticLog.caution("[TelemetryPipeline] shipPendingFrames: no currentReplayId, requeueing")
242
+
243
+ // Determine which session these frames belong to. Prefer the session ID
244
+ // captured at enqueue time; fall back to the current active session.
245
+ val targetSession = next.sessionId ?: currentReplayId
246
+ if (targetSession == null) {
247
+ DiagnosticLog.caution("[TelemetryPipeline] shipPendingFrames: no session ID, requeueing")
238
248
  frameQueue.requeue(next)
239
249
  return
240
250
  }
251
+
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
258
+ }
241
259
 
242
260
  DiagnosticLog.trace("[TelemetryPipeline] shipPendingFrames: transmitting ${next.count} frames to SegmentDispatcher")
243
261
 
@@ -641,7 +659,8 @@ private data class PendingFrameBundle(
641
659
  val payload: ByteArray,
642
660
  val rangeStart: Long,
643
661
  val rangeEnd: Long,
644
- val count: Int
662
+ val count: Int,
663
+ val sessionId: String? = null
645
664
  )
646
665
 
647
666
  private class FrameBundleQueue(private val maxPending: Int) {
@@ -671,4 +690,8 @@ private class FrameBundleQueue(private val maxPending: Int) {
671
690
  }
672
691
 
673
692
  fun size(): Int = queue.size
693
+
694
+ fun clear() {
695
+ lock.withLock { queue.clear() }
696
+ }
674
697
  }
@@ -79,6 +79,8 @@ class VisualCapture private constructor(private val context: Context) {
79
79
  private var deferredUntilCommit = false
80
80
  private var framesDiskPath: File? = null
81
81
  private var currentSessionId: String? = null
82
+ @Volatile var captureGeneration: Int = 0
83
+ private set
82
84
 
83
85
  private val mainHandler = Handler(Looper.getMainLooper())
84
86
 
@@ -103,10 +105,35 @@ class VisualCapture private constructor(private val context: Context) {
103
105
 
104
106
  fun beginCapture(sessionOrigin: Long) {
105
107
  DiagnosticLog.trace("[VisualCapture] beginCapture called, currentActivity=${currentActivity?.get()?.javaClass?.simpleName ?: "null"}, state=${stateMachine.currentState}")
108
+
109
+ // If we're still in CAPTURING state (halt() from previous session hasn't
110
+ // run yet due to async mainHandler.post), force-halt first to prevent the
111
+ // stale halt from stopping the new session's capture later.
112
+ if (stateMachine.currentState == CaptureState.CAPTURING) {
113
+ DiagnosticLog.trace("[VisualCapture] Force-halting stale capture before starting new session")
114
+ stopCaptureTimer()
115
+ stateMachine.transition(CaptureState.HALTED)
116
+ }
117
+
106
118
  if (!stateMachine.transition(CaptureState.CAPTURING)) {
107
119
  DiagnosticLog.trace("[VisualCapture] beginCapture REJECTED - state transition failed from ${stateMachine.currentState}")
108
120
  return
109
121
  }
122
+
123
+ // Bump generation so any stale halt() posted by the previous session
124
+ // (via mainHandler.post) becomes a no-op and doesn't stop this capture.
125
+ captureGeneration++
126
+
127
+ // Discard any frames left over from a previous session to prevent
128
+ // cross-session frame leakage (frames from session A appearing in session B).
129
+ stateLock.withLock {
130
+ val staleCount = screenshots.size
131
+ if (staleCount > 0) {
132
+ DiagnosticLog.trace("[VisualCapture] Clearing $staleCount stale frames from previous session")
133
+ screenshots.clear()
134
+ }
135
+ }
136
+
110
137
  sessionEpoch = sessionOrigin
111
138
  frameCounter.set(0)
112
139
 
@@ -122,7 +149,13 @@ class VisualCapture private constructor(private val context: Context) {
122
149
  startCaptureTimer()
123
150
  }
124
151
 
125
- fun halt() {
152
+ fun halt(expectedGeneration: Int = -1) {
153
+ // If a specific generation is expected (async/posted halt from a previous
154
+ // session), skip if a new session has already started capture.
155
+ if (expectedGeneration >= 0 && expectedGeneration != captureGeneration) {
156
+ DiagnosticLog.trace("[VisualCapture] Skipping stale halt (gen=$expectedGeneration, current=$captureGeneration)")
157
+ return
158
+ }
126
159
  if (!stateMachine.transition(CaptureState.HALTED)) return
127
160
  stopCaptureTimer()
128
161
 
@@ -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
@@ -353,12 +354,14 @@ public final class RejourneyImpl: NSObject {
353
354
  }
354
355
 
355
356
  self.stateLock.lock()
356
- if case .active(let sid, _) = self.state {
357
+ switch self.state {
358
+ case .active(let sid, _), .starting(let sid, _):
357
359
  self.stateLock.unlock()
358
360
  resolve(["success": true, "sessionId": sid])
359
361
  return
362
+ default:
363
+ self.stateLock.unlock()
360
364
  }
361
- self.stateLock.unlock()
362
365
 
363
366
  if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
364
367
  self.currentUserIdentity = userId
@@ -376,11 +379,17 @@ public final class RejourneyImpl: NSObject {
376
379
  // Activate native network interception
377
380
  RejourneyURLProtocol.enable()
378
381
 
382
+ let pendingSessionId = "session_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())"
383
+ let pendingStart = Date().timeIntervalSince1970
384
+ self.stateLock.lock()
385
+ self.state = .starting(sessionId: pendingSessionId, startTime: pendingStart)
386
+ self.stateLock.unlock()
387
+
379
388
  ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
380
389
 
381
390
  // Allow orchestrator time to spin up
382
391
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
383
- let sid = ReplayOrchestrator.shared.replayId ?? UUID().uuidString
392
+ let sid = ReplayOrchestrator.shared.replayId ?? pendingSessionId
384
393
  let start = Date().timeIntervalSince1970
385
394
 
386
395
  self.stateLock.lock()
@@ -235,10 +235,14 @@ public final class ReplayOrchestrator: NSObject {
235
235
  ]
236
236
  let queueDepthAtFinalize = TelemetryPipeline.shared.getQueueDepth()
237
237
 
238
+ // Capture the current generation so a stale halt posted here won't
239
+ // stop a new session's capture that starts before this block runs.
240
+ let haltGeneration = VisualCapture.shared.captureGeneration
241
+
238
242
  // Do local teardown immediately so lifecycle rollover never depends on network latency.
239
243
  DispatchQueue.main.async {
240
244
  TelemetryPipeline.shared.shutdown()
241
- VisualCapture.shared.halt()
245
+ VisualCapture.shared.halt(expectedGeneration: haltGeneration)
242
246
  InteractionRecorder.shared.deactivate()
243
247
  FaultTracker.shared.deactivate()
244
248
  ResponsivenessWatcher.shared.halt()
@@ -127,6 +127,9 @@ public final class TelemetryPipeline: NSObject {
127
127
 
128
128
  SegmentDispatcher.shared.halt()
129
129
  _appSuspending()
130
+
131
+ // Clear pending frame bundles so they don't leak into the next session
132
+ _frameQueue.clear()
130
133
  }
131
134
 
132
135
  @objc public func finalizeAndShip() {
@@ -146,8 +149,11 @@ public final class TelemetryPipeline: NSObject {
146
149
  }
147
150
 
148
151
  @objc public func submitFrameBundle(payload: Data, filename: String, startMs: UInt64, endMs: UInt64, frameCount: Int) {
152
+ // Capture the session ID now so frames are always attributed to the
153
+ // session that was active when they were captured, not when they ship.
154
+ let capturedSessionId = currentReplayId
149
155
  _serialWorker.async {
150
- let bundle = PendingFrameBundle(tag: filename, payload: payload, rangeStart: startMs, rangeEnd: endMs, count: frameCount)
156
+ let bundle = PendingFrameBundle(tag: filename, payload: payload, rangeStart: startMs, rangeEnd: endMs, count: frameCount, sessionId: capturedSessionId)
151
157
  self._frameQueue.enqueue(bundle)
152
158
  if !self._deferredMode { self._shipPendingFrames() }
153
159
  }
@@ -246,7 +252,19 @@ public final class TelemetryPipeline: NSObject {
246
252
  }
247
253
 
248
254
  private func _shipPendingFrames() {
249
- guard !_deferredMode, let next = _frameQueue.dequeue(), currentReplayId != nil else { return }
255
+ guard !_deferredMode, let next = _frameQueue.dequeue() else { return }
256
+
257
+ guard currentReplayId != nil else {
258
+ _frameQueue.requeue(next)
259
+ return
260
+ }
261
+
262
+ // Drop frames that belong to a session that is no longer active
263
+ if let bundleSession = next.sessionId, bundleSession != currentReplayId {
264
+ DiagnosticLog.trace("[TelemetryPipeline] Dropping \(next.count) stale frames from session \(bundleSession)")
265
+ _shipPendingFrames()
266
+ return
267
+ }
250
268
 
251
269
  SegmentDispatcher.shared.transmitFrameBundle(
252
270
  payload: next.payload,
@@ -569,6 +587,7 @@ private struct PendingFrameBundle {
569
587
  let rangeStart: UInt64
570
588
  let rangeEnd: UInt64
571
589
  let count: Int
590
+ let sessionId: String?
572
591
  }
573
592
 
574
593
  private final class FrameBundleQueue {
@@ -605,4 +624,10 @@ private final class FrameBundleQueue {
605
624
  defer { _lock.unlock() }
606
625
  _queue.insert(bundle, at: 0)
607
626
  }
627
+
628
+ func clear() {
629
+ _lock.lock()
630
+ defer { _lock.unlock() }
631
+ _queue.removeAll()
632
+ }
608
633
  }
@@ -44,6 +44,7 @@ public final class VisualCapture: NSObject {
44
44
  private var _deferredUntilCommit = false
45
45
  private var _framesDiskPath: URL?
46
46
  private var _currentSessionId: String?
47
+ @objc public private(set) var captureGeneration: Int = 0
47
48
 
48
49
  // Use OperationQueue like industry standard - serialized, utility QoS
49
50
  private let _encodeQueue: OperationQueue = {
@@ -105,7 +106,28 @@ public final class VisualCapture: NSObject {
105
106
  }
106
107
 
107
108
  @objc public func beginCapture(sessionOrigin: UInt64) {
109
+ // If still in CAPTURING state (halt() from previous session hasn't
110
+ // run yet), force-halt first to prevent it from stopping the new session.
111
+ if _stateMachine.currentState == .capturing {
112
+ DiagnosticLog.trace("[VisualCapture] Force-halting stale capture before starting new session")
113
+ _stopCaptureTimer()
114
+ _ = _stateMachine.transition(to: .halted)
115
+ }
116
+
108
117
  guard _stateMachine.transition(to: .capturing) else { return }
118
+
119
+ // Bump generation so any stale halt() becomes a no-op
120
+ captureGeneration += 1
121
+
122
+ // Discard leftover frames from the previous session
123
+ _stateLock.lock()
124
+ let staleCount = _screenshots.count
125
+ if staleCount > 0 {
126
+ DiagnosticLog.trace("[VisualCapture] Clearing \(staleCount) stale frames from previous session")
127
+ _screenshots.removeAll()
128
+ }
129
+ _stateLock.unlock()
130
+
109
131
  _sessionEpoch = sessionOrigin
110
132
  _frameCounter = 0
111
133
 
@@ -120,7 +142,13 @@ public final class VisualCapture: NSObject {
120
142
  _startCaptureTimer()
121
143
  }
122
144
 
123
- @objc public func halt() {
145
+ @objc public func halt(expectedGeneration: Int = -1) {
146
+ // If a specific generation is expected (async/posted halt from a previous
147
+ // session), skip if a new session has already started capture.
148
+ if expectedGeneration >= 0 && expectedGeneration != captureGeneration {
149
+ DiagnosticLog.trace("[VisualCapture] Skipping stale halt (gen=\(expectedGeneration), current=\(captureGeneration))")
150
+ return
151
+ }
124
152
  guard _stateMachine.transition(to: .halted) else { return }
125
153
  _stopCaptureTimer()
126
154
 
@@ -292,7 +292,7 @@ let _authErrorSubscription = null;
292
292
  let _currentAppState = 'active'; // Default to active, will be updated on init
293
293
  let _userIdentity = null;
294
294
  let _backgroundEntryTime = null; // Track when app went to background
295
- let _storedMetadata = {}; // Accumulate metadata for session rollover
295
+ const _storedMetadata = {}; // Accumulate metadata for session rollover
296
296
 
297
297
  // Session timeout - must match native side (60 seconds)
298
298
  const SESSION_TIMEOUT_MS = 60_000;
@@ -189,7 +189,7 @@ let _authErrorSubscription = null;
189
189
  let _currentAppState = 'active'; // Default to active, will be updated on init
190
190
  let _userIdentity = null;
191
191
  let _backgroundEntryTime = null; // Track when app went to background
192
- let _storedMetadata = {}; // Accumulate metadata for session rollover
192
+ const _storedMetadata = {}; // Accumulate metadata for session rollover
193
193
 
194
194
  // Session timeout - must match native side (60 seconds)
195
195
  const SESSION_TIMEOUT_MS = 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rejourneyco/react-native",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Rejourney Session Recording SDK for React Native",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
package/src/index.ts CHANGED
@@ -212,7 +212,7 @@ let _authErrorSubscription: { remove: () => void } | null = null;
212
212
  let _currentAppState: string = 'active'; // Default to active, will be updated on init
213
213
  let _userIdentity: string | null = null;
214
214
  let _backgroundEntryTime: number | null = null; // Track when app went to background
215
- let _storedMetadata: Record<string, string | number | boolean> = {}; // Accumulate metadata for session rollover
215
+ const _storedMetadata: Record<string, string | number | boolean> = {}; // Accumulate metadata for session rollover
216
216
 
217
217
  // Session timeout - must match native side (60 seconds)
218
218
  const SESSION_TIMEOUT_MS = 60_000;