@rejourneyco/react-native 1.0.11 → 1.0.13

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/README.md CHANGED
@@ -64,22 +64,29 @@ Rejourney.trackScreen('Custom Screen Name');
64
64
 
65
65
  ## Custom Events & Metadata
66
66
 
67
- You can track custom events and assign metadata to sessions to filter and segment them later.
67
+ Track user actions and attach session-level context for filtering and segmentation in the dashboard.
68
68
 
69
69
  ```typescript
70
70
  import { Rejourney } from '@rejourneyco/react-native';
71
71
 
72
- // Log custom events
73
- Rejourney.logEvent('button_clicked', { buttonName: 'signup' });
72
+ // Log custom events with optional properties
73
+ Rejourney.logEvent('signup_completed');
74
+ Rejourney.logEvent('purchase_completed', {
75
+ plan: 'pro',
76
+ amount: 29.99
77
+ });
74
78
 
75
- // Add custom session metadata
79
+ // Attach session-level metadata (key-value context)
76
80
  Rejourney.setMetadata('plan', 'premium');
77
81
  Rejourney.setMetadata({
78
- role: 'tester',
79
- ab_test_group: 'A'
82
+ role: 'admin',
83
+ ab_variant: 'checkout_v2'
80
84
  });
81
85
  ```
82
86
 
87
+ **Events** = things that happened (actions, timestamped, can occur multiple times)
88
+ **Metadata** = who the user is / what state they're in (session-level, one value per key)
89
+
83
90
  ## API Reference & Compatibility
84
91
 
85
92
  Rejourney supports both a standardized `Rejourney.` namespace and standalone function exports (AKA calls). Both are fully supported.
@@ -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()
@@ -483,6 +487,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
483
487
 
