@scalebun/react-native 1.10.7 → 1.11.1

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 (54) hide show
  1. package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -25
  2. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +15 -3
  3. package/dist/scalebun.full.js +467 -255
  4. package/dist/scalebun.slim.js +466 -254
  5. package/ios/Capture/InteractionTracker.swift +8 -4
  6. package/ios/Ota/OtaSlotManager.swift +19 -5
  7. package/lib/commonjs/analytics/EventTracker.js +5 -5
  8. package/lib/commonjs/analytics/automaticEvents.js +3 -2
  9. package/lib/commonjs/core/constants/version.js +7 -2
  10. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -103
  11. package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
  12. package/lib/commonjs/features/journey/uiState.js +8 -1
  13. package/lib/commonjs/features/ota/OtaOrchestrator.js +174 -48
  14. package/lib/commonjs/features/ota/OtaTypes.js +4 -0
  15. package/lib/commonjs/features/ota/useOtaUpdate.js +11 -2
  16. package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
  17. package/lib/commonjs/features/session/SessionManager.js +37 -38
  18. package/lib/commonjs/public/ScaleBunFacade.js +115 -2
  19. package/lib/module/analytics/EventTracker.js +5 -5
  20. package/lib/module/analytics/automaticEvents.js +3 -2
  21. package/lib/module/core/constants/version.js +7 -2
  22. package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -103
  23. package/lib/module/features/journey/interactionProtocol.js +38 -0
  24. package/lib/module/features/journey/uiState.js +8 -1
  25. package/lib/module/features/ota/OtaOrchestrator.js +174 -48
  26. package/lib/module/features/ota/OtaTypes.js +1 -1
  27. package/lib/module/features/ota/useOtaUpdate.js +11 -2
  28. package/lib/module/features/session/JourneyEventPipeline.js +6 -5
  29. package/lib/module/features/session/SessionManager.js +37 -38
  30. package/lib/module/public/ScaleBunFacade.js +115 -2
  31. package/lib/typescript/analytics/EventTracker.d.ts +1 -1
  32. package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
  33. package/lib/typescript/core/constants/version.d.ts +7 -2
  34. package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
  35. package/lib/typescript/features/ota/OtaEventEmitter.d.ts +15 -1
  36. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +22 -3
  37. package/lib/typescript/features/ota/OtaTypes.d.ts +29 -32
  38. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
  39. package/lib/typescript/features/session/SessionManager.d.ts +15 -10
  40. package/lib/typescript/public/ScaleBunFacade.d.ts +27 -0
  41. package/package.json +4 -3
  42. package/src/analytics/EventTracker.ts +5 -5
  43. package/src/analytics/automaticEvents.ts +4 -0
  44. package/src/core/constants/version.ts +7 -2
  45. package/src/features/journey/ScaleBunDebugRoot.tsx +96 -97
  46. package/src/features/journey/interactionProtocol.ts +65 -0
  47. package/src/features/journey/uiState.ts +9 -4
  48. package/src/features/ota/OtaEventEmitter.ts +12 -0
  49. package/src/features/ota/OtaOrchestrator.ts +209 -62
  50. package/src/features/ota/OtaTypes.ts +37 -39
  51. package/src/features/ota/useOtaUpdate.ts +11 -2
  52. package/src/features/session/JourneyEventPipeline.ts +7 -5
  53. package/src/features/session/SessionManager.ts +75 -38
  54. package/src/public/ScaleBunFacade.ts +127 -3
