@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,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
|
+
}
|