@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 +13 -6
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +9 -1
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +8 -3
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +29 -6
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +34 -1
- package/ios/Recording/RejourneyURLProtocol.swift +68 -18
- package/ios/Recording/ReplayOrchestrator.swift +8 -1
- package/ios/Recording/SegmentDispatcher.swift +8 -1
- package/ios/Recording/TelemetryPipeline.swift +27 -2
- package/ios/Recording/VisualCapture.swift +38 -3
- package/lib/commonjs/index.js +1 -1
- package/lib/commonjs/sdk/autoTracking.js +3 -0
- package/lib/commonjs/sdk/metricsTracking.js +3 -0
- package/lib/commonjs/sdk/networkInterceptor.js +7 -7
- package/lib/module/index.js +1 -1
- package/lib/module/sdk/autoTracking.js +3 -0
- package/lib/module/sdk/metricsTracking.js +3 -0
- package/lib/module/sdk/networkInterceptor.js +7 -7
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/sdk/autoTracking.ts +3 -0
- package/src/sdk/metricsTracking.ts +3 -0
- package/src/sdk/networkInterceptor.ts +7 -7
package/README.md
CHANGED
|
@@ -64,22 +64,29 @@ Rejourney.trackScreen('Custom Screen Name');
|
|
|
64
64
|
|
|
65
65
|
## Custom Events & Metadata
|
|
66
66
|
|
|
67
|
-
|
|
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('
|
|
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
|
-
//
|
|
79
|
+
// Attach session-level metadata (key-value context)
|
|
76
80
|
Rejourney.setMetadata('plan', 'premium');
|
|
77
81
|
Rejourney.setMetadata({
|
|
78
|
-
role: '
|
|
79
|
-
|
|
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)
|
|
128
|
+
.connectTimeout(5, TimeUnit.SECONDS)
|
|
129
129
|
.readTimeout(10, TimeUnit.SECONDS)
|
|
130
130
|
.writeTimeout(10, TimeUnit.SECONDS)
|
|
131
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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: -
|
|
151
|
+
// MARK: - Callbacks from SessionDelegateAdapter
|
|
143
152
|
|
|
144
|
-
|
|
153
|
+
fileprivate func handleDidReceiveData(_ data: Data) {
|
|
145
154
|
client?.urlProtocol(self, didLoad: data)
|
|
146
155
|
}
|
|
147
156
|
|
|
148
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
package/lib/commonjs/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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({
|
package/lib/module/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
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
|
-
|
|
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;
|
package/src/sdk/autoTracking.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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;
|