@rejourneyco/react-native 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -18,64 +18,64 @@ import Foundation
18
18
 
19
19
  @objc(AnrSentinel)
20
20
  public final class AnrSentinel: NSObject {
21
-
21
+
22
22
  @objc public static let shared = AnrSentinel()
23
-
23
+
24
24
  private let _freezeThreshold: TimeInterval = 5.0
25
25
  private let _pollFrequency: TimeInterval = 2.0
26
-
26
+
27
27
  private var _watchThread: Thread?
28
28
  private var _volatile = VolatileState()
29
29
  private let _stateLock = os_unfair_lock_t.allocate(capacity: 1)
30
-
30
+
31
31
  private override init() {
32
32
  _stateLock.initialize(to: os_unfair_lock())
33
33
  super.init()
34
34
  }
35
-
35
+
36
36
  deinit {
37
37
  _stateLock.deallocate()
38
38
  }
39
-
39
+
40
40
  @objc public func activate() {
41
41
  os_unfair_lock_lock(_stateLock)
42
42
  guard _watchThread == nil else {
43
43
  os_unfair_lock_unlock(_stateLock)
44
44
  return
45
45
  }
46
-
46
+
47
47
  _volatile.running = true
48
48
  _volatile.lastResponse = Date().timeIntervalSince1970
49
-
49
+
50
50
  let t = Thread { [weak self] in self?._watchLoop() }
51
51
  t.name = "co.rejourney.anr"
52
52
  t.qualityOfService = .utility
53
53
  _watchThread = t
54
54
  os_unfair_lock_unlock(_stateLock)
55
-
55
+
56
56
  t.start()
57
57
  }
58
-
58
+
59
59
  @objc public func halt() {
60
60
  os_unfair_lock_lock(_stateLock)
61
61
  _volatile.running = false
62
62
  _watchThread = nil
63
63
  os_unfair_lock_unlock(_stateLock)
64
64
  }
65
-
65
+
66
66
  private func _watchLoop() {
67
67
  while true {
68
68
  os_unfair_lock_lock(_stateLock)
69
69
  let running = _volatile.running
70
70
  os_unfair_lock_unlock(_stateLock)
71
71
  guard running else { break }
72
-
72
+
73
73
  _sendPing()
74
74
  Thread.sleep(forTimeInterval: _pollFrequency)
75
75
  _checkPong()
76
76
  }
77
77
  }
78
-
78
+
79
79
  private func _sendPing() {
80
80
  os_unfair_lock_lock(_stateLock)
81
81
  if _volatile.awaitingPong {
@@ -84,7 +84,7 @@ public final class AnrSentinel: NSObject {
84
84
  }
85
85
  _volatile.awaitingPong = true
86
86
  os_unfair_lock_unlock(_stateLock)
87
-
87
+
88
88
  DispatchQueue.main.async { [weak self] in
89
89
  guard let self else { return }
90
90
  os_unfair_lock_lock(self._stateLock)
@@ -93,30 +93,62 @@ public final class AnrSentinel: NSObject {
93
93
  os_unfair_lock_unlock(self._stateLock)
94
94
  }
95
95
  }
96
-
96
+
97
97
  private func _checkPong() {
98
98
  os_unfair_lock_lock(_stateLock)
99
99
  let awaiting = _volatile.awaitingPong
100
100
  let last = _volatile.lastResponse
101
+ let lastReportedAt = _volatile.lastAnrReport
101
102
  os_unfair_lock_unlock(_stateLock)
102
-
103
+
103
104
  guard awaiting else { return }
104
-
105
- let delta = Date().timeIntervalSince1970 - last
105
+
106
+ let now = Date().timeIntervalSince1970
107
+ let delta = now - last
106
108
  if delta >= _freezeThreshold {
109
+ // Avoid spamming duplicate ANRs while one long freeze persists.
110
+ if now - lastReportedAt < _freezeThreshold {
111
+ return
112
+ }
113
+
114
+ os_unfair_lock_lock(_stateLock)
115
+ _volatile.lastAnrReport = now
116
+ _volatile.lastResponse = now
117
+ _volatile.awaitingPong = false
118
+ os_unfair_lock_unlock(_stateLock)
119
+
107
120
  _reportFreeze(duration: delta)
108
121
  }
109
122
  }
110
-
123
+
111
124
  private func _reportFreeze(duration: TimeInterval) {
112
125
  DiagnosticLog.emit(.caution, "Main thread frozen for \(String(format: "%.1f", duration))s")
113
-
126
+
114
127
  ReplayOrchestrator.shared.incrementStalledTally()
115
-
128
+
116
129
  let trace = Thread.callStackSymbols.joined(separator: "\n")
117
130
  let ms = Int(duration * 1000)
118
-
131
+
119
132
  TelemetryPipeline.shared.recordAnrEvent(durationMs: ms, stack: trace)
133
+
134
+ // Persist ANR incident and send through /api/ingest/fault so ANRs survive
135
+ // process termination/background upload loss, similar to crash recovery.
136
+ let incident = IncidentRecord(
137
+ sessionId: StabilityMonitor.shared.currentSessionId ?? ReplayOrchestrator.shared.replayId ?? "unknown",
138
+ timestampMs: UInt64(Date().timeIntervalSince1970 * 1000),
139
+ category: "anr",
140
+ identifier: "MainThreadFrozen",
141
+ detail: "Main thread unresponsive for \(ms)ms",
142
+ frames: trace
143
+ .split(separator: "\n")
144
+ .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) },
145
+ context: [
146
+ "durationMs": String(ms),
147
+ "threadState": "blocked"
148
+ ]
149
+ )
150
+ StabilityMonitor.shared.persistIncidentSync(incident)
151
+ StabilityMonitor.shared.transmitStoredReport()
120
152
  }
121
153
  }
