@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.
Files changed (105) hide show
  1. package/README.md +29 -0
  2. package/android/build.gradle.kts +135 -0
  3. package/android/consumer-rules.pro +10 -0
  4. package/android/proguard-rules.pro +1 -0
  5. package/android/src/main/AndroidManifest.xml +15 -0
  6. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +860 -0
  7. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +290 -0
  8. package/android/src/main/java/com/rejourney/engine/DiagnosticLog.kt +385 -0
  9. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +512 -0
  10. package/android/src/main/java/com/rejourney/platform/OEMDetector.kt +173 -0
  11. package/android/src/main/java/com/rejourney/platform/PerfTiming.kt +384 -0
  12. package/android/src/main/java/com/rejourney/platform/SessionLifecycleService.kt +160 -0
  13. package/android/src/main/java/com/rejourney/platform/Telemetry.kt +301 -0
  14. package/android/src/main/java/com/rejourney/platform/WindowUtils.kt +100 -0
  15. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +129 -0
  16. package/android/src/main/java/com/rejourney/recording/EventBuffer.kt +330 -0
  17. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +519 -0
  18. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +740 -0
  19. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +559 -0
  20. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +238 -0
  21. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +633 -0
  22. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +232 -0
  23. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +474 -0
  24. package/android/src/main/java/com/rejourney/utility/DataCompression.kt +63 -0
  25. package/android/src/main/java/com/rejourney/utility/ImageBlur.kt +412 -0
  26. package/android/src/main/java/com/rejourney/utility/ViewIdentifier.kt +169 -0
  27. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +232 -0
  28. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  29. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +268 -0
  30. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  31. package/ios/Engine/DeviceRegistrar.swift +288 -0
  32. package/ios/Engine/DiagnosticLog.swift +387 -0
  33. package/ios/Engine/RejourneyImpl.swift +719 -0
  34. package/ios/Recording/AnrSentinel.swift +142 -0
  35. package/ios/Recording/EventBuffer.swift +326 -0
  36. package/ios/Recording/InteractionRecorder.swift +428 -0
  37. package/ios/Recording/ReplayOrchestrator.swift +624 -0
  38. package/ios/Recording/SegmentDispatcher.swift +492 -0
  39. package/ios/Recording/StabilityMonitor.swift +223 -0
  40. package/ios/Recording/TelemetryPipeline.swift +547 -0
  41. package/ios/Recording/ViewHierarchyScanner.swift +156 -0
  42. package/ios/Recording/VisualCapture.swift +675 -0
  43. package/ios/Rejourney.h +38 -0
  44. package/ios/Rejourney.mm +375 -0
  45. package/ios/Utility/DataCompression.swift +55 -0
  46. package/ios/Utility/ImageBlur.swift +89 -0
  47. package/ios/Utility/RuntimeMethodSwap.swift +41 -0
  48. package/ios/Utility/ViewIdentifier.swift +37 -0
  49. package/lib/commonjs/NativeRejourney.js +40 -0
  50. package/lib/commonjs/components/Mask.js +88 -0
  51. package/lib/commonjs/index.js +1443 -0
  52. package/lib/commonjs/sdk/autoTracking.js +1087 -0
  53. package/lib/commonjs/sdk/constants.js +166 -0
  54. package/lib/commonjs/sdk/errorTracking.js +187 -0
  55. package/lib/commonjs/sdk/index.js +50 -0
  56. package/lib/commonjs/sdk/metricsTracking.js +205 -0
  57. package/lib/commonjs/sdk/navigation.js +128 -0
  58. package/lib/commonjs/sdk/networkInterceptor.js +375 -0
  59. package/lib/commonjs/sdk/utils.js +433 -0
  60. package/lib/commonjs/sdk/version.js +13 -0
  61. package/lib/commonjs/types/expo-router.d.js +2 -0
  62. package/lib/commonjs/types/index.js +2 -0
  63. package/lib/module/NativeRejourney.js +38 -0
  64. package/lib/module/components/Mask.js +83 -0
  65. package/lib/module/index.js +1341 -0
  66. package/lib/module/sdk/autoTracking.js +1059 -0
  67. package/lib/module/sdk/constants.js +154 -0
  68. package/lib/module/sdk/errorTracking.js +177 -0
  69. package/lib/module/sdk/index.js +26 -0
  70. package/lib/module/sdk/metricsTracking.js +187 -0
  71. package/lib/module/sdk/navigation.js +120 -0
  72. package/lib/module/sdk/networkInterceptor.js +364 -0
  73. package/lib/module/sdk/utils.js +412 -0
  74. package/lib/module/sdk/version.js +7 -0
  75. package/lib/module/types/expo-router.d.js +2 -0
  76. package/lib/module/types/index.js +2 -0
  77. package/lib/typescript/NativeRejourney.d.ts +160 -0
  78. package/lib/typescript/components/Mask.d.ts +54 -0
  79. package/lib/typescript/index.d.ts +117 -0
  80. package/lib/typescript/sdk/autoTracking.d.ts +226 -0
  81. package/lib/typescript/sdk/constants.d.ts +138 -0
  82. package/lib/typescript/sdk/errorTracking.d.ts +47 -0
  83. package/lib/typescript/sdk/index.d.ts +24 -0
  84. package/lib/typescript/sdk/metricsTracking.d.ts +75 -0
  85. package/lib/typescript/sdk/navigation.d.ts +48 -0
  86. package/lib/typescript/sdk/networkInterceptor.d.ts +62 -0
  87. package/lib/typescript/sdk/utils.d.ts +193 -0
  88. package/lib/typescript/sdk/version.d.ts +6 -0
  89. package/lib/typescript/types/index.d.ts +618 -0
  90. package/package.json +122 -0
  91. package/rejourney.podspec +23 -0
  92. package/src/NativeRejourney.ts +185 -0
  93. package/src/components/Mask.tsx +93 -0
  94. package/src/index.ts +1555 -0
  95. package/src/sdk/autoTracking.ts +1245 -0
  96. package/src/sdk/constants.ts +155 -0
  97. package/src/sdk/errorTracking.ts +231 -0
  98. package/src/sdk/index.ts +25 -0
  99. package/src/sdk/metricsTracking.ts +227 -0
  100. package/src/sdk/navigation.ts +152 -0
  101. package/src/sdk/networkInterceptor.ts +423 -0
  102. package/src/sdk/utils.ts +442 -0
  103. package/src/sdk/version.ts +6 -0
  104. package/src/types/expo-router.d.ts +7 -0
  105. 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
+ )