@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,492 @@
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
+ import Foundation
18
+
19
+ final class SegmentDispatcher {
20
+
21
+ static let shared = SegmentDispatcher()
22
+
23
+ var endpoint: String = "https://api.rejourney.co"
24
+ var currentReplayId: String?
25
+ var apiToken: String?
26
+ var credential: String?
27
+ var projectId: String?
28
+ var isSampledIn: Bool = true // SDK's sampling decision for server-side enforcement
29
+
30
+ private var batchSeqNumber = 0
31
+ private var billingBlocked = false
32
+ private var consecutiveFailures = 0
33
+ private var circuitOpen = false
34
+ private var circuitOpenTime: TimeInterval = 0
35
+ private let circuitBreakerThreshold = 5
36
+ private let circuitResetTime: TimeInterval = 60
37
+
38
+ private let workerQueue: OperationQueue = {
39
+ let q = OperationQueue()
40
+ q.maxConcurrentOperationCount = 2
41
+ q.qualityOfService = .utility
42
+ q.name = "co.rejourney.uploader"
43
+ return q
44
+ }()
45
+
46
+ private let httpSession: URLSession = {
47
+ // Industry standard: Use ephemeral config with explicit connection limits
48
+ let cfg = URLSessionConfiguration.ephemeral
49
+ cfg.httpMaximumConnectionsPerHost = 4
50
+ cfg.waitsForConnectivity = true
51
+ cfg.timeoutIntervalForRequest = 30
52
+ cfg.timeoutIntervalForResource = 60
53
+ return URLSession(configuration: cfg)
54
+ }()
55
+
56
+ private var retryQueue: [PendingUpload] = []
57
+ private let retryLock = NSLock()
58
+ private var active = true
59
+
60
+ private init() {}
61
+
62
+ func configure(replayId: String, apiToken: String?, credential: String?, projectId: String?, isSampledIn: Bool = true) {
63
+ currentReplayId = replayId
64
+ self.apiToken = apiToken
65
+ self.credential = credential
66
+ self.projectId = projectId
67
+ self.isSampledIn = isSampledIn
68
+ batchSeqNumber = 0
69
+ billingBlocked = false
70
+ consecutiveFailures = 0
71
+ }
72
+
73
+ /// Reactivate the dispatcher for a new session
74
+ func activate() {
75
+ active = true
76
+ consecutiveFailures = 0
77
+ circuitOpen = false
78
+ }
79
+
80
+ func halt() {
81
+ active = false
82
+ workerQueue.cancelAllOperations()
83
+ }
84
+
85
+ func shipPending() {
86
+ workerQueue.addOperation { [weak self] in self?.drainRetryQueue() }
87
+ workerQueue.waitUntilAllOperationsAreFinished()
88
+ }
89
+
90
+ func transmitFrameBundle(payload: Data, startMs: UInt64, endMs: UInt64, frameCount: Int, completion: ((Bool) -> Void)? = nil) {
91
+ guard let sid = currentReplayId, canUploadNow() else {
92
+ completion?(false)
93
+ return
94
+ }
95
+
96
+ let upload = PendingUpload(
97
+ sessionId: sid,
98
+ contentType: "screenshots",
99
+ payload: payload,
100
+ rangeStart: startMs,
101
+ rangeEnd: endMs,
102
+ itemCount: frameCount,
103
+ attempt: 0
104
+ )
105
+ scheduleUpload(upload, completion: completion)
106
+ }
107
+
108
+ func transmitHierarchy(replayId: String, hierarchyPayload: Data, timestampMs: UInt64, completion: ((Bool) -> Void)? = nil) {
109
+ guard canUploadNow() else {
110
+ completion?(false)
111
+ return
112
+ }
113
+
114
+ let upload = PendingUpload(
115
+ sessionId: replayId,
116
+ contentType: "hierarchy",
117
+ payload: hierarchyPayload,
118
+ rangeStart: timestampMs,
119
+ rangeEnd: timestampMs,
120
+ itemCount: 1,
121
+ attempt: 0
122
+ )
123
+ scheduleUpload(upload, completion: completion)
124
+ }
125
+
126
+ func transmitEventBatch(payload: Data, batchNumber: Int, eventCount: Int, completion: ((Bool) -> Void)? = nil) {
127
+ guard let sid = currentReplayId, canUploadNow() else {
128
+ completion?(false)
129
+ return
130
+ }
131
+
132
+ workerQueue.addOperation { [weak self] in
133
+ self?.executeEventBatchUpload(sessionId: sid, payload: payload, batchNum: batchNumber, eventCount: eventCount, completion: completion)
134
+ }
135
+ }
136
+
137
+ func transmitEventBatchAlternate(replayId: String, eventPayload: Data, eventCount: Int, completion: ((Bool) -> Void)? = nil) {
138
+ guard canUploadNow() else {
139
+ completion?(false)
140
+ return
141
+ }
142
+
143
+ batchSeqNumber += 1
144
+ let seq = batchSeqNumber
145
+
146
+ workerQueue.addOperation { [weak self] in
147
+ self?.executeEventBatchUpload(sessionId: replayId, payload: eventPayload, batchNum: seq, eventCount: eventCount, completion: completion)
148
+ }
149
+ }
150
+
151
+ func concludeReplay(replayId: String, concludedAt: UInt64, backgroundDurationMs: UInt64, metrics: [String: Any]?, completion: @escaping (Bool) -> Void) {
152
+ guard let url = URL(string: "\(endpoint)/api/ingest/session/end") else {
153
+ completion(false)
154
+ return
155
+ }
156
+
157
+ var req = URLRequest(url: url)
158
+ req.httpMethod = "POST"
159
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
160
+ applyAuthHeaders(&req)
161
+
162
+ var body: [String: Any] = ["sessionId": replayId, "endedAt": concludedAt]
163
+ if backgroundDurationMs > 0 { body["totalBackgroundTimeMs"] = backgroundDurationMs }
164
+ if let m = metrics { body["metrics"] = m }
165
+
166
+ do {
167
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
168
+ } catch {
169
+ completion(false)
170
+ return
171
+ }
172
+
173
+ httpSession.dataTask(with: req) { _, resp, _ in
174
+ completion((resp as? HTTPURLResponse)?.statusCode == 200)
175
+ }.resume()
176
+ }
177
+
178
+ func evaluateReplayRetention(replayId: String, metrics: [String: Any], completion: @escaping (Bool, String) -> Void) {
179
+ guard let url = URL(string: "\(endpoint)/api/ingest/replay/evaluate") else {
180
+ completion(false, "bad_url")
181
+ return
182
+ }
183
+
184
+ var req = URLRequest(url: url)
185
+ req.httpMethod = "POST"
186
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
187
+ applyAuthHeaders(&req)
188
+
189
+ var body: [String: Any] = ["sessionId": replayId]
190
+ metrics.forEach { body[$0.key] = $0.value }
191
+
192
+ do {
193
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
194
+ } catch {
195
+ completion(false, "serialize_error")
196
+ return
197
+ }
198
+
199
+ httpSession.dataTask(with: req) { data, resp, _ in
200
+ guard let data, (resp as? HTTPURLResponse)?.statusCode == 200,
201
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
202
+ completion(false, "request_failed")
203
+ return
204
+ }
205
+ let retained = json["promoted"] as? Bool ?? false
206
+ let reason = json["reason"] as? String ?? "unknown"
207
+ completion(retained, reason)
208
+ }.resume()
209
+ }
210
+
211
+ private func canUploadNow() -> Bool {
212
+ if billingBlocked { return false }
213
+ if circuitOpen {
214
+ if Date().timeIntervalSince1970 - circuitOpenTime > circuitResetTime {
215
+ circuitOpen = false
216
+ } else {
217
+ return false
218
+ }
219
+ }
220
+ return true
221
+ }
222
+
223
+ private func registerFailure() {
224
+ consecutiveFailures += 1
225
+ if consecutiveFailures >= circuitBreakerThreshold {
226
+ circuitOpen = true
227
+ circuitOpenTime = Date().timeIntervalSince1970
228
+ }
229
+ }
230
+
231
+ private func registerSuccess() {
232
+ consecutiveFailures = 0
233
+ }
234
+
235
+ private func scheduleUpload(_ upload: PendingUpload, completion: ((Bool) -> Void)?) {
236
+ guard active else {
237
+ completion?(false)
238
+ return
239
+ }
240
+ workerQueue.addOperation { [weak self] in
241
+ self?.executeSegmentUpload(upload, completion: completion)
242
+ }
243
+ }
244
+
245
+ private func executeSegmentUpload(_ upload: PendingUpload, completion: ((Bool) -> Void)?) {
246
+ guard active else {
247
+ completion?(false)
248
+ return
249
+ }
250
+
251
+ requestPresignedUrl(upload: upload) { [weak self] presignResponse in
252
+ guard let self, self.active else {
253
+ completion?(false)
254
+ return
255
+ }
256
+
257
+ guard let presign = presignResponse else {
258
+ self.registerFailure()
259
+ self.scheduleRetryIfNeeded(upload, completion: completion)
260
+ return
261
+ }
262
+
263
+ self.uploadToS3(url: presign.presignedUrl, payload: upload.payload, contentType: upload.contentType) { s3ok in
264
+ guard s3ok else {
265
+ self.registerFailure()
266
+ self.scheduleRetryIfNeeded(upload, completion: completion)
267
+ return
268
+ }
269
+
270
+ self.confirmBatchComplete(batchId: presign.batchId, upload: upload) { confirmOk in
271
+ if confirmOk {
272
+ self.registerSuccess()
273
+ }
274
+ completion?(confirmOk)
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ private func scheduleRetryIfNeeded(_ upload: PendingUpload, completion: ((Bool) -> Void)?) {
281
+ if upload.attempt < 3 {
282
+ var retry = upload
283
+ retry.attempt += 1
284
+ retryLock.lock()
285
+ retryQueue.append(retry)
286
+ retryLock.unlock()
287
+ }
288
+ completion?(false)
289
+ }
290
+
291
+ private func drainRetryQueue() {
292
+ retryLock.lock()
293
+ let items = retryQueue
294
+ retryQueue.removeAll()
295
+ retryLock.unlock()
296
+ items.forEach { executeSegmentUpload($0, completion: nil) }
297
+ }
298
+
299
+ private func requestPresignedUrl(upload: PendingUpload, completion: @escaping (PresignResponse?) -> Void) {
300
+ let urlPath = upload.contentType == "events" ? "/api/ingest/presign" : "/api/ingest/segment/presign"
301
+
302
+ guard let url = URL(string: "\(endpoint)\(urlPath)") else {
303
+ completion(nil)
304
+ return
305
+ }
306
+
307
+ var req = URLRequest(url: url)
308
+ req.httpMethod = "POST"
309
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
310
+ applyAuthHeaders(&req)
311
+
312
+ var body: [String: Any] = [
313
+ "sessionId": upload.sessionId,
314
+ "sizeBytes": upload.payload.count
315
+ ]
316
+
317
+ if upload.contentType == "events" {
318
+ body["contentType"] = "events"
319
+ body["batchNumber"] = batchSeqNumber
320
+ body["isSampledIn"] = isSampledIn // Server-side enforcement
321
+ } else {
322
+ body["kind"] = upload.contentType
323
+ body["startTime"] = upload.rangeStart
324
+ body["endTime"] = upload.rangeEnd
325
+ body["frameCount"] = upload.itemCount
326
+ body["compression"] = "gzip"
327
+ }
328
+
329
+ do {
330
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
331
+ } catch {
332
+ completion(nil)
333
+ return
334
+ }
335
+
336
+ httpSession.dataTask(with: req) { [weak self] data, resp, _ in
337
+ guard let httpResp = resp as? HTTPURLResponse else {
338
+ completion(nil)
339
+ return
340
+ }
341
+
342
+ if httpResp.statusCode == 402 {
343
+ self?.billingBlocked = true
344
+ completion(nil)
345
+ return
346
+ }
347
+
348
+ guard httpResp.statusCode == 200,
349
+ let data,
350
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
351
+ completion(nil)
352
+ return
353
+ }
354
+
355
+ if json["skipUpload"] as? Bool == true {
356
+ completion(nil)
357
+ return
358
+ }
359
+
360
+ guard let presignedUrl = json["presignedUrl"] as? String else {
361
+ completion(nil)
362
+ return
363
+ }
364
+
365
+ let batchId = json["batchId"] as? String ?? json["segmentId"] as? String ?? ""
366
+
367
+ completion(PresignResponse(presignedUrl: presignedUrl, batchId: batchId))
368
+ }.resume()
369
+ }
370
+
371
+ private func uploadToS3(url: String, payload: Data, contentType: String, completion: @escaping (Bool) -> Void) {
372
+ guard let uploadUrl = URL(string: url) else {
373
+ completion(false)
374
+ return
375
+ }
376
+
377
+ var req = URLRequest(url: uploadUrl)
378
+ req.httpMethod = "PUT"
379
+
380
+ switch contentType {
381
+ case "video": req.setValue("video/mp4", forHTTPHeaderField: "Content-Type")
382
+ default: req.setValue("application/gzip", forHTTPHeaderField: "Content-Type")
383
+ }
384
+
385
+ req.httpBody = payload
386
+
387
+ httpSession.dataTask(with: req) { _, resp, _ in
388
+ let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
389
+ completion(status >= 200 && status < 300)
390
+ }.resume()
391
+ }
392
+
393
+ private func confirmBatchComplete(batchId: String, upload: PendingUpload, completion: @escaping (Bool) -> Void) {
394
+ let urlPath = upload.contentType == "events" ? "/api/ingest/batch/complete" : "/api/ingest/segment/complete"
395
+
396
+ guard let url = URL(string: "\(endpoint)\(urlPath)") else {
397
+ completion(false)
398
+ return
399
+ }
400
+
401
+ var req = URLRequest(url: url)
402
+ req.httpMethod = "POST"
403
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
404
+ applyAuthHeaders(&req)
405
+
406
+ var body: [String: Any] = [
407
+ "actualSizeBytes": upload.payload.count,
408
+ "timestamp": Date().timeIntervalSince1970 * 1000
409
+ ]
410
+
411
+ if upload.contentType == "events" {
412
+ body["batchId"] = batchId
413
+ body["eventCount"] = upload.itemCount
414
+ } else {
415
+ body["segmentId"] = batchId
416
+ body["frameCount"] = upload.itemCount
417
+ }
418
+
419
+ do {
420
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
421
+ } catch {
422
+ completion(false)
423
+ return
424
+ }
425
+
426
+ httpSession.dataTask(with: req) { _, resp, _ in
427
+ completion((resp as? HTTPURLResponse)?.statusCode == 200)
428
+ }.resume()
429
+ }
430
+
431
+ private func executeEventBatchUpload(sessionId: String, payload: Data, batchNum: Int, eventCount: Int, completion: ((Bool) -> Void)?) {
432
+ let upload = PendingUpload(
433
+ sessionId: sessionId,
434
+ contentType: "events",
435
+ payload: payload,
436
+ rangeStart: 0,
437
+ rangeEnd: 0,
438
+ itemCount: eventCount,
439
+ attempt: 0
440
+ )
441
+
442
+ requestPresignedUrl(upload: upload) { [weak self] presignResponse in
443
+ guard let self, let presign = presignResponse else {
444
+ self?.registerFailure()
445
+ completion?(false)
446
+ return
447
+ }
448
+
449
+ self.uploadToS3(url: presign.presignedUrl, payload: payload, contentType: "events") { s3ok in
450
+ guard s3ok else {
451
+ self.registerFailure()
452
+ completion?(false)
453
+ return
454
+ }
455
+
456
+ self.confirmBatchComplete(batchId: presign.batchId, upload: upload) { confirmOk in
457
+ if confirmOk {
458
+ self.registerSuccess()
459
+ }
460
+ completion?(confirmOk)
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ private func applyAuthHeaders(_ req: inout URLRequest) {
467
+ if let t = apiToken {
468
+ req.setValue(t, forHTTPHeaderField: "x-rejourney-key")
469
+ }
470
+ if let c = credential {
471
+ req.setValue(c, forHTTPHeaderField: "x-upload-token")
472
+ }
473
+ if let sid = currentReplayId {
474
+ req.setValue(sid, forHTTPHeaderField: "x-session-id")
475
+ }
476
+ }
477
+ }
478
+
479
+ private struct PendingUpload {
480
+ let sessionId: String
481
+ let contentType: String
482
+ let payload: Data
483
+ let rangeStart: UInt64
484
+ let rangeEnd: UInt64
485
+ let itemCount: Int
486
+ var attempt: Int
487
+ }
488
+
489
+ private struct PresignResponse {
490
+ let presignedUrl: String
491
+ let batchId: String
492
+ }
@@ -0,0 +1,223 @@
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
+ import Foundation
18
+ import MachO
19
+
20
+ struct IncidentRecord: Codable {
21
+ let sessionId: String
22
+ let timestampMs: UInt64
23
+ let category: String
24
+ let identifier: String
25
+ let detail: String
26
+ let frames: [String]
27
+ let context: [String: String]
28
+ }
29
+
30
+ private func _rjSignalHandler(_ signum: Int32) {
31
+ let name: String
32
+ switch signum {
33
+ case SIGABRT: name = "SIGABRT"
34
+ case SIGBUS: name = "SIGBUS"
35
+ case SIGFPE: name = "SIGFPE"
36
+ case SIGILL: name = "SIGILL"
37
+ case SIGSEGV: name = "SIGSEGV"
38
+ case SIGTRAP: name = "SIGTRAP"
39
+ default: name = "SIG\(signum)"
40
+ }
41
+
42
+ let incident = IncidentRecord(
43
+ sessionId: StabilityMonitor.shared.currentSessionId ?? "unknown",
44
+ timestampMs: UInt64(Date().timeIntervalSince1970 * 1000),
45
+ category: "signal",
46
+ identifier: name,
47
+ detail: "Signal \(signum) received",
48
+ frames: Thread.callStackSymbols.map { $0.trimmingCharacters(in: .whitespaces) },
49
+ context: [
50
+ "threadName": Thread.current.name ?? "unnamed",
51
+ "isMain": Thread.isMainThread ? "true" : "false",
52
+ "priority": String(format: "%.2f", Thread.current.threadPriority)
53
+ ]
54
+ )
55
+
56
+ ReplayOrchestrator.shared.incrementFaultTally()
57
+ StabilityMonitor.shared.persistIncidentSync(incident)
58
+
59
+ signal(signum, SIG_DFL)
60
+ raise(signum)
61
+ }
62
+
63
+ @objc(StabilityMonitor)
64
+ public final class StabilityMonitor: NSObject {
65
+
66
+ @objc public static let shared = StabilityMonitor()
67
+ @objc public var isMonitoring = false
68
+ @objc public var currentSessionId: String?
69
+
70
+ private let _incidentStore: URL
71
+ private let _workerQueue = DispatchQueue(label: "co.rejourney.stability", qos: .utility)
72
+
73
+ private static var _chainedExceptionHandler: NSUncaughtExceptionHandler?
74
+ private static var _chainedSignalHandlers: [Int32: sig_t] = [:]
75
+ private static let _trackedSignals: [Int32] = [SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV, SIGTRAP]
76
+
77
+ private override init() {
78
+ let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
79
+ _incidentStore = cache.appendingPathComponent("rj_incidents.json")
80
+ super.init()
81
+ }
82
+
83
+ @objc public func activate() {
84
+ guard !isMonitoring else { return }
85
+ isMonitoring = true
86
+
87
+ StabilityMonitor._chainedExceptionHandler = NSGetUncaughtExceptionHandler()
88
+ NSSetUncaughtExceptionHandler { ex in
89
+ StabilityMonitor.shared._captureException(ex)
90
+ StabilityMonitor._chainedExceptionHandler?(ex)
91
+ }
92
+
93
+ for sig in StabilityMonitor._trackedSignals {
94
+ StabilityMonitor._chainedSignalHandlers[sig] = signal(sig, _rjSignalHandler)
95
+ }
96
+
97
+ _workerQueue.async { [weak self] in
98
+ self?._uploadStoredIncidents()
99
+ }
100
+ }
101
+
102
+ @objc public func deactivate() {
103
+ guard isMonitoring else { return }
104
+ isMonitoring = false
105
+
106
+ NSSetUncaughtExceptionHandler(nil)
107
+ StabilityMonitor._chainedExceptionHandler = nil
108
+
109
+ for sig in StabilityMonitor._trackedSignals {
110
+ if let prev = StabilityMonitor._chainedSignalHandlers[sig] {
111
+ signal(sig, prev)
112
+ } else {
113
+ signal(sig, SIG_DFL)
114
+ }
115
+ }
116
+ StabilityMonitor._chainedSignalHandlers.removeAll()
117
+ }
118
+
119
+ @objc public func transmitStoredReport() {
120
+ _workerQueue.async { [weak self] in
121
+ self?._uploadStoredIncidents()
122
+ }
123
+ }
124
+
125
+ private func _captureException(_ exception: NSException) {
126
+ let incident = IncidentRecord(
127
+ sessionId: currentSessionId ?? "unknown",
128
+ timestampMs: UInt64(Date().timeIntervalSince1970 * 1000),
129
+ category: "exception",
130
+ identifier: exception.name.rawValue,
131
+ detail: exception.reason ?? "",
132
+ frames: _formatFrames(exception.callStackSymbols),
133
+ context: _captureContext()
134
+ )
135
+
136
+ ReplayOrchestrator.shared.incrementFaultTally()
137
+ _persistIncident(incident)
138
+ Thread.sleep(forTimeInterval: 0.15)
139
+ }
140
+
141
+ func persistIncidentSync(_ incident: IncidentRecord) {
142
+ do {
143
+ let data = try JSONEncoder().encode(incident)
144
+ try data.write(to: _incidentStore, options: .atomic)
145
+ } catch {
146
+ print("[Rejourney] Incident persist failed: \(error)")
147
+ }
148
+ }
149
+
150
+ private func _formatFrames(_ raw: [String]) -> [String] {
151
+ raw.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
152
+ }
153
+
154
+ private func _captureContext() -> [String: String] {
155
+ [
156
+ "threadName": Thread.current.name ?? "unnamed",
157
+ "isMain": Thread.isMainThread ? "true" : "false",
158
+ "priority": String(format: "%.2f", Thread.current.threadPriority)
159
+ ]
160
+ }
161
+
162
+ private func _persistIncident(_ incident: IncidentRecord) {
163
+ do {
164
+ let data = try JSONEncoder().encode(incident)
165
+ try data.write(to: _incidentStore, options: .atomic)
166
+ } catch {
167
+ print("[Rejourney] Incident persist failed: \(error)")
168
+ }
169
+ }
170
+
171
+ private func _uploadStoredIncidents() {
172
+ guard FileManager.default.fileExists(atPath: _incidentStore.path),
173
+ let data = try? Data(contentsOf: _incidentStore),
174
+ let incident = try? JSONDecoder().decode(IncidentRecord.self, from: data) else { return }
175
+
176
+ _transmitIncident(incident) { [weak self] ok in
177
+ if ok { try? FileManager.default.removeItem(at: self!._incidentStore) }
178
+ }
179
+ }
180
+
181
+ private func _transmitIncident(_ incident: IncidentRecord, completion: @escaping (Bool) -> Void) {
182
+ let base = SegmentDispatcher.shared.endpoint
183
+ guard let url = URL(string: "\(base)/api/ingest/fault") else {
184
+ completion(false)
185
+ return
186
+ }
187
+
188
+ var req = URLRequest(url: url)
189
+ req.httpMethod = "POST"
190
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
191
+
192
+ if let key = SegmentDispatcher.shared.apiToken {
193
+ req.setValue(key, forHTTPHeaderField: "x-rejourney-key")
194
+ }
195
+
196
+ do {
197
+ req.httpBody = try JSONEncoder().encode(incident)
198
+ } catch {
199
+ completion(false)
200
+ return
201
+ }
202
+
203
+ URLSession.shared.dataTask(with: req) { _, resp, _ in
204
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
205
+ completion(code >= 200 && code < 300)
206
+ }.resume()
207
+ }
208
+ }
209
+
210
+ @objc(FaultTracker)
211
+ public final class FaultTracker: NSObject {
212
+ @objc public static let shared = FaultTracker()
213
+
214
+ private override init() { super.init() }
215
+
216
+ @objc public func activate() {
217
+ StabilityMonitor.shared.activate()
218
+ }
219
+
220
+ @objc public func deactivate() {
221
+ StabilityMonitor.shared.deactivate()
222
+ }
223
+ }