@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,547 @@
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 UIKit
18
+ import QuartzCore
19
+
20
+ @objc(TelemetryPipeline)
21
+ public final class TelemetryPipeline: NSObject {
22
+
23
+ @objc public static let shared = TelemetryPipeline()
24
+
25
+ @objc public var endpoint = "https://api.rejourney.co" {
26
+ didSet { SegmentDispatcher.shared.endpoint = endpoint }
27
+ }
28
+
29
+ @objc public var currentReplayId: String? {
30
+ didSet {
31
+ SegmentDispatcher.shared.currentReplayId = currentReplayId
32
+ }
33
+ }
34
+
35
+ public var credential: String? {
36
+ didSet { SegmentDispatcher.shared.credential = credential }
37
+ }
38
+
39
+ public var apiToken: String? {
40
+ didSet { SegmentDispatcher.shared.apiToken = apiToken }
41
+ }
42
+
43
+ public var projectId: String? {
44
+ didSet { SegmentDispatcher.shared.projectId = projectId }
45
+ }
46
+
47
+ /// SDK's sampling decision for server-side enforcement
48
+ public var isSampledIn: Bool = true {
49
+ didSet { SegmentDispatcher.shared.isSampledIn = isSampledIn }
50
+ }
51
+
52
+ private let _eventRing = EventRingBuffer(capacity: 5000)
53
+ private let _frameQueue = FrameBundleQueue(maxPending: 200)
54
+ private var _deferredMode = false
55
+ private var _batchSeq = 0
56
+ private var _draining = false
57
+ private var _backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
58
+
59
+ private let _serialWorker = DispatchQueue(label: "co.rejourney.telemetry", qos: .utility)
60
+ private var _heartbeat: Timer?
61
+
62
+ private let _batchSizeLimit = 500_000
63
+
64
+ // Dead tap detection — timestamp comparison.
65
+ // After a tap, a 400ms timer fires and checks whether any "response" event
66
+ // (navigation, input, haptics, or animation) occurred since the tap. If not → dead tap.
67
+ // We do NOT cancel the timer proactively because gesture-recognizer scroll
68
+ // events fire on nearly every tap due to micro-movement and would mask real dead taps.
69
+ private static let _deadTapTimeoutSec: Double = 0.4
70
+ private var _deadTapTimer: DispatchWorkItem?
71
+ private var _lastTapLabel: String = ""
72
+ private var _lastTapX: UInt64 = 0
73
+ private var _lastTapY: UInt64 = 0
74
+ private var _lastTapTs: Int64 = 0
75
+ private var _lastResponseTs: Int64 = 0
76
+
77
+ /// Call this when haptic feedback, animations, or other UI responses occur.
78
+ /// This prevents the current tap from being marked as a "dead tap".
79
+ @objc public func markResponseReceived() {
80
+ _lastResponseTs = _ts()
81
+ }
82
+
83
+ private override init() {
84
+ super.init()
85
+ }
86
+
87
+ @objc public func activate() {
88
+ // Upload any pending data from previous sessions first
89
+ _uploadPendingSessions()
90
+
91
+ DispatchQueue.main.async { [weak self] in
92
+ guard let self else { return }
93
+ // Industry standard: Use default run loop mode (NOT .common)
94
+ // This lets the timer pause during scrolling which prevents stutter
95
+ self._heartbeat = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
96
+ self?.dispatchNow()
97
+ }
98
+ }
99
+
100
+ NotificationCenter.default.addObserver(self, selector: #selector(_appSuspending), name: UIApplication.willResignActiveNotification, object: nil)
101
+ NotificationCenter.default.addObserver(self, selector: #selector(_appSuspending), name: UIApplication.willTerminateNotification, object: nil)
102
+ }
103
+
104
+ @objc public func shutdown() {
105
+ _heartbeat?.invalidate()
106
+ _heartbeat = nil
107
+ NotificationCenter.default.removeObserver(self)
108
+
109
+ SegmentDispatcher.shared.halt()
110
+ _appSuspending()
111
+ }
112
+
113
+ @objc public func finalizeAndShip() {
114
+ shutdown()
115
+ }
116
+
117
+ @objc public func activateDeferredMode() {
118
+ _serialWorker.async { self._deferredMode = true }
119
+ }
120
+
121
+ @objc public func commitDeferredData() {
122
+ _serialWorker.async {
123
+ self._deferredMode = false
124
+ self._shipPendingEvents()
125
+ self._shipPendingFrames()
126
+ }
127
+ }
128
+
129
+ @objc public func submitFrameBundle(payload: Data, filename: String, startMs: UInt64, endMs: UInt64, frameCount: Int) {
130
+ _serialWorker.async {
131
+ let bundle = PendingFrameBundle(tag: filename, payload: payload, rangeStart: startMs, rangeEnd: endMs, count: frameCount)
132
+ self._frameQueue.enqueue(bundle)
133
+ if !self._deferredMode { self._shipPendingFrames() }
134
+ }
135
+ }
136
+
137
+ @objc public func dispatchNow() {
138
+ _serialWorker.async {
139
+ self._shipPendingEvents()
140
+ self._shipPendingFrames()
141
+ }
142
+ }
143
+
144
+ @objc private func _appSuspending() {
145
+ guard !_draining else { return }
146
+ _draining = true
147
+
148
+ // Request background time to complete uploads
149
+ _backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "RejourneyFlush") { [weak self] in
150
+ self?._endBackgroundTask()
151
+ }
152
+
153
+ // Flush visual frames to disk immediately
154
+ VisualCapture.shared.flushToDisk()
155
+
156
+ // Try to upload pending data with remaining background time
157
+ _serialWorker.async { [weak self] in
158
+ self?._shipPendingEvents()
159
+ self?._shipPendingFrames()
160
+
161
+ // Allow a short delay for network operations to complete
162
+ Thread.sleep(forTimeInterval: 0.5)
163
+
164
+ DispatchQueue.main.async {
165
+ self?._endBackgroundTask()
166
+ }
167
+ }
168
+ }
169
+
170
+ private func _endBackgroundTask() {
171
+ guard _backgroundTaskId != .invalid else { return }
172
+ UIApplication.shared.endBackgroundTask(_backgroundTaskId)
173
+ _backgroundTaskId = .invalid
174
+ _draining = false
175
+ }
176
+
177
+ private func _uploadPendingSessions() {
178
+ // TODO: Re-enable when EventBuffer is added to Xcode project
179
+ // For now, just upload pending frames
180
+ }
181
+
182
+ private func _uploadSessionEvents(sessionId: String, events: [[String: Any]], completion: @escaping (Bool) -> Void) {
183
+ let payload = _serializeBatchFromEvents(events: events)
184
+ guard let compressed = payload.gzipCompress() else {
185
+ completion(false)
186
+ return
187
+ }
188
+
189
+ SegmentDispatcher.shared.transmitEventBatchAlternate(
190
+ replayId: sessionId,
191
+ eventPayload: compressed,
192
+ eventCount: events.count,
193
+ completion: completion
194
+ )
195
+ }
196
+
197
+ private func _serializeBatchFromEvents(events: [[String: Any]]) -> Data {
198
+ let device = UIDevice.current
199
+
200
+ let networkType = ReplayOrchestrator.shared.currentNetworkType
201
+ let isConstrained = ReplayOrchestrator.shared.networkIsConstrained
202
+ let isExpensive = ReplayOrchestrator.shared.networkIsExpensive
203
+
204
+ let meta: [String: Any] = [
205
+ "platform": "ios",
206
+ "model": device.model,
207
+ "osVersion": device.systemVersion,
208
+ "vendorId": device.identifierForVendor?.uuidString ?? "",
209
+ "time": Date().timeIntervalSince1970,
210
+ "networkType": networkType,
211
+ "isConstrained": isConstrained,
212
+ "isExpensive": isExpensive
213
+ ]
214
+
215
+ let wrapper: [String: Any] = ["events": events, "deviceInfo": meta]
216
+ return (try? JSONSerialization.data(withJSONObject: wrapper)) ?? Data()
217
+ }
218
+
219
+ private func _shipPendingFrames() {
220
+ guard !_deferredMode, let next = _frameQueue.dequeue(), currentReplayId != nil else { return }
221
+
222
+ SegmentDispatcher.shared.transmitFrameBundle(
223
+ payload: next.payload,
224
+ startMs: next.rangeStart,
225
+ endMs: next.rangeEnd,
226
+ frameCount: next.count
227
+ ) { [weak self] ok in
228
+ if !ok { self?._frameQueue.requeue(next) }
229
+ else { self?._serialWorker.async { self?._shipPendingFrames() } }
230
+ }
231
+ }
232
+
233
+ private func _shipPendingEvents() {
234
+ guard !_deferredMode else { return }
235
+ let batch = _eventRing.drain(maxBytes: _batchSizeLimit)
236
+ guard !batch.isEmpty else { return }
237
+
238
+ let payload = _serializeBatch(events: batch)
239
+ guard let compressed = payload.gzipCompress() else {
240
+ batch.forEach { _eventRing.push($0) }
241
+ return
242
+ }
243
+
244
+ let seq = _batchSeq
245
+ _batchSeq += 1
246
+
247
+ SegmentDispatcher.shared.transmitEventBatch(payload: compressed, batchNumber: seq, eventCount: batch.count) { [weak self] ok in
248
+ if !ok { batch.forEach { self?._eventRing.push($0) } }
249
+ else if self?._draining == true { }
250
+ }
251
+ }
252
+
253
+ private func _serializeBatch(events: [EventEntry]) -> Data {
254
+ var jsonEvents: [[String: Any]] = []
255
+ for e in events {
256
+ var clean = e.data
257
+ if clean.last == 0x0A { clean = clean.dropLast() }
258
+ if let obj = try? JSONSerialization.jsonObject(with: clean) as? [String: Any] { jsonEvents.append(obj) }
259
+ }
260
+
261
+ let device = UIDevice.current
262
+ let bounds = UIScreen.main.bounds
263
+
264
+ // Get current network state from orchestrator
265
+ let networkType = ReplayOrchestrator.shared.currentNetworkType
266
+ let isConstrained = ReplayOrchestrator.shared.networkIsConstrained
267
+ let isExpensive = ReplayOrchestrator.shared.networkIsExpensive
268
+
269
+ // Get app version from bundle
270
+ let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
271
+ let appId = Bundle.main.bundleIdentifier ?? "unknown"
272
+
273
+ let meta: [String: Any] = [
274
+ "platform": "ios",
275
+ "model": device.model,
276
+ "osVersion": device.systemVersion,
277
+ "vendorId": device.identifierForVendor?.uuidString ?? "",
278
+ "time": Date().timeIntervalSince1970,
279
+ "networkType": networkType,
280
+ "isConstrained": isConstrained,
281
+ "isExpensive": isExpensive,
282
+ "appVersion": appVersion,
283
+ "appId": appId,
284
+ "screenWidth": Int(bounds.width),
285
+ "screenHeight": Int(bounds.height),
286
+ "screenScale": Int(UIScreen.main.scale),
287
+ "systemName": device.systemName,
288
+ "name": device.name
289
+ ]
290
+
291
+ let wrapper: [String: Any] = ["events": jsonEvents, "deviceInfo": meta]
292
+ return (try? JSONSerialization.data(withJSONObject: wrapper)) ?? Data()
293
+ }
294
+
295
+ @objc public func recordAttribute(key: String, value: String) {
296
+ _enqueue(["type": "custom", "timestamp": _ts(), "name": "attribute", "payload": "{\"key\":\"\(key)\",\"value\":\"\(value)\"}"])
297
+ }
298
+
299
+ @objc public func recordCustomEvent(name: String, payload: String) {
300
+ _enqueue(["type": "custom", "timestamp": _ts(), "name": name, "payload": payload])
301
+ }
302
+
303
+ @objc public func recordJSErrorEvent(name: String, message: String, stack: String?) {
304
+ var event: [String: Any] = [
305
+ "type": "error",
306
+ "timestamp": _ts(),
307
+ "name": name,
308
+ "message": message
309
+ ]
310
+ if let stack = stack {
311
+ event["stack"] = stack
312
+ }
313
+ _enqueue(event)
314
+ }
315
+
316
+ @objc public func recordAnrEvent(durationMs: Int, stack: String?) {
317
+ var event: [String: Any] = [
318
+ "type": "anr",
319
+ "timestamp": _ts(),
320
+ "durationMs": durationMs,
321
+ "threadState": "blocked"
322
+ ]
323
+ if let stack = stack {
324
+ event["stack"] = stack
325
+ }
326
+ _enqueue(event)
327
+ }
328
+
329
+ @objc public func recordUserAssociation(_ userId: String) {
330
+ _enqueue(["type": "user_identity_changed", "timestamp": _ts(), "userId": userId])
331
+ }
332
+
333
+ @objc public func recordTapEvent(label: String, x: UInt64, y: UInt64, isInteractive: Bool = false) {
334
+ // Cancel any existing dead tap timer (new tap supersedes previous)
335
+ _cancelDeadTapTimer()
336
+
337
+ let tapTs = _ts()
338
+ _enqueue(["type": "touch", "gestureType": "tap", "timestamp": tapTs, "label": label, "x": x, "y": y, "touches": [["x": x, "y": y, "timestamp": tapTs]]])
339
+
340
+ // Skip dead tap detection for interactive elements (buttons, touchables, etc.)
341
+ // These are expected to respond, so we don't need to track "no response" as dead.
342
+ if isInteractive {
343
+ // Interactive elements are assumed to respond — no dead tap timer needed
344
+ return
345
+ }
346
+
347
+ // Start dead tap timer only for non-interactive elements (labels, images, empty space)
348
+ // When it fires, check if any response event occurred after this tap. If not → dead tap.
349
+ _lastTapLabel = label
350
+ _lastTapX = x
351
+ _lastTapY = y
352
+ _lastTapTs = tapTs
353
+ let work = DispatchWorkItem { [weak self] in
354
+ guard let self = self else { return }
355
+ self._deadTapTimer = nil
356
+ // Only fire dead tap if no response event occurred since this tap
357
+ if self._lastResponseTs <= self._lastTapTs {
358
+ self.recordDeadTapEvent(label: self._lastTapLabel, x: self._lastTapX, y: self._lastTapY)
359
+ ReplayOrchestrator.shared.incrementDeadTapTally()
360
+ }
361
+ }
362
+ _deadTapTimer = work
363
+ DispatchQueue.main.asyncAfter(deadline: .now() + TelemetryPipeline._deadTapTimeoutSec, execute: work)
364
+ }
365
+
366
+ @objc public func recordRageTapEvent(label: String, x: UInt64, y: UInt64, count: Int) {
367
+ _enqueue([
368
+ "type": "gesture",
369
+ "gestureType": "rage_tap",
370
+ "timestamp": _ts(),
371
+ "label": label,
372
+ "x": x,
373
+ "y": y,
374
+ "count": count,
375
+ "frustrationKind": "rage_tap",
376
+ "touches": [["x": x, "y": y, "timestamp": _ts()]]
377
+ ])
378
+ }
379
+
380
+ @objc public func recordDeadTapEvent(label: String, x: UInt64, y: UInt64) {
381
+ _enqueue([
382
+ "type": "gesture",
383
+ "gestureType": "dead_tap",
384
+ "timestamp": _ts(),
385
+ "label": label,
386
+ "x": x,
387
+ "y": y,
388
+ "frustrationKind": "dead_tap",
389
+ "touches": [["x": x, "y": y, "timestamp": _ts()]]
390
+ ])
391
+ }
392
+
393
+ @objc public func recordSwipeEvent(label: String, x: UInt64, y: UInt64, direction: String) {
394
+ _enqueue(["type": "gesture", "gestureType": "swipe", "timestamp": _ts(), "label": label, "x": x, "y": y, "direction": direction, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
395
+ }
396
+
397
+ @objc public func recordScrollEvent(label: String, x: UInt64, y: UInt64, direction: String) {
398
+ // NOTE: Do NOT mark scroll as a "response" for dead tap detection.
399
+ // Gesture recognisers classify micro-movement during a tap as a scroll,
400
+ // which would mask nearly every dead tap. Only navigation and input
401
+ // count as definitive responses.
402
+ _enqueue(["type": "gesture", "gestureType": "scroll", "timestamp": _ts(), "label": label, "x": x, "y": y, "direction": direction, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
403
+ }
404
+
405
+ @objc public func recordPanEvent(label: String, x: UInt64, y: UInt64) {
406
+ _enqueue(["type": "gesture", "gestureType": "pan", "timestamp": _ts(), "label": label, "x": x, "y": y, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
407
+ }
408
+
409
+ @objc public func recordLongPressEvent(label: String, x: UInt64, y: UInt64) {
410
+ _enqueue(["type": "gesture", "gestureType": "long_press", "timestamp": _ts(), "label": label, "x": x, "y": y, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
411
+ }
412
+
413
+ @objc public func recordPinchEvent(label: String, x: UInt64, y: UInt64, scale: Double) {
414
+ _enqueue(["type": "gesture", "gestureType": "pinch", "timestamp": _ts(), "label": label, "x": x, "y": y, "scale": scale, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
415
+ }
416
+
417
+ @objc public func recordRotationEvent(label: String, x: UInt64, y: UInt64, angle: Double) {
418
+ _enqueue(["type": "gesture", "gestureType": "rotation", "timestamp": _ts(), "label": label, "x": x, "y": y, "angle": angle, "touches": [["x": x, "y": y, "timestamp": _ts()]]])
419
+ }
420
+
421
+ @objc public func recordInputEvent(value: String, redacted: Bool, label: String) {
422
+ _lastResponseTs = _ts() // keyboard input = definitive response
423
+ _enqueue(["type": "input", "timestamp": _ts(), "value": redacted ? "***" : value, "redacted": redacted, "label": label])
424
+ }
425
+
426
+ @objc public func recordViewTransition(viewId: String, viewLabel: String, entering: Bool) {
427
+ _lastResponseTs = _ts() // navigation = definitive response
428
+ _enqueue(["type": "navigation", "timestamp": _ts(), "screen": viewLabel, "screenName": viewLabel, "viewId": viewId, "entering": entering])
429
+ }
430
+
431
+ @objc public func recordNetworkEvent(details: [String: Any]) {
432
+ var e = details
433
+ e["type"] = "network_request"
434
+ e["timestamp"] = _ts()
435
+ _enqueue(e)
436
+ }
437
+
438
+ @objc public func recordAppStartup(durationMs: Int64) {
439
+ _enqueue([
440
+ "type": "app_startup",
441
+ "timestamp": _ts(),
442
+ "durationMs": durationMs,
443
+ "platform": "ios"
444
+ ])
445
+ }
446
+
447
+ @objc public func recordAppForeground(totalBackgroundTimeMs: UInt64) {
448
+ _enqueue([
449
+ "type": "app_foreground",
450
+ "timestamp": _ts(),
451
+ "totalBackgroundTime": totalBackgroundTimeMs
452
+ ])
453
+ }
454
+
455
+ // MARK: - Dead Tap Timer
456
+
457
+ private func _cancelDeadTapTimer() {
458
+ _deadTapTimer?.cancel()
459
+ _deadTapTimer = nil
460
+ }
461
+
462
+ private func _enqueue(_ dict: [String: Any]) {
463
+ // Keep in memory ring for immediate upload
464
+ guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
465
+ var d = data
466
+ d.append(0x0A)
467
+ _eventRing.push(EventEntry(data: d, size: d.count))
468
+ }
469
+
470
+ private func _ts() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) }
471
+ }
472
+
473
+ private struct EventEntry {
474
+ let data: Data
475
+ let size: Int
476
+ }
477
+
478
+ private final class EventRingBuffer {
479
+ private var _storage: ContiguousArray<EventEntry> = []
480
+ private let _capacity: Int
481
+ private let _lock = NSLock()
482
+
483
+ init(capacity: Int) {
484
+ _capacity = capacity
485
+ _storage.reserveCapacity(capacity)
486
+ }
487
+
488
+ func push(_ entry: EventEntry) {
489
+ _lock.lock()
490
+ defer { _lock.unlock() }
491
+ if _storage.count >= _capacity { _storage.removeFirst() }
492
+ _storage.append(entry)
493
+ }
494
+
495
+ func drain(maxBytes: Int) -> [EventEntry] {
496
+ _lock.lock()
497
+ defer { _lock.unlock() }
498
+ var result: [EventEntry] = []
499
+ var total = 0
500
+ while !_storage.isEmpty {
501
+ let next = _storage.first!
502
+ if total + next.size > maxBytes { break }
503
+ result.append(next)
504
+ total += next.size
505
+ _storage.removeFirst()
506
+ }
507
+ return result
508
+ }
509
+ }
510
+
511
+ private struct PendingFrameBundle {
512
+ let tag: String
513
+ let payload: Data
514
+ let rangeStart: UInt64
515
+ let rangeEnd: UInt64
516
+ let count: Int
517
+ }
518
+
519
+ private final class FrameBundleQueue {
520
+ private var _queue: [PendingFrameBundle] = []
521
+ private let _maxPending: Int
522
+ private let _lock = NSLock()
523
+
524
+ init(maxPending: Int) {
525
+ _maxPending = maxPending
526
+ }
527
+
528
+ func enqueue(_ bundle: PendingFrameBundle) {
529
+ _lock.lock()
530
+ defer { _lock.unlock() }
531
+ if _queue.count >= _maxPending { _queue.removeFirst() }
532
+ _queue.append(bundle)
533
+ }
534
+
535
+ func dequeue() -> PendingFrameBundle? {
536
+ _lock.lock()
537
+ defer { _lock.unlock() }
538
+ guard !_queue.isEmpty else { return nil }
539
+ return _queue.removeFirst()
540
+ }
541
+
542
+ func requeue(_ bundle: PendingFrameBundle) {
543
+ _lock.lock()
544
+ defer { _lock.unlock() }
545
+ _queue.insert(bundle, at: 0)
546
+ }
547
+ }