@rejourneyco/react-native 1.0.7
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 +29 -0
- package/android/build.gradle.kts +135 -0
- package/android/consumer-rules.pro +10 -0
- package/android/proguard-rules.pro +1 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
- package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
- package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
- package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
- package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
- package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
- package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
- package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
- package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
- package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
- package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
- package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
- package/ios/Engine/DeviceRegistrar.swift +288 -0
- package/ios/Engine/DiagnosticLog.swift +387 -0
- package/ios/Engine/RejourneyImpl.swift +719 -0
- package/ios/Recording/AnrSentinel.swift +142 -0
- package/ios/Recording/EventBuffer.swift +326 -0
- package/ios/Recording/InteractionRecorder.swift +428 -0
- package/ios/Recording/ReplayOrchestrator.swift +624 -0
- package/ios/Recording/SegmentDispatcher.swift +492 -0
- package/ios/Recording/StabilityMonitor.swift +223 -0
- package/ios/Recording/TelemetryPipeline.swift +547 -0
- package/ios/Recording/ViewHierarchyScanner.swift +156 -0
- package/ios/Recording/VisualCapture.swift +675 -0
- package/ios/Rejourney.h +38 -0
- package/ios/Rejourney.mm +375 -0
- package/ios/Utility/DataCompression.swift +55 -0
- package/ios/Utility/ImageBlur.swift +89 -0
- package/ios/Utility/RuntimeMethodSwap.swift +41 -0
- package/ios/Utility/ViewIdentifier.swift +37 -0
- package/lib/commonjs/NativeRejourney.js +40 -0
- package/lib/commonjs/components/Mask.js +88 -0
- package/lib/commonjs/index.js +1443 -0
- package/lib/commonjs/sdk/autoTracking.js +1087 -0
- package/lib/commonjs/sdk/constants.js +166 -0
- package/lib/commonjs/sdk/errorTracking.js +187 -0
- package/lib/commonjs/sdk/index.js +50 -0
- package/lib/commonjs/sdk/metricsTracking.js +205 -0
- package/lib/commonjs/sdk/navigation.js +128 -0
- package/lib/commonjs/sdk/networkInterceptor.js +375 -0
- package/lib/commonjs/sdk/utils.js +433 -0
- package/lib/commonjs/sdk/version.js +13 -0
- package/lib/commonjs/types/expo-router.d.js +2 -0
- package/lib/commonjs/types/index.js +2 -0
- package/lib/module/NativeRejourney.js +38 -0
- package/lib/module/components/Mask.js +83 -0
- package/lib/module/index.js +1341 -0
- package/lib/module/sdk/autoTracking.js +1059 -0
- package/lib/module/sdk/constants.js +154 -0
- package/lib/module/sdk/errorTracking.js +177 -0
- package/lib/module/sdk/index.js +26 -0
- package/lib/module/sdk/metricsTracking.js +187 -0
- package/lib/module/sdk/navigation.js +120 -0
- package/lib/module/sdk/networkInterceptor.js +364 -0
- package/lib/module/sdk/utils.js +412 -0
- package/lib/module/sdk/version.js +7 -0
- package/lib/module/types/expo-router.d.js +2 -0
- package/lib/module/types/index.js +2 -0
- package/lib/typescript/NativeRejourney.d.ts +160 -0
- package/lib/typescript/components/Mask.d.ts +54 -0
- package/lib/typescript/index.d.ts +117 -0
- package/lib/typescript/sdk/autoTracking.d.ts +226 -0
- package/lib/typescript/sdk/constants.d.ts +138 -0
- package/lib/typescript/sdk/errorTracking.d.ts +47 -0
- package/lib/typescript/sdk/index.d.ts +24 -0
- package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
- package/lib/typescript/sdk/navigation.d.ts +48 -0
- package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
- package/lib/typescript/sdk/utils.d.ts +193 -0
- package/lib/typescript/sdk/version.d.ts +6 -0
- package/lib/typescript/types/index.d.ts +618 -0
- package/package.json +122 -0
- package/rejourney.podspec +23 -0
- package/src/NativeRejourney.ts +185 -0
- package/src/components/Mask.tsx +93 -0
- package/src/index.ts +1555 -0
- package/src/sdk/autoTracking.ts +1245 -0
- package/src/sdk/constants.ts +155 -0
- package/src/sdk/errorTracking.ts +231 -0
- package/src/sdk/index.ts +25 -0
- package/src/sdk/metricsTracking.ts +227 -0
- package/src/sdk/navigation.ts +152 -0
- package/src/sdk/networkInterceptor.ts +423 -0
- package/src/sdk/utils.ts +442 -0
- package/src/sdk/version.ts +6 -0
- package/src/types/expo-router.d.ts +7 -0
- package/src/types/index.ts +709 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2026 Rejourney
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
package com.rejourney.recording
|
|
18
|
+
|
|
19
|
+
import com.rejourney.engine.DiagnosticLog
|
|
20
|
+
import kotlinx.coroutines.*
|
|
21
|
+
import okhttp3.*
|
|
22
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
23
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
import java.util.concurrent.Executors
|
|
26
|
+
import java.util.concurrent.TimeUnit
|
|
27
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
28
|
+
import kotlin.concurrent.withLock
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handles segment uploads with presigned URLs and circuit breaker
|
|
32
|
+
* Android implementation aligned with iOS SegmentDispatcher.swift
|
|
33
|
+
*/
|
|
34
|
+
class SegmentDispatcher private constructor() {
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
@Volatile
|
|
38
|
+
private var instance: SegmentDispatcher? = null
|
|
39
|
+
|
|
40
|
+
val shared: SegmentDispatcher
|
|
41
|
+
get() = instance ?: synchronized(this) {
|
|
42
|
+
instance ?: SegmentDispatcher().also { instance = it }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var endpoint: String = "https://api.rejourney.co"
|
|
47
|
+
var currentReplayId: String? = null
|
|
48
|
+
var apiToken: String? = null
|
|
49
|
+
var credential: String? = null
|
|
50
|
+
var projectId: String? = null
|
|
51
|
+
var isSampledIn: Boolean = true // SDK's sampling decision for server-side enforcement
|
|
52
|
+
|
|
53
|
+
private var batchSeqNumber = 0
|
|
54
|
+
private var billingBlocked = false
|
|
55
|
+
private var consecutiveFailures = 0
|
|
56
|
+
private var circuitOpen = false
|
|
57
|
+
private var circuitOpenTime: Long = 0
|
|
58
|
+
private val circuitBreakerThreshold = 5
|
|
59
|
+
private val circuitResetTime: Long = 60_000 // 60 seconds
|
|
60
|
+
|
|
61
|
+
// Metrics
|
|
62
|
+
var uploadSuccessCount = 0
|
|
63
|
+
var uploadFailureCount = 0
|
|
64
|
+
var totalBytesUploaded = 0L
|
|
65
|
+
var circuitBreakerOpenCount = 0
|
|
66
|
+
|
|
67
|
+
private val workerExecutor = Executors.newFixedThreadPool(2)
|
|
68
|
+
private val scope = CoroutineScope(workerExecutor.asCoroutineDispatcher() + SupervisorJob())
|
|
69
|
+
|
|
70
|
+
private val httpClient: OkHttpClient = OkHttpClient.Builder()
|
|
71
|
+
.connectTimeout(5, TimeUnit.SECONDS) // Short timeout for debugging
|
|
72
|
+
.readTimeout(10, TimeUnit.SECONDS)
|
|
73
|
+
.writeTimeout(10, TimeUnit.SECONDS)
|
|
74
|
+
.build()
|
|
75
|
+
|
|
76
|
+
private val retryQueue = mutableListOf<PendingUpload>()
|
|
77
|
+
private val retryLock = ReentrantLock()
|
|
78
|
+
private var active = true
|
|
79
|
+
|
|
80
|
+
fun configure(replayId: String, apiToken: String?, credential: String?, projectId: String?, isSampledIn: Boolean = true) {
|
|
81
|
+
currentReplayId = replayId
|
|
82
|
+
this.apiToken = apiToken
|
|
83
|
+
this.credential = credential
|
|
84
|
+
this.projectId = projectId
|
|
85
|
+
this.isSampledIn = isSampledIn
|
|
86
|
+
batchSeqNumber = 0
|
|
87
|
+
billingBlocked = false
|
|
88
|
+
consecutiveFailures = 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fun activate() {
|
|
92
|
+
active = true
|
|
93
|
+
consecutiveFailures = 0
|
|
94
|
+
circuitOpen = false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun halt() {
|
|
98
|
+
active = false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fun shipPending() {
|
|
102
|
+
scope.launch {
|
|
103
|
+
drainRetryQueue()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fun transmitFrameBundle(
|
|
108
|
+
payload: ByteArray,
|
|
109
|
+
startMs: Long,
|
|
110
|
+
endMs: Long,
|
|
111
|
+
frameCount: Int,
|
|
112
|
+
completion: ((Boolean) -> Unit)? = null
|
|
113
|
+
) {
|
|
114
|
+
val sid = currentReplayId
|
|
115
|
+
val canUpload = canUploadNow()
|
|
116
|
+
DiagnosticLog.notice("[SegmentDispatcher] transmitFrameBundle: sid=${sid?.take(12) ?: "null"}, canUpload=$canUpload, frames=$frameCount, bytes=${payload.size}")
|
|
117
|
+
|
|
118
|
+
if (sid != null) {
|
|
119
|
+
DiagnosticLog.debugPresignRequest(endpoint, sid, "screenshots", payload.size)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (sid == null || !canUpload) {
|
|
123
|
+
DiagnosticLog.caution("[SegmentDispatcher] transmitFrameBundle: rejected - sid=${sid != null}, canUpload=$canUpload")
|
|
124
|
+
completion?.invoke(false)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
val upload = PendingUpload(
|
|
129
|
+
sessionId = sid,
|
|
130
|
+
contentType = "screenshots",
|
|
131
|
+
payload = payload,
|
|
132
|
+
rangeStart = startMs,
|
|
133
|
+
rangeEnd = endMs,
|
|
134
|
+
itemCount = frameCount,
|
|
135
|
+
attempt = 0
|
|
136
|
+
)
|
|
137
|
+
scheduleUpload(upload, completion)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fun transmitHierarchy(
|
|
141
|
+
replayId: String,
|
|
142
|
+
hierarchyPayload: ByteArray,
|
|
143
|
+
timestampMs: Long,
|
|
144
|
+
completion: ((Boolean) -> Unit)? = null
|
|
145
|
+
) {
|
|
146
|
+
if (!canUploadNow()) {
|
|
147
|
+
completion?.invoke(false)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
val upload = PendingUpload(
|
|
152
|
+
sessionId = replayId,
|
|
153
|
+
contentType = "hierarchy",
|
|
154
|
+
payload = hierarchyPayload,
|
|
155
|
+
rangeStart = timestampMs,
|
|
156
|
+
rangeEnd = timestampMs,
|
|
157
|
+
itemCount = 1,
|
|
158
|
+
attempt = 0
|
|
159
|
+
)
|
|
160
|
+
scheduleUpload(upload, completion)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fun transmitEventBatch(
|
|
164
|
+
payload: ByteArray,
|
|
165
|
+
batchNumber: Int,
|
|
166
|
+
eventCount: Int,
|
|
167
|
+
completion: ((Boolean) -> Unit)? = null
|
|
168
|
+
) {
|
|
169
|
+
val sid = currentReplayId
|
|
170
|
+
if (sid == null || !canUploadNow()) {
|
|
171
|
+
completion?.invoke(false)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
scope.launch {
|
|
176
|
+
executeEventBatchUpload(sid, payload, batchNumber, eventCount, completion)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fun transmitEventBatchAlternate(
|
|
181
|
+
replayId: String,
|
|
182
|
+
eventPayload: ByteArray,
|
|
183
|
+
eventCount: Int,
|
|
184
|
+
completion: ((Boolean) -> Unit)? = null
|
|
185
|
+
) {
|
|
186
|
+
if (!canUploadNow()) {
|
|
187
|
+
completion?.invoke(false)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
batchSeqNumber++
|
|
192
|
+
val seq = batchSeqNumber
|
|
193
|
+
|
|
194
|
+
scope.launch {
|
|
195
|
+
executeEventBatchUpload(replayId, eventPayload, seq, eventCount, completion)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fun concludeReplay(
|
|
200
|
+
replayId: String,
|
|
201
|
+
concludedAt: Long,
|
|
202
|
+
backgroundDurationMs: Long,
|
|
203
|
+
metrics: Map<String, Any>?,
|
|
204
|
+
completion: (Boolean) -> Unit
|
|
205
|
+
) {
|
|
206
|
+
val url = "$endpoint/api/ingest/session/end"
|
|
207
|
+
|
|
208
|
+
val body = JSONObject().apply {
|
|
209
|
+
put("sessionId", replayId)
|
|
210
|
+
put("endedAt", concludedAt)
|
|
211
|
+
if (backgroundDurationMs > 0) put("totalBackgroundTimeMs", backgroundDurationMs)
|
|
212
|
+
metrics?.let { put("metrics", JSONObject(it)) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val request = buildRequest(url, body)
|
|
216
|
+
|
|
217
|
+
scope.launch {
|
|
218
|
+
try {
|
|
219
|
+
val response = httpClient.newCall(request).execute()
|
|
220
|
+
completion(response.code == 200)
|
|
221
|
+
} catch (e: Exception) {
|
|
222
|
+
completion(false)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fun evaluateReplayRetention(
|
|
228
|
+
replayId: String,
|
|
229
|
+
metrics: Map<String, Any>,
|
|
230
|
+
completion: (Boolean, String) -> Unit
|
|
231
|
+
) {
|
|
232
|
+
val url = "$endpoint/api/ingest/replay/evaluate"
|
|
233
|
+
|
|
234
|
+
val body = JSONObject().apply {
|
|
235
|
+
put("sessionId", replayId)
|
|
236
|
+
metrics.forEach { (key, value) -> put(key, value) }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
val request = buildRequest(url, body)
|
|
240
|
+
|
|
241
|
+
scope.launch {
|
|
242
|
+
try {
|
|
243
|
+
val response = httpClient.newCall(request).execute()
|
|
244
|
+
val responseBody = response.body?.string()
|
|
245
|
+
|
|
246
|
+
if (response.code == 200 && responseBody != null) {
|
|
247
|
+
val json = JSONObject(responseBody)
|
|
248
|
+
val retained = json.optBoolean("promoted", false)
|
|
249
|
+
val reason = json.optString("reason", "unknown")
|
|
250
|
+
completion(retained, reason)
|
|
251
|
+
} else {
|
|
252
|
+
completion(false, "request_failed")
|
|
253
|
+
}
|
|
254
|
+
} catch (e: Exception) {
|
|
255
|
+
completion(false, "request_failed")
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private fun canUploadNow(): Boolean {
|
|
261
|
+
if (billingBlocked) return false
|
|
262
|
+
if (circuitOpen) {
|
|
263
|
+
if (System.currentTimeMillis() - circuitOpenTime > circuitResetTime) {
|
|
264
|
+
circuitOpen = false
|
|
265
|
+
} else {
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun registerFailure() {
|
|
273
|
+
consecutiveFailures++
|
|
274
|
+
uploadFailureCount++
|
|
275
|
+
if (consecutiveFailures >= circuitBreakerThreshold) {
|
|
276
|
+
if (!circuitOpen) circuitBreakerOpenCount++
|
|
277
|
+
circuitOpen = true
|
|
278
|
+
circuitOpenTime = System.currentTimeMillis()
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun registerSuccess() {
|
|
283
|
+
consecutiveFailures = 0
|
|
284
|
+
uploadSuccessCount++
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private fun scheduleUpload(upload: PendingUpload, completion: ((Boolean) -> Unit)?) {
|
|
288
|
+
DiagnosticLog.notice("[SegmentDispatcher] scheduleUpload: active=$active, type=${upload.contentType}, items=${upload.itemCount}")
|
|
289
|
+
if (!active) {
|
|
290
|
+
DiagnosticLog.caution("[SegmentDispatcher] scheduleUpload: rejected - not active")
|
|
291
|
+
completion?.invoke(false)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
scope.launch {
|
|
295
|
+
executeSegmentUpload(upload, completion)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private suspend fun executeSegmentUpload(upload: PendingUpload, completion: ((Boolean) -> Unit)?) {
|
|
300
|
+
if (!active) {
|
|
301
|
+
completion?.invoke(false)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
val presignResponse = requestPresignedUrl(upload)
|
|
306
|
+
if (presignResponse == null) {
|
|
307
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ requestPresignedUrl FAILED for ${upload.contentType}")
|
|
308
|
+
DiagnosticLog.caution("[SegmentDispatcher] requestPresignedUrl FAILED for ${upload.contentType}")
|
|
309
|
+
registerFailure()
|
|
310
|
+
scheduleRetryIfNeeded(upload, completion)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
val s3ok = uploadToS3(presignResponse.presignedUrl, upload.payload, upload.contentType)
|
|
315
|
+
if (!s3ok) {
|
|
316
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ uploadToS3 FAILED for ${upload.contentType}")
|
|
317
|
+
DiagnosticLog.caution("[SegmentDispatcher] uploadToS3 FAILED for ${upload.contentType}")
|
|
318
|
+
registerFailure()
|
|
319
|
+
scheduleRetryIfNeeded(upload, completion)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
val confirmOk = confirmBatchComplete(presignResponse.batchId, upload)
|
|
324
|
+
if (confirmOk) {
|
|
325
|
+
registerSuccess()
|
|
326
|
+
} else {
|
|
327
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ confirmBatchComplete FAILED for ${upload.contentType}")
|
|
328
|
+
DiagnosticLog.caution("[SegmentDispatcher] confirmBatchComplete FAILED for ${upload.contentType}")
|
|
329
|
+
registerFailure()
|
|
330
|
+
}
|
|
331
|
+
completion?.invoke(confirmOk)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private fun scheduleRetryIfNeeded(upload: PendingUpload, completion: ((Boolean) -> Unit)?) {
|
|
335
|
+
if (upload.attempt < 3) {
|
|
336
|
+
val retry = upload.copy(attempt = upload.attempt + 1)
|
|
337
|
+
retryLock.withLock {
|
|
338
|
+
retryQueue.add(retry)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
completion?.invoke(false)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private fun drainRetryQueue() {
|
|
345
|
+
val items = retryLock.withLock {
|
|
346
|
+
val copy = retryQueue.toList()
|
|
347
|
+
retryQueue.clear()
|
|
348
|
+
copy
|
|
349
|
+
}
|
|
350
|
+
items.forEach {
|
|
351
|
+
scope.launch { executeSegmentUpload(it, null) }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private suspend fun requestPresignedUrl(upload: PendingUpload): PresignResponse? {
|
|
356
|
+
val urlPath = if (upload.contentType == "events") "/api/ingest/presign" else "/api/ingest/segment/presign"
|
|
357
|
+
val url = "$endpoint$urlPath"
|
|
358
|
+
|
|
359
|
+
val body = JSONObject().apply {
|
|
360
|
+
put("sessionId", upload.sessionId)
|
|
361
|
+
put("sizeBytes", upload.payload.size)
|
|
362
|
+
|
|
363
|
+
if (upload.contentType == "events") {
|
|
364
|
+
put("contentType", "events")
|
|
365
|
+
put("batchNumber", batchSeqNumber)
|
|
366
|
+
put("isSampledIn", isSampledIn) // Server-side enforcement
|
|
367
|
+
} else {
|
|
368
|
+
put("kind", upload.contentType)
|
|
369
|
+
put("startTime", upload.rangeStart)
|
|
370
|
+
put("endTime", upload.rangeEnd)
|
|
371
|
+
put("frameCount", upload.itemCount)
|
|
372
|
+
put("compression", "gzip")
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
val request = buildRequest(url, body)
|
|
377
|
+
val startTime = System.currentTimeMillis()
|
|
378
|
+
|
|
379
|
+
return try {
|
|
380
|
+
val response = httpClient.newCall(request).execute()
|
|
381
|
+
val durationMs = (System.currentTimeMillis() - startTime).toDouble()
|
|
382
|
+
val responseBody = response.body?.string()
|
|
383
|
+
|
|
384
|
+
DiagnosticLog.debugPresignResponse(response.code, null, null, durationMs)
|
|
385
|
+
|
|
386
|
+
if (response.code == 402) {
|
|
387
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ presign: 402 Payment Required - billing blocked")
|
|
388
|
+
billingBlocked = true
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (response.code != 200 || responseBody == null) {
|
|
393
|
+
val bodyPreview = responseBody?.take(300) ?: "null"
|
|
394
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ presign failed: status=${response.code} body=$bodyPreview")
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
val json = JSONObject(responseBody)
|
|
399
|
+
|
|
400
|
+
if (json.optBoolean("skipUpload", false)) {
|
|
401
|
+
return null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
val presignedUrl = json.optString("presignedUrl", null) ?: return null
|
|
405
|
+
val batchId = json.optString("batchId", null)
|
|
406
|
+
?: json.optString("segmentId", "")
|
|
407
|
+
?: ""
|
|
408
|
+
|
|
409
|
+
DiagnosticLog.debugPresignResponse(response.code, batchId, presignedUrl, durationMs)
|
|
410
|
+
PresignResponse(presignedUrl, batchId)
|
|
411
|
+
} catch (e: Exception) {
|
|
412
|
+
val durationMs = (System.currentTimeMillis() - startTime).toDouble()
|
|
413
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ presign exception (${durationMs.toLong()}ms): ${e.javaClass.simpleName}: ${e.message}")
|
|
414
|
+
DiagnosticLog.fault("[SegmentDispatcher] presign exception: ${e.message}")
|
|
415
|
+
null
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private suspend fun uploadToS3(url: String, payload: ByteArray, contentType: String): Boolean {
|
|
420
|
+
val mediaType = when (contentType) {
|
|
421
|
+
"video" -> "video/mp4".toMediaType()
|
|
422
|
+
else -> "application/gzip".toMediaType()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
val request = Request.Builder()
|
|
426
|
+
.url(url)
|
|
427
|
+
.put(payload.toRequestBody(mediaType))
|
|
428
|
+
.header("Content-Type", mediaType.toString())
|
|
429
|
+
.build()
|
|
430
|
+
|
|
431
|
+
val startTime = System.currentTimeMillis()
|
|
432
|
+
return try {
|
|
433
|
+
val response = httpClient.newCall(request).execute()
|
|
434
|
+
val durationMs = (System.currentTimeMillis() - startTime).toDouble()
|
|
435
|
+
DiagnosticLog.debugUploadComplete("", response.code, durationMs, 0.0)
|
|
436
|
+
|
|
437
|
+
if (response.code in 200..299) {
|
|
438
|
+
totalBytesUploaded += payload.size
|
|
439
|
+
true
|
|
440
|
+
} else {
|
|
441
|
+
false
|
|
442
|
+
}
|
|
443
|
+
} catch (e: Exception) {
|
|
444
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ S3 upload exception: ${e.message}")
|
|
445
|
+
DiagnosticLog.fault("[SegmentDispatcher] S3 upload exception: ${e.message}")
|
|
446
|
+
false
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private suspend fun confirmBatchComplete(batchId: String, upload: PendingUpload): Boolean {
|
|
451
|
+
val urlPath = if (upload.contentType == "events") "/api/ingest/batch/complete" else "/api/ingest/segment/complete"
|
|
452
|
+
val url = "$endpoint$urlPath"
|
|
453
|
+
|
|
454
|
+
val body = JSONObject().apply {
|
|
455
|
+
put("actualSizeBytes", upload.payload.size)
|
|
456
|
+
put("timestamp", System.currentTimeMillis())
|
|
457
|
+
|
|
458
|
+
if (upload.contentType == "events") {
|
|
459
|
+
put("batchId", batchId)
|
|
460
|
+
put("eventCount", upload.itemCount)
|
|
461
|
+
} else {
|
|
462
|
+
put("segmentId", batchId)
|
|
463
|
+
put("frameCount", upload.itemCount)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
val request = buildRequest(url, body)
|
|
468
|
+
|
|
469
|
+
return try {
|
|
470
|
+
val response = httpClient.newCall(request).execute()
|
|
471
|
+
response.code == 200
|
|
472
|
+
} catch (e: Exception) {
|
|
473
|
+
false
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private suspend fun executeEventBatchUpload(
|
|
478
|
+
sessionId: String,
|
|
479
|
+
payload: ByteArray,
|
|
480
|
+
batchNum: Int,
|
|
481
|
+
eventCount: Int,
|
|
482
|
+
completion: ((Boolean) -> Unit)?
|
|
483
|
+
) {
|
|
484
|
+
val upload = PendingUpload(
|
|
485
|
+
sessionId = sessionId,
|
|
486
|
+
contentType = "events",
|
|
487
|
+
payload = payload,
|
|
488
|
+
rangeStart = 0,
|
|
489
|
+
rangeEnd = 0,
|
|
490
|
+
itemCount = eventCount,
|
|
491
|
+
attempt = 0
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
val presignResponse = requestPresignedUrl(upload)
|
|
495
|
+
if (presignResponse == null) {
|
|
496
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ requestPresignedUrl FAILED for ${upload.contentType}")
|
|
497
|
+
DiagnosticLog.caution("[SegmentDispatcher] requestPresignedUrl FAILED for ${upload.contentType}")
|
|
498
|
+
registerFailure()
|
|
499
|
+
scheduleRetryIfNeeded(upload, completion)
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
val s3ok = uploadToS3(presignResponse.presignedUrl, upload.payload, upload.contentType)
|
|
504
|
+
if (!s3ok) {
|
|
505
|
+
DiagnosticLog.notice("[SegmentDispatcher] ❌ uploadToS3 FAILED for ${upload.contentType}")
|
|
506
|
+
DiagnosticLog.caution("[SegmentDispatcher] uploadToS3 FAILED for ${upload.contentType}")
|
|
507
|
+
registerFailure()
|
|
508
|
+
scheduleRetryIfNeeded(upload, completion)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
val confirmOk = confirmBatchComplete(presignResponse.batchId, upload)
|
|
513
|
+
if (confirmOk) {
|
|
514
|
+
registerSuccess()
|
|
515
|
+
} else {
|
|
516
|
+
DiagnosticLog.caution("[SegmentDispatcher] confirmBatchComplete FAILED for ${upload.contentType} (batchId=${presignResponse.batchId})")
|
|
517
|
+
registerFailure()
|
|
518
|
+
}
|
|
519
|
+
completion?.invoke(confirmOk)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private fun buildRequest(url: String, body: JSONObject): Request {
|
|
523
|
+
// Log auth state before building request
|
|
524
|
+
DiagnosticLog.notice("[SegmentDispatcher] buildRequest: apiToken=${apiToken?.take(15) ?: "NULL"}, credential=${credential?.take(15) ?: "NULL"}, replayId=${currentReplayId?.take(20) ?: "NULL"}")
|
|
525
|
+
|
|
526
|
+
val requestBody = body.toString().toRequestBody("application/json".toMediaType())
|
|
527
|
+
|
|
528
|
+
val request = Request.Builder()
|
|
529
|
+
.url(url)
|
|
530
|
+
.post(requestBody)
|
|
531
|
+
.header("Content-Type", "application/json")
|
|
532
|
+
.apply {
|
|
533
|
+
apiToken?.let {
|
|
534
|
+
header("x-rejourney-key", it)
|
|
535
|
+
} ?: DiagnosticLog.fault("[SegmentDispatcher] ⚠️ apiToken is NULL - auth will fail!")
|
|
536
|
+
credential?.let { header("x-upload-token", it) }
|
|
537
|
+
currentReplayId?.let { header("x-session-id", it) }
|
|
538
|
+
}
|
|
539
|
+
.build()
|
|
540
|
+
|
|
541
|
+
DiagnosticLog.debugNetworkRequest("POST", url, request.headers.toMultimap().mapValues { it.value.first() })
|
|
542
|
+
return request
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private data class PendingUpload(
|
|
547
|
+
val sessionId: String,
|
|
548
|
+
val contentType: String,
|
|
549
|
+
val payload: ByteArray,
|
|
550
|
+
val rangeStart: Long,
|
|
551
|
+
val rangeEnd: Long,
|
|
552
|
+
val itemCount: Int,
|
|
553
|
+
val attempt: Int
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
private data class PresignResponse(
|
|
557
|
+
val presignedUrl: String,
|
|
558
|
+
val batchId: String
|
|
559
|
+
)
|