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