@rejourneyco/react-native 1.0.7 → 1.0.8

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.
Files changed (29) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +20 -18
  3. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +28 -0
  4. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +42 -33
  5. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +242 -34
  6. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  7. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +6 -4
  8. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +156 -64
  9. package/ios/Engine/RejourneyImpl.swift +3 -18
  10. package/ios/Recording/InteractionRecorder.swift +28 -0
  11. package/ios/Recording/ReplayOrchestrator.swift +50 -17
  12. package/ios/Recording/SegmentDispatcher.swift +147 -13
  13. package/ios/Recording/SpecialCases.swift +614 -0
  14. package/ios/Recording/StabilityMonitor.swift +2 -2
  15. package/ios/Recording/TelemetryPipeline.swift +21 -3
  16. package/ios/Recording/VisualCapture.swift +50 -20
  17. package/lib/commonjs/index.js +4 -5
  18. package/lib/commonjs/sdk/constants.js +2 -2
  19. package/lib/commonjs/sdk/utils.js +1 -1
  20. package/lib/module/index.js +4 -5
  21. package/lib/module/sdk/constants.js +2 -2
  22. package/lib/module/sdk/utils.js +1 -1
  23. package/lib/typescript/sdk/constants.d.ts +2 -2
  24. package/lib/typescript/types/index.d.ts +1 -6
  25. package/package.json +2 -2
  26. package/src/index.ts +9 -10
  27. package/src/sdk/constants.ts +2 -2
  28. package/src/sdk/utils.ts +1 -1
  29. package/src/types/index.ts +1 -6
package/README.md CHANGED
@@ -26,4 +26,4 @@ Full integration guides and API reference: https://rejourney.co/docs/reactnative
26
26
 
27
27
  ## License
28
28
 
