@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,624 @@
|
|
|
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 Network
|
|
19
|
+
import QuartzCore
|
|
20
|
+
|
|
21
|
+
@objc(ReplayOrchestrator)
|
|
22
|
+
public final class ReplayOrchestrator: NSObject {
|
|
23
|
+
|
|
24
|
+
@objc public static let shared = ReplayOrchestrator()
|
|
25
|
+
|
|
26
|
+
@objc public var apiToken: String?
|
|
27
|
+
@objc public var replayId: String?
|
|
28
|
+
@objc public var replayStartMs: UInt64 = 0
|
|
29
|
+
@objc public var deferredUploadMode = false
|
|
30
|
+
@objc public var frameBundleSize: Int = 5
|
|
31
|
+
|
|
32
|
+
public var serverEndpoint: String {
|
|
33
|
+
get { TelemetryPipeline.shared.endpoint }
|
|
34
|
+
set {
|
|
35
|
+
TelemetryPipeline.shared.endpoint = newValue
|
|
36
|
+
SegmentDispatcher.shared.endpoint = newValue
|
|
37
|
+
DeviceRegistrar.shared.endpoint = newValue
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@objc public var snapshotInterval: Double = 0.33
|
|
42
|
+
@objc public var compressionLevel: Double = 0.5
|
|
43
|
+
@objc public var visualCaptureEnabled: Bool = true
|
|
44
|
+
@objc public var interactionCaptureEnabled: Bool = true
|
|
45
|
+
@objc public var faultTrackingEnabled: Bool = true
|
|
46
|
+
@objc public var responsivenessCaptureEnabled: Bool = true
|
|
47
|
+
@objc public var consoleCaptureEnabled: Bool = true
|
|
48
|
+
@objc public var wifiRequired: Bool = false
|
|
49
|
+
@objc public var hierarchyCaptureEnabled: Bool = true
|
|
50
|
+
@objc public var hierarchyCaptureInterval: Double = 2.0
|
|
51
|
+
@objc public private(set) var currentScreenName: String?
|
|
52
|
+
|
|
53
|
+
// Remote config from backend (set via setRemoteConfig before session start)
|
|
54
|
+
@objc public private(set) var remoteRejourneyEnabled: Bool = true
|
|
55
|
+
@objc public private(set) var remoteRecordingEnabled: Bool = true
|
|
56
|
+
@objc public private(set) var remoteSampleRate: Int = 100
|
|
57
|
+
@objc public private(set) var remoteMaxRecordingMinutes: Int = 10
|
|
58
|
+
|
|
59
|
+
private var _netMonitor: NWPathMonitor?
|
|
60
|
+
private var _netReady = false
|
|
61
|
+
private var _live = false
|
|
62
|
+
|
|
63
|
+
// Network state tracking
|
|
64
|
+
@objc public private(set) var currentNetworkType: String = "unknown"
|
|
65
|
+
@objc public private(set) var currentCellularGeneration: String = "unknown"
|
|
66
|
+
@objc public private(set) var networkIsConstrained: Bool = false
|
|
67
|
+
@objc public private(set) var networkIsExpensive: Bool = false
|
|
68
|
+
|
|
69
|
+
// App startup tracking - use actual process start time from kernel
|
|
70
|
+
private static var processStartTime: TimeInterval = {
|
|
71
|
+
// Get the actual process start time from the kernel
|
|
72
|
+
var kinfo = kinfo_proc()
|
|
73
|
+
var size = MemoryLayout<kinfo_proc>.stride
|
|
74
|
+
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
75
|
+
|
|
76
|
+
if sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0 {
|
|
77
|
+
let startSec = kinfo.kp_proc.p_starttime.tv_sec
|
|
78
|
+
let startUsec = kinfo.kp_proc.p_starttime.tv_usec
|
|
79
|
+
return TimeInterval(startSec) + TimeInterval(startUsec) / 1_000_000.0
|
|
80
|
+
}
|
|
81
|
+
// Fallback to current time if sysctl fails
|
|
82
|
+
return Date().timeIntervalSince1970
|
|
83
|
+
}()
|
|
84
|
+
|
|
85
|
+
private var _crashCount = 0
|
|
86
|
+
private var _freezeCount = 0
|
|
87
|
+
private var _errorCount = 0
|
|
88
|
+
private var _tapCount = 0
|
|
89
|
+
private var _scrollCount = 0
|
|
90
|
+
private var _gestureCount = 0
|
|
91
|
+
private var _rageCount = 0
|
|
92
|
+
private var _deadTapCount = 0
|
|
93
|
+
private var _visitedScreens: [String] = []
|
|
94
|
+
private var _bgTimeMs: UInt64 = 0
|
|
95
|
+
private var _bgStartMs: UInt64?
|
|
96
|
+
private var _finalized = false
|
|
97
|
+
private var _hierarchyTimer: Timer?
|
|
98
|
+
private var _lastHierarchyHash: String?
|
|
99
|
+
private var _durationLimitTimer: DispatchWorkItem?
|
|
100
|
+
|
|
101
|
+
private override init() {
|
|
102
|
+
super.init()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Fast session start using existing credentials - skips credential fetch for faster restart
|
|
106
|
+
@objc public func beginReplayFast(apiToken: String, serverEndpoint: String, credential: String, captureSettings: [String: Any]? = nil) {
|
|
107
|
+
let perf = PerformanceSnapshot.capture()
|
|
108
|
+
DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_FAST_INIT", details: "beginReplayFast with existing credential", perf: perf)
|
|
109
|
+
|
|
110
|
+
self.apiToken = apiToken
|
|
111
|
+
self.serverEndpoint = serverEndpoint
|
|
112
|
+
_applySettings(captureSettings)
|
|
113
|
+
|
|
114
|
+
// Set credentials AND endpoint directly without network fetch
|
|
115
|
+
TelemetryPipeline.shared.apiToken = apiToken
|
|
116
|
+
TelemetryPipeline.shared.credential = credential
|
|
117
|
+
TelemetryPipeline.shared.endpoint = serverEndpoint
|
|
118
|
+
SegmentDispatcher.shared.apiToken = apiToken
|
|
119
|
+
SegmentDispatcher.shared.credential = credential
|
|
120
|
+
SegmentDispatcher.shared.endpoint = serverEndpoint
|
|
121
|
+
|
|
122
|
+
// Skip network monitoring, assume network is available since we just came from background
|
|
123
|
+
DispatchQueue.main.async { [weak self] in
|
|
124
|
+
self?._beginRecording(token: apiToken)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@objc public func beginReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
|
|
129
|
+
let perf = PerformanceSnapshot.capture()
|
|
130
|
+
DiagnosticLog.debugSessionCreate(phase: "ORCHESTRATOR_INIT", details: "beginReplay", perf: perf)
|
|
131
|
+
|
|
132
|
+
self.apiToken = apiToken
|
|
133
|
+
self.serverEndpoint = serverEndpoint
|
|
134
|
+
_applySettings(captureSettings)
|
|
135
|
+
|
|
136
|
+
DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_START", details: "Requesting device credential")
|
|
137
|
+
|
|
138
|
+
DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
|
|
139
|
+
guard let self, ok else {
|
|
140
|
+
DiagnosticLog.debugSessionCreate(phase: "CREDENTIAL_FAIL", details: "Failed")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
TelemetryPipeline.shared.apiToken = apiToken
|
|
145
|
+
TelemetryPipeline.shared.credential = cred
|
|
146
|
+
SegmentDispatcher.shared.apiToken = apiToken
|
|
147
|
+
SegmentDispatcher.shared.credential = cred
|
|
148
|
+
|
|
149
|
+
self._monitorNetwork(token: apiToken)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@objc public func beginDeferredReplay(apiToken: String, serverEndpoint: String, captureSettings: [String: Any]? = nil) {
|
|
154
|
+
self.apiToken = apiToken
|
|
155
|
+
self.serverEndpoint = serverEndpoint
|
|
156
|
+
deferredUploadMode = true
|
|
157
|
+
|
|
158
|
+
_applySettings(captureSettings)
|
|
159
|
+
|
|
160
|
+
DeviceRegistrar.shared.obtainCredential(apiToken: apiToken) { [weak self] ok, cred in
|
|
161
|
+
guard let self, ok else { return }
|
|
162
|
+
TelemetryPipeline.shared.apiToken = apiToken
|
|
163
|
+
TelemetryPipeline.shared.credential = cred
|
|
164
|
+
SegmentDispatcher.shared.apiToken = apiToken
|
|
165
|
+
SegmentDispatcher.shared.credential = cred
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_initSession()
|
|
169
|
+
TelemetryPipeline.shared.activateDeferredMode()
|
|
170
|
+
|
|
171
|
+
let renderCfg = _computeRender(fps: 3, tier: "standard")
|
|
172
|
+
|
|
173
|
+
if visualCaptureEnabled {
|
|
174
|
+
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
175
|
+
VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs)
|
|
176
|
+
VisualCapture.shared.activateDeferredMode()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
|
|
180
|
+
if faultTrackingEnabled { FaultTracker.shared.activate() }
|
|
181
|
+
|
|
182
|
+
_live = true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@objc public func commitDeferredReplay() {
|
|
186
|
+
deferredUploadMode = false
|
|
187
|
+
TelemetryPipeline.shared.commitDeferredData()
|
|
188
|
+
VisualCapture.shared.commitDeferredData()
|
|
189
|
+
TelemetryPipeline.shared.activate()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@objc public func endReplay() {
|
|
193
|
+
endReplay(completion: nil)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@objc public func endReplay(completion: ((Bool, Bool) -> Void)?) {
|
|
197
|
+
guard _live else {
|
|
198
|
+
completion?(false, false)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
_live = false
|
|
202
|
+
|
|
203
|
+
let sid = replayId ?? ""
|
|
204
|
+
let termMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
205
|
+
let elapsed = Int((termMs - replayStartMs) / 1000)
|
|
206
|
+
|
|
207
|
+
_netMonitor?.cancel()
|
|
208
|
+
_netMonitor = nil
|
|
209
|
+
_hierarchyTimer?.invalidate()
|
|
210
|
+
_hierarchyTimer = nil
|
|
211
|
+
_stopDurationLimitTimer()
|
|
212
|
+
_detachLifecycle()
|
|
213
|
+
|
|
214
|
+
let metrics: [String: Any] = [
|
|
215
|
+
"crashCount": _crashCount,
|
|
216
|
+
"anrCount": _freezeCount,
|
|
217
|
+
"errorCount": _errorCount,
|
|
218
|
+
"durationSeconds": elapsed,
|
|
219
|
+
"touchCount": _tapCount,
|
|
220
|
+
"scrollCount": _scrollCount,
|
|
221
|
+
"gestureCount": _gestureCount,
|
|
222
|
+
"rageTapCount": _rageCount,
|
|
223
|
+
"deadTapCount": _deadTapCount,
|
|
224
|
+
"screensVisited": _visitedScreens,
|
|
225
|
+
"screenCount": Set(_visitedScreens).count
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
SegmentDispatcher.shared.evaluateReplayRetention(replayId: sid, metrics: metrics) { [weak self] retain, reason in
|
|
229
|
+
guard let self else { return }
|
|
230
|
+
|
|
231
|
+
// UI operations MUST run on main thread
|
|
232
|
+
DispatchQueue.main.async {
|
|
233
|
+
TelemetryPipeline.shared.shutdown()
|
|
234
|
+
VisualCapture.shared.halt()
|
|
235
|
+
InteractionRecorder.shared.deactivate()
|
|
236
|
+
FaultTracker.shared.deactivate()
|
|
237
|
+
ResponsivenessWatcher.shared.halt()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
SegmentDispatcher.shared.shipPending()
|
|
241
|
+
|
|
242
|
+
guard !self._finalized else {
|
|
243
|
+
self._clearRecovery()
|
|
244
|
+
completion?(true, true)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
self._finalized = true
|
|
248
|
+
|
|
249
|
+
SegmentDispatcher.shared.concludeReplay(replayId: sid, concludedAt: termMs, backgroundDurationMs: self._bgTimeMs, metrics: metrics) { [weak self] ok in
|
|
250
|
+
if ok { self?._clearRecovery() }
|
|
251
|
+
completion?(true, ok)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
replayId = nil
|
|
256
|
+
replayStartMs = 0
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@objc public func redactView(_ view: UIView) {
|
|
260
|
+
VisualCapture.shared.registerRedaction(view)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@objc public func unredactView(_ view: UIView) {
|
|
264
|
+
VisualCapture.shared.unregisterRedaction(view)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Set remote configuration from backend
|
|
268
|
+
/// Called by JS side before startSession to apply server-side settings
|
|
269
|
+
@objc public func setRemoteConfig(
|
|
270
|
+
rejourneyEnabled: Bool,
|
|
271
|
+
recordingEnabled: Bool,
|
|
272
|
+
sampleRate: Int,
|
|
273
|
+
maxRecordingMinutes: Int
|
|
274
|
+
) {
|
|
275
|
+
self.remoteRejourneyEnabled = rejourneyEnabled
|
|
276
|
+
self.remoteRecordingEnabled = recordingEnabled
|
|
277
|
+
self.remoteSampleRate = sampleRate
|
|
278
|
+
self.remoteMaxRecordingMinutes = maxRecordingMinutes
|
|
279
|
+
|
|
280
|
+
// Set isSampledIn for server-side enforcement
|
|
281
|
+
// recordingEnabled=false means either dashboard disabled OR session sampled out by JS
|
|
282
|
+
TelemetryPipeline.shared.isSampledIn = recordingEnabled
|
|
283
|
+
|
|
284
|
+
// Apply recording settings immediately
|
|
285
|
+
// If recording is disabled, disable visual capture
|
|
286
|
+
if !recordingEnabled {
|
|
287
|
+
visualCaptureEnabled = false
|
|
288
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Visual capture disabled by remote config (recordingEnabled=false)")
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If already recording, restart the duration limit timer with updated config
|
|
292
|
+
if _live {
|
|
293
|
+
_startDurationLimitTimer()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Remote config applied: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate)%, maxRecording=\(maxRecordingMinutes)min, isSampledIn=\(recordingEnabled)")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
@objc public func attachAttribute(key: String, value: String) {
|
|
300
|
+
TelemetryPipeline.shared.recordAttribute(key: key, value: value)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@objc public func recordCustomEvent(name: String, payload: String?) {
|
|
304
|
+
TelemetryPipeline.shared.recordCustomEvent(name: name, payload: payload ?? "")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@objc public func associateUser(_ userId: String) {
|
|
308
|
+
TelemetryPipeline.shared.recordUserAssociation(userId)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@objc public func currentReplayId() -> String {
|
|
312
|
+
replayId ?? ""
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
@objc public func activateGestureRecording() {
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@objc public func recoverInterruptedReplay(completion: @escaping (String?) -> Void) {
|
|
319
|
+
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
320
|
+
completion(nil)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let path = docs.appendingPathComponent("rejourney_recovery.json")
|
|
325
|
+
|
|
326
|
+
guard let data = try? Data(contentsOf: path),
|
|
327
|
+
let checkpoint = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
328
|
+
let recId = checkpoint["replayId"] as? String else {
|
|
329
|
+
completion(nil)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let origStart = checkpoint["startMs"] as? UInt64 ?? 0
|
|
334
|
+
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
335
|
+
|
|
336
|
+
if let token = checkpoint["apiToken"] as? String {
|
|
337
|
+
SegmentDispatcher.shared.apiToken = token
|
|
338
|
+
}
|
|
339
|
+
if let endpoint = checkpoint["endpoint"] as? String {
|
|
340
|
+
SegmentDispatcher.shared.endpoint = endpoint
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let crashMetrics: [String: Any] = [
|
|
344
|
+
"crashCount": 1,
|
|
345
|
+
"durationSeconds": Int((nowMs - origStart) / 1000)
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
SegmentDispatcher.shared.concludeReplay(replayId: recId, concludedAt: nowMs, backgroundDurationMs: 0, metrics: crashMetrics) { [weak self] ok in
|
|
349
|
+
self?._clearRecovery()
|
|
350
|
+
completion(ok ? recId : nil)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@objc public func incrementFaultTally() { _crashCount += 1 }
|
|
355
|
+
@objc public func incrementStalledTally() { _freezeCount += 1 }
|
|
356
|
+
@objc public func incrementExceptionTally() { _errorCount += 1 }
|
|
357
|
+
@objc public func incrementTapTally() { _tapCount += 1 }
|
|
358
|
+
@objc public func logScrollAction() { _scrollCount += 1 }
|
|
359
|
+
@objc public func incrementGestureTally() { _gestureCount += 1 }
|
|
360
|
+
@objc public func incrementRageTapTally() { _rageCount += 1 }
|
|
361
|
+
@objc public func incrementDeadTapTally() { _deadTapCount += 1 }
|
|
362
|
+
|
|
363
|
+
@objc public func logScreenView(_ screenId: String) {
|
|
364
|
+
guard !screenId.isEmpty else { return }
|
|
365
|
+
_visitedScreens.append(screenId)
|
|
366
|
+
currentScreenName = screenId
|
|
367
|
+
if hierarchyCaptureEnabled { _captureHierarchy() }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private func _initSession() {
|
|
371
|
+
replayStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
372
|
+
let uuidPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
|
|
373
|
+
replayId = "session_\(replayStartMs)_\(uuidPart)"
|
|
374
|
+
_finalized = false
|
|
375
|
+
|
|
376
|
+
_crashCount = 0
|
|
377
|
+
_freezeCount = 0
|
|
378
|
+
_errorCount = 0
|
|
379
|
+
_tapCount = 0
|
|
380
|
+
_scrollCount = 0
|
|
381
|
+
_gestureCount = 0
|
|
382
|
+
_rageCount = 0
|
|
383
|
+
_deadTapCount = 0
|
|
384
|
+
_visitedScreens.removeAll()
|
|
385
|
+
_bgTimeMs = 0
|
|
386
|
+
_bgStartMs = nil
|
|
387
|
+
|
|
388
|
+
TelemetryPipeline.shared.currentReplayId = replayId
|
|
389
|
+
SegmentDispatcher.shared.currentReplayId = replayId
|
|
390
|
+
StabilityMonitor.shared.currentSessionId = replayId
|
|
391
|
+
|
|
392
|
+
_attachLifecycle()
|
|
393
|
+
_saveRecovery()
|
|
394
|
+
|
|
395
|
+
// Record app startup time
|
|
396
|
+
_recordAppStartup()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private func _recordAppStartup() {
|
|
400
|
+
let nowSec = Date().timeIntervalSince1970
|
|
401
|
+
let startupDurationMs = Int64((nowSec - ReplayOrchestrator.processStartTime) * 1000)
|
|
402
|
+
|
|
403
|
+
// Only record if it's a reasonable startup time (> 0 and < 60 seconds)
|
|
404
|
+
guard startupDurationMs > 0 && startupDurationMs < 60000 else { return }
|
|
405
|
+
|
|
406
|
+
TelemetryPipeline.shared.recordAppStartup(durationMs: startupDurationMs)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private func _applySettings(_ cfg: [String: Any]?) {
|
|
410
|
+
guard let cfg else { return }
|
|
411
|
+
snapshotInterval = cfg["captureRate"] as? Double ?? 0.33
|
|
412
|
+
compressionLevel = cfg["imgCompression"] as? Double ?? 0.5
|
|
413
|
+
visualCaptureEnabled = cfg["captureScreen"] as? Bool ?? true
|
|
414
|
+
interactionCaptureEnabled = cfg["captureAnalytics"] as? Bool ?? true
|
|
415
|
+
faultTrackingEnabled = cfg["captureCrashes"] as? Bool ?? true
|
|
416
|
+
responsivenessCaptureEnabled = cfg["captureANR"] as? Bool ?? true
|
|
417
|
+
consoleCaptureEnabled = cfg["captureLogs"] as? Bool ?? true
|
|
418
|
+
wifiRequired = cfg["wifiOnly"] as? Bool ?? false
|
|
419
|
+
frameBundleSize = cfg["screenshotBatchSize"] as? Int ?? 5
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private func _monitorNetwork(token: String) {
|
|
423
|
+
_netMonitor = NWPathMonitor()
|
|
424
|
+
_netMonitor?.pathUpdateHandler = { [weak self] path in
|
|
425
|
+
self?.handlePathChange(path: path, token: token)
|
|
426
|
+
}
|
|
427
|
+
_netMonitor?.start(queue: DispatchQueue.global(qos: .utility))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private func handlePathChange(path: NWPath, token: String) {
|
|
431
|
+
let canProceed: Bool
|
|
432
|
+
|
|
433
|
+
if path.status != .satisfied {
|
|
434
|
+
canProceed = false
|
|
435
|
+
} else if wifiRequired && !path.isExpensive {
|
|
436
|
+
canProceed = true
|
|
437
|
+
} else if wifiRequired && path.isExpensive {
|
|
438
|
+
canProceed = false
|
|
439
|
+
} else {
|
|
440
|
+
canProceed = true
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Extract network interface type
|
|
444
|
+
let networkType: String
|
|
445
|
+
let isExpensive = path.isExpensive
|
|
446
|
+
let isConstrained = path.isConstrained
|
|
447
|
+
|
|
448
|
+
if path.status != .satisfied {
|
|
449
|
+
networkType = "none"
|
|
450
|
+
} else if path.usesInterfaceType(.wifi) {
|
|
451
|
+
networkType = "wifi"
|
|
452
|
+
} else if path.usesInterfaceType(.cellular) {
|
|
453
|
+
networkType = "cellular"
|
|
454
|
+
} else if path.usesInterfaceType(.wiredEthernet) {
|
|
455
|
+
networkType = "wired"
|
|
456
|
+
} else if path.usesInterfaceType(.loopback) {
|
|
457
|
+
networkType = "other"
|
|
458
|
+
} else {
|
|
459
|
+
networkType = "other"
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
DispatchQueue.main.async { [weak self] in
|
|
463
|
+
guard let self else { return }
|
|
464
|
+
self._netReady = canProceed
|
|
465
|
+
self.currentNetworkType = networkType
|
|
466
|
+
self.networkIsExpensive = isExpensive
|
|
467
|
+
self.networkIsConstrained = isConstrained
|
|
468
|
+
|
|
469
|
+
if canProceed && !self._live {
|
|
470
|
+
self._beginRecording(token: token)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private func _beginRecording(token: String) {
|
|
476
|
+
guard !_live else { return }
|
|
477
|
+
_live = true
|
|
478
|
+
|
|
479
|
+
self.apiToken = token
|
|
480
|
+
_initSession()
|
|
481
|
+
|
|
482
|
+
// Reactivate the dispatcher in case it was halted from a previous session
|
|
483
|
+
SegmentDispatcher.shared.activate()
|
|
484
|
+
TelemetryPipeline.shared.activate()
|
|
485
|
+
|
|
486
|
+
let renderCfg = _computeRender(fps: 3, tier: "high")
|
|
487
|
+
VisualCapture.shared.configure(snapshotInterval: renderCfg.interval, jpegQuality: renderCfg.quality)
|
|
488
|
+
|
|
489
|
+
if visualCaptureEnabled { VisualCapture.shared.beginCapture(sessionOrigin: replayStartMs) }
|
|
490
|
+
if interactionCaptureEnabled { InteractionRecorder.shared.activate() }
|
|
491
|
+
if faultTrackingEnabled { FaultTracker.shared.activate() }
|
|
492
|
+
if responsivenessCaptureEnabled { ResponsivenessWatcher.shared.activate() }
|
|
493
|
+
if hierarchyCaptureEnabled { _startHierarchyCapture() }
|
|
494
|
+
|
|
495
|
+
// Start duration limit timer based on remote config
|
|
496
|
+
_startDurationLimitTimer()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// MARK: - Duration Limit Timer
|
|
500
|
+
|
|
501
|
+
private func _startDurationLimitTimer() {
|
|
502
|
+
_stopDurationLimitTimer()
|
|
503
|
+
|
|
504
|
+
let maxMinutes = remoteMaxRecordingMinutes
|
|
505
|
+
guard maxMinutes > 0 else { return }
|
|
506
|
+
|
|
507
|
+
let maxMs = UInt64(maxMinutes) * 60 * 1000
|
|
508
|
+
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
509
|
+
let elapsed = now - replayStartMs
|
|
510
|
+
let remaining = maxMs > elapsed ? maxMs - elapsed : 0
|
|
511
|
+
|
|
512
|
+
guard remaining > 0 else {
|
|
513
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Duration limit already exceeded, stopping session")
|
|
514
|
+
endReplay()
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
519
|
+
guard let self, self._live else { return }
|
|
520
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Recording duration limit reached (\(maxMinutes)min), stopping session")
|
|
521
|
+
self.endReplay()
|
|
522
|
+
}
|
|
523
|
+
_durationLimitTimer = workItem
|
|
524
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(remaining)), execute: workItem)
|
|
525
|
+
|
|
526
|
+
DiagnosticLog.notice("[ReplayOrchestrator] Duration limit timer set: \(remaining / 1000)s remaining (max \(maxMinutes)min)")
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private func _stopDurationLimitTimer() {
|
|
530
|
+
_durationLimitTimer?.cancel()
|
|
531
|
+
_durationLimitTimer = nil
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private func _saveRecovery() {
|
|
535
|
+
guard let sid = replayId, let token = apiToken else { return }
|
|
536
|
+
let checkpoint: [String: Any] = ["replayId": sid, "apiToken": token, "startMs": replayStartMs, "endpoint": serverEndpoint]
|
|
537
|
+
guard let data = try? JSONSerialization.data(withJSONObject: checkpoint),
|
|
538
|
+
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
|
539
|
+
try? data.write(to: docs.appendingPathComponent("rejourney_recovery.json"))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private func _clearRecovery() {
|
|
543
|
+
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
|
544
|
+
try? FileManager.default.removeItem(at: docs.appendingPathComponent("rejourney_recovery.json"))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private func _attachLifecycle() {
|
|
548
|
+
NotificationCenter.default.addObserver(self, selector: #selector(_onBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
549
|
+
NotificationCenter.default.addObserver(self, selector: #selector(_onForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func _detachLifecycle() {
|
|
553
|
+
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
554
|
+
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@objc private func _onBackground() {
|
|
558
|
+
_bgStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
@objc private func _onForeground() {
|
|
562
|
+
guard let start = _bgStartMs else { return }
|
|
563
|
+
let now = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
564
|
+
_bgTimeMs += (now - start)
|
|
565
|
+
_bgStartMs = nil
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private func _startHierarchyCapture() {
|
|
569
|
+
_hierarchyTimer?.invalidate()
|
|
570
|
+
// Industry standard: Use default run loop mode (NOT .common)
|
|
571
|
+
// This lets the timer pause during scrolling which prevents stutter
|
|
572
|
+
_hierarchyTimer = Timer.scheduledTimer(withTimeInterval: hierarchyCaptureInterval, repeats: true) { [weak self] _ in
|
|
573
|
+
self?._captureHierarchy()
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
577
|
+
self?._captureHierarchy()
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private func _captureHierarchy() {
|
|
582
|
+
guard _live, let sid = replayId else { return }
|
|
583
|
+
if !Thread.isMainThread {
|
|
584
|
+
DispatchQueue.main.async { [weak self] in self?._captureHierarchy() }
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
guard let hierarchy = ViewHierarchyScanner.shared.captureHierarchy() else { return }
|
|
589
|
+
|
|
590
|
+
let hash = _hierarchyHash(hierarchy)
|
|
591
|
+
if hash == _lastHierarchyHash { return }
|
|
592
|
+
_lastHierarchyHash = hash
|
|
593
|
+
|
|
594
|
+
guard let json = try? JSONSerialization.data(withJSONObject: hierarchy) else { return }
|
|
595
|
+
let ts = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
596
|
+
|
|
597
|
+
SegmentDispatcher.shared.transmitHierarchy(replayId: sid, hierarchyPayload: json, timestampMs: ts, completion: nil)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private func _hierarchyHash(_ h: [String: Any]) -> String {
|
|
601
|
+
let screen = currentScreenName ?? "unknown"
|
|
602
|
+
var childCount = 0
|
|
603
|
+
if let root = h["root"] as? [String: Any], let children = root["children"] as? [[String: Any]] {
|
|
604
|
+
childCount = children.count
|
|
605
|
+
}
|
|
606
|
+
return "\(screen):\(childCount)"
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private func _computeRender(fps: Int, tier: String) -> (interval: Double, quality: Double) {
|
|
611
|
+
let interval = 1.0 / Double(max(1, min(fps, 99)))
|
|
612
|
+
let quality: Double
|
|
613
|
+
switch tier.lowercased() {
|
|
614
|
+
case "low": quality = 0.4
|
|
615
|
+
case "standard": quality = 0.5
|
|
616
|
+
case "high": quality = 0.6
|
|
617
|
+
default: quality = 0.5
|
|
618
|
+
}
|
|
619
|
+
return (interval, quality)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
func computeQualityPreset(targetFps: Int, preset: String) -> (interval: Double, quality: Double) {
|
|
623
|
+
_computeRender(fps: targetFps, tier: preset)
|
|
624
|
+
}
|