@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
@@ -90,6 +90,11 @@ async function deliverOtaEvents(params) {
90
90
  kind: 'ota_event',
91
91
  type: e.type,
92
92
  bundleId: e.bundleId,
93
+ // Without this the backend stored a null releaseId on every row it ingested,
94
+ // while serving the release id on every check — so the delivery funnel could
95
+ // only ever be grouped by bundle, and a bundle re-promoted under a second
96
+ // release merged the two into one indistinguishable series.
97
+ releaseId: e.releaseId,
93
98
  installationId: params.installationId,
94
99
  // OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
95
100
  // resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
@@ -127,7 +132,6 @@ async function deliverOtaEvents(params) {
127
132
  /** Boot-guard configuration */
128
133
 
129
134
  const DEFAULT_HEALTHY_AFTER_MS = 10_000;
130
- const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
131
135
  class OtaOrchestrator {
132
136
  enabled = false;
133
137
  currentBundle = null;
@@ -204,22 +208,35 @@ class OtaOrchestrator {
204
208
  this.signatureConfig = config?.signature;
205
209
  __DEV__ && _internalLogger.logger.debug(`[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` + `${this.environment.bridgeless ? ', bridgeless' : ''}` + `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`);
206
210
 
211
+ // ONE read of the slot state, shared by both boot-time consumers.
212
+ //
213
+ // `getSlotState()` is CONSUME-ON-READ for the revert record: both native
214
+ // implementations delete it as they serialise, so that a rollback is
215
+ // reported exactly once instead of on every launch forever. Calling it
216
+ // twice therefore means the second caller never sees the revert — which is
217
+ // precisely what happened, since hydration ran first and swallowed it. It
218
+ // is also a blocking synchronous bridge call, so one read is cheaper.
219
+ const slotState = this.readSlotState();
220
+
207
221
  // Rehydrate which bundle we are running from the native slot state.
208
222
  // Without this `currentBundle` stays null for the whole process after a
209
223
  // restart, so every check reported no current bundle and the backend had
210
224
  // no way to know what the device was actually on.
211
- this.hydrateCurrentBundleFromSlots();
225
+ this.hydrateCurrentBundleFromSlots(slotState);
212
226
 
213
227
  // Warm the device-country cache (edge Worker lookup) so checks can carry
214
228
  // a country when the API itself sits behind no geo-stamping CDN.
215
229
  // Fire-and-forget and failure-soft — a check without a country is valid.
216
230
  void (0, _geoCountry.prefetchDeviceCountry)();
217
231
 
232
+ // Check if the boot guard fired on this launch (native reverted before JS
233
+ // loaded). BEFORE the identity check, because both read the install record
234
+ // and the identity check retires it — a revert must get its chance to name
235
+ // the bundle that failed while the record still describes it.
236
+ this.checkBootGuardRecovery(slotState);
237
+
218
238
  // Prove the bundle we installed is the bundle that loaded.
219
239
  this.verifyRunningBundleIdentity();
220
-
221
- // Check if the boot guard fired on this launch (native reverted before JS loaded)
222
- this.checkBootGuardRecovery();
223
240
  });
224
241
  }
225
242
 
@@ -227,10 +244,20 @@ class OtaOrchestrator {
227
244
  * Read the active slot back into `currentBundle` so the next check reports
228
245
  * what this device is genuinely running.
229
246
  */
230
- hydrateCurrentBundleFromSlots() {
231
- if (!_NativeScaleBunOta.default) return;
247
+ /**
248
+ * Parse the native slot state once. Returns null when the module is absent or
249
+ * the payload is unreadable — every caller treats that as "factory bundle".
250
+ */
251
+ readSlotState() {
252
+ if (!_NativeScaleBunOta.default) return null;
253
+ try {
254
+ return JSON.parse(_NativeScaleBunOta.default.getSlotState());
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ hydrateCurrentBundleFromSlots(state) {
232
260
  try {
233
- const state = JSON.parse(_NativeScaleBunOta.default.getSlotState());
234
261
  const current = state?.current;
235
262
  if (!current?.sha256) return;
236
263
 
@@ -245,6 +272,7 @@ class OtaOrchestrator {
245
272
  this.currentBundle = {
246
273
  id: record.bundleId,
247
274
  version: record.version,
275
+ releaseId: record.releaseId ?? undefined,
248
276
  sha256: record.sha256
249
277
  };
250
278
  __DEV__ && _internalLogger.logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
@@ -281,37 +309,42 @@ class OtaOrchestrator {
281
309
  verifyRunningBundleIdentity() {
282
310
  if (!this.currentBundle) return;
283
311
  const running = readRunningBundleMarker();
284
- const expected = this.readInstallExpectation();
312
+ const record = this.readInstallRecord();
313
+
314
+ // No record at all — installed by an SDK that predates install records, or
315
+ // local storage was cleared. NOTHING can be concluded here and nothing is
316
+ // reported: the marker is a random per-publish token, never the bundle id,
317
+ // so comparing the two would flag a false mismatch on every healthy launch.
318
+ if (!record) return;
285
319
 
286
- // Stale record from an earlier install the slot has moved on since.
287
- if (expected && expected.bundleId !== this.currentBundle.id) {
320
+ // The record describes a bundle that is no longer the active one (a revert,
321
+ // or a bundle staged by another path). It cannot verify this launch, and
322
+ // keeping it would make the next launch mis-report what is running.
323
+ if (record.sha256 !== this.currentBundle.sha256) {
288
324
  this.clearInstallExpectation();
289
325
  return;
290
326
  }
291
- if (expected) {
292
- if (running === expected.identityToken) {
293
- // Proven: the bundle we installed is the bundle executing.
294
- __DEV__ && _internalLogger.logger.debug('[OTA] Install verified running bundle matches what was installed.');
295
- this.clearInstallExpectation();
296
- return;
297
- }
298
- _internalLogger.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).');
299
- _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
300
- error: `install_not_effective expected ${expected.identityToken}, running ${running ?? 'none'}`
301
- });
302
- // Deliberately NOT cleared: the condition is still true on the next boot
303
- // and should keep reporting until the integration is fixed. Clearing here
304
- // would make a permanently broken install look like a one-off.
327
+
328
+ // Already proven on an earlier launch. The token is retired once verified
329
+ // while the REST of the record stays — it is also the sha256 -> bundleId map
330
+ // that `hydrateCurrentBundleFromSlots` reads to report what this device is
331
+ // running. Wiping the whole record here is what made every launch after the
332
+ // first send `currentBundleId: undefined`, which the server reads as "not on
333
+ // this bundle" and answers by serving the same bundle again, forever.
334
+ if (!record.identityToken) return;
335
+ if (running === record.identityToken) {
336
+ __DEV__ && _internalLogger.logger.debug('[OTA] Install verified running bundle matches what was installed.');
337
+ this.retireIdentityToken(record);
305
338
  return;
306
339
  }
307
- if (running && running !== this.currentBundle.id) {
308
- // No recorded expectation (installed by an older SDK), but the running
309
- // marker disagrees with the active slot outright. Still conclusive.
310
- _internalLogger.logger.error(`[OTA] BUNDLE MISMATCH slot says ${this.currentBundle.id} is active but the ` + `running bundle identifies as ${running}.`);
311
- _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
312
- error: `bundle_identity_mismatch running ${running}`
313
- });
314
- }
340
+ _internalLogger.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).');
341
+ _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
342
+ releaseId: this.currentBundle.releaseId,
343
+ error: `install_not_effectiveexpected ${record.identityToken}, running ${running ?? 'none'}`
344
+ });
345
+ // Deliberately NOT retired: the condition is still true on the next boot and
346
+ // should keep reporting until the integration is fixed. Retiring the token
347
+ // here would make a permanently broken install look like a one-off.
315
348
  }
316
349
 
317
350
  // ── Install expectation ────────────────────────────────────────────────────
@@ -325,6 +358,11 @@ class OtaOrchestrator {
325
358
  this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
326
359
  bundleId: bundle.id,
327
360
  version: bundle.version,
361
+ // Carried so telemetry emitted on a LATER launch (a boot-guard
362
+ // rollback, an ineffective install) can still be attributed to the
363
+ // release, not merely the bundle. The check response is long gone by
364
+ // then; this record is the only thing that remembers.
365
+ releaseId: bundle.releaseId ?? null,
328
366
  // The join key back to the native slot, which records sha256 and
329
367
  // nothing else identifying.
330
368
  sha256: bundle.sha256,
@@ -344,12 +382,28 @@ class OtaOrchestrator {
344
382
  return null;
345
383
  }
346
384
  }
347
- readInstallExpectation() {
348
- const record = this.readInstallRecord();
349
- return record && record.identityToken ? {
350
- bundleId: record.bundleId,
351
- identityToken: record.identityToken
352
- } : null;
385
+
386
+ /**
387
+ * Drop the identity token once the install has been proven, keeping the rest
388
+ * of the record.
389
+ *
390
+ * The record does two jobs: it proves an install took effect (once), and it
391
+ * maps the native slot's sha256 back to a bundle id (for the life of that
392
+ * bundle). Only the first job is finished after a successful verification, so
393
+ * only the token is retired.
394
+ */
395
+ retireIdentityToken(record) {
396
+ try {
397
+ this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, JSON.stringify({
398
+ bundleId: record.bundleId,
399
+ version: record.version,
400
+ releaseId: record.releaseId ?? null,
401
+ sha256: record.sha256,
402
+ identityToken: null
403
+ }));
404
+ } catch {
405
+ /* non-fatal */
406
+ }
353
407
  }
354
408
  clearInstallExpectation() {
355
409
  try {
@@ -368,17 +422,43 @@ class OtaOrchestrator {
368
422
  * If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
369
423
  * and no 'current' OTA bundle, the native layer already reverted.
370
424
  */
371
- checkBootGuardRecovery() {
372
- if (!_NativeScaleBunOta.default) return;
425
+ checkBootGuardRecovery(state) {
373
426
  try {
374
- const stateJson = _NativeScaleBunOta.default.getSlotState();
375
- const state = JSON.parse(stateJson);
376
- if (state.bootGuardReverted) {
377
- _internalLogger.logger.warn('[OTA] Boot guard firedapp was reverted to previous bundle');
378
- _OtaEventEmitter.otaEventEmitter.emitSimple('AUTO_ROLLBACK', state.previous?.bundleId ?? 'unknown', {
379
- reason: 'boot_guard_crash_loop_detected'
427
+ if (!state?.bootGuardReverted) return;
428
+
429
+ // WHICH bundle was rolled back, and it has to come from the install
430
+ // record. The obvious source`state.previous` is wrong twice over:
431
+ // slot meta.json carries only `{sha256, installedAt}` so it has no bundle
432
+ // id to read, and `revert()` deletes the previous slot as its last step,
433
+ // so by the time this runs there is no previous meta at all. The event
434
+ // therefore reported `bundleId: 'unknown'` on every rollback, and the
435
+ // backend drops any ota_event whose bundleId does not resolve to a bundle
436
+ // the app owns — so the crash-guard's own rollback signal never arrived.
437
+ //
438
+ // Native now stamps the sha256 of the bundle it reverted AWAY FROM into
439
+ // the revert record; the install record maps that back to a bundle id.
440
+ const record = this.readInstallRecord();
441
+ const revertedSha = state.bootGuardRevertedSha256;
442
+ const matchesRecord = !!record && (!revertedSha || record.sha256 === revertedSha);
443
+
444
+ // Report the reason native actually determined — a hash mismatch and a
445
+ // crash loop are different incidents and were being collapsed into one.
446
+ const reason = state.bootGuardRevertReason || 'boot_crash_guard';
447
+ if (matchesRecord && record) {
448
+ _internalLogger.logger.warn(`[OTA] Boot guard fired — reverted away from bundle ${record.bundleId} ` + `(v${record.version}); reason: ${reason}`);
449
+ _OtaEventEmitter.otaEventEmitter.emitSimple('AUTO_ROLLBACK', record.bundleId, {
450
+ releaseId: record.releaseId ?? undefined,
451
+ version: record.version,
452
+ reason
380
453
  });
454
+ // The record describes a bundle this device is no longer running.
455
+ this.clearInstallExpectation();
456
+ return;
381
457
  }
458
+
459
+ // No usable record (older SDK, cleared storage). Say so rather than
460
+ // emitting an event the server is obliged to discard.
461
+ _internalLogger.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.');
382
462
  } catch {
383
463
  // Slot state parsing failed — non-fatal
384
464
  }
@@ -479,6 +559,22 @@ class OtaOrchestrator {
479
559
  };
480
560
  }
481
561
  __DEV__ && _internalLogger.logger.debug('[OTA] Sync started…');
562
+
563
+ // The funnel's denominator, for devices already on an OTA bundle. Emitted
564
+ // before the request, so a check that fails outright still counts as a
565
+ // check — the only CHECK rows before this came from a server-side geo
566
+ // side-effect that is skipped whenever the request carries no country.
567
+ //
568
+ // Only when a current bundle is known: every ota_event must name a bundle
569
+ // the app owns or the server drops it, and a device still on the binary's
570
+ // factory bundle has no such id to give. Those devices are counted
571
+ // server-side when they are offered something.
572
+ if (this.currentBundle?.id) {
573
+ _OtaEventEmitter.otaEventEmitter.emitSimple('CHECK', this.currentBundle.id, {
574
+ releaseId: this.currentBundle.releaseId,
575
+ version: this.currentBundle.version
576
+ });
577
+ }
482
578
  const checkRes = await this.checkForUpdate(params);
483
579
  if (checkRes.action === 'NONE') {
484
580
  __DEV__ && _internalLogger.logger.debug('[OTA] App is up to date');
@@ -492,7 +588,9 @@ class OtaOrchestrator {
492
588
  // Handle server-initiated rollback (Sprint 4)
493
589
  if (checkRes.action === 'ROLLBACK') {
494
590
  _internalLogger.logger.warn('[OTA] Server requested ROLLBACK — reverting to previous bundle');
495
- _OtaEventEmitter.otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown');
591
+ _OtaEventEmitter.otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown', {
592
+ releaseId: this.currentBundle?.releaseId
593
+ });
496
594
  const reverted = await _NativeScaleBunOta.default.revertToPrevious();
497
595
  if (reverted) {
498
596
  this.currentBundle = null;
@@ -517,6 +615,15 @@ class OtaOrchestrator {
517
615
  const bundle = checkRes.bundle;
518
616
  let patchUsed = false;
519
617
 
618
+ // The offer itself. Everything downstream (download, install, activation)
619
+ // is a conversion against this, so without it the top of the funnel was
620
+ // unmeasurable and a rollout that never reached devices looked identical
621
+ // to one whose devices all declined to download.
622
+ _OtaEventEmitter.otaEventEmitter.emitSimple('OFFERED', bundle.id, {
623
+ releaseId: bundle.releaseId,
624
+ version: bundle.version
625
+ });
626
+
520
627
  // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
521
628
  // Before anything touches the disk. SHA-256 proves the bytes arrived
522
629
  // intact; only the signature proves they came from you. Checking after
@@ -524,6 +631,7 @@ class OtaOrchestrator {
524
631
  const signatureOutcome = await (0, _signature.verifyBundleSignature)(bundle.sha256, bundle.signature, this.signatureConfig);
525
632
  if (!signatureOutcome.ok) {
526
633
  _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
634
+ releaseId: bundle.releaseId,
527
635
  error: `Signature check failed: ${signatureOutcome.reason}`,
528
636
  version: bundle.version
529
637
  });
@@ -536,6 +644,7 @@ class OtaOrchestrator {
536
644
 
537
645
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
538
646
  _OtaEventEmitter.otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
647
+ releaseId: bundle.releaseId,
539
648
  version: bundle.version
540
649
  });
541
650
  const downloadStart = Date.now();
@@ -634,6 +743,7 @@ class OtaOrchestrator {
634
743
  postProgress(0, 'FAILED');
635
744
  _internalLogger.logger.error('[OTA] Staging bundle failed after retries');
636
745
  _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
746
+ releaseId: bundle.releaseId,
637
747
  error: 'Staging failed — SHA-256 mismatch or download error',
638
748
  version: bundle.version
639
749
  });
@@ -646,6 +756,7 @@ class OtaOrchestrator {
646
756
  postProgress(bundle.size, 'COMPLETED');
647
757
  const downloadDuration = Date.now() - downloadStart;
648
758
  _OtaEventEmitter.otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
759
+ releaseId: bundle.releaseId,
649
760
  version: bundle.version,
650
761
  durationMs: downloadDuration,
651
762
  patchUsed
@@ -657,6 +768,7 @@ class OtaOrchestrator {
657
768
  if (!applied) {
658
769
  _internalLogger.logger.error('[OTA] Applying update failed');
659
770
  _OtaEventEmitter.otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
771
+ releaseId: bundle.releaseId,
660
772
  error: 'Atomic slot swap failed',
661
773
  version: bundle.version
662
774
  });
@@ -680,6 +792,7 @@ class OtaOrchestrator {
680
792
  // it is skipped when there is no marker to compare.
681
793
  this.recordInstallExpectation(bundle);
682
794
  _OtaEventEmitter.otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
795
+ releaseId: bundle.releaseId,
683
796
  version: bundle.version
684
797
  });
685
798
  __DEV__ && _internalLogger.logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
@@ -752,6 +865,19 @@ class OtaOrchestrator {
752
865
  _NativeScaleBunOta.default?.markHealthy();
753
866
  __DEV__ && _internalLogger.logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
754
867
  this.healthyTimer = null;
868
+
869
+ // The honest activation signal. INSTALLED is emitted optimistically,
870
+ // BEFORE the bundle has ever executed, so counting it as adoption
871
+ // credits bundles that were installed and then crash-reverted. This
872
+ // fires only once the bundle has actually booted and survived.
873
+ const running = this.currentBundle;
874
+ if (running?.id) {
875
+ _OtaEventEmitter.otaEventEmitter.emitSimple('BOOT_SUCCESS', running.id, {
876
+ releaseId: running.releaseId,
877
+ version: running.version,
878
+ durationMs: healthyMs
879
+ });
880
+ }
755
881
  });
756
882
  }, healthyMs);
757
883
  } catch {
@@ -1,2 +1,6 @@
1
1
  "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
2
6
  //# sourceMappingURL=OtaTypes.js.map
@@ -66,7 +66,13 @@ function useOtaUpdate(options) {
66
66
  setIsSyncing(true);
67
67
  setDownloadProgress(0);
68
68
  try {
69
- const result = await _OtaOrchestrator.otaOrchestrator.sync(options);
69
+ // Read through the ref, not the closed-over `options`. The dependency list
70
+ // below cannot name the targeting fields (`attributes` and `segmentIds` are
71
+ // fresh object/array identities on every render, so listing them would
72
+ // rebuild this callback each time), which left a host that changed
73
+ // `channelName` or `lifecycleStage` syncing against the values captured on
74
+ // first render. The ref is assigned on every render, so it is always current.
75
+ const result = await _OtaOrchestrator.otaOrchestrator.sync(optionsRef.current);
70
76
  setSyncResult(result);
71
77
 
72
78
  // Handle mandatory update blocking
@@ -77,7 +83,10 @@ function useOtaUpdate(options) {
77
83
  } finally {
78
84
  setIsSyncing(false);
79
85
  }
80
- }, [options.apiUrl, options.clientKey, options.appVersion, options.installationId, options.autoRestart]);
86
+ // Empty: everything this reads comes from `optionsRef`, so the callback has a
87
+ // stable identity and never needs rebuilding. A host can safely pass it to a
88
+ // memoised child or an effect dependency list.
89
+ }, []);
81
90
  const restart = (0, _react.useCallback)(() => {
82
91
  setMandatoryUpdatePending(false);
83
92
  _OtaOrchestrator.otaOrchestrator.restart();
@@ -70,26 +70,27 @@ class JourneyEventPipeline {
70
70
  emit(type, opts) {
71
71
  try {
72
72
  const key = `${type}:${opts?.subtype ?? ''}`;
73
- const now = Date.now();
74
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
73
+ const receivedAt = Date.now();
74
+ const occurredAt = opts?.timestamp ?? receivedAt;
75
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
75
76
  // Within dedup window — allow high-confidence native events to
76
77
  // REPLACE a prior low-confidence JS event for the same gesture.
77
78
  // This prevents the race where JS fires first and the pipeline
78
79
  // drops the native event that has more accurate coordinates.
79
80
  const incomingConfidence = opts?.payload?.confidence;
80
81
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
81
- this._replaceLastEvent(key, now, opts);
82
+ this._replaceLastEvent(key, receivedAt, opts);
82
83
  }
83
84
  return null;
84
85
  }
85
86
  this.lastEventKey = key;
86
- this.lastEventTs = now;
87
+ this.lastEventTs = receivedAt;
87
88
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
88
89
  const event = {
89
90
  eventId: generateEventId(),
90
91
  sessionId: this.sessionId,
91
92
  journeyId: opts?.journeyId,
92
- ts: now,
93
+ ts: occurredAt,
93
94
  type,
94
95
  subtype: opts?.subtype,
95
96
  severity: opts?.severity ?? inferSeverity(type),
@@ -17,6 +17,8 @@ var _bridgeAdapter = require("../replay/bridge/adapters/bridgeAdapter");
17
17
  var _redaction = require("../../debug/redaction");
18
18
  var _calibrationContext = require("../journey/calibrationContext");
19
19
  var _device = require("../../core/context/device");
20
+ var _automaticEvents = require("../../analytics/automaticEvents");
21
+ var _interactionProtocol = require("../journey/interactionProtocol");
20
22
  /**
21
23
  * ScaleBun SDK — Session Manager
22
24
  *
@@ -35,12 +37,6 @@ var _device = require("../../core/context/device");
35
37
 
36
38
  // ─── Types ──────────────────────────────────────────────────────────────────
37
39
 
38
- /**
39
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
40
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
41
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
42
- */
43
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
44
40
  // ─── Singleton ──────────────────────────────────────────────────────────────
45
41
 
46
42
  let _instance = null;
@@ -56,15 +52,6 @@ class SessionManager {
56
52
  * lane even when no replay recording is active. Additive, opt-in (default off).
57
53
  */
58
54
  _captureInteractionHeatmap = false;
59
- /**
60
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
61
- * ON by default, an unbounded one-event-per-gesture stream could materially
62
- * inflate ingest volume. We cap emitted interactions per analytics-session
63
- * window (finalize-scoped per foreground): the first N gestures define the
64
- * hotspot shape; the long tail is dropped. Resets when the window changes.
65
- */
66
- _heatmapWindowSessionId = null;
67
- _heatmapWindowCount = 0;
68
55
  session = null;
69
56
  active = false;
70
57
  timeoutTimer = null;
@@ -666,21 +653,11 @@ class SessionManager {
666
653
  * injected sensitive keys (e.g. a label) are stripped before transport.
667
654
  * - Never throws.
668
655
  */
669
- _emitInteractionToAnalytics(gestureType, payload) {
670
- if (!this._captureInteractionHeatmap) return;
671
- if (this.active) return; // recording lane already carries this tap
656
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
657
+ if (!this._captureInteractionHeatmap) return false;
658
+ if (this.active) return false; // recording lane already carries this tap
672
659
  const adapter = this._backendTransport;
673
- if (!adapter) return;
674
-
675
- // Per-foreground sampling cap. Reset the counter when the analytics
676
- // session window rolls over, then drop anything past the cap.
677
- const windowId = adapter.analyticsSessionId ?? '';
678
- if (windowId !== this._heatmapWindowSessionId) {
679
- this._heatmapWindowSessionId = windowId;
680
- this._heatmapWindowCount = 0;
681
- }
682
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
683
- this._heatmapWindowCount++;
660
+ if (!adapter) return false;
684
661
  try {
685
662
  // Normalize coords in place (same logic as the recording lane).
686
663
  this._normalizeInteractionPayload(payload);
@@ -707,16 +684,18 @@ class SessionManager {
707
684
  eventId: (0, _sessionId.generateEventId)(),
708
685
  // sessionId is (re)stamped by the analytics lane at flush time.
709
686
  sessionId: adapter.analyticsSessionId ?? '',
710
- ts: Date.now(),
687
+ ts: occurredAt,
711
688
  type: 'USER_ACTION',
712
689
  subtype: `gesture:${gestureType}`,
713
- screen: this._lastKnownScreen ?? undefined,
690
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
714
691
  payload: safePayload,
715
692
  source: 'user'
716
693
  };
717
694
  adapter.trackEvent(event);
695
+ return true;
718
696
  } catch (err) {
719
697
  _internalLogger.logger.error('[SessionManager] heatmap analytics emit failed:', err);
698
+ return false;
720
699
  }
721
700
  }
722
701
 
@@ -936,13 +915,14 @@ class SessionManager {
936
915
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
937
916
  * Also triggers frame capture for desktop-initiated recordings.
938
917
  */
939
- onUserAction(subtype, payload) {
918
+ onUserAction(subtype, payload, context) {
940
919
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
941
920
  this.emitEvent('USER_ACTION', {
942
921
  subtype,
943
- screen: this._lastKnownScreen ?? undefined,
922
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
944
923
  payload,
945
- source: 'user'
924
+ source: 'user',
925
+ timestamp: context?.timestamp
946
926
  });
947
927
  // NOTE: Do NOT call captureManager.onInteraction() here.
948
928
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -957,7 +937,8 @@ class SessionManager {
957
937
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
958
938
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
959
939
  // with no coords never collides with a real origin tap).
960
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
940
+ const suppliedInteractionId = details?.interactionId;
941
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
961
942
  const nowTs = Date.now();
962
943
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
963
944
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -969,7 +950,15 @@ class SessionManager {
969
950
  // Build the canonical gesture payload ONCE so the recording lane and the
970
951
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
971
952
  // gestureType, etc.). Same object shape that was previously inlined.
953
+ const interactionId = suppliedInteractionId ?? (0, _interactionProtocol.generateInteractionId)();
954
+ const occurredAt = details?.occurredAt ?? Date.now();
955
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
972
956
  const payload = {
957
+ interaction_id: interactionId,
958
+ interaction_protocol: details?.interactionProtocol ?? _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
959
+ state_status: stateStatus,
960
+ ui: details?.ui,
961
+ target_id: details?.targetId,
973
962
  gestureType,
974
963
  x: details?.x,
975
964
  y: details?.y,
@@ -1014,9 +1003,16 @@ class SessionManager {
1014
1003
  // when the flag is OFF, no backend transport is attached, or recording is
1015
1004
  // active (the recording path below already carries this tap). Uses a fresh
1016
1005
  // payload clone so analytics normalization never mutates the recording one.
1017
- this._emitInteractionToAnalytics(gestureType, {
1006
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1018
1007
  ...payload
1019
- });
1008
+ }, occurredAt, details?.screenName);
1009
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1010
+ if (details?.emitAutomaticAnalytics) {
1011
+ try {
1012
+ const safePayload = (0, _redaction.redactBody)(payload);
1013
+ (0, _automaticEvents.emitAutomaticEvent)('element_interacted', (0, _interactionProtocol.automaticInteractionProperties)(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1014
+ } catch {/* automatic projection must never affect interaction capture */}
1015
+ }
1020
1016
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1021
1017
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1022
1018
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1026,7 +1022,10 @@ class SessionManager {
1026
1022
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1027
1023
  // first — so enriching the label cannot change heatmap gesture classification.
1028
1024
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1029
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1025
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1026
+ screen: details?.screenName,
1027
+ timestamp: occurredAt
1028
+ });
1030
1029
  }
1031
1030
 
1032
1031
  /**