@rejourneyco/react-native 1.0.7 → 1.0.9
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 +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- package/src/types/index.ts +16 -9
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ import com.rejourney.platform.TaskRemovedListener
|
|
|
45
45
|
import com.rejourney.recording.*
|
|
46
46
|
import kotlinx.coroutines.*
|
|
47
47
|
import java.security.MessageDigest
|
|
48
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
48
49
|
import java.util.concurrent.locks.ReentrantLock
|
|
49
50
|
import kotlin.concurrent.withLock
|
|
50
51
|
|
|
@@ -68,9 +69,11 @@ class RejourneyModuleImpl(
|
|
|
68
69
|
var sdkVersion = "1.0.1"
|
|
69
70
|
|
|
70
71
|
private const val SESSION_TIMEOUT_MS = 60_000L // 60 seconds
|
|
72
|
+
private const val SESSION_ROLLOVER_GRACE_MS = 2_000L
|
|
71
73
|
|
|
72
74
|
private const val PREFS_NAME = "com.rejourney.prefs"
|
|
73
75
|
private const val KEY_USER_IDENTITY = "user_identity"
|
|
76
|
+
private const val KEY_ANONYMOUS_ID = "anonymous_id"
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// State machine
|
|
@@ -143,8 +146,14 @@ class RejourneyModuleImpl(
|
|
|
143
146
|
registerActivityLifecycleCallbacks()
|
|
144
147
|
registerProcessLifecycleObserver()
|
|
145
148
|
|
|
146
|
-
//
|
|
147
|
-
|
|
149
|
+
// Recover any session interrupted by a previous crash.
|
|
150
|
+
// Transmit stored fault reports only after recovery restores auth context.
|
|
151
|
+
ReplayOrchestrator.getInstance(reactContext).recoverInterruptedReplay { recoveredId ->
|
|
152
|
+
if (recoveredId != null) {
|
|
153
|
+
DiagnosticLog.notice("[Rejourney] Recovered crashed session: $recoveredId")
|
|
154
|
+
}
|
|
155
|
+
StabilityMonitor.getInstance(reactContext).transmitStoredReport()
|
|
156
|
+
}
|
|
148
157
|
|
|
149
158
|
// Android-specific: OEM detection and task removed handling
|
|
150
159
|
setupOEMSpecificHandling()
|
|
@@ -252,16 +261,52 @@ class RejourneyModuleImpl(
|
|
|
252
261
|
|
|
253
262
|
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
|
|
254
263
|
|
|
264
|
+
val restartStarted = AtomicBoolean(false)
|
|
265
|
+
val triggerRestart: (String) -> Unit = { source ->
|
|
266
|
+
if (restartStarted.compareAndSet(false, true)) {
|
|
267
|
+
DiagnosticLog.notice("[Rejourney] Session rollover trigger source=$source, oldSession=$oldSessionId")
|
|
268
|
+
mainHandler.post { startNewSessionAfterTimeout() }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
mainHandler.postDelayed({
|
|
273
|
+
if (!restartStarted.get()) {
|
|
274
|
+
DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (${SESSION_ROLLOVER_GRACE_MS}ms), forcing new session start")
|
|
275
|
+
}
|
|
276
|
+
triggerRestart("grace_timeout")
|
|
277
|
+
}, SESSION_ROLLOVER_GRACE_MS)
|
|
278
|
+
|
|
255
279
|
backgroundScope.launch {
|
|
256
|
-
ReplayOrchestrator.shared
|
|
280
|
+
val orchestrator = ReplayOrchestrator.shared
|
|
281
|
+
if (orchestrator == null) {
|
|
282
|
+
triggerRestart("orchestrator_missing")
|
|
283
|
+
return@launch
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
orchestrator.endReplayWithReason("background_timeout") { success, uploaded ->
|
|
257
287
|
DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
|
|
258
|
-
|
|
288
|
+
triggerRestart("end_replay_callback")
|
|
259
289
|
}
|
|
260
290
|
}
|
|
261
291
|
} else {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
292
|
+
val orchestratorSessionId = ReplayOrchestrator.shared?.replayId
|
|
293
|
+
if (orchestratorSessionId.isNullOrEmpty()) {
|
|
294
|
+
// The old session can end while app is backgrounded (e.g. duration limit).
|
|
295
|
+
// Do not resume a dead session; start a fresh one.
|
|
296
|
+
state = SessionState.Idle
|
|
297
|
+
DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
|
|
298
|
+
mainHandler.post { startNewSessionAfterTimeout() }
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (orchestratorSessionId != currentState.sessionId) {
|
|
303
|
+
state = SessionState.Active(orchestratorSessionId, System.currentTimeMillis())
|
|
304
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '$orchestratorSessionId' (was '${currentState.sessionId}')")
|
|
305
|
+
} else {
|
|
306
|
+
// Resume existing session
|
|
307
|
+
state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
|
|
308
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
|
|
309
|
+
}
|
|
265
310
|
|
|
266
311
|
// Record foreground event
|
|
267
312
|
TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
|
|
@@ -494,6 +539,7 @@ class RejourneyModuleImpl(
|
|
|
494
539
|
state = SessionState.Idle
|
|
495
540
|
}
|
|
496
541
|
|
|
542
|
+
|
|
497
543
|
// Android-specific: Stop SessionLifecycleService
|
|
498
544
|
stopSessionLifecycleService()
|
|
499
545
|
|
|
@@ -502,7 +548,7 @@ class RejourneyModuleImpl(
|
|
|
502
548
|
return@post
|
|
503
549
|
}
|
|
504
550
|
|
|
505
|
-
ReplayOrchestrator.shared?.
|
|
551
|
+
ReplayOrchestrator.shared?.endReplayWithReason("user_initiated") { success, uploaded ->
|
|
506
552
|
DiagnosticLog.replayEnded(targetSid)
|
|
507
553
|
|
|
508
554
|
promise.resolve(Arguments.createMap().apply {
|
|
@@ -554,6 +600,30 @@ class RejourneyModuleImpl(
|
|
|
554
600
|
promise.resolve(currentUserIdentity)
|
|
555
601
|
}
|
|
556
602
|
|
|
603
|
+
fun setAnonymousId(anonymousId: String, promise: Promise) {
|
|
604
|
+
try {
|
|
605
|
+
val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
606
|
+
if (anonymousId.isBlank()) {
|
|
607
|
+
prefs.edit().remove(KEY_ANONYMOUS_ID).apply()
|
|
608
|
+
} else {
|
|
609
|
+
prefs.edit().putString(KEY_ANONYMOUS_ID, anonymousId).apply()
|
|
610
|
+
}
|
|
611
|
+
} catch (e: Exception) {
|
|
612
|
+
DiagnosticLog.fault("[Rejourney] Failed to persist anonymous ID: ${e.message}")
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
promise.resolve(createResultMap(true))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
fun getAnonymousId(promise: Promise) {
|
|
619
|
+
try {
|
|
620
|
+
val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
621
|
+
promise.resolve(prefs.getString(KEY_ANONYMOUS_ID, null))
|
|
622
|
+
} catch (_: Exception) {
|
|
623
|
+
promise.resolve(null)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
557
627
|
fun logEvent(eventType: String, details: ReadableMap, promise: Promise) {
|
|
558
628
|
// Handle network_request events specially
|
|
559
629
|
if (eventType == "network_request") {
|
|
@@ -589,6 +659,17 @@ class RejourneyModuleImpl(
|
|
|
589
659
|
return
|
|
590
660
|
}
|
|
591
661
|
|
|
662
|
+
// Handle console log events - preserve type:"log" with level and message
|
|
663
|
+
// so the dashboard replay can display them in the console terminal
|
|
664
|
+
if (eventType == "log") {
|
|
665
|
+
val detailsMap = details.toHashMap()
|
|
666
|
+
val level = detailsMap["level"]?.toString() ?: "log"
|
|
667
|
+
val message = detailsMap["message"]?.toString() ?: ""
|
|
668
|
+
TelemetryPipeline.shared?.recordConsoleLogEvent(level, message)
|
|
669
|
+
promise.resolve(createResultMap(true))
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
592
673
|
// All other events go through custom event recording
|
|
593
674
|
val payload = try {
|
|
594
675
|
val json = org.json.JSONObject(details.toHashMap()).toString()
|
|
@@ -709,26 +790,28 @@ class RejourneyModuleImpl(
|
|
|
709
790
|
fun getSDKMetrics(promise: Promise) {
|
|
710
791
|
val dispatcher = SegmentDispatcher.shared
|
|
711
792
|
val pipeline = TelemetryPipeline.shared
|
|
793
|
+
val telemetry = dispatcher.sdkTelemetrySnapshot(pipeline?.getQueueDepth() ?: 0)
|
|
794
|
+
|
|
795
|
+
fun toIntValue(key: String): Int = (telemetry[key] as? Number)?.toInt() ?: 0
|
|
796
|
+
fun toDoubleValue(key: String, fallback: Double = 0.0): Double = (telemetry[key] as? Number)?.toDouble() ?: fallback
|
|
797
|
+
fun toLongValue(key: String): Long? = (telemetry[key] as? Number)?.toLong()
|
|
712
798
|
|
|
713
799
|
promise.resolve(Arguments.createMap().apply {
|
|
714
|
-
putInt("uploadSuccessCount",
|
|
715
|
-
putInt("uploadFailureCount",
|
|
716
|
-
putInt("retryAttemptCount",
|
|
717
|
-
putInt("circuitBreakerOpenCount",
|
|
718
|
-
putInt("memoryEvictionCount",
|
|
719
|
-
putInt("offlinePersistCount",
|
|
720
|
-
putInt("sessionStartCount",
|
|
721
|
-
putInt("crashCount",
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
putDouble("
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
putNull("lastRetryTime")
|
|
730
|
-
putDouble("totalBytesUploaded", dispatcher.totalBytesUploaded.toDouble())
|
|
731
|
-
putInt("totalBytesEvicted", 0)
|
|
800
|
+
putInt("uploadSuccessCount", toIntValue("uploadSuccessCount"))
|
|
801
|
+
putInt("uploadFailureCount", toIntValue("uploadFailureCount"))
|
|
802
|
+
putInt("retryAttemptCount", toIntValue("retryAttemptCount"))
|
|
803
|
+
putInt("circuitBreakerOpenCount", toIntValue("circuitBreakerOpenCount"))
|
|
804
|
+
putInt("memoryEvictionCount", toIntValue("memoryEvictionCount"))
|
|
805
|
+
putInt("offlinePersistCount", toIntValue("offlinePersistCount"))
|
|
806
|
+
putInt("sessionStartCount", toIntValue("sessionStartCount"))
|
|
807
|
+
putInt("crashCount", toIntValue("crashCount"))
|
|
808
|
+
putDouble("uploadSuccessRate", toDoubleValue("uploadSuccessRate", 1.0))
|
|
809
|
+
putDouble("avgUploadDurationMs", toDoubleValue("avgUploadDurationMs", 0.0))
|
|
810
|
+
putInt("currentQueueDepth", toIntValue("currentQueueDepth"))
|
|
811
|
+
toLongValue("lastUploadTime")?.let { putDouble("lastUploadTime", it.toDouble()) } ?: putNull("lastUploadTime")
|
|
812
|
+
toLongValue("lastRetryTime")?.let { putDouble("lastRetryTime", it.toDouble()) } ?: putNull("lastRetryTime")
|
|
813
|
+
putDouble("totalBytesUploaded", toDoubleValue("totalBytesUploaded", 0.0))
|
|
814
|
+
putDouble("totalBytesEvicted", toDoubleValue("totalBytesEvicted", 0.0))
|
|
732
815
|
})
|
|
733
816
|
}
|
|
734
817
|
|
|
@@ -64,6 +64,7 @@ class DeviceRegistrar private constructor(private val context: Context) {
|
|
|
64
64
|
// Private State
|
|
65
65
|
private val prefsKey = "com.rejourney.device"
|
|
66
66
|
private val fingerprintKey = "device_fingerprint"
|
|
67
|
+
private val fallbackIdKey = "device_fallback_id"
|
|
67
68
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
68
69
|
|
|
69
70
|
private val httpClient: OkHttpClient = OkHttpClient.Builder()
|
|
@@ -169,7 +170,6 @@ class DeviceRegistrar private constructor(private val context: Context) {
|
|
|
169
170
|
var composite = packageName
|
|
170
171
|
composite += Build.MODEL
|
|
171
172
|
composite += Build.MANUFACTURER
|
|
172
|
-
composite += Build.VERSION.RELEASE
|
|
173
173
|
composite += getAndroidId()
|
|
174
174
|
|
|
175
175
|
return sha256(composite)
|
|
@@ -177,12 +177,27 @@ class DeviceRegistrar private constructor(private val context: Context) {
|
|
|
177
177
|
|
|
178
178
|
private fun getAndroidId(): String {
|
|
179
179
|
return try {
|
|
180
|
-
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
|
180
|
+
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
|
181
|
+
?: stableDeviceFallback()
|
|
181
182
|
} catch (e: Exception) {
|
|
182
|
-
|
|
183
|
+
stableDeviceFallback()
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Returns a SharedPreferences-persisted UUID so the fingerprint stays stable
|
|
189
|
+
* even when ANDROID_ID is unavailable (restricted profiles, some OEM devices).
|
|
190
|
+
*/
|
|
191
|
+
private fun stableDeviceFallback(): String {
|
|
192
|
+
val prefs = context.getSharedPreferences(prefsKey, Context.MODE_PRIVATE)
|
|
193
|
+
val existing = prefs.getString(fallbackIdKey, null)
|
|
194
|
+
if (existing != null) return existing
|
|
195
|
+
|
|
196
|
+
val fresh = java.util.UUID.randomUUID().toString()
|
|
197
|
+
prefs.edit().putString(fallbackIdKey, fresh).apply()
|
|
198
|
+
return fresh
|
|
199
|
+
}
|
|
200
|
+
|
|
186
201
|
// Server Communication
|
|
187
202
|
|
|
188
203
|
private suspend fun fetchServerCredential(
|
|
@@ -30,6 +30,7 @@ import androidx.lifecycle.LifecycleOwner
|
|
|
30
30
|
import androidx.lifecycle.ProcessLifecycleOwner
|
|
31
31
|
import com.rejourney.recording.*
|
|
32
32
|
import java.security.MessageDigest
|
|
33
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
33
34
|
import java.util.concurrent.locks.ReentrantLock
|
|
34
35
|
import kotlin.concurrent.withLock
|
|
35
36
|
|
|
@@ -45,26 +46,26 @@ sealed class SessionState {
|
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Main SDK implementation aligned with iOS RejourneyImpl.swift
|
|
48
|
-
*
|
|
49
|
+
*
|
|
49
50
|
* This class provides the core SDK functionality for native Android usage.
|
|
50
51
|
* For React Native, use RejourneyModuleImpl instead.
|
|
51
52
|
*/
|
|
52
|
-
class RejourneyImpl private constructor(private val context: Context) :
|
|
53
|
+
class RejourneyImpl private constructor(private val context: Context) :
|
|
53
54
|
Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
|
|
54
55
|
|
|
55
56
|
companion object {
|
|
56
57
|
@Volatile
|
|
57
58
|
private var instance: RejourneyImpl? = null
|
|
58
|
-
|
|
59
|
+
|
|
59
60
|
fun getInstance(context: Context): RejourneyImpl {
|
|
60
61
|
return instance ?: synchronized(this) {
|
|
61
62
|
instance ?: RejourneyImpl(context.applicationContext).also { instance = it }
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
|
-
|
|
65
|
+
|
|
65
66
|
val shared: RejourneyImpl?
|
|
66
67
|
get() = instance
|
|
67
|
-
|
|
68
|
+
|
|
68
69
|
var sdkVersion = "1.0.1"
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -81,14 +82,23 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
81
82
|
|
|
82
83
|
// Session timeout threshold (60 seconds)
|
|
83
84
|
private val sessionTimeoutMs = 60_000L
|
|
85
|
+
private val sessionRolloverGraceMs = 2_000L
|
|
84
86
|
|
|
85
87
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
86
|
-
|
|
88
|
+
|
|
87
89
|
@Volatile
|
|
88
90
|
private var isInitialized = false
|
|
89
91
|
|
|
90
92
|
init {
|
|
91
93
|
setupLifecycleListeners()
|
|
94
|
+
|
|
95
|
+
// Recover sessions interrupted by a previous crash first, then send stored faults.
|
|
96
|
+
ReplayOrchestrator.getInstance(context).recoverInterruptedReplay { recoveredId ->
|
|
97
|
+
if (recoveredId != null) {
|
|
98
|
+
DiagnosticLog.notice("[Rejourney] Recovered crashed session: $recoveredId")
|
|
99
|
+
}
|
|
100
|
+
StabilityMonitor.getInstance(context).transmitStoredReport()
|
|
101
|
+
}
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
private fun setupLifecycleListeners() {
|
|
@@ -97,10 +107,10 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
97
107
|
mainHandler.post {
|
|
98
108
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
99
109
|
}
|
|
100
|
-
|
|
110
|
+
|
|
101
111
|
// Register activity callbacks
|
|
102
112
|
(context.applicationContext as? Application)?.registerActivityLifecycleCallbacks(this)
|
|
103
|
-
|
|
113
|
+
|
|
104
114
|
} catch (e: Exception) {
|
|
105
115
|
DiagnosticLog.fault("[Rejourney] Failed to setup lifecycle listeners: ${e.message}")
|
|
106
116
|
}
|
|
@@ -123,7 +133,7 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
123
133
|
state = SessionState.Paused(currentState.sessionId, currentState.startTimeMs)
|
|
124
134
|
backgroundEntryTimeMs = System.currentTimeMillis()
|
|
125
135
|
DiagnosticLog.notice("[Rejourney] ⏸️ Session '${currentState.sessionId}' paused (app backgrounded)")
|
|
126
|
-
|
|
136
|
+
|
|
127
137
|
TelemetryPipeline.shared?.dispatchNow()
|
|
128
138
|
SegmentDispatcher.shared.shipPending()
|
|
129
139
|
}
|
|
@@ -158,16 +168,49 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
158
168
|
|
|
159
169
|
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '$oldSessionId' and creating new one")
|
|
160
170
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
val restartStarted = AtomicBoolean(false)
|
|
172
|
+
val triggerRestart: (String) -> Unit = { source ->
|
|
173
|
+
if (restartStarted.compareAndSet(false, true)) {
|
|
174
|
+
DiagnosticLog.notice("[Rejourney] Session rollover trigger source=$source, oldSession=$oldSessionId")
|
|
164
175
|
mainHandler.post { startNewSessionAfterTimeout() }
|
|
165
176
|
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
mainHandler.postDelayed({
|
|
180
|
+
if (!restartStarted.get()) {
|
|
181
|
+
DiagnosticLog.caution("[Rejourney] Session rollover grace timeout reached (${sessionRolloverGraceMs}ms), forcing new session start")
|
|
182
|
+
}
|
|
183
|
+
triggerRestart("grace_timeout")
|
|
184
|
+
}, sessionRolloverGraceMs)
|
|
185
|
+
|
|
186
|
+
Thread {
|
|
187
|
+
val orchestrator = ReplayOrchestrator.shared
|
|
188
|
+
if (orchestrator == null) {
|
|
189
|
+
triggerRestart("orchestrator_missing")
|
|
190
|
+
} else {
|
|
191
|
+
orchestrator.endReplayWithReason("background_timeout") { success, uploaded ->
|
|
192
|
+
DiagnosticLog.notice("[Rejourney] Old session ended (success: $success, uploaded: $uploaded)")
|
|
193
|
+
triggerRestart("end_replay_callback")
|
|
194
|
+
}
|
|
195
|
+
}
|
|
166
196
|
}.start()
|
|
167
197
|
} else {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
val orchestratorSessionId = ReplayOrchestrator.shared?.replayId
|
|
199
|
+
if (orchestratorSessionId.isNullOrEmpty()) {
|
|
200
|
+
state = SessionState.Idle
|
|
201
|
+
DiagnosticLog.notice("[Rejourney] Session ended while backgrounded, starting fresh session on foreground")
|
|
202
|
+
mainHandler.post { startNewSessionAfterTimeout() }
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (orchestratorSessionId != currentState.sessionId) {
|
|
207
|
+
state = SessionState.Active(orchestratorSessionId, System.currentTimeMillis())
|
|
208
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Foreground reconciled to active session '$orchestratorSessionId' (was '${currentState.sessionId}')")
|
|
209
|
+
} else {
|
|
210
|
+
// Resume existing session
|
|
211
|
+
state = SessionState.Active(currentState.sessionId, currentState.startTimeMs)
|
|
212
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '${currentState.sessionId}'")
|
|
213
|
+
}
|
|
171
214
|
|
|
172
215
|
TelemetryPipeline.shared?.recordAppForeground(backgroundDuration)
|
|
173
216
|
StabilityMonitor.shared?.transmitStoredReport()
|
|
@@ -323,7 +366,7 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
323
366
|
return@post
|
|
324
367
|
}
|
|
325
368
|
|
|
326
|
-
ReplayOrchestrator.shared?.
|
|
369
|
+
ReplayOrchestrator.shared?.endReplayWithReason("user_initiated") { success, uploaded ->
|
|
327
370
|
DiagnosticLog.replayEnded(targetSid)
|
|
328
371
|
callback?.invoke(success, targetSid, uploaded)
|
|
329
372
|
}
|
|
@@ -385,6 +428,15 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
385
428
|
return
|
|
386
429
|
}
|
|
387
430
|
|
|
431
|
+
// Handle console log events - preserve type:"log" with level and message
|
|
432
|
+
// so the dashboard replay can display them in the console terminal
|
|
433
|
+
if (eventType == "log") {
|
|
434
|
+
val level = details?.get("level")?.toString() ?: "log"
|
|
435
|
+
val message = details?.get("message")?.toString() ?: ""
|
|
436
|
+
TelemetryPipeline.shared?.recordConsoleLogEvent(level, message)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
388
440
|
val payload = try {
|
|
389
441
|
org.json.JSONObject(details ?: emptyMap<String, Any>()).toString()
|
|
390
442
|
} catch (e: Exception) {
|
|
@@ -499,7 +551,7 @@ class RejourneyImpl private constructor(private val context: Context) :
|
|
|
499
551
|
else -> {}
|
|
500
552
|
}
|
|
501
553
|
}
|
|
502
|
-
|
|
554
|
+
|
|
503
555
|
try {
|
|
504
556
|
(context.applicationContext as? Application)?.unregisterActivityLifecycleCallbacks(this)
|
|
505
557
|
mainHandler.post {
|
|
@@ -55,6 +55,11 @@ class AnrSentinel private constructor() {
|
|
|
55
55
|
|
|
56
56
|
fun activate() {
|
|
57
57
|
if (isActive.getAndSet(true)) return
|
|
58
|
+
|
|
59
|
+
// Reset watchdog state on each activation to avoid stale timings from
|
|
60
|
+
// previous app background periods.
|
|
61
|
+
lastResponseTime.set(System.currentTimeMillis())
|
|
62
|
+
pongSequence.set(pingSequence.get())
|
|
58
63
|
|
|
59
64
|
startWatchdog()
|
|
60
65
|
}
|
|
@@ -87,7 +92,7 @@ class AnrSentinel private constructor() {
|
|
|
87
92
|
val elapsed = System.currentTimeMillis() - lastResponseTime.get()
|
|
88
93
|
val missedPongs = pingSequence.get() - pongSequence.get()
|
|
89
94
|
|
|
90
|
-
if (elapsed
|
|
95
|
+
if (elapsed >= anrThresholdMs && missedPongs > 0) {
|
|
91
96
|
captureAnr(elapsed)
|
|
92
97
|
|
|
93
98
|
// Reset to avoid duplicate reports
|
|
@@ -113,12 +118,32 @@ class AnrSentinel private constructor() {
|
|
|
113
118
|
"${element.className}.${element.methodName}(${element.fileName}:${element.lineNumber})"
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
ReplayOrchestrator.shared?.
|
|
121
|
+
ReplayOrchestrator.shared?.incrementStalledTally()
|
|
117
122
|
|
|
118
123
|
// Route ANR through TelemetryPipeline so it arrives in the events
|
|
119
124
|
// batch and the backend ingest worker can insert it into the anrs table
|
|
120
125
|
val stackStr = frames.joinToString("\n")
|
|
121
126
|
TelemetryPipeline.shared?.recordAnrEvent(durationMs, stackStr)
|
|
127
|
+
|
|
128
|
+
// Persist ANR incident and send through /api/ingest/fault so ANRs survive
|
|
129
|
+
// process termination/background upload loss, similar to crash recovery.
|
|
130
|
+
val sessionId = StabilityMonitor.shared?.currentSessionId
|
|
131
|
+
?: ReplayOrchestrator.shared?.replayId
|
|
132
|
+
?: "unknown"
|
|
133
|
+
val incident = IncidentRecord(
|
|
134
|
+
sessionId = sessionId,
|
|
135
|
+
timestampMs = System.currentTimeMillis(),
|
|
136
|
+
category = "anr",
|
|
137
|
+
identifier = "MainThreadFrozen",
|
|
138
|
+
detail = "Main thread unresponsive for ${durationMs}ms",
|
|
139
|
+
frames = frames,
|
|
140
|
+
context = mapOf(
|
|
141
|
+
"durationMs" to durationMs.toString(),
|
|
142
|
+
"threadState" to "blocked"
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
StabilityMonitor.shared?.persistIncidentSync(incident)
|
|
146
|
+
StabilityMonitor.shared?.transmitStoredReport()
|
|
122
147
|
|
|
123
148
|
DiagnosticLog.fault("ANR detected: ${durationMs}ms hang")
|
|
124
149
|
|
|
@@ -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,29 @@ 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
|
+
VisualCapture.shared?.invalidateMaskCache()
|
|
138
|
+
SpecialCases.shared.notifyTouchBegan()
|
|
139
|
+
}
|
|
140
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
|
|
141
|
+
SpecialCases.shared.notifyTouchEnded()
|
|
142
|
+
}
|
|
126
143
|
}
|
|
127
144
|
return original.dispatchTouchEvent(event)
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
148
|
|
|
149
|
+
private fun markInteractionNow() {
|
|
150
|
+
lastInteractionTimestampMs.set(System.currentTimeMillis())
|
|
151
|
+
}
|
|
152
|
+
|
|
132
153
|
private fun removeGlobalTouchListener() {
|
|
133
154
|
val window = installedWindow?.get()
|
|
134
155
|
if (window != null) {
|
|
@@ -450,6 +471,11 @@ private class GestureAggregator(
|
|
|
450
471
|
}
|
|
451
472
|
|
|
452
473
|
private fun resolveTarget(location: PointF): String {
|
|
474
|
+
// When a map view is visible, skip the expensive hit-test walk
|
|
475
|
+
// through the deep SurfaceView/TextureView hierarchy.
|
|
476
|
+
if (SpecialCases.shared.mapVisible) {
|
|
477
|
+
return "map"
|
|
478
|
+
}
|
|
453
479
|
return "view_${location.x.toInt()}_${location.y.toInt()}"
|
|
454
480
|
}
|
|
455
481
|
|
|
@@ -462,6 +488,10 @@ private class GestureAggregator(
|
|
|
462
488
|
* (e.g. TextView inside a Pressable), not the clickable Pressable itself.
|
|
463
489
|
*/
|
|
464
490
|
private fun isViewInteractive(location: PointF): Boolean {
|
|
491
|
+
// Skip the expensive hierarchy walk when a map is visible to
|
|
492
|
+
// prevent micro-stutter during pan/zoom gestures.
|
|
493
|
+
if (SpecialCases.shared.mapVisible) return false
|
|
494
|
+
|
|
465
495
|
val activity = recorder.currentActivity?.get() ?: return false
|
|
466
496
|
val decorView = activity.window?.decorView ?: return false
|
|
467
497
|
val hit = findViewAt(decorView, location.x.toInt(), location.y.toInt()) ?: return false
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package com.rejourney.recording
|
|
2
|
+
|
|
3
|
+
import okhttp3.Interceptor
|
|
4
|
+
import okhttp3.Response
|
|
5
|
+
import java.io.IOException
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native OkHttp Interceptor for Rejourney
|
|
10
|
+
*
|
|
11
|
+
* Captures native network traffic and routes it to the Rejourney TelemetryPipeline.
|
|
12
|
+
* To use, add this interceptor to your native OkHttpClient:
|
|
13
|
+
* val client = OkHttpClient.Builder()
|
|
14
|
+
* .addInterceptor(RejourneyNetworkInterceptor())
|
|
15
|
+
* .build()
|
|
16
|
+
*/
|
|
17
|
+
class RejourneyNetworkInterceptor : Interceptor {
|
|
18
|
+
|
|
19
|
+
@Throws(IOException::class)
|
|
20
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
21
|
+
val request = chain.request()
|
|
22
|
+
val host = request.url.host
|
|
23
|
+
|
|
24
|
+
// Skip Rejourney's own API traffic to avoid ingestion duplication (mirrors iOS RejourneyURLProtocol)
|
|
25
|
+
if (host.contains("api.rejourney.co") || host.contains("rejourney")) {
|
|
26
|
+
return chain.proceed(request)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
val startMs = System.currentTimeMillis()
|
|
30
|
+
|
|
31
|
+
var response: Response? = null
|
|
32
|
+
var error: Exception? = null
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
response = chain.proceed(request)
|
|
36
|
+
} catch (e: Exception) {
|
|
37
|
+
error = e
|
|
38
|
+
throw e
|
|
39
|
+
} finally {
|
|
40
|
+
try {
|
|
41
|
+
val endMs = System.currentTimeMillis()
|
|
42
|
+
val duration = endMs - startMs
|
|
43
|
+
|
|
44
|
+
val isSuccess = response?.isSuccessful == true
|
|
45
|
+
val statusCode = response?.code ?: 0
|
|
46
|
+
|
|
47
|
+
val urlStr = request.url.toString()
|
|
48
|
+
val pathStr = request.url.encodedPath
|
|
49
|
+
|
|
50
|
+
var maxUrlStr = urlStr
|
|
51
|
+
if (maxUrlStr.length > 300) {
|
|
52
|
+
maxUrlStr = maxUrlStr.substring(0, 300)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
val reqSize = request.body?.contentLength()?.takeIf { it >= 0 }?.toInt() ?: 0
|
|
56
|
+
val resSize = response?.body?.contentLength()?.takeIf { it >= 0 }?.toInt() ?: 0
|
|
57
|
+
|
|
58
|
+
val event = mutableMapOf<String, Any>(
|
|
59
|
+
"requestId" to "n_${UUID.randomUUID()}",
|
|
60
|
+
"method" to request.method,
|
|
61
|
+
"url" to maxUrlStr,
|
|
62
|
+
"urlPath" to pathStr,
|
|
63
|
+
"urlHost" to request.url.host,
|
|
64
|
+
"statusCode" to statusCode,
|
|
65
|
+
"duration" to duration,
|
|
66
|
+
"startTimestamp" to startMs,
|
|
67
|
+
"endTimestamp" to endMs,
|
|
68
|
+
"success" to isSuccess
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (reqSize > 0) {
|
|
72
|
+
event["requestBodySize"] = reqSize
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
request.body?.contentType()?.let {
|
|
76
|
+
event["requestContentType"] = it.toString()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (resSize > 0) {
|
|
80
|
+
event["responseBodySize"] = resSize
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
response?.body?.contentType()?.let {
|
|
84
|
+
event["responseContentType"] = it.toString()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
error?.let {
|
|
88
|
+
event["errorMessage"] = it.message ?: "Network error"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
TelemetryPipeline.shared?.recordNetworkEvent(event)
|
|
92
|
+
|
|
93
|
+
} catch (ignore: Exception) {
|
|
94
|
+
// Ignore to avoid breaking the application network call
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return response!!
|
|
99
|
+
}
|
|
100
|
+
}
|