@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,719 @@
|
|
|
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 React
|
|
19
|
+
import CommonCrypto
|
|
20
|
+
|
|
21
|
+
@objc(RejourneyImpl)
|
|
22
|
+
public final class RejourneyImpl: NSObject {
|
|
23
|
+
@objc public static let shared = RejourneyImpl()
|
|
24
|
+
@objc public static var sdkVersion = "1.0.1"
|
|
25
|
+
|
|
26
|
+
// MARK: - State Machine
|
|
27
|
+
|
|
28
|
+
private enum SessionState {
|
|
29
|
+
case idle
|
|
30
|
+
case active(sessionId: String, startTime: TimeInterval)
|
|
31
|
+
case paused(sessionId: String, startTime: TimeInterval)
|
|
32
|
+
case terminated
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private var state: SessionState = .idle
|
|
36
|
+
private let stateLock = NSLock()
|
|
37
|
+
|
|
38
|
+
// MARK: - Internal Storage
|
|
39
|
+
|
|
40
|
+
private var currentUserIdentity: String?
|
|
41
|
+
private var internalEventStream: [[String: Any]] = []
|
|
42
|
+
private var backgroundStartTime: TimeInterval?
|
|
43
|
+
private var lastSessionConfig: [String: Any]?
|
|
44
|
+
private var lastApiUrl: String?
|
|
45
|
+
private var lastPublicKey: String?
|
|
46
|
+
|
|
47
|
+
// Session timeout threshold (60 seconds)
|
|
48
|
+
private let sessionTimeoutSeconds: TimeInterval = 60
|
|
49
|
+
|
|
50
|
+
private let userIdentityKey = "com.rejourney.user.identity"
|
|
51
|
+
|
|
52
|
+
public override init() {
|
|
53
|
+
super.init()
|
|
54
|
+
setupLifecycleListeners()
|
|
55
|
+
_loadPersistedIdentity()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private func _loadPersistedIdentity() {
|
|
59
|
+
if let persisted = UserDefaults.standard.string(forKey: userIdentityKey), !persisted.isEmpty {
|
|
60
|
+
self.currentUserIdentity = persisted
|
|
61
|
+
DiagnosticLog.notice("[Rejourney] Restored persisted user identity: \(persisted)")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
deinit {
|
|
66
|
+
NotificationCenter.default.removeObserver(self)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func setupLifecycleListeners() {
|
|
70
|
+
let center = NotificationCenter.default
|
|
71
|
+
center.addObserver(self, selector: #selector(handleTermination), name: UIApplication.willTerminateNotification, object: nil)
|
|
72
|
+
center.addObserver(self, selector: #selector(handleBackgrounding), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
|
73
|
+
center.addObserver(self, selector: #selector(handleForegrounding), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - State Transitions
|
|
77
|
+
|
|
78
|
+
@objc private func handleTermination() {
|
|
79
|
+
stateLock.lock()
|
|
80
|
+
defer { stateLock.unlock() }
|
|
81
|
+
|
|
82
|
+
switch state {
|
|
83
|
+
case .active, .paused:
|
|
84
|
+
state = .terminated
|
|
85
|
+
TelemetryPipeline.shared.finalizeAndShip()
|
|
86
|
+
SegmentDispatcher.shared.shipPending()
|
|
87
|
+
default:
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@objc private func handleBackgrounding() {
|
|
93
|
+
stateLock.lock()
|
|
94
|
+
defer { stateLock.unlock() }
|
|
95
|
+
|
|
96
|
+
if case .active(let sid, let start) = state {
|
|
97
|
+
state = .paused(sessionId: sid, startTime: start)
|
|
98
|
+
backgroundStartTime = Date().timeIntervalSince1970
|
|
99
|
+
DiagnosticLog.notice("[Rejourney] ⏸️ Session '\(sid)' paused (app backgrounded)")
|
|
100
|
+
TelemetryPipeline.shared.dispatchNow()
|
|
101
|
+
SegmentDispatcher.shared.shipPending()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@objc private func handleForegrounding() {
|
|
106
|
+
// Dispatch to avoid blocking the main thread notification handler
|
|
107
|
+
DispatchQueue.main.async { [weak self] in
|
|
108
|
+
self?._processForegrounding()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func _processForegrounding() {
|
|
113
|
+
stateLock.lock()
|
|
114
|
+
|
|
115
|
+
guard case .paused(let sid, let start) = state else {
|
|
116
|
+
DiagnosticLog.trace("[Rejourney] Foreground: not in paused state, ignoring")
|
|
117
|
+
stateLock.unlock()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check if we've been in background longer than the timeout
|
|
122
|
+
let backgroundDuration: TimeInterval
|
|
123
|
+
if let bgStart = backgroundStartTime {
|
|
124
|
+
backgroundDuration = Date().timeIntervalSince1970 - bgStart
|
|
125
|
+
} else {
|
|
126
|
+
backgroundDuration = 0
|
|
127
|
+
}
|
|
128
|
+
backgroundStartTime = nil
|
|
129
|
+
|
|
130
|
+
DiagnosticLog.notice("[Rejourney] App foregrounded after \(Int(backgroundDuration))s (timeout: \(Int(sessionTimeoutSeconds))s)")
|
|
131
|
+
|
|
132
|
+
if backgroundDuration > sessionTimeoutSeconds {
|
|
133
|
+
// End current session and start a new one
|
|
134
|
+
state = .idle
|
|
135
|
+
stateLock.unlock()
|
|
136
|
+
|
|
137
|
+
DiagnosticLog.notice("[Rejourney] 🔄 Session timeout! Ending session '\(sid)' and creating new one")
|
|
138
|
+
|
|
139
|
+
// End the old session asynchronously, then start new one
|
|
140
|
+
// Use a background queue to avoid blocking main thread
|
|
141
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
142
|
+
ReplayOrchestrator.shared.endReplay { success, uploaded in
|
|
143
|
+
DiagnosticLog.notice("[Rejourney] Old session ended (success: \(success), uploaded: \(uploaded))")
|
|
144
|
+
// Start a new session with preserved config and user identity
|
|
145
|
+
DispatchQueue.main.async {
|
|
146
|
+
self?._startNewSessionAfterTimeout()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
// Resume existing session
|
|
152
|
+
state = .active(sessionId: sid, startTime: start)
|
|
153
|
+
stateLock.unlock()
|
|
154
|
+
|
|
155
|
+
DiagnosticLog.notice("[Rejourney] ▶️ Resuming session '\(sid)'")
|
|
156
|
+
|
|
157
|
+
// Record the foreground event with background duration
|
|
158
|
+
let bgMs = UInt64(backgroundDuration * 1000)
|
|
159
|
+
TelemetryPipeline.shared.recordAppForeground(totalBackgroundTimeMs: bgMs)
|
|
160
|
+
|
|
161
|
+
StabilityMonitor.shared.transmitStoredReport()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func _startNewSessionAfterTimeout() {
|
|
166
|
+
guard let apiUrl = lastApiUrl, let publicKey = lastPublicKey else {
|
|
167
|
+
DiagnosticLog.caution("[Rejourney] Cannot restart session - missing API config")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let savedUserId = currentUserIdentity
|
|
172
|
+
|
|
173
|
+
DiagnosticLog.notice("[Rejourney] Starting new session after timeout (user: \(savedUserId ?? "nil"))")
|
|
174
|
+
|
|
175
|
+
// Use a faster path: directly call beginSessionFast if credentials are still valid
|
|
176
|
+
// This avoids the network roundtrip for credential re-fetch
|
|
177
|
+
DispatchQueue.main.async { [weak self] in
|
|
178
|
+
guard let self else { return }
|
|
179
|
+
|
|
180
|
+
// Try the fast path first - if credentials are still valid
|
|
181
|
+
if let existingCred = DeviceRegistrar.shared.uploadCredential, DeviceRegistrar.shared.credentialValid {
|
|
182
|
+
DiagnosticLog.notice("[Rejourney] Using cached credentials for fast session restart")
|
|
183
|
+
ReplayOrchestrator.shared.beginReplayFast(
|
|
184
|
+
apiToken: publicKey,
|
|
185
|
+
serverEndpoint: apiUrl,
|
|
186
|
+
credential: existingCred,
|
|
187
|
+
captureSettings: self.lastSessionConfig
|
|
188
|
+
)
|
|
189
|
+
} else {
|
|
190
|
+
// Fall back to full credential fetch
|
|
191
|
+
DiagnosticLog.notice("[Rejourney] No cached credentials, doing full session start")
|
|
192
|
+
ReplayOrchestrator.shared.beginReplay(
|
|
193
|
+
apiToken: publicKey,
|
|
194
|
+
serverEndpoint: apiUrl,
|
|
195
|
+
captureSettings: self.lastSessionConfig
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Poll for session to be ready (up to 3 seconds)
|
|
200
|
+
self._waitForSessionReady(savedUserId: savedUserId, attempts: 0)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private func _waitForSessionReady(savedUserId: String?, attempts: Int) {
|
|
205
|
+
let maxAttempts = 30 // 3 seconds max (30 * 100ms)
|
|
206
|
+
|
|
207
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
208
|
+
guard let self else { return }
|
|
209
|
+
|
|
210
|
+
// Check if ReplayOrchestrator has generated a new session ID
|
|
211
|
+
if let newSid = ReplayOrchestrator.shared.replayId, !newSid.isEmpty {
|
|
212
|
+
let start = Date().timeIntervalSince1970
|
|
213
|
+
|
|
214
|
+
self.stateLock.lock()
|
|
215
|
+
self.state = .active(sessionId: newSid, startTime: start)
|
|
216
|
+
self.stateLock.unlock()
|
|
217
|
+
|
|
218
|
+
ReplayOrchestrator.shared.activateGestureRecording()
|
|
219
|
+
|
|
220
|
+
// Re-apply user identity if it was set
|
|
221
|
+
if let userId = savedUserId, userId != "anonymous", !userId.hasPrefix("anon_") {
|
|
222
|
+
ReplayOrchestrator.shared.associateUser(userId)
|
|
223
|
+
DiagnosticLog.notice("[Rejourney] ✅ Restored user identity '\(userId)' to new session \(newSid)")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
DiagnosticLog.replayBegan(newSid)
|
|
227
|
+
DiagnosticLog.notice("[Rejourney] ✅ New session started: \(newSid)")
|
|
228
|
+
} else if attempts < maxAttempts {
|
|
229
|
+
// Keep polling
|
|
230
|
+
self._waitForSessionReady(savedUserId: savedUserId, attempts: attempts + 1)
|
|
231
|
+
} else {
|
|
232
|
+
DiagnosticLog.caution("[Rejourney] ⚠️ Timeout waiting for new session to initialize")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Public API
|
|
238
|
+
|
|
239
|
+
@objc(startSession:apiUrl:publicKey:resolve:reject:)
|
|
240
|
+
public func startSession(
|
|
241
|
+
_ userId: String,
|
|
242
|
+
apiUrl: String,
|
|
243
|
+
publicKey: String,
|
|
244
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
245
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
246
|
+
) {
|
|
247
|
+
startSessionWithOptions(
|
|
248
|
+
[
|
|
249
|
+
"userId": userId,
|
|
250
|
+
"apiUrl": apiUrl,
|
|
251
|
+
"publicKey": publicKey
|
|
252
|
+
] as NSDictionary,
|
|
253
|
+
resolve: resolve,
|
|
254
|
+
reject: reject
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@objc(startSessionWithOptions:resolve:reject:)
|
|
259
|
+
public func startSessionWithOptions(
|
|
260
|
+
_ options: NSDictionary,
|
|
261
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
262
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
263
|
+
) {
|
|
264
|
+
if let debug = options["debug"] as? Bool, debug {
|
|
265
|
+
DiagnosticLog.setVerbose(true)
|
|
266
|
+
DiagnosticLog.notice("[Rejourney] Debug mode ENABLED - verbose logging active")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let startParams = PerformanceSnapshot.capture()
|
|
270
|
+
|
|
271
|
+
let userId = options["userId"] as? String ?? "anonymous"
|
|
272
|
+
let apiUrl = options["apiUrl"] as? String ?? "https://api.rejourney.co"
|
|
273
|
+
let publicKey = options["publicKey"] as? String ?? ""
|
|
274
|
+
|
|
275
|
+
guard !publicKey.isEmpty else {
|
|
276
|
+
reject("INVALID_KEY", "publicKey is required", nil)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var config: [String: Any] = [:]
|
|
281
|
+
if let val = options["captureScreen"] as? Bool { config["captureScreen"] = val }
|
|
282
|
+
if let val = options["captureAnalytics"] as? Bool { config["captureAnalytics"] = val }
|
|
283
|
+
if let val = options["captureCrashes"] as? Bool { config["captureCrashes"] = val }
|
|
284
|
+
if let val = options["captureANR"] as? Bool { config["captureANR"] = val }
|
|
285
|
+
if let val = options["wifiOnly"] as? Bool { config["wifiOnly"] = val }
|
|
286
|
+
|
|
287
|
+
if let fps = options["fps"] as? Int {
|
|
288
|
+
config["captureRate"] = 1.0 / Double(max(1, min(fps, 30)))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if let quality = options["quality"] as? String {
|
|
292
|
+
switch quality.lowercased() {
|
|
293
|
+
case "low": config["imgCompression"] = 0.4
|
|
294
|
+
case "high": config["imgCompression"] = 0.7
|
|
295
|
+
default: config["imgCompression"] = 0.5
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Critical: Ensure async dispatch to allow React Native bridge to return
|
|
300
|
+
DispatchQueue.main.async { [weak self] in
|
|
301
|
+
guard let self else {
|
|
302
|
+
resolve(["success": false, "sessionId": "", "error": "Instance released"])
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
self.stateLock.lock()
|
|
307
|
+
if case .active(let sid, _) = self.state {
|
|
308
|
+
self.stateLock.unlock()
|
|
309
|
+
resolve(["success": true, "sessionId": sid])
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
self.stateLock.unlock()
|
|
313
|
+
|
|
314
|
+
if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
315
|
+
self.currentUserIdentity = userId
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Store config for session restart after background timeout
|
|
319
|
+
self.lastSessionConfig = config
|
|
320
|
+
self.lastApiUrl = apiUrl
|
|
321
|
+
self.lastPublicKey = publicKey
|
|
322
|
+
|
|
323
|
+
TelemetryPipeline.shared.endpoint = apiUrl
|
|
324
|
+
SegmentDispatcher.shared.endpoint = apiUrl
|
|
325
|
+
DeviceRegistrar.shared.endpoint = apiUrl
|
|
326
|
+
|
|
327
|
+
ReplayOrchestrator.shared.beginReplay(apiToken: publicKey, serverEndpoint: apiUrl, captureSettings: config)
|
|
328
|
+
|
|
329
|
+
// Allow orchestrator time to spin up
|
|
330
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
331
|
+
let sid = ReplayOrchestrator.shared.replayId ?? UUID().uuidString
|
|
332
|
+
let start = Date().timeIntervalSince1970
|
|
333
|
+
|
|
334
|
+
self.stateLock.lock()
|
|
335
|
+
self.state = .active(sessionId: sid, startTime: start)
|
|
336
|
+
self.stateLock.unlock()
|
|
337
|
+
|
|
338
|
+
ReplayOrchestrator.shared.activateGestureRecording()
|
|
339
|
+
|
|
340
|
+
if userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
341
|
+
ReplayOrchestrator.shared.associateUser(userId)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
DiagnosticLog.replayBegan(sid)
|
|
345
|
+
resolve(["success": true, "sessionId": sid])
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@objc(stopSession:reject:)
|
|
351
|
+
public func stopSession(
|
|
352
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
353
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
354
|
+
) {
|
|
355
|
+
DispatchQueue.main.async { [weak self] in
|
|
356
|
+
guard let self else { return }
|
|
357
|
+
|
|
358
|
+
var targetSid = ""
|
|
359
|
+
|
|
360
|
+
self.stateLock.lock()
|
|
361
|
+
if case .active(let sid, _) = self.state {
|
|
362
|
+
targetSid = sid
|
|
363
|
+
}
|
|
364
|
+
self.state = .idle
|
|
365
|
+
self.stateLock.unlock()
|
|
366
|
+
|
|
367
|
+
guard !targetSid.isEmpty else {
|
|
368
|
+
resolve(["success": true, "sessionId": "", "uploadSuccess": true])
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
ReplayOrchestrator.shared.endReplay { success, uploaded in
|
|
373
|
+
DiagnosticLog.replayEnded(targetSid)
|
|
374
|
+
|
|
375
|
+
resolve([
|
|
376
|
+
"success": success,
|
|
377
|
+
"sessionId": targetSid,
|
|
378
|
+
"uploadSuccess": uploaded
|
|
379
|
+
])
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@objc(getSessionId:reject:)
|
|
385
|
+
public func getSessionId(
|
|
386
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
387
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
388
|
+
) {
|
|
389
|
+
stateLock.lock()
|
|
390
|
+
defer { stateLock.unlock() }
|
|
391
|
+
|
|
392
|
+
switch state {
|
|
393
|
+
case .active(let sid, _), .paused(let sid, _):
|
|
394
|
+
resolve(sid)
|
|
395
|
+
default:
|
|
396
|
+
resolve(nil)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
@objc(setUserIdentity:resolve:reject:)
|
|
401
|
+
public func setUserIdentity(
|
|
402
|
+
_ userId: String,
|
|
403
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
404
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
405
|
+
) {
|
|
406
|
+
if !userId.isEmpty && userId != "anonymous" && !userId.hasPrefix("anon_") {
|
|
407
|
+
currentUserIdentity = userId
|
|
408
|
+
|
|
409
|
+
// Persist natively
|
|
410
|
+
UserDefaults.standard.set(userId, forKey: userIdentityKey)
|
|
411
|
+
DiagnosticLog.notice("[Rejourney] Persisted user identity: \(userId)")
|
|
412
|
+
|
|
413
|
+
ReplayOrchestrator.shared.associateUser(userId)
|
|
414
|
+
} else if userId == "anonymous" || userId.isEmpty {
|
|
415
|
+
// Clear identity
|
|
416
|
+
currentUserIdentity = nil
|
|
417
|
+
UserDefaults.standard.removeObject(forKey: userIdentityKey)
|
|
418
|
+
DiagnosticLog.notice("[Rejourney] Cleared user identity")
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
resolve(["success": true])
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@objc(getUserIdentity:reject:)
|
|
425
|
+
public func getUserIdentity(
|
|
426
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
427
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
428
|
+
) {
|
|
429
|
+
resolve(currentUserIdentity)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@objc(logEvent:details:resolve:reject:)
|
|
433
|
+
public func logEvent(
|
|
434
|
+
_ eventType: String,
|
|
435
|
+
details: NSDictionary,
|
|
436
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
437
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
438
|
+
) {
|
|
439
|
+
// Handle network_request events specially to preserve type for backend metrics
|
|
440
|
+
if eventType == "network_request" {
|
|
441
|
+
// Convert NSDictionary to Swift dictionary for network event encoding
|
|
442
|
+
if let detailsDict = details as? [String: Any] {
|
|
443
|
+
TelemetryPipeline.shared.recordNetworkEvent(details: detailsDict)
|
|
444
|
+
}
|
|
445
|
+
resolve(["success": true])
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Handle JS error events - route through TelemetryPipeline as type:"error"
|
|
450
|
+
// so the backend ingest worker processes them into the errors table
|
|
451
|
+
if eventType == "error" {
|
|
452
|
+
let message = details["message"] as? String ?? "Unknown error"
|
|
453
|
+
let name = details["name"] as? String ?? "Error"
|
|
454
|
+
let stack = details["stack"] as? String
|
|
455
|
+
TelemetryPipeline.shared.recordJSErrorEvent(name: name, message: message, stack: stack)
|
|
456
|
+
resolve(["success": true])
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Handle dead_tap events from JS-side detection
|
|
461
|
+
// Native view hierarchy inspection is unreliable in React Native,
|
|
462
|
+
// so dead tap detection runs in JS and reports back via logEvent.
|
|
463
|
+
if eventType == "dead_tap" {
|
|
464
|
+
let x = (details["x"] as? NSNumber)?.uint64Value ?? 0
|
|
465
|
+
let y = (details["y"] as? NSNumber)?.uint64Value ?? 0
|
|
466
|
+
let label = details["label"] as? String ?? "unknown"
|
|
467
|
+
TelemetryPipeline.shared.recordDeadTapEvent(label: label, x: x, y: y)
|
|
468
|
+
ReplayOrchestrator.shared.incrementDeadTapTally()
|
|
469
|
+
resolve(["success": true])
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// All other events go through custom event recording
|
|
474
|
+
var payload = "{}"
|
|
475
|
+
if let data = try? JSONSerialization.data(withJSONObject: details),
|
|
476
|
+
let str = String(data: data, encoding: .utf8) {
|
|
477
|
+
payload = str
|
|
478
|
+
}
|
|
479
|
+
ReplayOrchestrator.shared.recordCustomEvent(name: eventType, payload: payload)
|
|
480
|
+
resolve(["success": true])
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
@objc(screenChanged:resolve:reject:)
|
|
484
|
+
public func screenChanged(
|
|
485
|
+
_ screenName: String,
|
|
486
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
487
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
488
|
+
) {
|
|
489
|
+
TelemetryPipeline.shared.recordViewTransition(viewId: screenName, viewLabel: screenName, entering: true)
|
|
490
|
+
ReplayOrchestrator.shared.logScreenView(screenName)
|
|
491
|
+
resolve(["success": true])
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
@objc(onScroll:resolve:reject:)
|
|
495
|
+
public func onScroll(
|
|
496
|
+
_ offsetY: Double,
|
|
497
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
498
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
499
|
+
) {
|
|
500
|
+
ReplayOrchestrator.shared.logScrollAction()
|
|
501
|
+
resolve(["success": true])
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
@objc(markVisualChange:importance:resolve:reject:)
|
|
505
|
+
public func markVisualChange(
|
|
506
|
+
_ reason: String,
|
|
507
|
+
importance: String,
|
|
508
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
509
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
510
|
+
) {
|
|
511
|
+
if importance == "high" {
|
|
512
|
+
VisualCapture.shared.snapshotNow()
|
|
513
|
+
}
|
|
514
|
+
resolve(true)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
@objc(onExternalURLOpened:resolve:reject:)
|
|
518
|
+
public func onExternalURLOpened(
|
|
519
|
+
_ urlScheme: String,
|
|
520
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
521
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
522
|
+
) {
|
|
523
|
+
ReplayOrchestrator.shared.recordCustomEvent(name: "external_url_opened", payload: "{\"scheme\":\"\(urlScheme)\"}")
|
|
524
|
+
resolve(["success": true])
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
@objc(onOAuthStarted:resolve:reject:)
|
|
528
|
+
public func onOAuthStarted(
|
|
529
|
+
_ provider: String,
|
|
530
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
531
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
532
|
+
) {
|
|
533
|
+
ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_started", payload: "{\"provider\":\"\(provider)\"}")
|
|
534
|
+
resolve(["success": true])
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
@objc(onOAuthCompleted:success:resolve:reject:)
|
|
538
|
+
public func onOAuthCompleted(
|
|
539
|
+
_ provider: String,
|
|
540
|
+
success: Bool,
|
|
541
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
542
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
543
|
+
) {
|
|
544
|
+
ReplayOrchestrator.shared.recordCustomEvent(name: "oauth_completed", payload: "{\"provider\":\"\(provider)\",\"success\":\(success)}")
|
|
545
|
+
resolve(["success": true])
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@objc(maskViewByNativeID:resolve:reject:)
|
|
549
|
+
public func maskViewByNativeID(
|
|
550
|
+
_ nativeID: String,
|
|
551
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
552
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
553
|
+
) {
|
|
554
|
+
DispatchQueue.main.async {
|
|
555
|
+
if let target = self.findView(by: nativeID) {
|
|
556
|
+
ReplayOrchestrator.shared.redactView(target)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
resolve(["success": true])
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
@objc(unmaskViewByNativeID:resolve:reject:)
|
|
563
|
+
public func unmaskViewByNativeID(
|
|
564
|
+
_ nativeID: String,
|
|
565
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
566
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
567
|
+
) {
|
|
568
|
+
DispatchQueue.main.async {
|
|
569
|
+
if let target = self.findView(by: nativeID) {
|
|
570
|
+
ReplayOrchestrator.shared.unredactView(target)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
resolve(["success": true])
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private func findView(by identifier: String) -> UIView? {
|
|
577
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return nil }
|
|
578
|
+
return scanView(window, id: identifier)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private func scanView(_ node: UIView, id: String) -> UIView? {
|
|
582
|
+
if node.accessibilityIdentifier == id || node.nativeID == id {
|
|
583
|
+
return node
|
|
584
|
+
}
|
|
585
|
+
for child in node.subviews {
|
|
586
|
+
if let match = scanView(child, id: id) {
|
|
587
|
+
return match
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return nil
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
@objc(setDebugMode:resolve:reject:)
|
|
594
|
+
public func setDebugMode(
|
|
595
|
+
_ enabled: Bool,
|
|
596
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
597
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
598
|
+
) {
|
|
599
|
+
DiagnosticLog.setVerbose(enabled)
|
|
600
|
+
resolve(["success": true])
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
@objc(setRemoteConfigWithRejourneyEnabled:recordingEnabled:sampleRate:maxRecordingMinutes:resolve:reject:)
|
|
604
|
+
public func setRemoteConfig(
|
|
605
|
+
rejourneyEnabled: Bool,
|
|
606
|
+
recordingEnabled: Bool,
|
|
607
|
+
sampleRate: Int,
|
|
608
|
+
maxRecordingMinutes: Int,
|
|
609
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
610
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
611
|
+
) {
|
|
612
|
+
DiagnosticLog.notice("[Rejourney] setRemoteConfig: rejourneyEnabled=\(rejourneyEnabled), recordingEnabled=\(recordingEnabled), sampleRate=\(sampleRate), maxRecording=\(maxRecordingMinutes)min")
|
|
613
|
+
|
|
614
|
+
ReplayOrchestrator.shared.setRemoteConfig(
|
|
615
|
+
rejourneyEnabled: rejourneyEnabled,
|
|
616
|
+
recordingEnabled: recordingEnabled,
|
|
617
|
+
sampleRate: sampleRate,
|
|
618
|
+
maxRecordingMinutes: maxRecordingMinutes
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
resolve(["success": true])
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
@objc(setSDKVersion:)
|
|
625
|
+
public func setSDKVersion(_ version: String) {
|
|
626
|
+
RejourneyImpl.sdkVersion = version
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
@objc(getSDKMetrics:reject:)
|
|
630
|
+
public func getSDKMetrics(
|
|
631
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
632
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
633
|
+
) {
|
|
634
|
+
resolve([
|
|
635
|
+
"uploadSuccessCount": 0,
|
|
636
|
+
"uploadFailureCount": 0,
|
|
637
|
+
"retryAttemptCount": 0,
|
|
638
|
+
"circuitBreakerOpenCount": 0,
|
|
639
|
+
"memoryEvictionCount": 0,
|
|
640
|
+
"offlinePersistCount": 0,
|
|
641
|
+
"sessionStartCount": (ReplayOrchestrator.shared.replayId != nil) ? 1 : 0,
|
|
642
|
+
"crashCount": 0,
|
|
643
|
+
"uploadSuccessRate": 1.0,
|
|
644
|
+
"avgUploadDurationMs": 0.0,
|
|
645
|
+
"currentQueueDepth": 0,
|
|
646
|
+
"lastUploadTime": NSNull(),
|
|
647
|
+
"lastRetryTime": NSNull(),
|
|
648
|
+
"totalBytesUploaded": 0,
|
|
649
|
+
"totalBytesEvicted": 0
|
|
650
|
+
])
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
@objc(getDeviceInfo:reject:)
|
|
654
|
+
public func getDeviceInfo(
|
|
655
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
656
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
657
|
+
) {
|
|
658
|
+
let device = UIDevice.current
|
|
659
|
+
let screen = UIScreen.main
|
|
660
|
+
|
|
661
|
+
resolve([
|
|
662
|
+
"platform": "ios",
|
|
663
|
+
"osVersion": device.systemVersion,
|
|
664
|
+
"model": device.model,
|
|
665
|
+
"deviceName": device.name,
|
|
666
|
+
"screenWidth": Int(screen.bounds.width * screen.scale),
|
|
667
|
+
"screenHeight": Int(screen.bounds.height * screen.scale),
|
|
668
|
+
"screenScale": screen.scale,
|
|
669
|
+
"deviceHash": computeHash(),
|
|
670
|
+
"bundleId": Bundle.main.bundleIdentifier ?? "unknown"
|
|
671
|
+
])
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
@objc(debugCrash)
|
|
675
|
+
public func debugCrash() {
|
|
676
|
+
DispatchQueue.main.async {
|
|
677
|
+
let arr: [Int] = []
|
|
678
|
+
_ = arr[1]
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
@objc(debugTriggerANR:)
|
|
683
|
+
public func debugTriggerANR(_ durationMs: Double) {
|
|
684
|
+
DispatchQueue.main.async {
|
|
685
|
+
Thread.sleep(forTimeInterval: durationMs / 1000.0)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
@objc(getSDKVersion:reject:)
|
|
690
|
+
public func getSDKVersion(
|
|
691
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
692
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
693
|
+
) {
|
|
694
|
+
resolve(Self.sdkVersion)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
@objc(setUserData:value:resolve:reject:)
|
|
698
|
+
public func setUserData(
|
|
699
|
+
_ key: String,
|
|
700
|
+
value: String,
|
|
701
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
702
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
703
|
+
) {
|
|
704
|
+
ReplayOrchestrator.shared.attachAttribute(key: key, value: value)
|
|
705
|
+
resolve(nil)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private func computeHash() -> String {
|
|
709
|
+
let uuid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
|
|
710
|
+
guard let data = uuid.data(using: .utf8) else { return "" }
|
|
711
|
+
|
|
712
|
+
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
713
|
+
data.withUnsafeBytes {
|
|
714
|
+
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return buffer.map { String(format: "%02x", $0) }.joined()
|
|
718
|
+
}
|
|
719
|
+
}
|