484
488
  fun logScreenView(screenId: String) {
485
489
  if (screenId.isEmpty()) return
490
+ if (visitedScreens.size >= 500) {
491
+ val excess = visitedScreens.size - 250
492
+ repeat(excess) { visitedScreens.removeAt(0) }
493
+ }
486
494
  visitedScreens.add(screenId)
487
495
  currentScreenName = screenId
488
496
  if (hierarchyCaptureEnabled) captureHierarchy()
@@ -125,15 +125,17 @@ class SegmentDispatcher private constructor() {
125
125
  private val scope = CoroutineScope(workerExecutor.asCoroutineDispatcher() + SupervisorJob())
126
126
 
127
127
  private val httpClient: OkHttpClient = OkHttpClient.Builder()
128
- .connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
128
+ .connectTimeout(5, TimeUnit.SECONDS)
129
129
  .readTimeout(10, TimeUnit.SECONDS)
130
130
  .writeTimeout(10, TimeUnit.SECONDS)
131
- // Mirror iOS URLProtocol: ensure native upload/auth traffic is captured
132
- .addInterceptor(RejourneyNetworkInterceptor())
131
+ // Intentionally NO RejourneyNetworkInterceptor here: intercepting our
132
+ // own upload traffic creates redundant network events, wastes bandwidth,
133
+ // and can cause circular upload→intercept→upload chains.
133
134
  .build()
134
135
 
135
136
  private val retryQueue = mutableListOf<PendingUpload>()
136
137
  private val retryLock = ReentrantLock()
138
+ private val maxRetryQueueSize = 20
137
139
  private var active = true
138
140
 
139
141
  fun configure(replayId: String, apiToken: String?, credential: String?, projectId: String?, isSampledIn: Boolean = true) {
@@ -411,6 +413,9 @@ class SegmentDispatcher private constructor() {
411
413
  if (upload.attempt < 3) {
412
414
  val retry = upload.copy(attempt = upload.attempt + 1)
413
415
  retryLock.withLock {
416
+ if (retryQueue.size >= maxRetryQueueSize) {
417
+ retryQueue.removeAt(0)
418
+ }
414
419
  retryQueue.add(retry)
415
420
  }
416
421
  metricsLock.withLock {
@@ -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
 
@@ -18,22 +18,26 @@ import Foundation
18
18
 
19
19
  /// Intercepts URLSession network traffic globally for Rejourney Session Replay.
20
20
  @objc(RejourneyURLProtocol)
21
- public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessionTaskDelegate {
21
+ public class RejourneyURLProtocol: URLProtocol {
22
22
 
23
- // We tag requests that we've already handled so we don't intercept them repeatedly.
24
23
  private static let _handledKey = "co.rejourney.handled"
25
24
 
26
25
  private var _dataTask: URLSessionDataTask?
27
26
  private var _startMs: Int64 = 0
28
27
  private var _endMs: Int64 = 0
29
- private var _responseData: Data?
30
28
  private var _response: URLResponse?
31
29
  private var _error: Error?
32
30
 
33
- // Session used to forward the intercepted request execution
34
- private lazy var _session: URLSession = {
35
- let config = URLSessionConfiguration.default
36
- return URLSession(configuration: config, delegate: self, delegateQueue: nil)
31
+ /// Shared forwarding session. Uses ephemeral config with protocol classes
32
+ /// stripped to prevent self-interception, and a delegate adapter that routes
33
+ /// callbacks to the correct RejourneyURLProtocol instance via a task map.
34
+ /// This avoids the per-instance URLSession retain cycle that previously
35
+ /// leaked every intercepted request (~1-3MB each).
36
+ private static let _delegateAdapter = SessionDelegateAdapter()
37
+ private static let _sharedSession: URLSession = {
38
+ let cfg = URLSessionConfiguration.ephemeral
39
+ cfg.protocolClasses = []
40
+ return URLSession(configuration: cfg, delegate: _delegateAdapter, delegateQueue: nil)
37
41
  }()
38
42
 
39
43
  @objc public static func enable() {
@@ -130,30 +134,32 @@ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessi
130
134
  URLProtocol.setProperty(true, forKey: RejourneyURLProtocol._handledKey, in: request)
131
135
 
132
136
  _startMs = Int64(Date().timeIntervalSince1970 * 1000)
133
- _dataTask = _session.dataTask(with: request as URLRequest)
134
- _dataTask?.resume()
137
+ let task = Self._sharedSession.dataTask(with: request as URLRequest)
138
+ Self._delegateAdapter.register(task: task, protocol: self)
139
+ _dataTask = task
140
+ task.resume()
135
141
  }
136
142
 
137
143
  public override func stopLoading() {
138
- _dataTask?.cancel()
144
+ if let task = _dataTask {
145
+ Self._delegateAdapter.unregister(task: task)
146
+ task.cancel()
147
+ }
139
148
  _dataTask = nil
140
149
  }
141
150
 
142
- // MARK: - URLSessionDataDelegate
151
+ // MARK: - Callbacks from SessionDelegateAdapter
143
152
 
144
- public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
153
+ fileprivate func handleDidReceiveData(_ data: Data) {
145
154
  client?.urlProtocol(self, didLoad: data)
146
155
  }
147
156
 
148
- public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
157
+ fileprivate func handleDidReceiveResponse(_ response: URLResponse) {
149
158
  _response = response
150
159
  client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
151
- completionHandler(.allow)
152
160
  }
153
161
 
154
- // MARK: - URLSessionTaskDelegate
155
-
156
- public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
162
+ fileprivate func handleDidComplete(error: Error?) {
157
163
  _endMs = Int64(Date().timeIntervalSince1970 * 1000)
158
164
  _error = error
159
165
 
@@ -162,7 +168,9 @@ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessi
162
168
  } else {
163
169
  client?.urlProtocolDidFinishLoading(self)
164
170
  }
165
-
171
+ }
172
+
173
+ fileprivate func handleDidCompleteLogging(task: URLSessionTask) {
166
174
  _logRequest(task: task)
167
175
  }
168
176
 
@@ -214,3 +222,45 @@ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessi
214
222
  TelemetryPipeline.shared.recordNetworkEvent(details: event)
215
223
  }
216
224
  }
225
+
226
+ /// Routes URLSession delegate callbacks to the correct RejourneyURLProtocol
227
+ /// instance using a task-to-protocol map. Uses weak references to avoid
228
+ /// retaining protocol instances that have been stopped by the URL loading system.
229
+ private final class SessionDelegateAdapter: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
230
+ private let _lock = NSLock()
231
+ private let _taskMap = NSMapTable<URLSessionTask, RejourneyURLProtocol>.strongToWeakObjects()
232
+
233
+ func register(task: URLSessionTask, protocol proto: RejourneyURLProtocol) {
234
+ _lock.lock()
235
+ _taskMap.setObject(proto, forKey: task)
236
+ _lock.unlock()
237
+ }
238
+
239
+ func unregister(task: URLSessionTask) {
240
+ _lock.lock()
241
+ _taskMap.removeObject(forKey: task)
242
+ _lock.unlock()
243
+ }
244
+
245
+ private func proto(for task: URLSessionTask) -> RejourneyURLProtocol? {
246
+ _lock.lock()
247
+ defer { _lock.unlock() }
248
+ return _taskMap.object(forKey: task)
249
+ }
250
+
251
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
252
+ proto(for: dataTask)?.handleDidReceiveData(data)
253
+ }
254
+
255
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
256
+ proto(for: dataTask)?.handleDidReceiveResponse(response)
257
+ completionHandler(.allow)
258
+ }
259
+
260
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
261
+ guard let p = proto(for: task) else { return }
262
+ p.handleDidComplete(error: error)
263
+ p.handleDidCompleteLogging(task: task)
264
+ unregister(task: task)
265
+ }
266
+ }
@@ -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()
@@ -429,6 +433,9 @@ public final class ReplayOrchestrator: NSObject {
429
433
 
430
434
  @objc public func logScreenView(_ screenId: String) {
431
435
  guard !screenId.isEmpty else { return }
436
+ if _visitedScreens.count >= 500 {
437
+ _visitedScreens.removeFirst(_visitedScreens.count - 250)
438
+ }
432
439
  _visitedScreens.append(screenId)
433
440
  currentScreenName = screenId
434
441
  if hierarchyCaptureEnabled { _captureHierarchy() }
@@ -44,17 +44,21 @@ final class SegmentDispatcher {
44
44
  }()
45
45
 
46
46
  private let httpSession: URLSession = {
47
- // Industry standard: Use ephemeral config with explicit connection limits
48
47
  let cfg = URLSessionConfiguration.ephemeral
49
48
  cfg.httpMaximumConnectionsPerHost = 4
50
49
  cfg.waitsForConnectivity = true
51
50
  cfg.timeoutIntervalForRequest = 30
52
51
  cfg.timeoutIntervalForResource = 60
52
+ // Strip our own protocol to prevent self-interception. Without this,
53
+ // every SDK upload is intercepted by RejourneyURLProtocol which
54
+ // generates redundant network events and wastes resources.
55
+ cfg.protocolClasses = cfg.protocolClasses?.filter { $0 != RejourneyURLProtocol.self } ?? []
53
56
  return URLSession(configuration: cfg)
54
57
  }()
55
58
 
56
59
  private var retryQueue: [PendingUpload] = []
57
60
  private let retryLock = NSLock()
61
+ private let maxRetryQueueSize = 20
58
62
  private var active = true
59
63
 
60
64
  private let metricsLock = NSLock()
@@ -332,6 +336,9 @@ final class SegmentDispatcher {
332
336
  var retry = upload
333
337
  retry.attempt += 1
334
338
  retryLock.lock()
339
+ if retryQueue.count >= maxRetryQueueSize {
340
+ retryQueue.removeFirst()
341
+ }
335
342
  retryQueue.append(retry)
336
343
  retryLock.unlock()
337
344
 
@@ -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
 
@@ -206,9 +234,16 @@ public final class VisualCapture: NSObject {
206
234
  // Refresh map detection state (very cheap shallow walk)
207
235
  SpecialCases.shared.refreshMapState()
208
236
 
209
- // Debug-only: confirm capture is running and map state
210
237
  if _frameCounter < 5 || _frameCounter % 30 == 0 {
211
- DiagnosticLog.trace("[VisualCapture] frame#\(_frameCounter) mapVisible=\(SpecialCases.shared.mapVisible) mapIdle=\(SpecialCases.shared.mapIdle) forced=\(forced)")
238
+ var info = mach_task_basic_info()
239
+ var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
240
+ let _ = withUnsafeMutablePointer(to: &info) {
241
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
242
+ task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
243
+ }
244
+ }
245
+ let memMB = Double(info.resident_size) / 1_048_576.0
246
+ DiagnosticLog.trace("[VisualCapture] frame#\(_frameCounter) mapVisible=\(SpecialCases.shared.mapVisible) mapIdle=\(SpecialCases.shared.mapIdle) forced=\(forced) residentMB=\(String(format: "%.0f", memMB))")
212
247
  }
213
248
 
214
249
  // Map stutter prevention: when a map view is visible and its camera
@@ -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;
@@ -902,6 +902,9 @@ function trackScreen(screenName) {
902
902
  }
903
903
  const previousScreen = currentScreen;
904
904
  currentScreen = screenName;
905
+ if (screensVisited.length >= 500) {
906
+ screensVisited.splice(0, screensVisited.length - 250);
907
+ }
905
908
  screensVisited.push(screenName);
906
909
  const uniqueScreens = new Set(screensVisited);
907
910
  metrics.uniqueScreensCount = uniqueScreens.size;
@@ -141,6 +141,9 @@ function incrementErrorCount() {
141
141
  metrics.totalEvents++;
142
142
  }
143
143
  function addScreenVisited(screenName) {
144
+ if (metrics.screensVisited.length >= 500) {
145
+ metrics.screensVisited.splice(0, metrics.screensVisited.length - 250);
146
+ }
144
147
  metrics.screensVisited.push(screenName);
145
148
  metrics.uniqueScreensCount = new Set(metrics.screensVisited).size;
146
149
  }
@@ -91,13 +91,10 @@ async function getFetchResponseSize(response) {
91
91
  const parsed = parseInt(contentLength, 10);
92
92
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
93
93
  }
94
- try {
95
- const cloned = response.clone();
96
- const buffer = await cloned.arrayBuffer();
97
- return buffer.byteLength;
98
- } catch {
99
- return 0;
100
- }
94
+
95
+ // Don't clone+buffer the full body just to measure size when
96
+ // content-length is missing — this doubles memory for large responses.
97
+ return 0;
101
98
  }
102
99
  function getXhrResponseSize(xhr) {
103
100
  try {
@@ -347,6 +344,9 @@ function interceptXHR() {
347
344
  }
348
345
  data.t = Date.now();
349
346
  const onComplete = () => {
347
+ this.removeEventListener('load', onComplete);
348
+ this.removeEventListener('error', onComplete);
349
+ this.removeEventListener('abort', onComplete);
350
350
  const endTime = Date.now();
351
351
  const responseBodySize = config.captureSizes ? getXhrResponseSize(this) : 0;
352
352
  queueRequest({
@@ -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;
@@ -872,6 +872,9 @@ export function trackScreen(screenName) {
872
872
  }
873
873
  const previousScreen = currentScreen;
874
874
  currentScreen = screenName;
875
+ if (screensVisited.length >= 500) {
876
+ screensVisited.splice(0, screensVisited.length - 250);
877
+ }
875
878
  screensVisited.push(screenName);
876
879
  const uniqueScreens = new Set(screensVisited);
877
880
  metrics.uniqueScreensCount = uniqueScreens.size;
@@ -123,6 +123,9 @@ export function incrementErrorCount() {
123
123
  metrics.totalEvents++;
124
124
  }
125
125
  export function addScreenVisited(screenName) {
126
+ if (metrics.screensVisited.length >= 500) {
127
+ metrics.screensVisited.splice(0, metrics.screensVisited.length - 250);
128
+ }
126
129
  metrics.screensVisited.push(screenName);
127
130
  metrics.uniqueScreensCount = new Set(metrics.screensVisited).size;
128
131
  }
@@ -80,13 +80,10 @@ async function getFetchResponseSize(response) {
80
80
  const parsed = parseInt(contentLength, 10);
81
81
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
82
82
  }
83
- try {
84
- const cloned = response.clone();
85
- const buffer = await cloned.arrayBuffer();
86
- return buffer.byteLength;
87
- } catch {
88
- return 0;
89
- }
83
+
84
+ // Don't clone+buffer the full body just to measure size when
85
+ // content-length is missing — this doubles memory for large responses.
86
+ return 0;
90
87
  }
91
88
  function getXhrResponseSize(xhr) {
92
89
  try {
@@ -336,6 +333,9 @@ function interceptXHR() {
336
333
  }
337
334
  data.t = Date.now();
338
335
  const onComplete = () => {
336
+ this.removeEventListener('load', onComplete);
337
+ this.removeEventListener('error', onComplete);
338
+ this.removeEventListener('abort', onComplete);
339
339
  const endTime = Date.now();
340
340
  const responseBodySize = config.captureSizes ? getXhrResponseSize(this) : 0;
341
341
  queueRequest({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rejourneyco/react-native",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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;
@@ -1035,6 +1035,9 @@ export function trackScreen(screenName: string): void {
1035
1035
 
1036
1036
  const previousScreen = currentScreen;
1037
1037
  currentScreen = screenName;
1038
+ if (screensVisited.length >= 500) {
1039
+ screensVisited.splice(0, screensVisited.length - 250);
1040
+ }
1038
1041
  screensVisited.push(screenName);
1039
1042
 
1040
1043
  const uniqueScreens = new Set(screensVisited);
@@ -153,6 +153,9 @@ export function incrementErrorCount(): void {
153
153
  }
154
154
 
155
155
  export function addScreenVisited(screenName: string): void {
156
+ if (metrics.screensVisited.length >= 500) {
157
+ metrics.screensVisited.splice(0, metrics.screensVisited.length - 250);
158
+ }
156
159
  metrics.screensVisited.push(screenName);
157
160
  metrics.uniqueScreensCount = new Set(metrics.screensVisited).size;
158
161
  }
@@ -98,13 +98,9 @@ async function getFetchResponseSize(response: Response): Promise<number> {
98
98
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
99
99
  }
100
100
 
101
- try {
102
- const cloned = response.clone();
103
- const buffer = await cloned.arrayBuffer();
104
- return buffer.byteLength;
105
- } catch {
106
- return 0;
107
- }
101
+ // Don't clone+buffer the full body just to measure size when
102
+ // content-length is missing — this doubles memory for large responses.
103
+ return 0;
108
104
  }
109
105
 
110
106
  function getXhrResponseSize(xhr: XMLHttpRequest): number {
@@ -403,6 +399,10 @@ function interceptXHR(): void {
403
399
  data.t = Date.now();
404
400
 
405
401
  const onComplete = () => {
402
+ this.removeEventListener('load', onComplete);
403
+ this.removeEventListener('error', onComplete);
404
+ this.removeEventListener('abort', onComplete);
405
+
406
406
  const endTime = Date.now();
407
407
 
408
408
  const responseBodySize = config.captureSizes ? getXhrResponseSize(this) : 0;