@scalebun/react-native 1.0.3 → 1.1.0

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 (244) hide show
  1. package/android/src/main/java/com/scalebun/core/crash/CrashStorage.kt +115 -1
  2. package/android/src/main/java/com/scalebun/core/crash/NativeCrashHandler.kt +14 -0
  3. package/android/src/main/java/com/scalebun/replaysdk/ReplaySdkModule.kt +25 -9
  4. package/android/src/main/java/com/scalebun/replaysdk/voice/VoiceTranscriber.kt +68 -2
  5. package/android/src/main/java/com/scalebun/rn/crash/ScaleBunCrashModule.kt +66 -2
  6. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +70 -4
  7. package/ios/Crash/ScaleBunCrashBridge.mm +6 -0
  8. package/ios/Crash/ScaleBunCrashHandler.swift +14 -1
  9. package/ios/Crash/ScaleBunCrashModule.swift +14 -0
  10. package/ios/Crash/ScaleBunCrashSignalBridge.h +32 -1
  11. package/ios/Crash/ScaleBunCrashSignalBridge.m +172 -7
  12. package/ios/Crash/ScaleBunCrashSignalStress.c +137 -0
  13. package/ios/Crash/ScaleBunCrashStorage.swift +75 -0
  14. package/lib/commonjs/analytics/EventTracker.js +30 -2
  15. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  16. package/lib/commonjs/bootstrap/SDKBootstrapper.js +31 -23
  17. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  18. package/lib/commonjs/compat/codepush.js +78 -15
  19. package/lib/commonjs/compat/codepush.js.map +1 -1
  20. package/lib/commonjs/core/config/schema.js +26 -5
  21. package/lib/commonjs/core/config/schema.js.map +1 -1
  22. package/lib/commonjs/core/constants/endpoints.js +9 -6
  23. package/lib/commonjs/core/constants/endpoints.js.map +1 -1
  24. package/lib/commonjs/core/context/device.js +130 -0
  25. package/lib/commonjs/core/context/device.js.map +1 -1
  26. package/lib/commonjs/debug/replayWiring.js +2 -1
  27. package/lib/commonjs/debug/replayWiring.js.map +1 -1
  28. package/lib/commonjs/features/bugreport/BugReportFeature.js +112 -0
  29. package/lib/commonjs/features/bugreport/BugReportFeature.js.map +1 -0
  30. package/lib/commonjs/features/bugreport/buildPayload.js +97 -0
  31. package/lib/commonjs/features/bugreport/buildPayload.js.map +1 -0
  32. package/lib/commonjs/features/crash/CrashReporter.js +25 -1
  33. package/lib/commonjs/features/crash/CrashReporter.js.map +1 -1
  34. package/lib/commonjs/features/crash/capture.js +27 -9
  35. package/lib/commonjs/features/crash/capture.js.map +1 -1
  36. package/lib/commonjs/features/crash/nativeCrashBridge.js +163 -28
  37. package/lib/commonjs/features/crash/nativeCrashBridge.js.map +1 -1
  38. package/lib/commonjs/features/engage/EngagePromptProvider.js +36 -2
  39. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  40. package/lib/commonjs/features/engage/EngagePromptView.js +47 -14
  41. package/lib/commonjs/features/engage/EngagePromptView.js.map +1 -1
  42. package/lib/commonjs/features/engage/engageTriggerEngine.js +10 -0
  43. package/lib/commonjs/features/engage/engageTriggerEngine.js.map +1 -1
  44. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  45. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +2 -1
  46. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js.map +1 -1
  47. package/lib/commonjs/features/ota/OtaOrchestrator.js +72 -5
  48. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  49. package/lib/commonjs/features/ota/deviceAttributes.js +48 -0
  50. package/lib/commonjs/features/ota/deviceAttributes.js.map +1 -0
  51. package/lib/commonjs/features/ota/geoCountry.js +93 -0
  52. package/lib/commonjs/features/ota/geoCountry.js.map +1 -0
  53. package/lib/commonjs/features/ota/useOtaUpdate.js +4 -1
  54. package/lib/commonjs/features/ota/useOtaUpdate.js.map +1 -1
  55. package/lib/commonjs/features/performance/PerformanceFeature.js +23 -9
  56. package/lib/commonjs/features/performance/PerformanceFeature.js.map +1 -1
  57. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +69 -9
  58. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  59. package/lib/commonjs/features/performance/transport/PerformanceTransport.js +25 -0
  60. package/lib/commonjs/features/performance/transport/PerformanceTransport.js.map +1 -1
  61. package/lib/commonjs/features/session/BackendSessionAdapter.js +128 -11
  62. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  63. package/lib/commonjs/features/session/JourneyEventPipeline.js +1 -0
  64. package/lib/commonjs/features/session/JourneyEventPipeline.js.map +1 -1
  65. package/lib/commonjs/features/session/ReplayCaptureManager.js +46 -0
  66. package/lib/commonjs/features/session/ReplayCaptureManager.js.map +1 -1
  67. package/lib/commonjs/features/session/SessionManager.js +42 -12
  68. package/lib/commonjs/features/session/SessionManager.js.map +1 -1
  69. package/lib/commonjs/features/session/nativeCapture.js +4 -4
  70. package/lib/commonjs/features/session/nativeCapture.js.map +1 -1
  71. package/lib/commonjs/features/session/outboxFrameCapture.js +3 -2
  72. package/lib/commonjs/features/session/outboxFrameCapture.js.map +1 -1
  73. package/lib/commonjs/features/session/sessionTypes.js.map +1 -1
  74. package/lib/commonjs/index.js +13 -0
  75. package/lib/commonjs/index.js.map +1 -1
  76. package/lib/commonjs/public/ScaleBunFacade.js +199 -2
  77. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  78. package/lib/commonjs/specs/NativeScaleBunCrash.js.map +1 -1
  79. package/lib/module/analytics/EventTracker.js +29 -1
  80. package/lib/module/analytics/EventTracker.js.map +1 -1
  81. package/lib/module/bootstrap/SDKBootstrapper.js +29 -21
  82. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  83. package/lib/module/compat/codepush.js +77 -14
  84. package/lib/module/compat/codepush.js.map +1 -1
  85. package/lib/module/core/config/schema.js +26 -5
  86. package/lib/module/core/config/schema.js.map +1 -1
  87. package/lib/module/core/constants/endpoints.js +9 -6
  88. package/lib/module/core/constants/endpoints.js.map +1 -1
  89. package/lib/module/core/context/device.js +119 -0
  90. package/lib/module/core/context/device.js.map +1 -1
  91. package/lib/module/debug/replayWiring.js +2 -1
  92. package/lib/module/debug/replayWiring.js.map +1 -1
  93. package/lib/module/features/bugreport/BugReportFeature.js +105 -0
  94. package/lib/module/features/bugreport/BugReportFeature.js.map +1 -0
  95. package/lib/module/features/bugreport/buildPayload.js +91 -0
  96. package/lib/module/features/bugreport/buildPayload.js.map +1 -0
  97. package/lib/module/features/crash/CrashReporter.js +25 -1
  98. package/lib/module/features/crash/CrashReporter.js.map +1 -1
  99. package/lib/module/features/crash/capture.js +28 -9
  100. package/lib/module/features/crash/capture.js.map +1 -1
  101. package/lib/module/features/crash/nativeCrashBridge.js +161 -28
  102. package/lib/module/features/crash/nativeCrashBridge.js.map +1 -1
  103. package/lib/module/features/engage/EngagePromptProvider.js +36 -2
  104. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  105. package/lib/module/features/engage/EngagePromptView.js +47 -14
  106. package/lib/module/features/engage/EngagePromptView.js.map +1 -1
  107. package/lib/module/features/engage/engageTriggerEngine.js +10 -0
  108. package/lib/module/features/engage/engageTriggerEngine.js.map +1 -1
  109. package/lib/module/features/engage/engageTypes.js.map +1 -1
  110. package/lib/module/features/journey/ScaleBunDebugRoot.js +2 -1
  111. package/lib/module/features/journey/ScaleBunDebugRoot.js.map +1 -1
  112. package/lib/module/features/ota/OtaOrchestrator.js +72 -5
  113. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  114. package/lib/module/features/ota/deviceAttributes.js +43 -0
  115. package/lib/module/features/ota/deviceAttributes.js.map +1 -0
  116. package/lib/module/features/ota/geoCountry.js +86 -0
  117. package/lib/module/features/ota/geoCountry.js.map +1 -0
  118. package/lib/module/features/ota/useOtaUpdate.js +4 -1
  119. package/lib/module/features/ota/useOtaUpdate.js.map +1 -1
  120. package/lib/module/features/performance/PerformanceFeature.js +23 -9
  121. package/lib/module/features/performance/PerformanceFeature.js.map +1 -1
  122. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +70 -10
  123. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  124. package/lib/module/features/performance/transport/PerformanceTransport.js +25 -0
  125. package/lib/module/features/performance/transport/PerformanceTransport.js.map +1 -1
  126. package/lib/module/features/session/BackendSessionAdapter.js +128 -11
  127. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  128. package/lib/module/features/session/JourneyEventPipeline.js +1 -0
  129. package/lib/module/features/session/JourneyEventPipeline.js.map +1 -1
  130. package/lib/module/features/session/ReplayCaptureManager.js +46 -0
  131. package/lib/module/features/session/ReplayCaptureManager.js.map +1 -1
  132. package/lib/module/features/session/SessionManager.js +42 -12
  133. package/lib/module/features/session/SessionManager.js.map +1 -1
  134. package/lib/module/features/session/nativeCapture.js +4 -5
  135. package/lib/module/features/session/nativeCapture.js.map +1 -1
  136. package/lib/module/features/session/outboxFrameCapture.js +3 -2
  137. package/lib/module/features/session/outboxFrameCapture.js.map +1 -1
  138. package/lib/module/features/session/sessionTypes.js.map +1 -1
  139. package/lib/module/index.js +4 -1
  140. package/lib/module/index.js.map +1 -1
  141. package/lib/module/public/ScaleBunFacade.js +199 -2
  142. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  143. package/lib/module/specs/NativeScaleBunCrash.js.map +1 -1
  144. package/lib/typescript/analytics/EventTracker.d.ts +20 -0
  145. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  146. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  147. package/lib/typescript/compat/codepush.d.ts +27 -4
  148. package/lib/typescript/compat/codepush.d.ts.map +1 -1
  149. package/lib/typescript/core/config/schema.d.ts +4 -1
  150. package/lib/typescript/core/config/schema.d.ts.map +1 -1
  151. package/lib/typescript/core/constants/endpoints.d.ts +9 -6
  152. package/lib/typescript/core/constants/endpoints.d.ts.map +1 -1
  153. package/lib/typescript/core/context/device.d.ts +46 -0
  154. package/lib/typescript/core/context/device.d.ts.map +1 -1
  155. package/lib/typescript/debug/replayWiring.d.ts.map +1 -1
  156. package/lib/typescript/features/bugreport/BugReportFeature.d.ts +59 -0
  157. package/lib/typescript/features/bugreport/BugReportFeature.d.ts.map +1 -0
  158. package/lib/typescript/features/bugreport/buildPayload.d.ts +51 -0
  159. package/lib/typescript/features/bugreport/buildPayload.d.ts.map +1 -0
  160. package/lib/typescript/features/crash/CrashReporter.d.ts +20 -1
  161. package/lib/typescript/features/crash/CrashReporter.d.ts.map +1 -1
  162. package/lib/typescript/features/crash/capture.d.ts +21 -2
  163. package/lib/typescript/features/crash/capture.d.ts.map +1 -1
  164. package/lib/typescript/features/crash/nativeCrashBridge.d.ts +16 -3
  165. package/lib/typescript/features/crash/nativeCrashBridge.d.ts.map +1 -1
  166. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  167. package/lib/typescript/features/engage/EngagePromptView.d.ts.map +1 -1
  168. package/lib/typescript/features/engage/EngageTransport.d.ts +1 -1
  169. package/lib/typescript/features/engage/EngageTransport.d.ts.map +1 -1
  170. package/lib/typescript/features/engage/engageTriggerEngine.d.ts.map +1 -1
  171. package/lib/typescript/features/engage/engageTypes.d.ts +15 -0
  172. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  173. package/lib/typescript/features/journey/ScaleBunDebugRoot.d.ts.map +1 -1
  174. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +14 -2
  175. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  176. package/lib/typescript/features/ota/deviceAttributes.d.ts +18 -0
  177. package/lib/typescript/features/ota/deviceAttributes.d.ts.map +1 -0
  178. package/lib/typescript/features/ota/geoCountry.d.ts +14 -0
  179. package/lib/typescript/features/ota/geoCountry.d.ts.map +1 -0
  180. package/lib/typescript/features/ota/useOtaUpdate.d.ts +20 -2
  181. package/lib/typescript/features/ota/useOtaUpdate.d.ts.map +1 -1
  182. package/lib/typescript/features/performance/PerformanceFeature.d.ts +0 -1
  183. package/lib/typescript/features/performance/PerformanceFeature.d.ts.map +1 -1
  184. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +33 -0
  185. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  186. package/lib/typescript/features/performance/transport/PerformanceTransport.d.ts.map +1 -1
  187. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +42 -2
  188. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  189. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +2 -0
  190. package/lib/typescript/features/session/JourneyEventPipeline.d.ts.map +1 -1
  191. package/lib/typescript/features/session/ReplayCaptureManager.d.ts +38 -1
  192. package/lib/typescript/features/session/ReplayCaptureManager.d.ts.map +1 -1
  193. package/lib/typescript/features/session/SessionManager.d.ts.map +1 -1
  194. package/lib/typescript/features/session/nativeCapture.d.ts +2 -1
  195. package/lib/typescript/features/session/nativeCapture.d.ts.map +1 -1
  196. package/lib/typescript/features/session/outboxFrameCapture.d.ts.map +1 -1
  197. package/lib/typescript/features/session/sessionTypes.d.ts +20 -3
  198. package/lib/typescript/features/session/sessionTypes.d.ts.map +1 -1
  199. package/lib/typescript/index.d.ts +1 -0
  200. package/lib/typescript/index.d.ts.map +1 -1
  201. package/lib/typescript/public/ScaleBunFacade.d.ts +69 -0
  202. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  203. package/lib/typescript/public/types.d.ts +34 -8
  204. package/lib/typescript/public/types.d.ts.map +1 -1
  205. package/lib/typescript/specs/NativeScaleBunCrash.d.ts +14 -0
  206. package/lib/typescript/specs/NativeScaleBunCrash.d.ts.map +1 -1
  207. package/lib/typescript/vendor/protocol/index.d.ts +17 -17
  208. package/package.json +1 -1
  209. package/src/analytics/EventTracker.ts +28 -1
  210. package/src/bootstrap/SDKBootstrapper.ts +27 -23
  211. package/src/compat/codepush.ts +87 -16
  212. package/src/core/config/schema.ts +26 -5
  213. package/src/core/constants/endpoints.ts +9 -6
  214. package/src/core/context/device.ts +146 -0
  215. package/src/debug/replayWiring.ts +2 -1
  216. package/src/features/bugreport/BugReportFeature.ts +128 -0
  217. package/src/features/bugreport/buildPayload.ts +116 -0
  218. package/src/features/crash/CrashReporter.ts +40 -1
  219. package/src/features/crash/capture.ts +36 -9
  220. package/src/features/crash/nativeCrashBridge.ts +156 -22
  221. package/src/features/engage/EngagePromptProvider.tsx +48 -2
  222. package/src/features/engage/EngagePromptView.tsx +51 -15
  223. package/src/features/engage/EngageTransport.ts +1 -1
  224. package/src/features/engage/engageTriggerEngine.ts +15 -0
  225. package/src/features/engage/engageTypes.ts +15 -0
  226. package/src/features/journey/ScaleBunDebugRoot.tsx +2 -1
  227. package/src/features/ota/OtaOrchestrator.ts +84 -6
  228. package/src/features/ota/deviceAttributes.ts +46 -0
  229. package/src/features/ota/geoCountry.ts +85 -0
  230. package/src/features/ota/useOtaUpdate.ts +20 -2
  231. package/src/features/performance/PerformanceFeature.ts +22 -9
  232. package/src/features/performance/collectors/FrameMetricsCollector.ts +72 -10
  233. package/src/features/performance/transport/PerformanceTransport.ts +18 -0
  234. package/src/features/session/BackendSessionAdapter.ts +137 -10
  235. package/src/features/session/JourneyEventPipeline.ts +3 -0
  236. package/src/features/session/ReplayCaptureManager.ts +53 -1
  237. package/src/features/session/SessionManager.ts +46 -7
  238. package/src/features/session/nativeCapture.ts +3 -2
  239. package/src/features/session/outboxFrameCapture.ts +3 -2
  240. package/src/features/session/sessionTypes.ts +26 -3
  241. package/src/index.ts +4 -1
  242. package/src/public/ScaleBunFacade.ts +198 -2
  243. package/src/public/types.ts +35 -8
  244. package/src/specs/NativeScaleBunCrash.ts +16 -0