@@ -84,6 +84,11 @@ async function deliverOtaEvents(params) {
84
84
  kind: 'ota_event',
85
85
  type: e.type,
86
86
  bundleId: e.bundleId,
87
+ // Without this the backend stored a null releaseId on every row it ingested,
88
+ // while serving the release id on every check — so the delivery funnel could
89
+ // only ever be grouped by bundle, and a bundle re-promoted under a second
90
+ // release merged the two into one indistinguishable series.
91
+ releaseId: e.releaseId,
87
92
  installationId: params.installationId,
88
93
  // OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
89
94
  // resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
@@ -121,7 +126,6 @@ async function deliverOtaEvents(params) {
121
126
  /** Boot-guard configuration */
122
127
 
123
128
  const DEFAULT_HEALTHY_AFTER_MS = 10_000;
124
- const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
125
129
  export class OtaOrchestrator {
126
130
  enabled = false;
127
131
  currentBundle = null;
@@ -198,22 +202,35 @@ export class OtaOrchestrator {
198
202
  this.signatureConfig = config?.signature;
199
203
  __DEV__ && logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
200
204
 
205
+ // ONE read of the slot state, shared by both boot-time consumers.
206
+ //
207
+ // `getSlotState()` is CONSUME-ON-READ for the revert record: both native
208
+ // implementations delete it as they serialise, so that a rollback is
209
+ // reported exactly once instead of on every launch forever. Calling it
210
+ // twice therefore means the second caller never sees the revert — which is
211
+ // precisely what happened, since hydration ran first and swallowed it. It
212
+ // is also a blocking synchronous bridge call, so one read is cheaper.
213
+ const slotState = this.readSlotState();
214
+
201
215
  // Rehydrate which bundle we are running from the native slot state.
202
216
  // Without this `currentBundle` stays null for the whole process after a
203
217
  // restart, so every check reported no current bundle and the backend had
204
218
  // no way to know what the device was actually on.
205
- this.hydrateCurrentBundleFromSlots();
219
+ this.hydrateCurrentBundleFromSlots(slotState);
206
220
 
207
221
  // Warm the device-country cache (edge Worker lookup) so checks can carry
208
222
  // a country when the API itself sits behind no geo-stamping CDN.
209
223
  // Fire-and-forget and failure-soft — a check without a country is valid.
210
224
  void prefetchDeviceCountry();
211
225
 
226
+ // Check if the boot guard fired on this launch (native reverted before JS
227
+ // loaded). BEFORE the identity check, because both read the install record
228
+ // and the identity check retires it — a revert must get its chance to name
229
+ // the bundle that failed while the record still describes it.
230
+ this.checkBootGuardRecovery(slotState);
231
+
212
232
  // Prove the bundle we installed is the bundle that loaded.
213
233
  this.verifyRunningBundleIdentity();
214
-
215
- // Check if the boot guard fired on this launch (native reverted before JS loaded)
216
- this.checkBootGuardRecovery();
217
234
  });
218
235
  }
219
236
 
@@ -221,10 +238,20 @@ export class OtaOrchestrator {
221
238
  * Read the active slot back into `currentBundle` so the next check reports
222
239
  * what this device is genuinely running.
223
240
  */
224
- hydrateCurrentBundleFromSlots() {
225
- if (!NativeScaleBunOta) return;
241
+ /**
242
+ * Parse the native slot state once. Returns null when the module is absent or
243
+ * the payload is unreadable — every caller treats that as "factory bundle".
244
+ */
245
+ readSlotState() {
246
+ if (!NativeScaleBunOta) return null;
247
+ try {
248
+ return JSON.parse(NativeScaleBunOta.getSlotState());
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+ hydrateCurrentBundleFromSlots(state) {
226
254
  try {
227
- const state = JSON.parse(NativeScaleBunOta.getSlotState());
228
255
  const current = state?.current;
229
256
  if (!current?.sha256) return;
230
257
 
@@ -239,6 +266,7 @@ export class OtaOrchestrator {
239
266
  this.currentBundle = {
240
267
  id: record.bundleId,
241
268
  version: record.version,
269
+ releaseId: record.releaseId ?? undefined,
242
270
  sha256: record.sha256
243
271
  };
244
272
  __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
@@ -275,37 +303,42 @@ export class OtaOrchestrator {
275
303
  verifyRunningBundleIdentity() {
276
304
  if (!this.currentBundle) return;
277
305
  const running = readRunningBundleMarker();
278
- const expected = this.readInstallExpectation();
306
+ const record = this.readInstallRecord();
307
+
308
+ // No record at all — installed by an SDK that predates install records, or
309
+ // local storage was cleared. NOTHING can be concluded here and nothing is
310
+ // reported: the marker is a random per-publish token, never the bundle id,
311
+ // so comparing the two would flag a false mismatch on every healthy launch.
312
+ if (!record) return;
279
313
 
280
- // Stale record from an earlier install the slot has moved on since.
281
- if (expected && expected.bundleId !== this.currentBundle.id) {
314
+ // The record describes a bundle that is no longer the active one (a revert,
315
+ // or a bundle staged by another path). It cannot verify this launch, and
316
+ // keeping it would make the next launch mis-report what is running.
317
+ if (record.sha256 !== this.currentBundle.sha256) {
282
318
  this.clearInstallExpectation();
283
319
  return;
284
320
  }
285
- if (expected) {
286
- if (running === expected.identityToken) {
287
- // Proven: the bundle we installed is the bundle executing.
288
- __DEV__ && logger.debug('[OTA] Install verified running bundle matches what was installed.');
289
- this.clearInstallExpectation();
290
- return;
291
- }
292
- logger.error(`[OTA] INSTALL DID NOT TAKE EFFECT — bundle ${this.currentBundle.id} was installed and ` + `carries a known identity marker, but the running bundle reports ` + `${running ?? 'no marker at all'}. The app is executing different code than the slot ` + 'manager believes. Check that the host app resolves the OTA bundle path at launch ' + '(see the ScaleBunOta integration for your React Native version).');
293
- otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
294
- error: `install_not_effective expected ${expected.identityToken}, running ${running ?? 'none'}`
295
- });
296
- // Deliberately NOT cleared: the condition is still true on the next boot
297
- // and should keep reporting until the integration is fixed. Clearing here
298
- // would make a permanently broken install look like a one-off.
321
+
322
+ // Already proven on an earlier launch. The token is retired once verified
323
+ // while the REST of the record stays — it is also the sha256 -> bundleId map
324
+ // that `hydrateCurrentBundleFromSlots` reads to report what this device is
325
+ // running. Wiping the whole record here is what made every launch after the
326
+ // first send `currentBundleId: undefined`, which the server reads as "not on
327
+ // this bundle" and answers by serving the same bundle again, forever.
328
+ if (!record.identityToken) return;
329
+ if (running === record.identityToken) {
330
+ __DEV__ && logger.debug('[OTA] Install verified running bundle matches what was installed.');
331
+ this.retireIdentityToken(record);
299
332
  return;
300
333
  }
301
- if (running && running !== this.currentBundle.id) {
302
- // No recorded expectation (installed by an older SDK), but the running
303
- // marker disagrees with the active slot outright. Still conclusive.
304
- logger.error(`[OTA] BUNDLE MISMATCH slot says ${this.currentBundle.id} is active but the ` + `running bundle identifies as ${running}.`);
305
- otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
306
- error: `bundle_identity_mismatch running ${running}`
307
- });
308
- }
334
+ logger.error(`[OTA] INSTALL DID NOT TAKE EFFECT — bundle ${this.currentBundle.id} was installed and ` + `carries a known identity marker, but the running bundle reports ` + `${running ?? 'no marker at all'}. The app is executing different code than the slot ` + 'manager believes. Check that the host app resolves the OTA bundle path at launch ' + '(see the ScaleBunOta integration for your React Native version).');
335
+ otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
336
+ releaseId: this.currentBundle.releaseId,
337
+ error: `install_not_effectiveexpected ${record.identityToken}, running ${running ?? 'none'}`
338
+ });
339
+ // Deliberately NOT retired: the condition is still true on the next boot and
340
+ // should keep reporting until the integration is fixed. Retiring the token
341
+ // here would make a permanently broken install look like a one-off.
309
342
  }
310
343
 
311
344
  // ── Install expectation ────────────────────────────────────────────────────
@@ -319,6 +352,11 @@ export class OtaOrchestrator {
319
352
  this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
320
353
  bundleId: bundle.id,
321
354
  version: bundle.version,
355
+ // Carried so telemetry emitted on a LATER launch (a boot-guard
356
+ // rollback, an ineffective install) can still be attributed to the
357
+ // release, not merely the bundle. The check response is long gone by
358
+ // then; this record is the only thing that remembers.
359
+ releaseId: bundle.releaseId ?? null,
322
360
  // The join key back to the native slot, which records sha256 and
323
361
  // nothing else identifying.
324
362
  sha256: bundle.sha256,
@@ -338,12 +376,28 @@ export class OtaOrchestrator {
338
376
  return null;
339
377
  }
340
378
  }
341
- readInstallExpectation() {
342
- const record = this.readInstallRecord();
343
- return record && record.identityToken ? {
344
- bundleId: record.bundleId,
345
- identityToken: record.identityToken
346
- } : null;
379
+
380
+ /**
381
+ * Drop the identity token once the install has been proven, keeping the rest
382
+ * of the record.
383
+ *
384
+ * The record does two jobs: it proves an install took effect (once), and it
385
+ * maps the native slot's sha256 back to a bundle id (for the life of that
386
+ * bundle). Only the first job is finished after a successful verification, so
387
+ * only the token is retired.
388
+ */
389
+ retireIdentityToken(record) {
390
+ try {
391
+ this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
392
+ bundleId: record.bundleId,
393
+ version: record.version,
394
+ releaseId: record.releaseId ?? null,
395
+ sha256: record.sha256,
396
+ identityToken: null
397
+ }));
398
+ } catch {
399
+ /* non-fatal */
400
+ }
347
401
  }
348
402
  clearInstallExpectation() {
349
403
  try {
@@ -362,17 +416,43 @@ export class OtaOrchestrator {
362
416
  * If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
363
417
  * and no 'current' OTA bundle, the native layer already reverted.
364
418
  */
365
- checkBootGuardRecovery() {
366
- if (!NativeScaleBunOta) return;
419
+ checkBootGuardRecovery(state) {
367
420
  try {
368
- const stateJson = NativeScaleBunOta.getSlotState();
369
- const state = JSON.parse(stateJson);
370
- if (state.bootGuardReverted) {
371
- logger.warn('[OTA] Boot guard firedapp was reverted to previous bundle');
372
- otaEventEmitter.emitSimple('AUTO_ROLLBACK', state.previous?.bundleId ?? 'unknown', {
373
- reason: 'boot_guard_crash_loop_detected'
421
+ if (!state?.bootGuardReverted) return;
422
+
423
+ // WHICH bundle was rolled back, and it has to come from the install
424
+ // record. The obvious source`state.previous` is wrong twice over:
425
+ // slot meta.json carries only `{sha256, installedAt}` so it has no bundle
426
+ // id to read, and `revert()` deletes the previous slot as its last step,
427
+ // so by the time this runs there is no previous meta at all. The event
428
+ // therefore reported `bundleId: 'unknown'` on every rollback, and the
429
+ // backend drops any ota_event whose bundleId does not resolve to a bundle
430
+ // the app owns — so the crash-guard's own rollback signal never arrived.
431
+ //
432
+ // Native now stamps the sha256 of the bundle it reverted AWAY FROM into
433
+ // the revert record; the install record maps that back to a bundle id.
434
+ const record = this.readInstallRecord();
435
+ const revertedSha = state.bootGuardRevertedSha256;
436
+ const matchesRecord = !!record && (!revertedSha || record.sha256 === revertedSha);
437
+
438
+ // Report the reason native actually determined — a hash mismatch and a
439
+ // crash loop are different incidents and were being collapsed into one.
440
+ const reason = state.bootGuardRevertReason || 'boot_crash_guard';
441
+ if (matchesRecord && record) {
442
+ logger.warn(`[OTA] Boot guard fired — reverted away from bundle ${record.bundleId} ` + `(v${record.version}); reason: ${reason}`);
443
+ otaEventEmitter.emitSimple('AUTO_ROLLBACK', record.bundleId, {
444
+ releaseId: record.releaseId ?? undefined,
445
+ version: record.version,
446
+ reason
374
447
  });
448
+ // The record describes a bundle this device is no longer running.
449
+ this.clearInstallExpectation();
450
+ return;
375
451
  }
452
+
453
+ // No usable record (older SDK, cleared storage). Say so rather than
454
+ // emitting an event the server is obliged to discard.
455
+ logger.warn(`[OTA] Boot guard fired (reason: ${reason}) but the rolled-back bundle could not be ` + 'identified locally — no install record. The rollback is not reported to the server.');
376
456
  } catch {
377
457
  // Slot state parsing failed — non-fatal
378
458
  }
@@ -473,6 +553,22 @@ export class OtaOrchestrator {
473
553
  };
474
554
  }
475
555
  __DEV__ && logger.debug('[OTA] Sync started…');
556
+
557
+ // The funnel's denominator, for devices already on an OTA bundle. Emitted
558
+ // before the request, so a check that fails outright still counts as a
559
+ // check — the only CHECK rows before this came from a server-side geo
560
+ // side-effect that is skipped whenever the request carries no country.
561
+ //
562
+ // Only when a current bundle is known: every ota_event must name a bundle
563
+ // the app owns or the server drops it, and a device still on the binary's
564
+ // factory bundle has no such id to give. Those devices are counted
565
+ // server-side when they are offered something.
566
+ if (this.currentBundle?.id) {
567
+ otaEventEmitter.emitSimple('CHECK', this.currentBundle.id, {
568
+ releaseId: this.currentBundle.releaseId,
569
+ version: this.currentBundle.version
570
+ });
571
+ }
476
572
  const checkRes = await this.checkForUpdate(params);
477
573
  if (checkRes.action === 'NONE') {
478
574
  __DEV__ && logger.debug('[OTA] App is up to date');
@@ -486,7 +582,9 @@ export class OtaOrchestrator {
486
582
  // Handle server-initiated rollback (Sprint 4)
487
583
  if (checkRes.action === 'ROLLBACK') {
488
584
  logger.warn('[OTA] Server requested ROLLBACK — reverting to previous bundle');
489
- otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown');
585
+ otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown', {
586
+ releaseId: this.currentBundle?.releaseId
587
+ });
490
588
  const reverted = await NativeScaleBunOta.revertToPrevious();
491
589
  if (reverted) {
492
590
  this.currentBundle = null;
@@ -511,6 +609,15 @@ export class OtaOrchestrator {
511
609
  const bundle = checkRes.bundle;
512
610
  let patchUsed = false;
513
611
 
612
+ // The offer itself. Everything downstream (download, install, activation)
613
+ // is a conversion against this, so without it the top of the funnel was
614
+ // unmeasurable and a rollout that never reached devices looked identical
615
+ // to one whose devices all declined to download.
616
+ otaEventEmitter.emitSimple('OFFERED', bundle.id, {
617
+ releaseId: bundle.releaseId,
618
+ version: bundle.version
619
+ });
620
+
514
621
  // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
515
622
  // Before anything touches the disk. SHA-256 proves the bytes arrived
516
623
  // intact; only the signature proves they came from you. Checking after
@@ -518,6 +625,7 @@ export class OtaOrchestrator {
518
625
  const signatureOutcome = await verifyBundleSignature(bundle.sha256, bundle.signature, this.signatureConfig);
519
626
  if (!signatureOutcome.ok) {
520
627
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
628
+ releaseId: bundle.releaseId,
521
629
  error: `Signature check failed: ${signatureOutcome.reason}`,
522
630
  version: bundle.version
523
631
  });
@@ -530,6 +638,7 @@ export class OtaOrchestrator {
530
638
 
531
639
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
532
640
  otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
641
+ releaseId: bundle.releaseId,
533
642
  version: bundle.version
534
643
  });
535
644
  const downloadStart = Date.now();
@@ -628,6 +737,7 @@ export class OtaOrchestrator {
628
737
  postProgress(0, 'FAILED');
629
738
  logger.error('[OTA] Staging bundle failed after retries');
630
739
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
740
+ releaseId: bundle.releaseId,
631
741
  error: 'Staging failed — SHA-256 mismatch or download error',
632
742
  version: bundle.version
633
743
  });
@@ -640,6 +750,7 @@ export class OtaOrchestrator {
640
750
  postProgress(bundle.size, 'COMPLETED');
641
751
  const downloadDuration = Date.now() - downloadStart;
642
752
  otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
753
+ releaseId: bundle.releaseId,
643
754
  version: bundle.version,
644
755
  durationMs: downloadDuration,
645
756
  patchUsed
@@ -651,6 +762,7 @@ export class OtaOrchestrator {
651
762
  if (!applied) {
652
763
  logger.error('[OTA] Applying update failed');
653
764
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
765
+ releaseId: bundle.releaseId,
654
766
  error: 'Atomic slot swap failed',
655
767
  version: bundle.version
656
768
  });
@@ -674,6 +786,7 @@ export class OtaOrchestrator {
674
786
  // it is skipped when there is no marker to compare.
675
787
  this.recordInstallExpectation(bundle);
676
788
  otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
789
+ releaseId: bundle.releaseId,
677
790
  version: bundle.version
678
791
  });
679
792
  __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
@@ -746,6 +859,19 @@ export class OtaOrchestrator {
746
859
  NativeScaleBunOta?.markHealthy();
747
860
  __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
748
861
  this.healthyTimer = null;
862
+
863
+ // The honest activation signal. INSTALLED is emitted optimistically,
864
+ // BEFORE the bundle has ever executed, so counting it as adoption
865
+ // credits bundles that were installed and then crash-reverted. This
866
+ // fires only once the bundle has actually booted and survived.
867
+ const running = this.currentBundle;
868
+ if (running?.id) {
869
+ otaEventEmitter.emitSimple('BOOT_SUCCESS', running.id, {
870
+ releaseId: running.releaseId,
871
+ version: running.version,
872
+ durationMs: healthyMs
873
+ });
874
+ }
749
875
  });
750
876
  }, healthyMs);
751
877
  } catch {
@@ -1,2 +1,2 @@
1
-
1
+ export {};
2
2
  //# sourceMappingURL=OtaTypes.js.map
@@ -60,7 +60,13 @@ export function useOtaUpdate(options) {
60
60
  setIsSyncing(true);
61
61
  setDownloadProgress(0);
62
62
  try {
63
- const result = await otaOrchestrator.sync(options);
63
+ // Read through the ref, not the closed-over `options`. The dependency list
64
+ // below cannot name the targeting fields (`attributes` and `segmentIds` are
65
+ // fresh object/array identities on every render, so listing them would
66
+ // rebuild this callback each time), which left a host that changed
67
+ // `channelName` or `lifecycleStage` syncing against the values captured on
68
+ // first render. The ref is assigned on every render, so it is always current.
69
+ const result = await otaOrchestrator.sync(optionsRef.current);
64
70
  setSyncResult(result);
65
71
 
66
72
  // Handle mandatory update blocking
@@ -71,7 +77,10 @@ export function useOtaUpdate(options) {
71
77
  } finally {
72
78
  setIsSyncing(false);
73
79
  }
74
- }, [options.apiUrl, options.clientKey, options.appVersion, options.installationId, options.autoRestart]);
80
+ // Empty: everything this reads comes from `optionsRef`, so the callback has a
81
+ // stable identity and never needs rebuilding. A host can safely pass it to a
82
+ // memoised child or an effect dependency list.
83
+ }, []);
75
84
  const restart = useCallback(() => {
76
85
  setMandatoryUpdatePending(false);
77
86
  otaOrchestrator.restart();
@@ -65,26 +65,27 @@ export class JourneyEventPipeline {
65
65
  emit(type, opts) {
66
66
  try {
67
67
  const key = `${type}:${opts?.subtype ?? ''}`;
68
- const now = Date.now();
69
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
68
+ const receivedAt = Date.now();
69
+ const occurredAt = opts?.timestamp ?? receivedAt;
70
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
70
71
  // Within dedup window — allow high-confidence native events to
71
72
  // REPLACE a prior low-confidence JS event for the same gesture.
72
73
  // This prevents the race where JS fires first and the pipeline
73
74
  // drops the native event that has more accurate coordinates.
74
75
  const incomingConfidence = opts?.payload?.confidence;
75
76
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
76
- this._replaceLastEvent(key, now, opts);
77
+ this._replaceLastEvent(key, receivedAt, opts);
77
78
  }
78
79
  return null;
79
80
  }
80
81
  this.lastEventKey = key;
81
- this.lastEventTs = now;
82
+ this.lastEventTs = receivedAt;
82
83
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
83
84
  const event = {
84
85
  eventId: generateEventId(),
85
86
  sessionId: this.sessionId,
86
87
  journeyId: opts?.journeyId,
87
- ts: now,
88
+ ts: occurredAt,
88
89
  type,
89
90
  subtype: opts?.subtype,
90
91
  severity: opts?.severity ?? inferSeverity(type),
@@ -27,15 +27,11 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
27
27
  import { redactBody } from '../../debug/redaction';
28
28
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
29
29
  import { resolvePlatformOS } from '../../core/context/device';
30
+ import { emitAutomaticEvent } from '../../analytics/automaticEvents';
31
+ import { INTERACTION_PROTOCOL_VERSION, automaticInteractionProperties, generateInteractionId } from '../journey/interactionProtocol';
30
32
 
31
33
  // ─── Types ──────────────────────────────────────────────────────────────────
32
34
 
33
- /**
34
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
35
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
36
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
37
- */
38
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
39
35
  // ─── Singleton ──────────────────────────────────────────────────────────────
40
36
 
41
37
  let _instance = null;
@@ -51,15 +47,6 @@ export class SessionManager {
51
47
  * lane even when no replay recording is active. Additive, opt-in (default off).
52
48
  */
53
49
  _captureInteractionHeatmap = false;
54
- /**
55
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
56
- * ON by default, an unbounded one-event-per-gesture stream could materially
57
- * inflate ingest volume. We cap emitted interactions per analytics-session
58
- * window (finalize-scoped per foreground): the first N gestures define the
59
- * hotspot shape; the long tail is dropped. Resets when the window changes.
60
- */
61
- _heatmapWindowSessionId = null;
62
- _heatmapWindowCount = 0;
63
50
  session = null;
64
51
  active = false;
65
52
  timeoutTimer = null;
@@ -661,21 +648,11 @@ export class SessionManager {
661
648
  * injected sensitive keys (e.g. a label) are stripped before transport.
662
649
  * - Never throws.
663
650
  */
664
- _emitInteractionToAnalytics(gestureType, payload) {
665
- if (!this._captureInteractionHeatmap) return;
666
- if (this.active) return; // recording lane already carries this tap
651
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
652
+ if (!this._captureInteractionHeatmap) return false;
653
+ if (this.active) return false; // recording lane already carries this tap
667
654
  const adapter = this._backendTransport;
668
- if (!adapter) return;
669
-
670
- // Per-foreground sampling cap. Reset the counter when the analytics
671
- // session window rolls over, then drop anything past the cap.
672
- const windowId = adapter.analyticsSessionId ?? '';
673
- if (windowId !== this._heatmapWindowSessionId) {
674
- this._heatmapWindowSessionId = windowId;
675
- this._heatmapWindowCount = 0;
676
- }
677
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
678
- this._heatmapWindowCount++;
655
+ if (!adapter) return false;
679
656
  try {
680
657
  // Normalize coords in place (same logic as the recording lane).
681
658
  this._normalizeInteractionPayload(payload);
@@ -702,16 +679,18 @@ export class SessionManager {
702
679
  eventId: generateEventId(),
703
680
  // sessionId is (re)stamped by the analytics lane at flush time.
704
681
  sessionId: adapter.analyticsSessionId ?? '',
705
- ts: Date.now(),
682
+ ts: occurredAt,
706
683
  type: 'USER_ACTION',
707
684
  subtype: `gesture:${gestureType}`,
708
- screen: this._lastKnownScreen ?? undefined,
685
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
709
686
  payload: safePayload,
710
687
  source: 'user'
711
688
  };
712
689
  adapter.trackEvent(event);
690
+ return true;
713
691
  } catch (err) {
714
692
  logger.error('[SessionManager] heatmap analytics emit failed:', err);
693
+ return false;
715
694
  }
716
695
  }
717
696
 
@@ -931,13 +910,14 @@ export class SessionManager {
931
910
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
932
911
  * Also triggers frame capture for desktop-initiated recordings.
933
912
  */
934
- onUserAction(subtype, payload) {
913
+ onUserAction(subtype, payload, context) {
935
914
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
936
915
  this.emitEvent('USER_ACTION', {
937
916
  subtype,
938
- screen: this._lastKnownScreen ?? undefined,
917
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
939
918
  payload,
940
- source: 'user'
919
+ source: 'user',
920
+ timestamp: context?.timestamp
941
921
  });
942
922
  // NOTE: Do NOT call captureManager.onInteraction() here.
943
923
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -952,7 +932,8 @@ export class SessionManager {
952
932
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
953
933
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
954
934
  // with no coords never collides with a real origin tap).
955
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
935
+ const suppliedInteractionId = details?.interactionId;
936
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
956
937
  const nowTs = Date.now();
957
938
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
958
939
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -964,7 +945,15 @@ export class SessionManager {
964
945
  // Build the canonical gesture payload ONCE so the recording lane and the
965
946
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
966
947
  // gestureType, etc.). Same object shape that was previously inlined.
948
+ const interactionId = suppliedInteractionId ?? generateInteractionId();
949
+ const occurredAt = details?.occurredAt ?? Date.now();
950
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
967
951
  const payload = {
952
+ interaction_id: interactionId,
953
+ interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
954
+ state_status: stateStatus,
955
+ ui: details?.ui,
956
+ target_id: details?.targetId,
968
957
  gestureType,
969
958
  x: details?.x,
970
959
  y: details?.y,
@@ -1009,9 +998,16 @@ export class SessionManager {
1009
998
  // when the flag is OFF, no backend transport is attached, or recording is
1010
999
  // active (the recording path below already carries this tap). Uses a fresh
1011
1000
  // payload clone so analytics normalization never mutates the recording one.
1012
- this._emitInteractionToAnalytics(gestureType, {
1001
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1013
1002
  ...payload
1014
- });
1003
+ }, occurredAt, details?.screenName);
1004
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1005
+ if (details?.emitAutomaticAnalytics) {
1006
+ try {
1007
+ const safePayload = redactBody(payload);
1008
+ emitAutomaticEvent('element_interacted', automaticInteractionProperties(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1009
+ } catch {/* automatic projection must never affect interaction capture */}
1010
+ }
1015
1011
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1016
1012
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1017
1013
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1021,7 +1017,10 @@ export class SessionManager {
1021
1017
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1022
1018
  // first — so enriching the label cannot change heatmap gesture classification.
1023
1019
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1024
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1020
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1021
+ screen: details?.screenName,
1022
+ timestamp: occurredAt
1023
+ });
1025
1024
  }
1026
1025
 
1027
1026
  /**