@rejourneyco/react-native 1.0.8 → 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 (42) hide show
  1. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +89 -8
  2. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  3. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  4. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  5. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +3 -1
  6. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  7. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +222 -145
  8. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +4 -0
  9. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  10. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +13 -0
  11. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  12. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +95 -21
  13. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  14. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  15. package/ios/Engine/DeviceRegistrar.swift +13 -3
  16. package/ios/Engine/RejourneyImpl.swift +199 -115
  17. package/ios/Recording/AnrSentinel.swift +58 -25
  18. package/ios/Recording/InteractionRecorder.swift +1 -0
  19. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  20. package/ios/Recording/ReplayOrchestrator.swift +204 -143
  21. package/ios/Recording/SegmentDispatcher.swift +8 -0
  22. package/ios/Recording/StabilityMonitor.swift +40 -32
  23. package/ios/Recording/TelemetryPipeline.swift +17 -0
  24. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  25. package/ios/Recording/VisualCapture.swift +54 -8
  26. package/ios/Rejourney.mm +27 -8
  27. package/ios/Utility/ImageBlur.swift +0 -1
  28. package/lib/commonjs/index.js +28 -15
  29. package/lib/commonjs/sdk/autoTracking.js +162 -11
  30. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  31. package/lib/module/index.js +28 -15
  32. package/lib/module/sdk/autoTracking.js +162 -11
  33. package/lib/module/sdk/networkInterceptor.js +84 -4
  34. package/lib/typescript/NativeRejourney.d.ts +5 -2
  35. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  36. package/lib/typescript/types/index.d.ts +14 -2
  37. package/package.json +4 -4
  38. package/src/NativeRejourney.ts +8 -5
  39. package/src/index.ts +37 -19
  40. package/src/sdk/autoTracking.ts +176 -11
  41. package/src/sdk/networkInterceptor.ts +110 -1
  42. package/src/types/index.ts +15 -3