@@ -258,6 +258,7 @@ export class BackendSessionAdapter implements SessionTransport {
258
258
  bridgeAdapter.getDeviceInfo()
259
259
  .then(nativeInfo => {
260
260
  const m = session.metadata;
261
+ this._cachedAppVersion = nativeInfo?.appVersion ?? m?.appVersion ?? null;
261
262
  const payload = {
262
263
  sessionId: session.sessionId,
263
264
  deviceId: session.deviceId,
@@ -394,6 +395,7 @@ export class BackendSessionAdapter implements SessionTransport {
394
395
  try {
395
396
  const nativeInfo = await bridgeAdapter.getDeviceInfo();
396
397
  const m = session.metadata;
398
+ this._cachedAppVersion = nativeInfo?.appVersion ?? m?.appVersion ?? null;
397
399
  const payload = {
398
400
  sessionId: session.sessionId,
399
401
  deviceId: session.deviceId,
@@ -639,18 +641,56 @@ export class BackendSessionAdapter implements SessionTransport {
639
641
  }
640
642
  }
641
643
 
644
+ // D8/D9/D12: this signature was {type, message, stack, screenName} — four
645
+ // fields — while the backend row and every dashboard surface were already
646
+ // built for the rest. The consequences of the four-field bottleneck:
647
+ // regression detection permanently false (no appVersion on any mobile
648
+ // error), Issue.primaryPlatform NULL for every RN crash (no platform), and
649
+ // native crash signal/thread/exception-type discarded one call before the
650
+ // wire (no metadata). The receiver needed nothing; only this signature did.
651
+ /** App version resolved at session start; errors stamp it so regression
652
+ * detection has a version to work with (D8). Null until a session starts. */
653
+ private _cachedAppVersion: string | null = null;
654
+
655
+ private _appVersion(): string | undefined {
656
+ return this._cachedAppVersion ?? undefined;
657
+ }
658
+
642
659
  queueError(data: {
643
660
  type: string; message: string; stack?: string; screenName?: string;
661
+ platform?: string;
662
+ appVersion?: string;
663
+ isFatal?: boolean;
664
+ metadata?: Record<string, unknown>;
665
+ /** Target session. Set ONLY by a recovered native crash; absent means the current session. */
666
+ sessionId?: string;
667
+ /** Stable idempotency key. Set ONLY by a recovered crash; the backend dedupes on it. */
668
+ clientId?: string;
644
669
  }): void {
645
670
  if (this.destroyed || !this.config.clientKey) return;
646
- this.pendingErrors.push({ ...data, clientId: this._clientId(), timestamp: new Date().toISOString() });
671
+ this.pendingErrors.push({
672
+ ...data,
673
+ // The session already knows the app version — never make a crash
674
+ // reporter's callers supply what the transport can fill itself.
675
+ appVersion: data.appVersion ?? this._appVersion(),
676
+ // A recovered crash brings its OWN clientId — the stable id written when the crash was
677
+ // first persisted. Generating a fresh one here would mint a new identity on every retry
678
+ // and defeat the unique constraint the backend dedupes with.
679
+ clientId: data.clientId ?? this._clientId(),
680
+ timestamp: new Date().toISOString(),
681
+ });
647
682
  if (this.pendingErrors.length >= 10) {
648
683
  this._flushErrors().catch(() => {});
649
684
  }
650
685
  }
651
686
 
652
- /** Public: force-flush queued errors immediately (handled-error delivery). */
653
- flushErrors(): Promise<void> {
687
+ /**
688
+ * Public: force-flush queued errors immediately (handled-error delivery).
689
+ *
690
+ * @returns whether the batch was DURABLY accepted. Crash recovery deletes its on-disk record on
691
+ * this answer, so it must reflect the backend and not merely that the call returned.
692
+ */
693
+ flushErrors(): Promise<boolean> {
654
694
  return this._flushErrors();
655
695
  }
656
696
 
@@ -724,13 +764,23 @@ export class BackendSessionAdapter implements SessionTransport {
724
764
  private async _enqueueNative(
725
765
  lane: 'events' | 'network' | 'errors' | 'performance' | 'logs',
726
766
  records: Array<Record<string, unknown>>,
767
+ /**
768
+ * Session to enqueue UNDER. Defaults to the current one, which is right for every live lane.
769
+ *
770
+ * A recovered native crash must override it. Without this parameter the outbox stamped
771
+ * `currentSessionId` on everything, so with the outbox enabled a recovered crash was filed
772
+ * against the session created after the restart — the exact defect the routing above exists
773
+ * to prevent, reappearing through a path that routing never touched.
774
+ */
775
+ targetSessionId?: string,
727
776
  ): Promise<boolean> {
728
- if (!this.nativeOutboxEnabled || !this.currentSessionId) return false;
777
+ const sessionId = targetSessionId || this.currentSessionId;
778
+ if (!this.nativeOutboxEnabled || !sessionId) return false;
729
779
  const native = getReplaySdkNative();
730
780
  if (!native?.enqueueOutboxRecords) return false;
731
781
  if (!(await this._ensureNativeOutbox(native))) return false;
732
782
  try {
733
- return await native.enqueueOutboxRecords(lane, this.currentSessionId, records);
783
+ return await native.enqueueOutboxRecords(lane, sessionId, records);
734
784
  } catch {
735
785
  return false;
736
786
  }
@@ -852,6 +902,17 @@ export class BackendSessionAdapter implements SessionTransport {
852
902
  if (screen) out.screenName = screen;
853
903
  const label = e.subtype ?? e.type;
854
904
  if (label && label !== e.type) out.label = label;
905
+ /**
906
+ * THIS MAPPER ENUMERATES. Every field the wire carries has to be named here, so a field
907
+ * added to JourneyEvent and not added here is dropped silently — no error, no warning, and
908
+ * nothing downstream can tell the difference between "the SDK did not send it" and "the
909
+ * user never produced it". `frameId` was exactly that case: the column, the DTO field and
910
+ * the ingest write all existed already, and the value died on this line.
911
+ *
912
+ * Omitted when absent rather than sent as null, matching how screenName and label behave
913
+ * above — the v2 lean shape drops empty fields and the server reconstructs them.
914
+ */
915
+ if (e.frameId) out.frameId = e.frameId;
855
916
  const data = this._pruneEmpty(e.payload);
856
917
  if (data && Object.keys(data).length > 0) out.data = data;
857
918
  return out;
@@ -1265,15 +1326,81 @@ export class BackendSessionAdapter implements SessionTransport {
1265
1326
  }
1266
1327
  }
1267
1328
 
1268
- private async _flushErrors(): Promise<void> {
1269
- if (this.pendingErrors.length === 0 || !this.currentSessionId || !this.config.clientKey) return;
1329
+ /**
1330
+ * D11: deliver a bug report to the SAME ingest the web SDK uses
1331
+ * (`POST /ingestion/sessions/:id/bug-reports`). Fire-and-forget with one
1332
+ * retry deferral: RN previously had NO path to this endpoint at all —
1333
+ * `reportBug()` emitted an analytics event no handler consumed, so the
1334
+ * bug-reports page was empty forever for every RN app.
1335
+ */
1336
+ submitBugReport(report: { message: string; title?: string; email?: string }): void {
1337
+ if (this.destroyed || !this.config.clientKey || !this.currentSessionId) return;
1338
+ void this._post(
1339
+ `/ingestion/sessions/${this.currentSessionId}/bug-reports`,
1340
+ { ...report, timestamp: Date.now() },
1341
+ ).catch(() => {
1342
+ /* best-effort; the $bug_report analytics breadcrumb still records intent */
1343
+ });
1344
+ }
1345
+
1346
+ /**
1347
+ * @returns TRUE only when every group in this batch was DURABLY accepted — an HTTP 2xx, or a
1348
+ * handoff to the native outbox, which is itself a persistent retrying queue.
1349
+ *
1350
+ * The return value is not cosmetic: `drainNativeCrash` deletes the on-disk crash record on the
1351
+ * strength of it. This method used to return `void` and swallow its own errors in the catch
1352
+ * below, so "the promise resolved" meant nothing at all — a failed POST looked exactly like a
1353
+ * successful one, and the caller would have deleted the only durable copy of a crash that never
1354
+ * arrived. Anything less than a real acknowledgement here reintroduces the loss this whole
1355
+ * change exists to prevent.
1356
+ */
1357
+ private async _flushErrors(): Promise<boolean> {
1358
+ if (this.pendingErrors.length === 0 || !this.currentSessionId || !this.config.clientKey) return false;
1270
1359
  const batch = this.pendingErrors.splice(0, 20);
1271
1360
  try {
1272
- if (await this._enqueueNative('errors', batch)) return;
1273
- if (!this._sessionRowReady()) { this.pendingErrors.unshift(...batch); return; }
1274
- await this._post(ENDPOINTS.INGESTION_ERRORS(this.currentSessionId), { errors: batch });
1361
+ if (!this._sessionRowReady()) { this.pendingErrors.unshift(...batch); return false; }
1362
+ /**
1363
+ * ROUTED BY THE ERROR'S OWN SESSION, not by whichever session is current.
1364
+ *
1365
+ * A recovered native crash carries the id of the session it actually happened in — that
1366
+ * process is gone, so the current session is the wrong owner. Sending it under
1367
+ * `currentSessionId` is what marked a healthy session crashed and left the crashed one
1368
+ * looking clean.
1369
+ *
1370
+ * The URL still carries a session because that is the established ingest shape; the
1371
+ * error's own `sessionId` simply chooses WHICH one. Errors are grouped by their target
1372
+ * so a batch mixing a recovered crash with live errors cannot drag the live ones onto
1373
+ * the dead session.
1374
+ */
1375
+ const byTarget = new Map<string, typeof batch>();
1376
+ for (const e of batch) {
1377
+ const target = (e as { sessionId?: string }).sessionId || this.currentSessionId;
1378
+ byTarget.set(target, [...(byTarget.get(target) ?? []), e]);
1379
+ }
1380
+ let allAccepted = true;
1381
+ for (const [target, errors] of byTarget) {
1382
+ // `sessionId` is a routing instruction, not payload — strip it so the stored row is
1383
+ // byte-identical to one sent the ordinary way.
1384
+ const cleaned = errors.map(({ sessionId: _s, ...rest }: any) => rest);
1385
+
1386
+ // The outbox is enqueued PER TARGET, so a recovered crash keeps its own session.
1387
+ if (await this._enqueueNative('errors', cleaned, target)) continue;
1388
+
1389
+ try {
1390
+ await this._post(ENDPOINTS.INGESTION_ERRORS(target), { errors: cleaned });
1391
+ } catch (err) {
1392
+ allAccepted = false;
1393
+ // A permanent error (4xx) will never succeed, so re-queuing it would spin
1394
+ // forever; anything else is worth another attempt.
1395
+ if (!BackendSessionAdapter._isPermanentHttpError(err)) {
1396
+ this.pendingErrors.unshift(...errors);
1397
+ }
1398
+ }
1399
+ }
1400
+ return allAccepted;
1275
1401
  } catch (err) {
1276
1402
  if (!BackendSessionAdapter._isPermanentHttpError(err)) this.pendingErrors.unshift(...batch);
1403
+ return false;
1277
1404
  }
1278
1405
  }
1279
1406
 
@@ -91,6 +91,8 @@ export class JourneyEventPipeline {
91
91
  traceId?: string;
92
92
  source?: JourneyEventSource;
93
93
  journeyId?: string;
94
+ /** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
95
+ frameId?: string;
94
96
  },
95
97
  ): JourneyEvent | null {
96
98
  try {
@@ -120,6 +122,7 @@ export class JourneyEventPipeline {
120
122
  subtype: opts?.subtype,
121
123
  severity: opts?.severity ?? inferSeverity(type),
122
124
  screen: opts?.screen,
125
+ frameId: opts?.frameId,
123
126
  payload: opts?.payload,
124
127
  correlationId: opts?.correlationId,
125
128
  traceId: opts?.traceId,
@@ -36,6 +36,7 @@ import {
36
36
  getFrameRetentionPriority,
37
37
  QUALITY_PRESETS,
38
38
  } from './sessionTypes';
39
+ import type { PlatformOS } from '../../core/context/device';
39
40
 
40
41
  // ─── Types ──────────────────────────────────────────────────────────────────
41
42
 
@@ -70,7 +71,8 @@ export interface StagedFrameResult {
70
71
  frameHash: string;
71
72
  masked: boolean;
72
73
  timestamp: number;
73
- platform: 'android' | 'ios';
74
+ // Widened: frames now carry the REAL Platform.OS instead of being relabelled 'android'.
75
+ platform: PlatformOS;
74
76
  }
75
77
 
76
78
  /** JS-authoritative frame metadata written to the outbox on commit. */
@@ -258,6 +260,15 @@ export class ReplayCaptureManager {
258
260
  private _lastKeyframeTs = 0;
259
261
  /** P2-5: frameId of the last emitted keyframe — delta base reference. */
260
262
  private _lastKeyframeId: string | null = null;
263
+ /**
264
+ * Screen the last emitted keyframe was captured on.
265
+ *
266
+ * Tracked alongside the id purely so `currentKeyframe()` can refuse to hand a frame to an
267
+ * event on a different screen. The id and timestamp alone cannot answer that, and a frame
268
+ * from the previous screen is worse than none — it attaches a tap to a photograph of
269
+ * somewhere else, which every layer downstream would then present with full confidence.
270
+ */
271
+ private _lastKeyframeScreen: string | null = null;
261
272
  /** P2-5: emitted frames since the last keyframe (keyframe-interval guard). */
262
273
  private _framesSinceKeyframe = 0;
263
274
 
@@ -275,6 +286,45 @@ export class ReplayCaptureManager {
275
286
 
276
287
  // ─── Public API ─────────────────────────────────────────────────────
277
288
 
289
+ /**
290
+ * The keyframe that is still showing, or null.
291
+ *
292
+ * Answers "which picture was the user looking at" for an event that is about to be emitted,
293
+ * so a tap can carry the id of the frame it happened ON. Read-only; captures nothing.
294
+ *
295
+ * TWO CONDITIONS, both refusals rather than approximations:
296
+ *
297
+ * SAME SCREEN. A keyframe from the previous screen is not what the user was looking at.
298
+ * Navigation evicts the pending capture rather than re-pointing it, so after a screen
299
+ * change the newest keyframe genuinely belongs to somewhere else.
300
+ *
301
+ * STILL FRESH. A screen mutates constantly — a list loads, a banner rotates, a sheet is
302
+ * dismissed. Past `maxAgeMs` the stored picture stops being evidence of what is on screen,
303
+ * and stale evidence presented as current is the failure mode this whole feature exists to
304
+ * avoid. 10 s is deliberately conservative: the capture manager already photographs on
305
+ * interaction, navigation and scroll settle, so a genuinely active screen refreshes well
306
+ * inside it, and a screen quiet for longer than that is one nobody is interacting with.
307
+ *
308
+ * Returning null is a correct, common answer and downstream MUST treat it as "not captured"
309
+ * rather than as a default.
310
+ */
311
+ currentKeyframe(
312
+ screenName?: string,
313
+ maxAgeMs = 10_000,
314
+ now: number = Date.now(),
315
+ ): { frameId: string; screen: string | null; capturedAt: number } | null {
316
+ if (!this._lastKeyframeId || this._lastKeyframeTs <= 0) return null;
317
+ if (now - this._lastKeyframeTs > maxAgeMs) return null;
318
+ // Compared only when the caller knows its screen. An event with no screen cannot be shown
319
+ // to belong to this frame, so it does not get one.
320
+ if (!screenName || this._lastKeyframeScreen !== screenName) return null;
321
+ return {
322
+ frameId: this._lastKeyframeId,
323
+ screen: this._lastKeyframeScreen,
324
+ capturedAt: this._lastKeyframeTs,
325
+ };
326
+ }
327
+
278
328
  /** Set callback for when a frame is captured */
279
329
  setFrameEmitCallback(cb: FrameEmitCallback): void {
280
330
  this.onFrameEmit = cb;
@@ -291,6 +341,7 @@ export class ReplayCaptureManager {
291
341
  this._lastFrameHash = null;
292
342
  this._lastKeyframeTs = 0;
293
343
  this._lastKeyframeId = null;
344
+ this._lastKeyframeScreen = null;
294
345
  this._framesSinceKeyframe = 0;
295
346
 
296
347
  // Start frequency timer if in frequency mode
@@ -808,6 +859,7 @@ export class ReplayCaptureManager {
808
859
  if (frameType === 'keyframe') {
809
860
  this._lastKeyframeId = frameId;
810
861
  this._lastKeyframeTs = now;
862
+ this._lastKeyframeScreen = screenName ?? null;
811
863
  this._framesSinceKeyframe = 0;
812
864
  } else {
813
865
  this._framesSinceKeyframe++;
@@ -40,6 +40,7 @@ import { generateEventId } from '../replay/core/ids/sessionId';
40
40
  import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
41
41
  import { redactBody } from '../../debug/redaction';
42
42
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
43
+ import { resolvePlatformOS } from '../../core/context/device';
43
44
 
44
45
  // ─── Types ──────────────────────────────────────────────────────────────────
45
46
 
@@ -385,12 +386,11 @@ export class SessionManager {
385
386
  startedAt: Date.now(),
386
387
  status: 'active',
387
388
  metadata: {
388
- platform: (() => {
389
- try {
390
- const { Platform } = require('react-native');
391
- return Platform.OS === 'ios' ? 'ios' : 'android';
392
- } catch { return 'android'; }
393
- })(), // Real OS; enriched by native device info when available
389
+ // The real Platform.OS. This used to be a ternary whose else-branch was 'android'
390
+ // and whose catch was ALSO 'android' — so a macOS session, a Windows session, and
391
+ // a failure to load react-native at all were reported identically, as Android.
392
+ // Enriched by native device info when the bridge answers.
393
+ platform: resolvePlatformOS(),
394
394
  buildType: __DEV__ ? 'debug' : 'release',
395
395
  ...metadata,
396
396
  // Capture window dp dims here so emitEvent() can normalize coords
@@ -748,6 +748,20 @@ export class SessionManager {
748
748
  // new object and strips known-sensitive keys; coords/gesture survive.
749
749
  const safePayload = redactBody(payload) as Record<string, unknown>;
750
750
 
751
+ /**
752
+ * NO `frameId` HERE, and it is not an oversight.
753
+ *
754
+ * This method is guarded by `if (this.active) return;` — it runs ONLY when no replay
755
+ * recording is active, which is precisely when no screenshots are being captured.
756
+ * There is no frame on screen to point at, so an analytics-lane tap can never be
757
+ * attributed to a visual state.
758
+ *
759
+ * That makes it a permanent property of this lane rather than a gap to close later,
760
+ * and the dashboard has to account for it: these taps live in the heatmap grid
761
+ * alongside recorded ones, so a state filter necessarily covers a subset of the
762
+ * traffic. `heat:screen-coverage` reports that subset's size per app for exactly this
763
+ * reason.
764
+ */
751
765
  const event: JourneyEvent = {
752
766
  eventId: generateEventId(),
753
767
  // sessionId is (re)stamped by the analytics lane at flush time.
@@ -849,7 +863,32 @@ export class SessionManager {
849
863
  } catch { /* no-throw */ }
850
864
  }
851
865
 
852
- const event = this.pipeline.emit(type, opts);
866
+ /**
867
+ * Which frame was on screen when this happened.
868
+ *
869
+ * Resolved BEFORE `pipeline.emit`, and deliberately before the capture triggers
870
+ * below: those capture the frame that shows the RESULT of the interaction, and a tap
871
+ * belongs to the state it was made IN, not the one it produced. Reading after them
872
+ * would attribute every tap to its own consequence.
873
+ *
874
+ * USER_ACTION only. A SCREEN_VIEW's frame is the screen being left, which is
875
+ * misleading rather than useful, and no consumer asks for it.
876
+ *
877
+ * `currentKeyframe` returns null unless the frame is from this same screen and still
878
+ * fresh — see its contract. Null is the common, correct answer and stays absent on
879
+ * the event rather than becoming a default.
880
+ */
881
+ const framedOpts =
882
+ type === 'USER_ACTION'
883
+ ? {
884
+ ...opts,
885
+ frameId: this.captureManager?.currentKeyframe(
886
+ opts?.screen ?? this._lastKnownScreen ?? undefined,
887
+ )?.frameId,
888
+ }
889
+ : opts;
890
+
891
+ const event = this.pipeline.emit(type, framedOpts);
853
892
 
854
893
  // Trigger capture on screen view or interaction
855
894
  if (event) {
@@ -4,6 +4,7 @@
4
4
  * Provides the callback for the SessionManager to request screenshots
5
5
  * from the native layer (PixelCopy on Android, UIWindow on iOS).
6
6
  */
7
+ import { resolvePlatformOS, type PlatformOS } from '../../core/context/device';
7
8
 
8
9
  export const nativeCapture = async (sessionId: string, reason: string, _quality: any) => {
9
10
  try {
@@ -17,8 +18,8 @@ export const nativeCapture = async (sessionId: string, reason: string, _quality:
17
18
  const result = await replaySdk.captureScreenshot(reason);
18
19
  if (!result?.base64) return null;
19
20
 
20
- const { Platform } = require('react-native');
21
- const platform: 'android' | 'ios' = Platform.OS === 'ios' ? 'ios' : 'android';
21
+ // Platform.OS verbatim. The ternary this replaces labelled every non-iOS frame 'android'.
22
+ const platform: PlatformOS = resolvePlatformOS();
22
23
 
23
24
  return {
24
25
  base64: result.base64 as string,
@@ -15,13 +15,14 @@
15
15
  * transparently falls back to the legacy `captureScreenshot` path.
16
16
  */
17
17
 
18
- import { Platform } from 'react-native';
19
18
  import { getReplaySdkNative } from '../replay/bridge/nativeModule';
19
+ import { resolvePlatformOS, type PlatformOS } from '../../core/context/device';
20
20
  import type { OutboxFrameCapture, StagedFrameResult, OutboxFrameMeta } from './ReplayCaptureManager';
21
21
 
22
22
  /** Build the adapter. `ensureReady` resolves false when the native outbox is off. */
23
23
  export function makeOutboxFrameCapture(ensureReady: () => Promise<boolean>): OutboxFrameCapture {
24
- const platform: 'android' | 'ios' = Platform.OS === 'ios' ? 'ios' : 'android';
24
+ // Platform.OS verbatim see core/context/device.
25
+ const platform: PlatformOS = resolvePlatformOS();
25
26
 
26
27
  return {
27
28
  async capture(sessionId, screenName, trigger): Promise<StagedFrameResult | null> {
@@ -1,3 +1,4 @@
1
+ import type { PlatformOS } from '../../core/context/device';
1
2
  /**
2
3
  * ScaleBun SDK — Unified Session Domain Model
3
4
  *
@@ -21,7 +22,9 @@ export interface SessionMetadata {
21
22
  appName?: string;
22
23
  appVersion?: string;
23
24
  bundleId?: string;
24
- platform?: 'android' | 'ios' | 'web';
25
+ // Widened to the real Platform.OS — see core/context/device. The wire's platform field is an
26
+ // open enum, so a value this union did not anticipate travels rather than being coerced.
27
+ platform?: PlatformOS;
25
28
  osVersion?: string;
26
29
  deviceModel?: string;
27
30
  deviceName?: string;
@@ -78,6 +81,22 @@ export interface JourneyEvent {
78
81
  severity?: JourneyEventSeverity;
79
82
  /** Screen name at time of event */
80
83
  screen?: string;
84
+ /**
85
+ * frameId of the screenshot that was ON SCREEN when this event happened.
86
+ *
87
+ * The picture the user was looking at, not the one that follows. Mobile has no DOM to
88
+ * reconstruct, so a captured frame is the only record of what a screen looked like at a
89
+ * moment — which makes this the join key between a tap and the state it was made in.
90
+ *
91
+ * Set only when a keyframe from the SAME screen was captured recently enough to still be
92
+ * showing (see SessionManager.currentFrameForEvent). Absent means "not captured", which is a
93
+ * DIFFERENT answer from "nothing was open" and must never be folded into it:
94
+ * · the analytics lane never has one — it runs only when no recording is active, so no
95
+ * screenshots exist at all
96
+ * · a tap right after navigation has none — the capture is debounced and evicted on
97
+ * screen change
98
+ */
99
+ frameId?: string;
81
100
  payload?: Record<string, unknown>;
82
101
  /** Links related events (e.g. network request start → complete) */
83
102
  correlationId?: string;
@@ -114,7 +133,9 @@ export interface ReplayFrame {
114
133
  quality: ReplayQuality;
115
134
  captureReason: CaptureReason;
116
135
  /** Native-only capture — JS never captures frames */
117
- sourcePlatform: 'android' | 'ios';
136
+ // Widened: a frame reports the REAL Platform.OS. The old union forced every non-iOS capture to
137
+ // be relabelled 'android', which is how RN-macOS and RN-Windows sessions became Android ones.
138
+ sourcePlatform: PlatformOS;
118
139
  redactionState?: 'none' | 'text-masked' | 'full';
119
140
  /** Screen name at capture time — for frame-to-screen correlation */
120
141
  screen?: string;
@@ -363,7 +384,9 @@ export interface NativeFrameResult {
363
384
  /** Byte size of the compressed image */
364
385
  byteSize: number;
365
386
  /** Platform that performed the capture */
366
- platform: 'android' | 'ios';
387
+ // Widened from 'android'|'ios': a frame is labelled with the REAL Platform.OS now, and the
388
+ // old union forced every non-iOS capture to be relabelled 'android'.
389
+ platform: PlatformOS;
367
390
  /** Actual on-device encoding after the AVIF->WebP->JPEG fallback (P2-2). */
368
391
  format?: string;
369
392
  /** Optional native frame hash for change detection (P2-1). JS falls back to hashing base64. */
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  // ─── Default Export: SDK Facade Singleton ───────────────────────────────────
14
14
  // Usage: import ScaleBun from '@scalebun/react-native';
15
- // await ScaleBun.init({ projectId: '...', publishableKey: '...', apiBaseUrl: '...' });
15
+ // await ScaleBun.init({ projectId: '...', publishableKey: '...' });
16
16
 
17
17
  export { default } from './public/ScaleBunFacade';
18
18
  export { ScaleBunProvider } from './public/ScaleBunProvider';
@@ -49,6 +49,9 @@ export {
49
49
  } from './features/engage/EngageAnchor';
50
50
 
51
51
  // ─── OTA Updates ───────────────────────────────────────────────────────────
52
+ // D3: manual capture — existed for a year, exported to nobody, delivered to a
53
+ // queue nothing drained. Now rides the proven crash pipeline.
54
+ export { captureException, captureMessage } from './features/crash/capture';
52
55
  export { otaOrchestrator, OtaOrchestrator } from './features/ota/OtaOrchestrator';
53
56
  export type { SyncResult, BootGuardConfig } from './features/ota/OtaOrchestrator';
54
57
  export { useOtaUpdate } from './features/ota/useOtaUpdate';