@scalebun/react-native 1.0.3 → 1.0.4

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 (223) 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/ios/Crash/ScaleBunCrashBridge.mm +6 -0
  7. package/ios/Crash/ScaleBunCrashHandler.swift +14 -1
  8. package/ios/Crash/ScaleBunCrashModule.swift +14 -0
  9. package/ios/Crash/ScaleBunCrashSignalBridge.h +32 -1
  10. package/ios/Crash/ScaleBunCrashSignalBridge.m +172 -7
  11. package/ios/Crash/ScaleBunCrashSignalStress.c +137 -0
  12. package/ios/Crash/ScaleBunCrashStorage.swift +75 -0
  13. package/lib/commonjs/analytics/EventTracker.js +30 -2
  14. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  15. package/lib/commonjs/bootstrap/SDKBootstrapper.js +19 -14
  16. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  17. package/lib/commonjs/core/config/schema.js +13 -5
  18. package/lib/commonjs/core/config/schema.js.map +1 -1
  19. package/lib/commonjs/core/context/device.js +130 -0
  20. package/lib/commonjs/core/context/device.js.map +1 -1
  21. package/lib/commonjs/debug/replayWiring.js +2 -1
  22. package/lib/commonjs/debug/replayWiring.js.map +1 -1
  23. package/lib/commonjs/features/bugreport/BugReportFeature.js +112 -0
  24. package/lib/commonjs/features/bugreport/BugReportFeature.js.map +1 -0
  25. package/lib/commonjs/features/bugreport/buildPayload.js +97 -0
  26. package/lib/commonjs/features/bugreport/buildPayload.js.map +1 -0
  27. package/lib/commonjs/features/crash/CrashReporter.js +25 -1
  28. package/lib/commonjs/features/crash/CrashReporter.js.map +1 -1
  29. package/lib/commonjs/features/crash/capture.js +27 -9
  30. package/lib/commonjs/features/crash/capture.js.map +1 -1
  31. package/lib/commonjs/features/crash/nativeCrashBridge.js +163 -28
  32. package/lib/commonjs/features/crash/nativeCrashBridge.js.map +1 -1
  33. package/lib/commonjs/features/engage/EngagePromptProvider.js +36 -2
  34. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  35. package/lib/commonjs/features/engage/EngagePromptView.js +47 -14
  36. package/lib/commonjs/features/engage/EngagePromptView.js.map +1 -1
  37. package/lib/commonjs/features/engage/engageTriggerEngine.js +10 -0
  38. package/lib/commonjs/features/engage/engageTriggerEngine.js.map +1 -1
  39. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  40. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +2 -1
  41. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js.map +1 -1
  42. package/lib/commonjs/features/ota/OtaOrchestrator.js +63 -5
  43. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  44. package/lib/commonjs/features/ota/deviceAttributes.js +48 -0
  45. package/lib/commonjs/features/ota/deviceAttributes.js.map +1 -0
  46. package/lib/commonjs/features/ota/geoCountry.js +93 -0
  47. package/lib/commonjs/features/ota/geoCountry.js.map +1 -0
  48. package/lib/commonjs/features/ota/useOtaUpdate.js +4 -1
  49. package/lib/commonjs/features/ota/useOtaUpdate.js.map +1 -1
  50. package/lib/commonjs/features/performance/PerformanceFeature.js +23 -9
  51. package/lib/commonjs/features/performance/PerformanceFeature.js.map +1 -1
  52. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +69 -9
  53. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  54. package/lib/commonjs/features/performance/transport/PerformanceTransport.js +25 -0
  55. package/lib/commonjs/features/performance/transport/PerformanceTransport.js.map +1 -1
  56. package/lib/commonjs/features/session/BackendSessionAdapter.js +128 -11
  57. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  58. package/lib/commonjs/features/session/JourneyEventPipeline.js +1 -0
  59. package/lib/commonjs/features/session/JourneyEventPipeline.js.map +1 -1
  60. package/lib/commonjs/features/session/ReplayCaptureManager.js +46 -0
  61. package/lib/commonjs/features/session/ReplayCaptureManager.js.map +1 -1
  62. package/lib/commonjs/features/session/SessionManager.js +42 -12
  63. package/lib/commonjs/features/session/SessionManager.js.map +1 -1
  64. package/lib/commonjs/features/session/nativeCapture.js +4 -4
  65. package/lib/commonjs/features/session/nativeCapture.js.map +1 -1
  66. package/lib/commonjs/features/session/outboxFrameCapture.js +3 -2
  67. package/lib/commonjs/features/session/outboxFrameCapture.js.map +1 -1
  68. package/lib/commonjs/features/session/sessionTypes.js.map +1 -1
  69. package/lib/commonjs/index.js +13 -0
  70. package/lib/commonjs/index.js.map +1 -1
  71. package/lib/commonjs/public/ScaleBunFacade.js +150 -2
  72. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  73. package/lib/commonjs/specs/NativeScaleBunCrash.js.map +1 -1
  74. package/lib/module/analytics/EventTracker.js +29 -1
  75. package/lib/module/analytics/EventTracker.js.map +1 -1
  76. package/lib/module/bootstrap/SDKBootstrapper.js +17 -12
  77. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  78. package/lib/module/core/config/schema.js +13 -5
  79. package/lib/module/core/config/schema.js.map +1 -1
  80. package/lib/module/core/context/device.js +119 -0
  81. package/lib/module/core/context/device.js.map +1 -1
  82. package/lib/module/debug/replayWiring.js +2 -1
  83. package/lib/module/debug/replayWiring.js.map +1 -1
  84. package/lib/module/features/bugreport/BugReportFeature.js +105 -0
  85. package/lib/module/features/bugreport/BugReportFeature.js.map +1 -0
  86. package/lib/module/features/bugreport/buildPayload.js +91 -0
  87. package/lib/module/features/bugreport/buildPayload.js.map +1 -0
  88. package/lib/module/features/crash/CrashReporter.js +25 -1
  89. package/lib/module/features/crash/CrashReporter.js.map +1 -1
  90. package/lib/module/features/crash/capture.js +28 -9
  91. package/lib/module/features/crash/capture.js.map +1 -1
  92. package/lib/module/features/crash/nativeCrashBridge.js +161 -28
  93. package/lib/module/features/crash/nativeCrashBridge.js.map +1 -1
  94. package/lib/module/features/engage/EngagePromptProvider.js +36 -2
  95. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  96. package/lib/module/features/engage/EngagePromptView.js +47 -14
  97. package/lib/module/features/engage/EngagePromptView.js.map +1 -1
  98. package/lib/module/features/engage/engageTriggerEngine.js +10 -0
  99. package/lib/module/features/engage/engageTriggerEngine.js.map +1 -1
  100. package/lib/module/features/engage/engageTypes.js.map +1 -1
  101. package/lib/module/features/journey/ScaleBunDebugRoot.js +2 -1
  102. package/lib/module/features/journey/ScaleBunDebugRoot.js.map +1 -1
  103. package/lib/module/features/ota/OtaOrchestrator.js +63 -5
  104. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  105. package/lib/module/features/ota/deviceAttributes.js +43 -0
  106. package/lib/module/features/ota/deviceAttributes.js.map +1 -0
  107. package/lib/module/features/ota/geoCountry.js +86 -0
  108. package/lib/module/features/ota/geoCountry.js.map +1 -0
  109. package/lib/module/features/ota/useOtaUpdate.js +4 -1
  110. package/lib/module/features/ota/useOtaUpdate.js.map +1 -1
  111. package/lib/module/features/performance/PerformanceFeature.js +23 -9
  112. package/lib/module/features/performance/PerformanceFeature.js.map +1 -1
  113. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +70 -10
  114. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  115. package/lib/module/features/performance/transport/PerformanceTransport.js +25 -0
  116. package/lib/module/features/performance/transport/PerformanceTransport.js.map +1 -1
  117. package/lib/module/features/session/BackendSessionAdapter.js +128 -11
  118. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  119. package/lib/module/features/session/JourneyEventPipeline.js +1 -0
  120. package/lib/module/features/session/JourneyEventPipeline.js.map +1 -1
  121. package/lib/module/features/session/ReplayCaptureManager.js +46 -0
  122. package/lib/module/features/session/ReplayCaptureManager.js.map +1 -1
  123. package/lib/module/features/session/SessionManager.js +42 -12
  124. package/lib/module/features/session/SessionManager.js.map +1 -1
  125. package/lib/module/features/session/nativeCapture.js +4 -5
  126. package/lib/module/features/session/nativeCapture.js.map +1 -1
  127. package/lib/module/features/session/outboxFrameCapture.js +3 -2
  128. package/lib/module/features/session/outboxFrameCapture.js.map +1 -1
  129. package/lib/module/features/session/sessionTypes.js.map +1 -1
  130. package/lib/module/index.js +3 -0
  131. package/lib/module/index.js.map +1 -1
  132. package/lib/module/public/ScaleBunFacade.js +150 -2
  133. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  134. package/lib/module/specs/NativeScaleBunCrash.js.map +1 -1
  135. package/lib/typescript/analytics/EventTracker.d.ts +20 -0
  136. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  137. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  138. package/lib/typescript/core/config/schema.d.ts +1 -1
  139. package/lib/typescript/core/config/schema.d.ts.map +1 -1
  140. package/lib/typescript/core/context/device.d.ts +46 -0
  141. package/lib/typescript/core/context/device.d.ts.map +1 -1
  142. package/lib/typescript/debug/replayWiring.d.ts.map +1 -1
  143. package/lib/typescript/features/bugreport/BugReportFeature.d.ts +59 -0
  144. package/lib/typescript/features/bugreport/BugReportFeature.d.ts.map +1 -0
  145. package/lib/typescript/features/bugreport/buildPayload.d.ts +51 -0
  146. package/lib/typescript/features/bugreport/buildPayload.d.ts.map +1 -0
  147. package/lib/typescript/features/crash/CrashReporter.d.ts +20 -1
  148. package/lib/typescript/features/crash/CrashReporter.d.ts.map +1 -1
  149. package/lib/typescript/features/crash/capture.d.ts +21 -2
  150. package/lib/typescript/features/crash/capture.d.ts.map +1 -1
  151. package/lib/typescript/features/crash/nativeCrashBridge.d.ts +16 -3
  152. package/lib/typescript/features/crash/nativeCrashBridge.d.ts.map +1 -1
  153. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  154. package/lib/typescript/features/engage/EngagePromptView.d.ts.map +1 -1
  155. package/lib/typescript/features/engage/engageTriggerEngine.d.ts.map +1 -1
  156. package/lib/typescript/features/engage/engageTypes.d.ts +15 -0
  157. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  158. package/lib/typescript/features/journey/ScaleBunDebugRoot.d.ts.map +1 -1
  159. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +8 -2
  160. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  161. package/lib/typescript/features/ota/deviceAttributes.d.ts +18 -0
  162. package/lib/typescript/features/ota/deviceAttributes.d.ts.map +1 -0
  163. package/lib/typescript/features/ota/geoCountry.d.ts +14 -0
  164. package/lib/typescript/features/ota/geoCountry.d.ts.map +1 -0
  165. package/lib/typescript/features/ota/useOtaUpdate.d.ts +20 -2
  166. package/lib/typescript/features/ota/useOtaUpdate.d.ts.map +1 -1
  167. package/lib/typescript/features/performance/PerformanceFeature.d.ts +0 -1
  168. package/lib/typescript/features/performance/PerformanceFeature.d.ts.map +1 -1
  169. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +33 -0
  170. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  171. package/lib/typescript/features/performance/transport/PerformanceTransport.d.ts.map +1 -1
  172. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +42 -2
  173. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  174. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +2 -0
  175. package/lib/typescript/features/session/JourneyEventPipeline.d.ts.map +1 -1
  176. package/lib/typescript/features/session/ReplayCaptureManager.d.ts +38 -1
  177. package/lib/typescript/features/session/ReplayCaptureManager.d.ts.map +1 -1
  178. package/lib/typescript/features/session/SessionManager.d.ts.map +1 -1
  179. package/lib/typescript/features/session/nativeCapture.d.ts +2 -1
  180. package/lib/typescript/features/session/nativeCapture.d.ts.map +1 -1
  181. package/lib/typescript/features/session/outboxFrameCapture.d.ts.map +1 -1
  182. package/lib/typescript/features/session/sessionTypes.d.ts +20 -3
  183. package/lib/typescript/features/session/sessionTypes.d.ts.map +1 -1
  184. package/lib/typescript/index.d.ts +1 -0
  185. package/lib/typescript/index.d.ts.map +1 -1
  186. package/lib/typescript/public/ScaleBunFacade.d.ts +58 -0
  187. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  188. package/lib/typescript/specs/NativeScaleBunCrash.d.ts +14 -0
  189. package/lib/typescript/specs/NativeScaleBunCrash.d.ts.map +1 -1
  190. package/lib/typescript/vendor/protocol/index.d.ts +17 -17
  191. package/package.json +1 -1
  192. package/src/analytics/EventTracker.ts +28 -1
  193. package/src/bootstrap/SDKBootstrapper.ts +15 -14
  194. package/src/core/config/schema.ts +13 -5
  195. package/src/core/context/device.ts +146 -0
  196. package/src/debug/replayWiring.ts +2 -1
  197. package/src/features/bugreport/BugReportFeature.ts +128 -0
  198. package/src/features/bugreport/buildPayload.ts +116 -0
  199. package/src/features/crash/CrashReporter.ts +40 -1
  200. package/src/features/crash/capture.ts +36 -9
  201. package/src/features/crash/nativeCrashBridge.ts +156 -22
  202. package/src/features/engage/EngagePromptProvider.tsx +48 -2
  203. package/src/features/engage/EngagePromptView.tsx +51 -15
  204. package/src/features/engage/engageTriggerEngine.ts +15 -0
  205. package/src/features/engage/engageTypes.ts +15 -0
  206. package/src/features/journey/ScaleBunDebugRoot.tsx +2 -1
  207. package/src/features/ota/OtaOrchestrator.ts +75 -6
  208. package/src/features/ota/deviceAttributes.ts +46 -0
  209. package/src/features/ota/geoCountry.ts +85 -0
  210. package/src/features/ota/useOtaUpdate.ts +20 -2
  211. package/src/features/performance/PerformanceFeature.ts +22 -9
  212. package/src/features/performance/collectors/FrameMetricsCollector.ts +72 -10
  213. package/src/features/performance/transport/PerformanceTransport.ts +18 -0
  214. package/src/features/session/BackendSessionAdapter.ts +137 -10
  215. package/src/features/session/JourneyEventPipeline.ts +3 -0
  216. package/src/features/session/ReplayCaptureManager.ts +53 -1
  217. package/src/features/session/SessionManager.ts +46 -7
  218. package/src/features/session/nativeCapture.ts +3 -2
  219. package/src/features/session/outboxFrameCapture.ts +3 -2
  220. package/src/features/session/sessionTypes.ts +26 -3
  221. package/src/index.ts +3 -0
  222. package/src/public/ScaleBunFacade.ts +153 -2
  223. package/src/specs/NativeScaleBunCrash.ts +16 -0
@@ -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,
@@ -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++;