29
- Licensed under Apache 2.0
29
+ Licensed under Apache 2.0
@@ -709,26 +709,28 @@ class RejourneyModuleImpl(
709
709
  fun getSDKMetrics(promise: Promise) {
710
710
  val dispatcher = SegmentDispatcher.shared
711
711
  val pipeline = TelemetryPipeline.shared
712
+ val telemetry = dispatcher.sdkTelemetrySnapshot(pipeline?.getQueueDepth() ?: 0)
713
+
714
+ fun toIntValue(key: String): Int = (telemetry[key] as? Number)?.toInt() ?: 0
715
+ fun toDoubleValue(key: String, fallback: Double = 0.0): Double = (telemetry[key] as? Number)?.toDouble() ?: fallback
716
+ fun toLongValue(key: String): Long? = (telemetry[key] as? Number)?.toLong()
712
717
 
713
718
  promise.resolve(Arguments.createMap().apply {
714
- putInt("uploadSuccessCount", dispatcher.uploadSuccessCount)
715
- putInt("uploadFailureCount", dispatcher.uploadFailureCount)
716
- putInt("retryAttemptCount", 0)
717
- putInt("circuitBreakerOpenCount", dispatcher.circuitBreakerOpenCount)
718
- putInt("memoryEvictionCount", 0)
719
- putInt("offlinePersistCount", 0)
720
- putInt("sessionStartCount", if (ReplayOrchestrator.shared?.replayId != null) 1 else 0)
721
- putInt("crashCount", 0)
722
-
723
- val total = dispatcher.uploadSuccessCount + dispatcher.uploadFailureCount
724
- putDouble("uploadSuccessRate", if (total > 0) dispatcher.uploadSuccessCount.toDouble() / total else 1.0)
725
-
726
- putDouble("avgUploadDurationMs", 0.0)
727
- putInt("currentQueueDepth", pipeline?.getQueueDepth() ?: 0)
728
- putNull("lastUploadTime")
729
- putNull("lastRetryTime")
730
- putDouble("totalBytesUploaded", dispatcher.totalBytesUploaded.toDouble())
731
- putInt("totalBytesEvicted", 0)
719
+ putInt("uploadSuccessCount", toIntValue("uploadSuccessCount"))
720
+ putInt("uploadFailureCount", toIntValue("uploadFailureCount"))
721
+ putInt("retryAttemptCount", toIntValue("retryAttemptCount"))
722
+ putInt("circuitBreakerOpenCount", toIntValue("circuitBreakerOpenCount"))
723
+ putInt("memoryEvictionCount", toIntValue("memoryEvictionCount"))
724
+ putInt("offlinePersistCount", toIntValue("offlinePersistCount"))
725
+ putInt("sessionStartCount", toIntValue("sessionStartCount"))
726
+ putInt("crashCount", toIntValue("crashCount"))
727
+ putDouble("uploadSuccessRate", toDoubleValue("uploadSuccessRate", 1.0))
728
+ putDouble("avgUploadDurationMs", toDoubleValue("avgUploadDurationMs", 0.0))
729
+ putInt("currentQueueDepth", toIntValue("currentQueueDepth"))
730
+ toLongValue("lastUploadTime")?.let { putDouble("lastUploadTime", it.toDouble()) } ?: putNull("lastUploadTime")
731
+ toLongValue("lastRetryTime")?.let { putDouble("lastRetryTime", it.toDouble()) } ?: putNull("lastRetryTime")
732
+ putDouble("totalBytesUploaded", toDoubleValue("totalBytesUploaded", 0.0))
733
+ putDouble("totalBytesEvicted", toDoubleValue("totalBytesEvicted", 0.0))
732
734
  })
733
735
  }
734
736
 
@@ -29,6 +29,7 @@ import android.widget.EditText
29
29
  import android.widget.ImageButton
30
30
  import java.lang.ref.WeakReference
31
31
  import java.util.concurrent.CopyOnWriteArrayList
32
+ import java.util.concurrent.atomic.AtomicLong
32
33
  import kotlin.math.abs
33
34
  import kotlin.math.sqrt
34
35
 
@@ -59,6 +60,7 @@ class InteractionRecorder private constructor(private val context: Context) {
59
60
  private val inputObservers = CopyOnWriteArrayList<WeakReference<EditText>>()
60
61
  private val navigationStack = mutableListOf<String>()
61
62
  private val coalesceWindow: Long = 300 // ms
63
+ private val lastInteractionTimestampMs = AtomicLong(0L)
62
64
 
63
65
  internal var currentActivity: WeakReference<Activity>? = null
64
66
 
@@ -86,8 +88,11 @@ class InteractionRecorder private constructor(private val context: Context) {
86
88
  gestureAggregator = null
87
89
  inputObservers.clear()
88
90
  navigationStack.clear()
91
+ lastInteractionTimestampMs.set(0L)
89
92
  }
90
93
 
94
+ fun latestInteractionTimestampMs(): Long = lastInteractionTimestampMs.get()
95
+
91
96
  fun observeTextField(field: EditText) {
92
97
  if (inputObservers.any { it.get() === field }) return
93
98
  inputObservers.add(WeakReference(field))
@@ -122,13 +127,27 @@ class InteractionRecorder private constructor(private val context: Context) {
122
127
  window.callback = object : Window.Callback by original {
123
128
  override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
124
129
  if (event != null) {
130
+ markInteractionNow()
125
131
  agg.processTouchEvent(event)
132
+
133
+ // Notify SpecialCases about touch phases for touch-based
134
+ // map idle detection (Mapbox v10+ fallback).
135
+ when (event.actionMasked) {
136
+ MotionEvent.ACTION_DOWN ->
137
+ SpecialCases.shared.notifyTouchBegan()
138
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
139
+ SpecialCases.shared.notifyTouchEnded()
140
+ }
126
141
  }
127
142
  return original.dispatchTouchEvent(event)
128
143
  }
129
144
  }
130
145
  }
131
146
 
147
+ private fun markInteractionNow() {
148
+ lastInteractionTimestampMs.set(System.currentTimeMillis())
149
+ }
150
+
132
151
  private fun removeGlobalTouchListener() {
133
152
  val window = installedWindow?.get()
134
153
  if (window != null) {
@@ -450,6 +469,11 @@ private class GestureAggregator(
450
469
  }
451
470
 
452
471
  private fun resolveTarget(location: PointF): String {
472
+ // When a map view is visible, skip the expensive hit-test walk
473
+ // through the deep SurfaceView/TextureView hierarchy.
474
+ if (SpecialCases.shared.mapVisible) {
475
+ return "map"
476
+ }
453
477
  return "view_${location.x.toInt()}_${location.y.toInt()}"
454
478
  }
455
479
 
@@ -462,6 +486,10 @@ private class GestureAggregator(
462
486
  * (e.g. TextView inside a Pressable), not the clickable Pressable itself.
463
487
  */
464
488
  private fun isViewInteractive(location: PointF): Boolean {
489
+ // Skip the expensive hierarchy walk when a map is visible to
490
+ // prevent micro-stutter during pan/zoom gestures.
491
+ if (SpecialCases.shared.mapVisible) return false
492
+
465
493
  val activity = recorder.currentActivity?.get() ?: return false
466
494
  val decorView = activity.window?.decorView ?: return false
467
495
  val hit = findViewAt(decorView, location.x.toInt(), location.y.toInt()) ?: return false
@@ -87,7 +87,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
87
87
  DeviceRegistrar.shared?.endpoint = value
88
88
  }
89
89
 
90
- var snapshotInterval: Double = 0.33
90
+ var snapshotInterval: Double = 1.0
91
91
  var compressionLevel: Double = 0.5
92
92
  var visualCaptureEnabled: Boolean = true
93
93
  var interactionCaptureEnabled: Boolean = true
@@ -169,20 +169,20 @@ class ReplayOrchestrator private constructor(private val context: Context) {
169
169
  }
170
170
 
171
171
  fun beginReplay(apiToken: String, serverEndpoint: String, captureSettings: Map<String, Any>? = null) {
172
- DiagnosticLog.notice("[ReplayOrchestrator] ★★★ beginReplay v2 ★★★")
172
+ DiagnosticLog.trace("[ReplayOrchestrator] beginReplay v2")
173
173
  val perf = PerformanceSnapshot.capture()
174
174
  DiagnosticLog.debugSessionCreate("ORCHESTRATOR_INIT", "beginReplay", perf)
175
- DiagnosticLog.notice("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
175
+ DiagnosticLog.trace("[ReplayOrchestrator] beginReplay called, endpoint=$serverEndpoint")
176
176
 
177
177
  this.apiToken = apiToken
178
178
  this.serverEndpoint = serverEndpoint
179
179
  applySettings(captureSettings)
180
180
 
181
181
  DiagnosticLog.debugSessionCreate("CREDENTIAL_START", "Requesting device credential")
182
- DiagnosticLog.notice("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
182
+ DiagnosticLog.trace("[ReplayOrchestrator] Requesting credential from DeviceRegistrar.shared=${DeviceRegistrar.shared != null}")
183
183
 
184
184
  DeviceRegistrar.shared?.obtainCredential(apiToken) { ok, cred ->
185
- DiagnosticLog.notice("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
185
+ DiagnosticLog.trace("[ReplayOrchestrator] Credential callback: ok=$ok, cred=${cred?.take(20) ?: "null"}...")
186
186
  if (!ok) {
187
187
  DiagnosticLog.debugSessionCreate("CREDENTIAL_FAIL", "Failed")
188
188
  DiagnosticLog.caution("[ReplayOrchestrator] Credential fetch FAILED - recording cannot start")
@@ -194,7 +194,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
194
194
  SegmentDispatcher.shared.apiToken = apiToken
195
195
  SegmentDispatcher.shared.credential = cred
196
196
 
197
- DiagnosticLog.notice("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
197
+ DiagnosticLog.trace("[ReplayOrchestrator] Credential OK, calling monitorNetwork")
198
198
  monitorNetwork(apiToken)
199
199
  }
200
200
  }
@@ -217,7 +217,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
217
217
  initSession()
218
218
  TelemetryPipeline.shared?.activateDeferredMode()
219
219
 
220
- val renderCfg = computeRender(3, "standard")
220
+ val renderCfg = computeRender(1, "standard")
221
221
 
222
222
  if (visualCaptureEnabled) {
223
223
  VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
@@ -267,6 +267,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
267
267
  "screensVisited" to visitedScreens.toList(),
268
268
  "screenCount" to visitedScreens.toSet().size
269
269
  )
270
+ val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
270
271
 
271
272
  SegmentDispatcher.shared.evaluateReplayRetention(sid, metrics) { retain, reason ->
272
273
  // UI operations MUST run on main thread
@@ -287,7 +288,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
287
288
  }
288
289
  finalized = true
289
290
 
290
- SegmentDispatcher.shared.concludeReplay(sid, termMs, bgTimeMs, metrics) { ok ->
291
+ SegmentDispatcher.shared.concludeReplay(sid, termMs, bgTimeMs, metrics, queueDepthAtFinalize) { ok ->
291
292
  if (ok) clearRecovery()
292
293
  completion?.invoke(true, ok)
293
294
  }
@@ -324,7 +325,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
324
325
  // If recording is disabled, disable visual capture
325
326
  if (!recordingEnabled) {
326
327
  visualCaptureEnabled = false
327
- DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
328
+ DiagnosticLog.trace("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
328
329
  }
329
330
 
330
331
  // If already recording, restart the duration limit timer with updated config
@@ -332,7 +333,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
332
333
  startDurationLimitTimer()
333
334
  }
334
335
 
335
- DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
336
+ DiagnosticLog.trace("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=$rejourneyEnabled, recordingEnabled=$recordingEnabled, sampleRate=$sampleRate%, maxRecording=${maxRecordingMinutes}min, isSampledIn=$recordingEnabled")
336
337
  }
337
338
 
338
339
  fun unredactView(view: View) {
@@ -387,8 +388,9 @@ class ReplayOrchestrator private constructor(private val context: Context) {
387
388
  "crashCount" to 1,
388
389
  "durationSeconds" to ((nowMs - origStart) / 1000).toInt()
389
390
  )
391
+ val queueDepthAtFinalize = TelemetryPipeline.shared?.getQueueDepth() ?: 0
390
392
 
391
- SegmentDispatcher.shared.concludeReplay(recId, nowMs, 0, crashMetrics) { ok ->
393
+ SegmentDispatcher.shared.concludeReplay(recId, nowMs, 0, crashMetrics, queueDepthAtFinalize) { ok ->
392
394
  clearRecovery()
393
395
  completion(if (ok) recId else null)
394
396
  }
@@ -467,10 +469,10 @@ class ReplayOrchestrator private constructor(private val context: Context) {
467
469
  }
468
470
 
469
471
  private fun monitorNetwork(token: String) {
470
- DiagnosticLog.notice("[ReplayOrchestrator] monitorNetwork called")
472
+ DiagnosticLog.trace("[ReplayOrchestrator] monitorNetwork called")
471
473
  val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
472
474
  if (connectivityManager == null) {
473
- DiagnosticLog.notice("[ReplayOrchestrator] No ConnectivityManager, starting recording directly")
475
+ DiagnosticLog.trace("[ReplayOrchestrator] No ConnectivityManager, starting recording directly")
474
476
  beginRecording(token)
475
477
  return
476
478
  }
@@ -496,12 +498,12 @@ class ReplayOrchestrator private constructor(private val context: Context) {
496
498
  // Check current network state immediately (callback only fires on CHANGES)
497
499
  val activeNetwork = connectivityManager.activeNetwork
498
500
  val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
499
- DiagnosticLog.notice("[ReplayOrchestrator] Network check: activeNetwork=${activeNetwork != null}, capabilities=${capabilities != null}")
501
+ DiagnosticLog.trace("[ReplayOrchestrator] Network check: activeNetwork=${activeNetwork != null}, capabilities=${capabilities != null}")
500
502
  if (capabilities != null) {
501
503
  handleNetworkChange(capabilities, token)
502
504
  } else {
503
505
  // No active network - start recording anyway, uploads will retry when network available
504
- DiagnosticLog.notice("[ReplayOrchestrator] No active network, starting recording anyway")
506
+ DiagnosticLog.trace("[ReplayOrchestrator] No active network, starting recording anyway")
505
507
  mainHandler.post { beginRecording(token) }
506
508
  }
507
509
  } catch (e: Exception) {
@@ -543,27 +545,27 @@ class ReplayOrchestrator private constructor(private val context: Context) {
543
545
  }
544
546
 
545
547
  private fun beginRecording(token: String) {
546
- DiagnosticLog.notice("[ReplayOrchestrator] beginRecording called, live=$live")
548
+ DiagnosticLog.trace("[ReplayOrchestrator] beginRecording called, live=$live")
547
549
  if (live) {
548
- DiagnosticLog.notice("[ReplayOrchestrator] Already live, skipping")
550
+ DiagnosticLog.trace("[ReplayOrchestrator] Already live, skipping")
549
551
  return
550
552
  }
551
553
  live = true
552
554
 
553
555
  this.apiToken = token
554
556
  initSession()
555
- DiagnosticLog.notice("[ReplayOrchestrator] Session initialized: replayId=$replayId")
557
+ DiagnosticLog.trace("[ReplayOrchestrator] Session initialized: replayId=$replayId")
556
558
 
557
559
  // Reactivate the dispatcher in case it was halted from a previous session
558
560
  SegmentDispatcher.shared.activate()
559
561
  TelemetryPipeline.shared?.activate()
560
562
 
561
- val renderCfg = computeRender(3, "high")
562
- DiagnosticLog.notice("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
563
+ val renderCfg = computeRender(1, "standard")
564
+ DiagnosticLog.trace("[ReplayOrchestrator] VisualCapture.shared=${VisualCapture.shared != null}, visualCaptureEnabled=$visualCaptureEnabled")
563
565
  VisualCapture.shared?.configure(renderCfg.first, renderCfg.second)
564
566
 
565
567
  if (visualCaptureEnabled) {
566
- DiagnosticLog.notice("[ReplayOrchestrator] Starting VisualCapture")
568
+ DiagnosticLog.trace("[ReplayOrchestrator] Starting VisualCapture")
567
569
  VisualCapture.shared?.beginCapture(replayStartMs)
568
570
  }
569
571
  if (interactionCaptureEnabled) InteractionRecorder.shared?.activate()
@@ -574,7 +576,7 @@ class ReplayOrchestrator private constructor(private val context: Context) {
574
576
  // Start duration limit timer based on remote config
575
577
  startDurationLimitTimer()
576
578
 
577
- DiagnosticLog.notice("[ReplayOrchestrator] beginRecording completed")
579
+ DiagnosticLog.trace("[ReplayOrchestrator] beginRecording completed")
578
580
  }
579
581
 
580
582
  // MARK: - Duration Limit Timer
@@ -591,19 +593,19 @@ class ReplayOrchestrator private constructor(private val context: Context) {
591
593
  val remaining = if (maxMs > elapsed) maxMs - elapsed else 0L
592
594
 
593
595
  if (remaining <= 0) {
594
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
596
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
595
597
  endReplay()
596
598
  return
597
599
  }
598
600
 
599
601
  durationLimitRunnable = Runnable {
600
602
  if (!live) return@Runnable
601
- DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
603
+ DiagnosticLog.trace("[ReplayOrchestrator] Recording duration limit reached (${maxMinutes}min), stopping session")
602
604
  endReplay()
603
605
  }
604
606
  mainHandler.postDelayed(durationLimitRunnable!!, remaining)
605
607
 
606
- DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
608
+ DiagnosticLog.trace("[ReplayOrchestrator] Duration limit timer set: ${remaining / 1000}s remaining (max ${maxMinutes}min)")
607
609
  }
608
610
 
609
611
  private fun stopDurationLimitTimer() {
@@ -704,6 +706,13 @@ class ReplayOrchestrator private constructor(private val context: Context) {
704
706
  return
705
707
  }
706
708
 
709
+ // Throttle hierarchy capture when map is visible and animating —
710
+ // ViewHierarchyScanner traverses the full view tree including map's
711
+ // deep SurfaceView/TextureView children, adding main-thread pressure.
712
+ if (SpecialCases.shared.mapVisible && !SpecialCases.shared.mapIdle) {
713
+ return
714
+ }
715
+
707
716
  val hierarchy = ViewHierarchyScanner.shared?.captureHierarchy() ?: return
708
717
 
709
718
  val hash = hierarchyHash(hierarchy)
@@ -729,12 +738,12 @@ class ReplayOrchestrator private constructor(private val context: Context) {
729
738
  }
730
739
 
731
740
  private fun computeRender(fps: Int, tier: String): Pair<Double, Double> {
732
- val interval = 1.0 / fps.coerceIn(1, 99)
733
- val quality = when (tier.lowercase()) {
734
- "low" -> 0.4
735
- "standard" -> 0.5
736
- "high" -> 0.6
737
- else -> 0.5
738
- }
739
- return Pair(interval, quality)
741
+ val tierLower = tier.lowercase()
742
+ return when (tierLower) {
743
+ "minimal" -> Pair(2.0, 0.4) // 0.5 fps for maximum size reduction
744
+ "low" -> Pair(1.0 / fps.coerceIn(1, 99), 0.4)
745
+ "standard" -> Pair(1.0 / fps.coerceIn(1, 99), 0.5)
746
+ "high" -> Pair(1.0 / fps.coerceIn(1, 99), 0.55)
747
+ else -> Pair(1.0 / fps.coerceIn(1, 99), 0.5)
748
+ }
740
749
  }