@rejourneyco/react-native 1.0.8 → 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/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
- 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 +3 -1
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
- 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 +199 -115
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +1 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +204 -143
- package/ios/Recording/SegmentDispatcher.swift +8 -0
- package/ios/Recording/StabilityMonitor.swift +40 -32
- package/ios/Recording/TelemetryPipeline.swift +17 -0
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +54 -8
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +28 -15
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/module/index.js +28 -15
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/types/index.d.ts +14 -2
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +37 -19
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/types/index.ts +15 -3
|
@@ -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()
|
|
@@ -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
|
|
|
@@ -133,8 +133,10 @@ class InteractionRecorder private constructor(private val context: Context) {
|
|
|
133
133
|
// Notify SpecialCases about touch phases for touch-based
|
|
134
134
|
// map idle detection (Mapbox v10+ fallback).
|
|
135
135
|
when (event.actionMasked) {
|
|
136
|
-
MotionEvent.ACTION_DOWN ->
|
|
136
|
+
MotionEvent.ACTION_DOWN -> {
|
|
137
|
+
VisualCapture.shared?.invalidateMaskCache()
|
|
137
138
|
SpecialCases.shared.notifyTouchBegan()
|
|
139
|
+
}
|
|
138
140
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
|
|
139
141
|
SpecialCases.shared.notifyTouchEnded()
|
|
140
142
|
}
|
|
@@ -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
|
+
}
|