122
154
 
@@ -124,18 +156,19 @@ private struct VolatileState {
124
156
  var running = false
125
157
  var awaitingPong = false
126
158
  var lastResponse: TimeInterval = 0
159
+ var lastAnrReport: TimeInterval = 0
127
160
  }
128
161
 
129
162
  @objc(ResponsivenessWatcher)
130
163
  public final class ResponsivenessWatcher: NSObject {
131
164
  @objc public static let shared = ResponsivenessWatcher()
132
-
165
+
133
166
  private override init() { super.init() }
134
-
167
+
135
168
  @objc public func activate() {
136
169
  AnrSentinel.shared.activate()
137
170
  }
138
-
171
+
139
172
  @objc public func halt() {
140
173
  AnrSentinel.shared.halt()
141
174
  }
@@ -28,6 +28,7 @@ public final class InteractionRecorder: NSObject {
28
28
  private var _inputObservers = NSMapTable<UITextField, AnyObject>.weakToStrongObjects()
29
29
  private var _navigationStack: [String] = []
30
30
  private let _coalesceWindow: TimeInterval = 0.3
31
+ private var _lastInteractionTimestampMs: UInt64 = 0
31
32
 
32
33
  private override init() {
33
34
  super.init()
@@ -48,6 +49,11 @@ public final class InteractionRecorder: NSObject {
48
49
  _gestureAggregator = nil
49
50
  _inputObservers.removeAllObjects()
50
51
  _navigationStack.removeAll()
52
+ _lastInteractionTimestampMs = 0
53
+ }
54
+
55
+ @objc public func latestInteractionTimestampMs() -> UInt64 {
56
+ _lastInteractionTimestampMs
51
57
  }
52
58
 
53
59
  @objc public func observeTextField(_ field: UITextField) {
@@ -88,6 +94,22 @@ public final class InteractionRecorder: NSObject {
88
94
  @objc public func processRawTouches(_ event: UIEvent, in window: UIWindow) {
89
95
  guard isTracking, let agg = _gestureAggregator else { return }
90
96
  guard let touches = event.allTouches else { return }
97
+ _lastInteractionTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
98
+
99
+ // Notify SpecialCases about touch phases for touch-based map idle detection
100
+ // (used by Mapbox v10+ where SDK idle callbacks can't be hooked).
101
+ for touch in touches {
102
+ switch touch.phase {
103
+ case .began:
104
+ VisualCapture.shared.invalidateMaskCache()
105
+ SpecialCases.shared.notifyTouchBegan()
106
+ case .ended, .cancelled:
107
+ SpecialCases.shared.notifyTouchEnded()
108
+ default:
109
+ break
110
+ }
111
+ }
112
+
91
113
  for touch in touches {
92
114
  agg.processTouch(touch, in: window)
93
115
  }
@@ -304,6 +326,13 @@ private final class GestureAggregator: NSObject {
304
326
  }
305
327
 
306
328
  private func _resolveTarget(at point: CGPoint, in window: UIWindow) -> (label: String, isInteractive: Bool) {
329
+ // When a map view is visible, skip hitTest entirely — performing
330
+ // hitTest on a deep Metal/OpenGL map hierarchy is expensive and
331
+ // causes micro-stutter during pan/zoom gestures.
332
+ if SpecialCases.shared.mapVisible {
333
+ return ("map", false)
334
+ }
335
+
307
336
  guard let hit = window.hitTest(point, with: nil) else { return ("window", false) }
308
337
 
309
338
  let label = hit.accessibilityIdentifier ?? hit.accessibilityLabel ?? String(describing: type(of: hit))
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import Foundation
18
+
19
+ /// Intercepts URLSession network traffic globally for Rejourney Session Replay.
20
+ @objc(RejourneyURLProtocol)
21
+ public class RejourneyURLProtocol: URLProtocol, URLSessionDataDelegate, URLSessionTaskDelegate {
22
+
23
+ // We tag requests that we've already handled so we don't intercept them repeatedly.
24
+ private static let _handledKey = "co.rejourney.handled"
25
+
26
+ private var _dataTask: URLSessionDataTask?
27
+ private var _startMs: Int64 = 0
28
+ private var _endMs: Int64 = 0
29
+ private var _responseData: Data?
30
+ private var _response: URLResponse?
31
+ private var _error: Error?
32
+
33
+ // Session used to forward the intercepted request execution
34
+ private lazy var _session: URLSession = {
35
+ let config = URLSessionConfiguration.default
36
+ return URLSession(configuration: config, delegate: self, delegateQueue: nil)
37
+ }()
38
+
39
+ @objc public static func enable() {
40
+ URLProtocol.registerClass(RejourneyURLProtocol.self)
41
+ // Hook into default session configs
42
+ if let method = class_getInstanceMethod(URLSessionConfiguration.self, #selector(getter: URLSessionConfiguration.protocolClasses)) {
43
+ let original = method_getImplementation(method)
44
+ // Note: Safest swizzling approach for URLSessionConfiguration here is complex,
45
+ // standard approach is registering the protocol which covers shared sessions and simple setups.
46
+ }
47
+ }
48
+
49
+ @objc public static func disable() {
50
+ URLProtocol.unregisterClass(RejourneyURLProtocol.self)
51
+ }
52
+
53
+ public override class func canInit(with request: URLRequest) -> Bool {
54
+ guard let url = request.url,
55
+ let scheme = url.scheme,
56
+ ["http", "https"].contains(scheme) else {
57
+ return false
58
+ }
59
+
60
+ // Prevent infinite loop by not intercepting our own forwarded requests
61
+ if URLProtocol.property(forKey: RejourneyURLProtocol._handledKey, in: request) != nil {
62
+ return false
63
+ }
64
+
65
+ // Ignore requests to the Rejourney API endpoints themselves to prevent ingestion duplication
66
+ if let host = url.host, host.contains("api.rejourney.co") {
67
+ return false
68
+ }
69
+
70
+ return true
71
+ }
72
+
73
+ public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
74
+ return request
75
+ }
76
+
77
+ public override func startLoading() {
78
+ guard let request = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {
79
+ return
80
+ }
81
+
82
+ URLProtocol.setProperty(true, forKey: RejourneyURLProtocol._handledKey, in: request)
83
+
84
+ _startMs = Int64(Date().timeIntervalSince1970 * 1000)
85
+ _dataTask = _session.dataTask(with: request as URLRequest)
86
+ _dataTask?.resume()
87
+ }
88
+
89
+ public override func stopLoading() {
90
+ _dataTask?.cancel()
91
+ _dataTask = nil
92
+ }
93
+
94
+ // MARK: - URLSessionDataDelegate
95
+
96
+ public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
97
+ client?.urlProtocol(self, didLoad: data)
98
+ }
99
+
100
+ public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
101
+ _response = response
102
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
103
+ completionHandler(.allow)
104
+ }
105
+
106
+ // MARK: - URLSessionTaskDelegate
107
+
108
+ public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
109
+ _endMs = Int64(Date().timeIntervalSince1970 * 1000)
110
+ _error = error
111
+
112
+ if let error = error {
113
+ client?.urlProtocol(self, didFailWithError: error)
114
+ } else {
115
+ client?.urlProtocolDidFinishLoading(self)
116
+ }
117
+
118
+ _logRequest(task: task)
119
+ }
120
+
121
+ private func _logRequest(task: URLSessionTask) {
122
+ guard let req = task.originalRequest, let url = req.url else { return }
123
+
124
+ let duration = _endMs - _startMs
125
+ let isSuccess = _error == nil && ((_response as? HTTPURLResponse)?.statusCode ?? 0) < 400
126
+ let statusCode = (_response as? HTTPURLResponse)?.statusCode ?? 0
127
+ let method = req.httpMethod ?? "GET"
128
+
129
+ var urlStr = url.absoluteString
130
+ if urlStr.count > 300 {
131
+ urlStr = String(urlStr.prefix(300))
132
+ }
133
+
134
+ let pathStr = url.path
135
+ let reqSize = req.httpBody?.count ?? 0
136
+ let resSize = task.countOfBytesReceived
137
+
138
+ var event: [String: Any] = [
139
+ "requestId": "n_\(UUID().uuidString)",
140
+ "method": method,
141
+ "url": urlStr,
142
+ "urlPath": pathStr.isEmpty ? "/" : pathStr,
143
+ "urlHost": url.host ?? "",
144
+ "statusCode": statusCode,
145
+ "duration": duration,
146
+ "startTimestamp": _startMs,
147
+ "endTimestamp": _endMs,
148
+ "success": isSuccess
149
+ ]
150
+
151
+ if reqSize > 0 { event["requestBodySize"] = reqSize }
152
+ if resSize > 0 { event["responseBodySize"] = resSize }
153
+
154
+ if let cType = req.value(forHTTPHeaderField: "Content-Type") {
155
+ event["requestContentType"] = cType
156
+ }
157
+
158
+ if let hr = _response as? HTTPURLResponse, let cType = hr.value(forHTTPHeaderField: "Content-Type") {
159
+ event["responseContentType"] = cType
160
+ }
161
+
162
+ if let e = _error {
163
+ event["errorMessage"] = e.localizedDescription
164
+ }
165
+
166
+ TelemetryPipeline.shared.recordNetworkEvent(details: event)
167
+ }
168
+ }