@@ -173,6 +173,8 @@ final class SegmentDispatcher {
173
173
  backgroundDurationMs: UInt64,
174
174
  metrics: [String: Any]?,
175
175
  currentQueueDepth: Int = 0,
176
+ endReason: String? = nil,
177
+ lifecycleVersion: Int? = nil,
176
178
  completion: @escaping (Bool) -> Void
177
179
  ) {
178
180
  guard let url = URL(string: "\(endpoint)/api/ingest/session/end") else {
@@ -190,6 +192,12 @@ final class SegmentDispatcher {
190
192
  if backgroundDurationMs > 0 { body["totalBackgroundTimeMs"] = backgroundDurationMs }
191
193
  if let m = metrics { body["metrics"] = m }
192
194
  body["sdkTelemetry"] = sdkTelemetrySnapshot(currentQueueDepth: currentQueueDepth)
195
+ if let endReason, !endReason.isEmpty {
196
+ body["endReason"] = endReason
197
+ }
198
+ if let lifecycleVersion, lifecycleVersion > 0 {
199
+ body["lifecycleVersion"] = lifecycleVersion
200
+ }
193
201
 
194
202
  do {
195
203
  req.httpBody = try JSONSerialization.data(withJSONObject: body)
@@ -38,7 +38,7 @@ private func _rjSignalHandler(_ signum: Int32) {
38
38
  case SIGTRAP: name = "SIGTRAP"
39
39
  default: name = "SIG\(signum)"
40
40
  }
41
-
41
+
42
42
  let incident = IncidentRecord(
43
43
  sessionId: StabilityMonitor.shared.currentSessionId ?? "unknown",
44
44
  timestampMs: UInt64(Date().timeIntervalSince1970 * 1000),
@@ -52,60 +52,63 @@ private func _rjSignalHandler(_ signum: Int32) {
52
52
  "priority": String(format: "%.2f", Thread.current.threadPriority)
53
53
  ]
54
54
  )
55
-
55
+
56
56
  ReplayOrchestrator.shared.incrementFaultTally()
57
57
  StabilityMonitor.shared.persistIncidentSync(incident)
58
-
58
+
59
+ // Flush visual frames to disk for crash safety
60
+ VisualCapture.shared.flushToDisk()
61
+
59
62
  signal(signum, SIG_DFL)
60
63
  raise(signum)
61
64
  }
62
65
 
63
66
  @objc(StabilityMonitor)
64
67
  public final class StabilityMonitor: NSObject {
65
-
68
+
66
69
  @objc public static let shared = StabilityMonitor()
67
70
  @objc public var isMonitoring = false
68
71
  @objc public var currentSessionId: String?
69
-
72
+
70
73
  private let _incidentStore: URL
71
74
  private let _workerQueue = DispatchQueue(label: "co.rejourney.stability", qos: .utility)
72
-
75
+
73
76
  private static var _chainedExceptionHandler: NSUncaughtExceptionHandler?
74
77
  private static var _chainedSignalHandlers: [Int32: sig_t] = [:]
75
78
  private static let _trackedSignals: [Int32] = [SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV, SIGTRAP]
76
-
79
+
77
80
  private override init() {
78
81
  let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
79
82
  _incidentStore = cache.appendingPathComponent("rj_incidents.json")
80
83
  super.init()
81
84
  }
82
-
85
+
83
86
  @objc public func activate() {
84
87
  guard !isMonitoring else { return }
85
88
  isMonitoring = true
86
-
89
+
87
90
  StabilityMonitor._chainedExceptionHandler = NSGetUncaughtExceptionHandler()
88
91
  NSSetUncaughtExceptionHandler { ex in
89
92
  StabilityMonitor.shared._captureException(ex)
90
93
  StabilityMonitor._chainedExceptionHandler?(ex)
91
94
  }
92
-
95
+
93
96
  for sig in StabilityMonitor._trackedSignals {
94
97
  StabilityMonitor._chainedSignalHandlers[sig] = signal(sig, _rjSignalHandler)
95
98
  }
96
-
99
+
97
100
  _workerQueue.async { [weak self] in
98
101
  self?._uploadStoredIncidents()
99
102
  }
100
103
  }
101
-
104
+
102
105
  @objc public func deactivate() {
103
106
  guard isMonitoring else { return }
104
107
  isMonitoring = false
105
-
108
+
106
109
  NSSetUncaughtExceptionHandler(nil)
107
110
  StabilityMonitor._chainedExceptionHandler = nil
108
-
111
+
109
112
  for sig in StabilityMonitor._trackedSignals {
110
113
  if let prev = StabilityMonitor._chainedSignalHandlers[sig] {
111
114
  signal(sig, prev)
@@ -115,13 +118,13 @@ public final class StabilityMonitor: NSObject {
115
118
  }
116
119
  StabilityMonitor._chainedSignalHandlers.removeAll()
117
120
  }
118
-
121
+
119
122
  @objc public func transmitStoredReport() {
120
123
  _workerQueue.async { [weak self] in
121
124
  self?._uploadStoredIncidents()
122
125
  }
123
126
  }
124
-
127
+
125
128
  private func _captureException(_ exception: NSException) {
126
129
  let incident = IncidentRecord(
127
130
  sessionId: currentSessionId ?? "unknown",
@@ -132,12 +135,16 @@ public final class StabilityMonitor: NSObject {
132
135
  frames: _formatFrames(exception.callStackSymbols),
133
136
  context: _captureContext()
134
137
  )
135
-
138
+
136
139
  ReplayOrchestrator.shared.incrementFaultTally()
137
140
  _persistIncident(incident)
141
+
142
+ // Flush visual frames to disk for crash safety
143
+ VisualCapture.shared.flushToDisk()
144
+
138
145
  Thread.sleep(forTimeInterval: 0.15)
139
146
  }
140
-
147
+
141
148
  func persistIncidentSync(_ incident: IncidentRecord) {
142
149
  do {
143
150
  let data = try JSONEncoder().encode(incident)
@@ -146,11 +153,11 @@ public final class StabilityMonitor: NSObject {
146
153
  DiagnosticLog.fault("[StabilityMonitor] Incident persist failed: \(error)")
147
154
  }
148
155
  }
149
-
156
+
150
157
  private func _formatFrames(_ raw: [String]) -> [String] {
151
158
  raw.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
152
159
  }
153
-
160
+
154
161
  private func _captureContext() -> [String: String] {
155
162
  [
156
163
  "threadName": Thread.current.name ?? "unnamed",
@@ -158,7 +165,7 @@ public final class StabilityMonitor: NSObject {
158
165
  "priority": String(format: "%.2f", Thread.current.threadPriority)
159
166
  ]
160
167
  }
161
-
168
+
162
169
  private func _persistIncident(_ incident: IncidentRecord) {
163
170
  do {
164
171
  let data = try JSONEncoder().encode(incident)
@@ -167,39 +174,40 @@ public final class StabilityMonitor: NSObject {
167
174
  DiagnosticLog.fault("[StabilityMonitor] Incident persist failed: \(error)")
168
175
  }
169
176
  }
170
-
177
+
171
178
  private func _uploadStoredIncidents() {
172
179
  guard FileManager.default.fileExists(atPath: _incidentStore.path),
173
180
  let data = try? Data(contentsOf: _incidentStore),
174
181
  let incident = try? JSONDecoder().decode(IncidentRecord.self, from: data) else { return }
175
-
182
+
176
183
  _transmitIncident(incident) { [weak self] ok in
177
- if ok { try? FileManager.default.removeItem(at: self!._incidentStore) }
184
+ guard ok, let self else { return }
185
+ try? FileManager.default.removeItem(at: self._incidentStore)
178
186
  }
179
187
  }
180
-
188
+
181
189
  private func _transmitIncident(_ incident: IncidentRecord, completion: @escaping (Bool) -> Void) {
182
190
  let base = SegmentDispatcher.shared.endpoint
183
191
  guard let url = URL(string: "\(base)/api/ingest/fault") else {
184
192
  completion(false)
185
193
  return
186
194
  }
187
-
195
+
188
196
  var req = URLRequest(url: url)
189
197
  req.httpMethod = "POST"
190
198
  req.setValue("application/json", forHTTPHeaderField: "Content-Type")
191
-
199
+
192
200
  if let key = SegmentDispatcher.shared.apiToken {
193
201
  req.setValue(key, forHTTPHeaderField: "x-rejourney-key")
194
202
  }
195
-
203
+
196
204
  do {
197
205
  req.httpBody = try JSONEncoder().encode(incident)
198
206
  } catch {
199
207
  completion(false)
200
208
  return
201
209
  }
202
-
210
+
203
211
  URLSession.shared.dataTask(with: req) { _, resp, _ in
204
212
  let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
205
213
  completion(code >= 200 && code < 300)
@@ -210,13 +218,13 @@ public final class StabilityMonitor: NSObject {
210
218
  @objc(FaultTracker)
211
219
  public final class FaultTracker: NSObject {
212
220
  @objc public static let shared = FaultTracker()
213
-
221
+
214
222
  private override init() { super.init() }
215
-
223
+
216
224
  @objc public func activate() {
217
225
  StabilityMonitor.shared.activate()
218
226
  }
219
-
227
+
220
228
  @objc public func deactivate() {
221
229
  StabilityMonitor.shared.deactivate()
222
230
  }
@@ -306,6 +306,15 @@ public final class TelemetryPipeline: NSObject {
306
306
  _enqueue(["type": "custom", "timestamp": _ts(), "name": name, "payload": payload])
307
307
  }
308
308
 
309
+ @objc public func recordConsoleLogEvent(level: String, message: String) {
310
+ _enqueue([
311
+ "type": "log",
312
+ "timestamp": _ts(),
313
+ "level": level,
314
+ "message": message
315
+ ])
316
+ }
317
+
309
318
  @objc public func recordJSErrorEvent(name: String, message: String, stack: String?) {
310
319
  var event: [String: Any] = [
311
320
  "type": "error",
@@ -317,6 +326,10 @@ public final class TelemetryPipeline: NSObject {
317
326
  event["stack"] = stack
318
327
  }
319
328
  _enqueue(event)
329
+ // Prioritize JS error delivery to reduce loss on fatal terminations.
330
+ _serialWorker.async { [weak self] in
331
+ self?._shipPendingEvents()
332
+ }
320
333
  }
321
334
 
322
335
  @objc public func recordAnrEvent(durationMs: Int, stack: String?) {
@@ -330,6 +343,10 @@ public final class TelemetryPipeline: NSObject {
330
343
  event["stack"] = stack
331
344
  }
332
345
  _enqueue(event)
346
+ // Prioritize ANR delivery while the process is still alive.
347
+ _serialWorker.async { [weak self] in
348
+ self?._shipPendingEvents()
349
+ }
333
350
  }
334
351
 
335
352
  @objc public func recordUserAssociation(_ userId: String) {
@@ -136,6 +136,7 @@ import UIKit
136
136
  private func _typeName(_ v: UIView) -> String { String(describing: type(of: v)) }
137
137
 
138
138
  private func _isSensitive(_ v: UIView) -> Bool {
139
+ if v.accessibilityHint == "rejourney_occlude" { return true }
139
140
  if let tf = v as? UITextField, tf.isSecureTextEntry { return true }
140
141
  return false
141
142
  }
@@ -162,6 +162,11 @@ public final class VisualCapture: NSObject {
162
162
  _redactionMask.remove(view)
163
163
  }
164
164
 
165
+ @objc public func invalidateMaskCache() {
166
+ _redactionMask.invalidateCache()
167
+ }
168
+
169
+
165
170
  @objc public func configure(snapshotInterval: Double, jpegQuality: Double, captureScale: CGFloat = 1.25) {
166
171
  self.snapshotInterval = snapshotInterval
167
172
  self.quality = CGFloat(jpegQuality)
@@ -374,10 +379,20 @@ public final class VisualCapture: NSObject {
374
379
 
375
380
  /// Load and upload any pending frames from disk for a session
376
381
  @objc public func uploadPendingFrames(sessionId: String) {
377
- guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
382
+ uploadPendingFrames(sessionId: sessionId, completion: nil)
383
+ }
384
+
385
+ public func uploadPendingFrames(sessionId: String, completion: ((Bool) -> Void)? = nil) {
386
+ guard let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
387
+ completion?(false)
388
+ return
389
+ }
378
390
  let framesPath = cacheDir.appendingPathComponent("rj_pending").appendingPathComponent(sessionId).appendingPathComponent("frames")
379
391
 
380
- guard let frameFiles = try? FileManager.default.contentsOfDirectory(at: framesPath, includingPropertiesForKeys: nil) else { return }
392
+ guard let frameFiles = try? FileManager.default.contentsOfDirectory(at: framesPath, includingPropertiesForKeys: nil) else {
393
+ completion?(true)
394
+ return
395
+ }
381
396
 
382
397
  var frames: [(Data, UInt64)] = []
383
398
  for file in frameFiles.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
@@ -392,18 +407,24 @@ public final class VisualCapture: NSObject {
392
407
  frames.append((data, ts))
393
408
  }
394
409
 
395
- guard !frames.isEmpty, let bundle = _packageFrameBundle(images: frames, sessionEpoch: frames.first?.1 ?? 0) else { return }
410
+ guard !frames.isEmpty, let bundle = _packageFrameBundle(images: frames, sessionEpoch: frames.first?.1 ?? 0) else {
411
+ completion?(frames.isEmpty)
412
+ return
413
+ }
396
414
 
397
415
  let endTs = frames.last?.1 ?? 0
398
- let fname = "\(sessionId)-\(endTs).tar.gz"
399
416
 
400
- TelemetryPipeline.shared.submitFrameBundle(
417
+ SegmentDispatcher.shared.transmitFrameBundle(
401
418
  payload: bundle,
402
- filename: fname,
403
419
  startMs: frames.first?.1 ?? 0,
404
420
  endMs: endTs,
405
421
  frameCount: frames.count
406
- )
422
+ ) { ok in
423
+ if ok {
424
+ try? FileManager.default.removeItem(at: framesPath)
425
+ }
426
+ completion?(ok)
427
+ }
407
428
  }
408
429
 
409
430
  /// Clear pending frames for a session after successful upload
@@ -512,7 +533,28 @@ private final class RedactionMask {
512
533
  // sensitive views (text inputs, cameras) don't appear/disappear at 3fps.
513
534
  private var _cachedAutoRects: [CGRect] = []
514
535
  private var _lastScanTime: CFAbsoluteTime = 0
515
- private let _scanCacheDurationSec: CFAbsoluteTime = 1.0
536
+ private let _scanCacheDurationSec: CFAbsoluteTime = 0.5
537
+
538
+ private var _observers: [Any] = []
539
+
540
+ init() {
541
+ _observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
542
+ _observers.append(NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
543
+ _observers.append(NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
544
+ _observers.append(NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: nil, queue: nil) { [weak self] _ in self?.invalidateCache() })
545
+ }
546
+
547
+ deinit {
548
+ for observer in _observers {
549
+ NotificationCenter.default.removeObserver(observer)
550
+ }
551
+ }
552
+
553
+ func invalidateCache() {
554
+ _lock.lock()
555
+ _lastScanTime = 0
556
+ _lock.unlock()
557
+ }
516
558
 
517
559
  // View class names that should always be masked (privacy sensitive)
518
560
  private let _sensitiveClassNames: Set<String> = [
@@ -668,6 +710,10 @@ private final class RedactionMask {
668
710
  }
669
711
 
670
712
  private func _shouldMask(_ view: UIView) -> Bool {
713
+ if view.accessibilityHint == "rejourney_occlude" {
714
+ return true
715
+ }
716
+
671
717
  // 1. Mask ALL text input fields by default (privacy first)
672
718
  // This includes password fields, instructions, notes, etc.
673
719
  if view is UITextField {
package/ios/Rejourney.mm CHANGED
@@ -70,11 +70,11 @@ RCT_EXPORT_MODULE()
70
70
 
71
71
  #pragma mark - Tap Event Emission (no-ops, dead tap detection is native-side)
72
72
 
73
- RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {
73
+ RCT_EXPORT_METHOD(addListener : (NSString *)eventName) {
74
74
  // No-op: dead tap detection is handled natively in TelemetryPipeline
75
75
  }
76
76
 
77
- RCT_EXPORT_METHOD(removeListeners:(double)count) {
77
+ RCT_EXPORT_METHOD(removeListeners : (double)count) {
78
78
  // No-op: dead tap detection is handled natively in TelemetryPipeline
79
79
  }
80
80
 
@@ -195,6 +195,26 @@ RCT_EXPORT_METHOD(getUserIdentity : (RCTPromiseResolveBlock)
195
195
  [impl getUserIdentity:resolve reject:reject];
196
196
  }
197
197
 
198
+ RCT_EXPORT_METHOD(setAnonymousId : (NSString *)anonymousId resolve : (
199
+ RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) {
200
+ RejourneyImpl *impl = [self ensureImpl];
201
+ if (!impl) {
202
+ resolve(@{@"success" : @NO});
203
+ return;
204
+ }
205
+ [impl setAnonymousId:anonymousId resolve:resolve reject:reject];
206
+ }
207
+
208
+ RCT_EXPORT_METHOD(getAnonymousId : (RCTPromiseResolveBlock)
209
+ resolve reject : (RCTPromiseRejectBlock)reject) {
210
+ RejourneyImpl *impl = [self ensureImpl];
211
+ if (!impl) {
212
+ resolve([NSNull null]);
213
+ return;
214
+ }
215
+ [impl getAnonymousId:resolve reject:reject];
216
+ }
217
+
198
218
  #pragma mark - Events and Tracking
199
219
 
200
220
  RCT_EXPORT_METHOD(logEvent : (NSString *)eventType details : (NSDictionary *)
@@ -312,12 +332,11 @@ RCT_EXPORT_METHOD(setDebugMode : (BOOL)enabled resolve : (
312
332
  [impl setDebugMode:enabled resolve:resolve reject:reject];
313
333
  }
314
334
 
315
- RCT_EXPORT_METHOD(setRemoteConfig : (BOOL)rejourneyEnabled
316
- recordingEnabled : (BOOL)recordingEnabled
317
- sampleRate : (double)sampleRate
318
- maxRecordingMinutes : (double)maxRecordingMinutes
319
- resolve : (RCTPromiseResolveBlock)resolve
320
- reject : (RCTPromiseRejectBlock)reject) {
335
+ RCT_EXPORT_METHOD(setRemoteConfig : (BOOL)rejourneyEnabled recordingEnabled : (
336
+ BOOL)recordingEnabled sampleRate : (double)
337
+ sampleRate maxRecordingMinutes : (double)
338
+ maxRecordingMinutes resolve : (RCTPromiseResolveBlock)
339
+ resolve reject : (RCTPromiseRejectBlock)reject) {
321
340
  RejourneyImpl *impl = [self ensureImpl];
322
341
  if (!impl) {
323
342
  resolve(@{@"success" : @NO});
@@ -18,7 +18,6 @@ import UIKit
18
18
  import Accelerate
19
19
 
20
20
  extension UIImage {
21
- /// Apply Gaussian blur to the image
22
21
  func gaussianBlur(radius: CGFloat) -> UIImage? {
23
22
  guard let cgImage = cgImage else { return nil }
24
23
 
@@ -680,6 +680,7 @@ const Rejourney = {
680
680
  trackJSErrors: true,
681
681
  trackPromiseRejections: true,
682
682
  trackReactNativeErrors: true,
683
+ trackConsoleLogs: _storedConfig?.trackConsoleLogs ?? true,
683
684
  collectDeviceInfo: _storedConfig?.collectDeviceInfo !== false
684
685
  }, {
685
686
  // Rage tap callback - log as frustration event
@@ -692,13 +693,8 @@ const Rejourney = {
692
693
  });
693
694
  getLogger().logFrustration(`Rage tap (${count} taps)`);
694
695
  },
695
- // Error callback - log as error event
696
+ // Error callback - SDK forwarding is handled in autoTracking.trackError
696
697
  onError: error => {
697
- this.logEvent('error', {
698
- message: error.message,
699
- stack: error.stack,
700
- name: error.name
701
- });
702
698
  getLogger().logError(error.message);
703
699
  },
704
700
  onScreen: (_screenName, _previousScreen) => {}
@@ -713,6 +709,11 @@ const Rejourney = {
713
709
  }
714
710
  if (_storedConfig?.autoTrackNetwork !== false) {
715
711
  try {
712
+ // JS-level fetch/XHR patching is the primary mechanism for capturing network
713
+ // calls within React Native. Native interceptors (RejourneyURLProtocol on iOS,
714
+ // RejourneyNetworkInterceptor on Android) are supplementary — they capture
715
+ // native-originated HTTP calls that bypass JS fetch(), but cannot intercept
716
+ // RN's own networking since it creates its NSURLSession/OkHttpClient at init time.
716
717
  const ignoreUrls = [apiUrl, '/api/sdk/config', '/api/ingest/presign', '/api/ingest/batch/complete', '/api/ingest/session/end', ...(_storedConfig?.networkIgnoreUrls || [])];
717
718
  getNetworkInterceptor().initNetworkInterceptor(request => {
718
719
  getAutoTracking().trackAPIRequest(request.success || false, request.statusCode, request.duration || 0, request.responseBodySize || 0);
@@ -1110,6 +1111,23 @@ const Rejourney = {
1110
1111
  getRejourneyNative().logEvent('network_request', networkEvent).catch(() => {});
1111
1112
  }, undefined);
1112
1113
  },
1114
+ /**
1115
+ * Log customer feedback (e.g. from an in-app survey or NPS widget).
1116
+ *
1117
+ * @param rating - Numeric rating (e.g. 1 to 5)
1118
+ * @param message - Associated feedback text or comment
1119
+ */
1120
+ logFeedback(rating, message) {
1121
+ safeNativeCallSync('logFeedback', () => {
1122
+ const feedbackEvent = {
1123
+ type: 'feedback',
1124
+ timestamp: Date.now(),
1125
+ rating,
1126
+ message
1127
+ };
1128
+ getRejourneyNative().logEvent('feedback', feedbackEvent).catch(() => {});
1129
+ }, undefined);
1130
+ },
1113
1131
  /**
1114
1132
  * Get SDK telemetry metrics for observability
1115
1133
  *
@@ -1145,17 +1163,12 @@ const Rejourney = {
1145
1163
  });
1146
1164
  },
1147
1165
  /**
1148
- * Trigger a debug ANR (Dev only)
1149
- * Blocks the main thread for the specified duration
1166
+ * Trigger an ANR test by blocking the main thread for the specified duration.
1150
1167
  */
1151
1168
  debugTriggerANR(durationMs) {
1152
- if (__DEV__) {
1153
- safeNativeCallSync('debugTriggerANR', () => {
1154
- getRejourneyNative().debugTriggerANR(durationMs);
1155
- }, undefined);
1156
- } else {
1157
- getLogger().warn('debugTriggerANR is only available in development mode');
1158
- }
1169
+ safeNativeCallSync('debugTriggerANR', () => {
1170
+ getRejourneyNative().debugTriggerANR(durationMs);
1171
+ }, undefined);
1159
1172
  },
1160
1173
  /**
1161
1174
  * Mask a view by its nativeID prop (will be occluded in recordings)