@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
@@ -110,6 +110,21 @@ export interface EngageCampaignConfig {
110
110
  storeUrl?: string;
111
111
  highRatingThreshold?: number;
112
112
  routeLowToFeedback?: boolean;
113
+ /**
114
+ * Route this campaign's submission to BUG REPORTS instead of Engage responses.
115
+ *
116
+ * Engage responses and bug reports are different products: a survey response measures sentiment
117
+ * in aggregate, while a bug report is a single item that gets a status, a priority and an
118
+ * assignee on /diagnose/bug-reports. A FEEDBACK campaign triggered by `shake_device` is almost
119
+ * always the second kind — someone shook the phone because something is broken.
120
+ *
121
+ * Opt-IN per campaign rather than rerouting every FEEDBACK campaign, because rerouting would
122
+ * silently break existing sentiment surveys, and because the destination should be visible to
123
+ * whoever configures the campaign rather than inferred from its trigger.
124
+ *
125
+ * Absent/false keeps the existing behaviour exactly.
126
+ */
127
+ fileAsBugReport?: boolean;
113
128
  enabled: boolean;
114
129
  }
115
130
 
@@ -30,6 +30,7 @@ import React, {
30
30
  import { View, NativeEventEmitter, NativeModules } from 'react-native';
31
31
  import { emitAutomaticEvent } from '../../analytics/automaticEvents';
32
32
  import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
33
+ import { resolvePlatformOS } from '../../core/context/device';
33
34
 
34
35
  // ─── Lazy imports to avoid native module cascade at load time ────────────────
35
36
  // We only import TYPE references at the top level; actual modules are require()'d
@@ -159,7 +160,7 @@ export function ScaleBunDebugRoot({
159
160
  byteSize: result.sizeBytes || Math.round(result.base64.length * 0.75),
160
161
  format: (result.format ?? 'jpeg') as string,
161
162
  frameHash: (result as any).frameHash as string | undefined,
162
- platform: (Platform.OS === 'ios' ? 'ios' : 'android') as 'android' | 'ios',
163
+ platform: resolvePlatformOS() as 'android' | 'ios',
163
164
  };
164
165
  } catch {
165
166
  return null;
@@ -11,10 +11,14 @@ import { noThrow } from '../../core/lifecycle/crashSafe';
11
11
  import NativeScaleBunOta from '../../specs/NativeScaleBunOta';
12
12
  import type { OtaCheckRequest, OtaCheckResponse, OtaBundlePayload } from './OtaTypes';
13
13
  import { otaEventEmitter } from './OtaEventEmitter';
14
+ import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
15
+ import { deviceTargetingAttributes } from './deviceAttributes';
16
+ import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
14
17
  import { verifyBundleSignature, type SignatureConfig } from './signature';
15
18
  import { detectOtaEnvironment, type OtaEnvironment } from './environment';
16
19
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
17
20
  import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
21
+ import { resolveMobileOS } from '../../core/context/device';
18
22
 
19
23
  /**
20
24
  * Identity marker injected into the bundle by `scalebun ota publish` before
@@ -67,6 +71,36 @@ function subscribeNativeProgress(
67
71
  /** POST at most once per this interval; first and terminal ticks always go. */
68
72
  const PROGRESS_POST_INTERVAL_MS = 1_000;
69
73
 
74
+ /**
75
+ * P8 — the SDK's canonical per-install id, shared with the analytics lane.
76
+ *
77
+ * OTA used to REQUIRE a caller-supplied `installationId`, and the documented
78
+ * example passed `deviceId` — so OTA telemetry lived in its own identity
79
+ * namespace, disconnected from the id every other lane reports. Every join
80
+ * from OTA data to CDP data (blast radius, MRR-at-risk, churn bands) was a
81
+ * cross-namespace join that matched by luck.
82
+ *
83
+ * Reads (or mints, for OTA-only apps that never initialize analytics) the same
84
+ * persisted value `EventTracker` uses, via the same storage backend and the
85
+ * same exported key — one install, one id, every lane.
86
+ */
87
+ function resolveInstallationId(): string {
88
+ try {
89
+ const storage = createStorageBackend();
90
+ const existing = storage.get(K_INSTALLATION_ID);
91
+ if (existing) return existing;
92
+ const id = `inst-${Math.random().toString(36).slice(2, 10)}${Math.random()
93
+ .toString(36)
94
+ .slice(2, 10)}`;
95
+ storage.set(K_INSTALLATION_ID, id);
96
+ return id;
97
+ } catch {
98
+ // Storage unavailable (tests, exotic hosts): a per-process id keeps sync
99
+ // functional; it will stabilize on the first run where storage works.
100
+ return `inst-ephemeral-${Math.random().toString(36).slice(2, 10)}`;
101
+ }
102
+ }
103
+
70
104
  /**
71
105
  * Deliver queued OTA lifecycle events to the backend's batch endpoint.
72
106
  *
@@ -93,13 +127,18 @@ async function deliverOtaEvents(params: {
93
127
  }): Promise<void> {
94
128
  const events = otaEventEmitter.flush();
95
129
  if (!events.length) return;
130
+ // A platform OTA cannot serve is not reported as Android — it is not reported at all.
131
+ const mobileOS = resolveMobileOS();
132
+ if (!mobileOS) return;
96
133
 
97
134
  const items = events.map((e) => ({
98
135
  kind: 'ota_event' as const,
99
136
  type: e.type,
100
137
  bundleId: e.bundleId,
101
138
  installationId: params.installationId,
102
- platform: Platform.OS === 'ios' ? 'ios' : 'android',
139
+ // OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
140
+ // resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
141
+ platform: mobileOS,
103
142
  appVersion: params.appVersion,
104
143
  durationMs: e.durationMs,
105
144
  errorCode: e.error ?? e.reason,
@@ -160,6 +199,15 @@ const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
160
199
  export class OtaOrchestrator {
161
200
  private enabled = false;
162
201
  private currentBundle: OtaBundlePayload | null = null;
202
+
203
+ /**
204
+ * The bundle this process is running, once hydrated from the native slots.
205
+ * Read-only view for integrations (e.g. the CodePush shim's
206
+ * getUpdateMetadata) that need to report the running version.
207
+ */
208
+ public getCurrentBundle(): OtaBundlePayload | null {
209
+ return this.currentBundle;
210
+ }
163
211
  private isRestartRequiredState = false;
164
212
  private healthyTimer: ReturnType<typeof setTimeout> | null = null;
165
213
  private bootGuardConfig: BootGuardConfig = {};
@@ -244,6 +292,11 @@ export class OtaOrchestrator {
244
292
  // no way to know what the device was actually on.
245
293
  this.hydrateCurrentBundleFromSlots();
246
294
 
295
+ // Warm the device-country cache (edge Worker lookup) so checks can carry
296
+ // a country when the API itself sits behind no geo-stamping CDN.
297
+ // Fire-and-forget and failure-soft — a check without a country is valid.
298
+ void prefetchDeviceCountry();
299
+
247
300
  // Prove the bundle we installed is the bundle that loaded.
248
301
  this.verifyRunningBundleIdentity();
249
302
 
@@ -472,15 +525,24 @@ export class OtaOrchestrator {
472
525
  // never carried it — so every device resolved to `default` no matter what
473
526
  // the host app asked for. Undefined fields are omitted by JSON.stringify,
474
527
  // so an app that supplies nothing behaves exactly as before.
528
+ // OTA has no bundle for a non-mobile target; claiming Android there would install the wrong one.
529
+ const mobileOS = resolveMobileOS();
530
+ if (!mobileOS) throw new Error('OTA is not supported on this platform');
475
531
  const payload: OtaCheckRequest = {
476
532
  appVersion: requestParams.appVersion,
477
- platform: Platform.OS === 'ios' ? 'ios' : 'android',
533
+ platform: mobileOS,
478
534
  installationId: requestParams.installationId,
479
535
  currentBundleId: this.currentBundle?.id,
480
536
  currentBundleHash: this.currentBundle?.sha256,
481
537
  channelName: requestParams.channelName,
482
- country: requestParams.country,
483
- attributes: requestParams.attributes,
538
+ // Host-supplied country wins; otherwise the cached edge-Worker lookup
539
+ // fills in (see geoCountry.ts). Undefined when neither knows — the
540
+ // server renders unknown as unknown rather than guessing.
541
+ country: requestParams.country ?? getDeviceCountry() ?? undefined,
542
+ // Hardware/OS context under the host's own attributes (host wins on
543
+ // collision) — this is what lets ATTRIBUTE rules target device classes
544
+ // (deviceModel / manufacturer / brand / osVersion / formFactor).
545
+ attributes: { ...deviceTargetingAttributes(), ...requestParams.attributes },
484
546
  segmentIds: requestParams.segmentIds,
485
547
  lifecycleStage: requestParams.lifecycleStage,
486
548
  // Runtime compatibility context. Without these the backend cannot tell
@@ -529,13 +591,29 @@ export class OtaOrchestrator {
529
591
  * Downloads, stage, and apply an update in one step.
530
592
  * Sprint 5: emits lifecycle events + starts boot-guard heartbeat.
531
593
  */
532
- async sync(params: {
594
+ async sync(rawParams: {
533
595
  apiUrl: string;
534
596
  clientKey: string;
535
597
  appVersion: string;
536
- installationId: string;
598
+ /** Omit to use the SDK's canonical per-install id (recommended — P8). */
599
+ installationId?: string;
600
+ // P1: these five existed on checkForUpdate and on the wire contract, but
601
+ // sync() — the only caller that matters — never accepted them, so they
602
+ // were structurally undefined on every real device: every install pinned
603
+ // to the `default` channel and the whole targeting engine matched nothing.
604
+ // The C1 comment in OtaTypes.ts claimed this was fixed; the plumbing had
605
+ // stopped exactly one function short of true.
606
+ channelName?: string;
607
+ country?: string;
608
+ attributes?: Record<string, string | number>;
609
+ segmentIds?: string[];
610
+ lifecycleStage?: string;
537
611
  autoRestart?: boolean;
538
612
  }): Promise<SyncResult> {
613
+ const params = {
614
+ ...rawParams,
615
+ installationId: rawParams.installationId ?? resolveInstallationId(),
616
+ };
539
617
  try {
540
618
  if (!this.enabled || !NativeScaleBunOta) {
541
619
  return { status: 'ERROR', error: 'OTA native module disabled or missing' };
@@ -0,0 +1,46 @@
1
+ import { Dimensions, Platform } from 'react-native';
2
+
3
+ /**
4
+ * Hardware/OS context auto-attached to OTA check payloads so ATTRIBUTE targeting
5
+ * rules can address device classes without any host-app wiring:
6
+ *
7
+ * deviceModel — 'SM-A155F', '23129RAA4G'… (Android; iOS does not expose it in JS)
8
+ * manufacturer — 'samsung', 'xiaomi'… (lowercased: rules shouldn't fail on casing)
9
+ * brand — 'redmi' vs manufacturer 'xiaomi' — sub-brands are real segments
10
+ * osVersion — Android Release ('14') / iOS version ('17.5')
11
+ * formFactor — 'phone' | 'tablet' | 'tv' (Android tablets via the 600dp
12
+ * smallest-width convention — the same line AndroidX draws)
13
+ *
14
+ * Host-supplied attributes always win on key collision: an app that knows better
15
+ * (e.g. its own device tiering) must not be overridden by generic detection.
16
+ * Everything here is best-effort — a missing constant yields a missing key,
17
+ * never a guessed value.
18
+ */
19
+ export function deviceTargetingAttributes(): Record<string, string> {
20
+ const out: Record<string, string> = {};
21
+ try {
22
+ const c = (Platform.constants ?? {}) as Record<string, unknown>;
23
+ if (Platform.OS === 'android') {
24
+ if (typeof c.Model === 'string' && c.Model) out.deviceModel = c.Model;
25
+ if (typeof c.Manufacturer === 'string' && c.Manufacturer) {
26
+ out.manufacturer = c.Manufacturer.toLowerCase();
27
+ }
28
+ if (typeof c.Brand === 'string' && c.Brand) out.brand = c.Brand.toLowerCase();
29
+ if (typeof c.Release === 'string' && c.Release) out.osVersion = c.Release;
30
+ const screen = Dimensions.get('screen');
31
+ const smallestDp = Math.min(screen.width, screen.height);
32
+ out.formFactor = Platform.isTV ? 'tv' : smallestDp >= 600 ? 'tablet' : 'phone';
33
+ } else {
34
+ const osVersion = typeof c.osVersion === 'string' ? c.osVersion : String(Platform.Version ?? '');
35
+ if (osVersion) out.osVersion = osVersion;
36
+ out.formFactor = Platform.isTV
37
+ ? 'tv'
38
+ : (c as { interfaceIdiom?: string }).interfaceIdiom === 'pad'
39
+ ? 'tablet'
40
+ : 'phone';
41
+ }
42
+ } catch {
43
+ // Targeting enrichment must never break a check. Missing keys are honest.
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,85 @@
1
+ import { createStorageBackend } from '../../storage/StorageBackend';
2
+ import { logger } from '../../core/logger/internalLogger';
3
+
4
+ /**
5
+ * Device country via an edge Worker — the workaround for an API that is not
6
+ * behind a geo-stamping CDN.
7
+ *
8
+ * The clean architecture is the API itself resolving `cf-ipcountry` at its own
9
+ * edge (see backend `resolveCountry`) — the device can't forge it and every
10
+ * request carries it. When the API record isn't proxied, that header never
11
+ * exists, so this module asks a Cloudflare Worker (which DOES see the device's
12
+ * edge geo) and attaches the answer to the OTA check payload. Host-supplied
13
+ * country always wins; the server treats a client country as locale-grade, not
14
+ * proof of location.
15
+ *
16
+ * Failure-soft by design: a dead Worker, a timeout, or a bad payload leaves the
17
+ * country unset — an unknown country must render as unknown, never be guessed.
18
+ */
19
+
20
+ /** Default lookup endpoint. Overridable via {@link setGeoEndpoint}. */
21
+ const DEFAULT_GEO_ENDPOINT = 'https://get-location.imohamadfahmy.workers.dev/';
22
+
23
+ const K_COUNTRY = 'scalebun.ota.geoCountry';
24
+ const K_FETCHED_AT = 'scalebun.ota.geoCountryAt';
25
+ const TTL_MS = 24 * 60 * 60 * 1000; // countries change when people travel — daily is plenty
26
+ const TIMEOUT_MS = 2500;
27
+
28
+ /** Cloudflare's non-countries: Tor exits and internal probes. Unknown, not a place. */
29
+ const NON_COUNTRIES = new Set(['XX', 'T1', 'ZZ']);
30
+
31
+ let endpoint = DEFAULT_GEO_ENDPOINT;
32
+ let memory: string | null = null;
33
+ let inFlight: Promise<void> | null = null;
34
+ const storage = createStorageBackend();
35
+
36
+ /** Override the lookup endpoint (e.g. a self-hosted Worker). */
37
+ export function setGeoEndpoint(url: string): void {
38
+ endpoint = url;
39
+ }
40
+
41
+ /**
42
+ * Synchronous read of the cached country (uppercase ISO 3166-1 alpha-2), or
43
+ * null when no fresh lookup has ever succeeded. Never triggers network.
44
+ */
45
+ export function getDeviceCountry(): string | null {
46
+ if (memory) return memory;
47
+ const stored = storage.get(K_COUNTRY);
48
+ const at = parseInt(storage.get(K_FETCHED_AT) ?? '0', 10) || 0;
49
+ if (stored && Date.now() - at < TTL_MS) {
50
+ memory = stored;
51
+ return stored;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Refresh the cache from the Worker if stale. Fire-and-forget: call it at
58
+ * orchestrator init; the first check of a cold install may go out without a
59
+ * country (the server stores the country per device on later checks anyway).
60
+ */
61
+ export function prefetchDeviceCountry(): Promise<void> {
62
+ if (getDeviceCountry()) return Promise.resolve(); // fresh — nothing to do
63
+ if (inFlight) return inFlight;
64
+ inFlight = (async () => {
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
67
+ try {
68
+ const res = await fetch(endpoint, { signal: controller.signal });
69
+ if (!res.ok) return;
70
+ const body = (await res.json()) as { country?: unknown };
71
+ const raw = typeof body?.country === 'string' ? body.country.trim().toUpperCase() : '';
72
+ if (!/^[A-Z]{2}$/.test(raw) || NON_COUNTRIES.has(raw)) return;
73
+ memory = raw;
74
+ storage.set(K_COUNTRY, raw);
75
+ storage.set(K_FETCHED_AT, String(Date.now()));
76
+ logger.debug(`[OTA] Device country resolved: ${raw}`);
77
+ } catch (err: any) {
78
+ logger.debug(`[OTA] Geo lookup skipped: ${err?.message ?? err}`);
79
+ } finally {
80
+ clearTimeout(timer);
81
+ inFlight = null;
82
+ }
83
+ })();
84
+ return inFlight;
85
+ }
@@ -7,7 +7,22 @@ export interface UseOtaUpdateOptions {
7
7
  apiUrl: string;
8
8
  clientKey: string;
9
9
  appVersion: string;
10
- installationId: string;
10
+ /**
11
+ * Omit to use the SDK's canonical per-install id (recommended). Supplying a
12
+ * different id here — the old docs suggested `deviceId` — put OTA telemetry
13
+ * in its own identity namespace, disconnected from every other SDK lane.
14
+ */
15
+ installationId?: string;
16
+ /** OTA channel to pull from. Omitted means `default`. */
17
+ channelName?: string;
18
+ /** ISO 3166-1 alpha-2. The server resolves this from the edge when absent. */
19
+ country?: string;
20
+ /** Free-form device attributes evaluated by ATTRIBUTE targeting rules. */
21
+ attributes?: Record<string, string | number>;
22
+ /** CDP segment ids this installation belongs to. */
23
+ segmentIds?: string[];
24
+ /** Lifecycle stage evaluated by LIFECYCLE_STAGE targeting rules. */
25
+ lifecycleStage?: string;
11
26
  autoRestart?: boolean;
12
27
  /**
13
28
  * When true and the server says `isMandatory`, the hook sets
@@ -49,7 +64,10 @@ export interface UseOtaUpdateReturn {
49
64
  * apiUrl: 'https://api.scalebun.com/api/v1',
50
65
  * clientKey: 'scalebun_ck_…',
51
66
  * appVersion: '1.0.0',
52
- * installationId: deviceId,
67
+ * // installationId is resolved from the SDK's own persisted install id —
68
+ * // do NOT pass deviceId here (the old example did; it split OTA telemetry
69
+ * // into a separate identity namespace from every other lane).
70
+ * channelName: 'production',
53
71
  * mandatoryBlocksUi: true,
54
72
  * });
55
73
  * ```
@@ -71,7 +71,6 @@ export class PerformanceFeature implements IFeature {
71
71
  private static readonly CAPTURE_DELAYS = [300, 600, 1200];
72
72
 
73
73
  // Track event function from SDK pipeline
74
- private trackFn: ((name: string, payload?: Record<string, unknown>) => void) | null = null;
75
74
 
76
75
  constructor(config?: Partial<PerformanceConfig>) {
77
76
  this.config = mergePerformanceConfig(config);
@@ -208,7 +207,6 @@ export class PerformanceFeature implements IFeature {
208
207
  async initialize(context: FeatureContext): Promise<void> {
209
208
  if (this.active || !this.config.enabled) return;
210
209
 
211
- this.trackFn = context.trackEvent;
212
210
  this.active = true;
213
211
 
214
212
  // Activate native performance collectors (Android AppStart/Frame/ANR,
@@ -549,13 +547,28 @@ export class PerformanceFeature implements IFeature {
549
547
  // Send to debug transport
550
548
  this.transport.sendEvent(event);
551
549
 
552
- // Also track through SDK pipeline (for backend ingestion)
553
- if (this.trackFn) {
554
- this.trackFn('$perf_event', {
555
- perfType: event.type,
556
- ...event,
557
- });
558
- }
550
+ /**
551
+ * REMOVED: a duplicate `$perf_event` through the analytics/event pipeline.
552
+ *
553
+ * This claimed to be "for backend ingestion", but the `queuePerformanceMetric` call below is
554
+ * the backend ingestion path — it writes the dedicated `performance_metrics` table, and that
555
+ * is what EVERY consumer reads: `perf-aggregate.ch.ts` (FPS, frameMetricsSessions), the
556
+ * Monitor low-FPS KPI, the page-load RenderingPanel and the Replay Inspector's FPS readout.
557
+ * Nothing in the backend or the dashboard reads `$perf_event` — verified by search.
558
+ *
559
+ * So this line wrote every perf sample a SECOND time, into the event lane, where it landed as
560
+ * `USER_ACTION` and was counted as a user interaction. Measured in production:
561
+ *
562
+ * performance_metrics.frame_metrics 75,278 ← read by everything
563
+ * replay_events (as USER_ACTION) 47,019 ← read by nothing
564
+ *
565
+ * `USER_ACTION` was 99% of all mobile events (62,026 of ~62,600), and only 980 of those
566
+ * carried real gesture data. One session showed 4,735 events against SIX real gestures. That
567
+ * made session event counts meaningless and polluted anything counting user interactions.
568
+ *
569
+ * Dropping it loses no data and no feature — the perf lane's copy is the more complete one —
570
+ * and it costs a duplicate serialize + upload per sample on every session.
571
+ */
559
572
 
560
573
  // Queue to SaaS backend dedicated performance endpoint
561
574
  const backendTransport = SessionManager.getExistingInstance()?.getBackendTransport();
@@ -11,7 +11,7 @@
11
11
  * - Emits FrameMetricsEvent for transport
12
12
  */
13
13
 
14
- import { NativeEventEmitter } from 'react-native';
14
+ import { AppState, NativeEventEmitter, type NativeEventSubscription } from 'react-native';
15
15
  import { getNativePerformanceModule } from '../getNativePerformanceModule';
16
16
  import type { FrameMetricsEvent, PerfContext } from '../models';
17
17
  import { PERF_SCHEMA_VERSION } from '../models';
@@ -25,6 +25,10 @@ export class FrameMetricsCollector {
25
25
  private contextProvider: () => PerfContext;
26
26
  private eventSubscription: any = null;
27
27
  private running = false;
28
+ /** AppState subscription — see `_installAppStateGate`. */
29
+ private appStateSubscription: NativeEventSubscription | null = null;
30
+ /** True while the native sampler is actually running (i.e. armed AND foregrounded). */
31
+ private sampling = false;
28
32
 
29
33
  constructor(
30
34
  config: PerformanceConfig,
@@ -56,22 +60,64 @@ export class FrameMetricsCollector {
56
60
  },
57
61
  );
58
62
 
59
- // Tell native module to start collecting frame metrics
60
- if (typeof perfModule.startFrameMetrics === 'function') {
61
- perfModule.startFrameMetrics(this.config.reportIntervalMs);
62
- }
63
+ this._startNativeSampler();
64
+ this._installAppStateGate();
63
65
  } catch {
64
66
  // Native module not available — graceful degradation
65
67
  }
66
68
  }
67
69
 
68
- stop(): void {
69
- this.running = false;
70
- if (this.eventSubscription) {
71
- this.eventSubscription.remove();
72
- this.eventSubscription = null;
70
+ /**
71
+ * Pause sampling while the app is backgrounded.
72
+ *
73
+ * THE BUG THIS FIXES. `startFrameMetrics` hands off to a native loop that runs until something
74
+ * calls `stopFrameMetrics`. Nothing did. The collector had no AppState awareness at all — unlike
75
+ * `AutoScreenLoadCollector`, which has subscribed to it since it was written — so once a session
76
+ * was sampled in, it kept sampling for as long as the PROCESS lived, not as long as the app was
77
+ * being used.
78
+ *
79
+ * Measured in production: sessions with `durationMs` of 86,400,000 (exactly 24h) and 70,797,000
80
+ * (~19.7h) carrying 39,766 and 3,709 frame samples against 0 and 6 real gestures respectively.
81
+ * `frame_metrics` was 75% of ALL mobile events for this reason.
82
+ *
83
+ * The `tier2SampleRate` knob does not help here: it gates WHICH sessions collect, not how long
84
+ * they collect for. A sampled-in session that gets backgrounded still samples for hours, which is
85
+ * why lowering it would have hidden the symptom while leaving the cause.
86
+ *
87
+ * It is also a DATA-QUALITY fix, not only a volume one. Frame timings from a backgrounded app
88
+ * describe a screen nobody is looking at, and they were feeding the low-FPS KPI and jank
89
+ * detection — so the noise was not merely stored, it was being reported as UI performance.
90
+ *
91
+ * NOTE: on iOS the OS already stops delivering CADisplayLink callbacks on background, so the
92
+ * practical effect is largest on Android. Gating in JS keeps the two platforms behaving the same
93
+ * either way, rather than relying on an OS side effect that is not part of any contract.
94
+ */
95
+ private _installAppStateGate(): void {
96
+ if (this.appStateSubscription) return;
97
+ this.appStateSubscription = AppState.addEventListener('change', (next) => {
98
+ if (!this.running) return; // stop() already tore us down
99
+ if (next === 'active') this._startNativeSampler();
100
+ else this._stopNativeSampler(); // 'background' and 'inactive' (iOS call/app-switcher)
101
+ });
102
+ }
103
+
104
+ /** Idempotent — AppState can fire 'active' more than once without an intervening background. */
105
+ private _startNativeSampler(): void {
106
+ if (this.sampling) return;
107
+ try {
108
+ const perfModule = getNativePerformanceModule();
109
+ if (typeof perfModule?.startFrameMetrics === 'function') {
110
+ perfModule.startFrameMetrics(this.config.reportIntervalMs);
111
+ this.sampling = true;
112
+ }
113
+ } catch {
114
+ // No-throw: a perf collector must never break the host app.
73
115
  }
116
+ }
74
117
 
118
+ private _stopNativeSampler(): void {
119
+ if (!this.sampling) return;
120
+ this.sampling = false;
75
121
  try {
76
122
  const perfModule = getNativePerformanceModule();
77
123
  if (typeof perfModule?.stopFrameMetrics === 'function') {
@@ -82,6 +128,22 @@ export class FrameMetricsCollector {
82
128
  }
83
129
  }
84
130
 
131
+ stop(): void {
132
+ this.running = false;
133
+ if (this.eventSubscription) {
134
+ this.eventSubscription.remove();
135
+ this.eventSubscription = null;
136
+ }
137
+ // Remove the AppState listener BEFORE stopping the sampler: a 'change' arriving mid-teardown
138
+ // would otherwise restart the native loop on a collector that is being shut down, which is the
139
+ // same leak this gate exists to close.
140
+ if (this.appStateSubscription) {
141
+ this.appStateSubscription.remove();
142
+ this.appStateSubscription = null;
143
+ }
144
+ this._stopNativeSampler();
145
+ }
146
+
85
147
  private _handleNativeFrameMetrics(data: any): void {
86
148
  if (!data || typeof data.totalFrames !== 'number') return;
87
149
 
@@ -68,6 +68,24 @@ export class PerformanceTransport {
68
68
 
69
69
  /** Send a metric value (for backward compat with existing metric handler) */
70
70
  sendMetric(name: string, value: number, unit?: string, tags?: Record<string, string>): void {
71
+ // D4: the desktop sink is only wired under enableDebug(), so in a
72
+ // production build everything through here — JS stall stats, launch
73
+ // timings, and the PUBLIC ScaleBun.sendMetric() API — was buffered into
74
+ // a bounded array and discarded. Mirror every metric onto the backend
75
+ // ingestion lane (the one that provably lands rows); the desktop sink
76
+ // remains the dev-tooling fan-out below.
77
+ try {
78
+ // Lazy import avoids a static cycle: session → performance → session.
79
+ const { SessionManager } = require('../../session/SessionManager');
80
+ const backend = SessionManager.getExistingInstance()?.getBackendTransport();
81
+ backend?.queuePerformanceMetric({
82
+ type: name,
83
+ value,
84
+ metadata: { ...(unit ? { unit } : {}), ...(tags ?? {}) },
85
+ });
86
+ } catch {
87
+ // Backend lane unavailable (no session yet) — dev sink may still get it.
88
+ }
71
89
  const msg = {
72
90
  type: 'metric' as const,
73
91
  protocolVersion: PROTOCOL_VERSION,