@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
@@ -106,6 +106,11 @@ async function deliverOtaEvents(params: {
106
106
  kind: 'ota_event' as const,
107
107
  type: e.type,
108
108
  bundleId: e.bundleId,
109
+ // Without this the backend stored a null releaseId on every row it ingested,
110
+ // while serving the release id on every check — so the delivery funnel could
111
+ // only ever be grouped by bundle, and a bundle re-promoted under a second
112
+ // release merged the two into one indistinguishable series.
113
+ releaseId: e.releaseId,
109
114
  installationId: params.installationId,
110
115
  // OTA bundles are compiled per platform, so this genuinely is ios|android. Narrowed via
111
116
  // resolveMobileOS so a non-mobile RN target is skipped rather than served an Android bundle.
@@ -158,14 +163,18 @@ export interface BootGuardConfig {
158
163
  */
159
164
  healthyAfterMs?: number;
160
165
  /**
161
- * Maximum consecutive boot-guard reverts before the SDK stops trying
162
- * OTA bundles and pins to the factory bundle. Default: 2.
166
+ * @deprecated Not honoured, and never was nothing in JS reads this field.
167
+ *
168
+ * The boot-attempt limit lives in native code, where the counter it bounds is
169
+ * incremented (`MAX_BOOT_ATTEMPTS` in `SlotManager.kt` / `OtaSlotManager.swift`,
170
+ * both 2). Setting it here has no effect; the field is kept only so existing
171
+ * call sites keep compiling. Change the limit natively, or file a request for
172
+ * it to be plumbed through `initOutbox`-style native config.
163
173
  */
164
174
  maxRevertAttempts?: number;
165
175
  }
166
176
 
167
177
  const DEFAULT_HEALTHY_AFTER_MS = 10_000;
168
- const DEFAULT_MAX_REVERT_ATTEMPTS = 2;
169
178
 
170
179
  export class OtaOrchestrator {
171
180
  private enabled = false;
@@ -257,22 +266,35 @@ export class OtaOrchestrator {
257
266
  `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
258
267
  );
259
268
 
269
+ // ONE read of the slot state, shared by both boot-time consumers.
270
+ //
271
+ // `getSlotState()` is CONSUME-ON-READ for the revert record: both native
272
+ // implementations delete it as they serialise, so that a rollback is
273
+ // reported exactly once instead of on every launch forever. Calling it
274
+ // twice therefore means the second caller never sees the revert — which is
275
+ // precisely what happened, since hydration ran first and swallowed it. It
276
+ // is also a blocking synchronous bridge call, so one read is cheaper.
277
+ const slotState = this.readSlotState();
278
+
260
279
  // Rehydrate which bundle we are running from the native slot state.
261
280
  // Without this `currentBundle` stays null for the whole process after a
262
281
  // restart, so every check reported no current bundle and the backend had
263
282
  // no way to know what the device was actually on.
264
- this.hydrateCurrentBundleFromSlots();
283
+ this.hydrateCurrentBundleFromSlots(slotState);
265
284
 
266
285
  // Warm the device-country cache (edge Worker lookup) so checks can carry
267
286
  // a country when the API itself sits behind no geo-stamping CDN.
268
287
  // Fire-and-forget and failure-soft — a check without a country is valid.
269
288
  void prefetchDeviceCountry();
270
289
 
290
+ // Check if the boot guard fired on this launch (native reverted before JS
291
+ // loaded). BEFORE the identity check, because both read the install record
292
+ // and the identity check retires it — a revert must get its chance to name
293
+ // the bundle that failed while the record still describes it.
294
+ this.checkBootGuardRecovery(slotState);
295
+
271
296
  // Prove the bundle we installed is the bundle that loaded.
272
297
  this.verifyRunningBundleIdentity();
273
-
274
- // Check if the boot guard fired on this launch (native reverted before JS loaded)
275
- this.checkBootGuardRecovery();
276
298
  });
277
299
  }
278
300
 
@@ -280,10 +302,21 @@ export class OtaOrchestrator {
280
302
  * Read the active slot back into `currentBundle` so the next check reports
281
303
  * what this device is genuinely running.
282
304
  */
283
- private hydrateCurrentBundleFromSlots(): void {
284
- if (!NativeScaleBunOta) return;
305
+ /**
306
+ * Parse the native slot state once. Returns null when the module is absent or
307
+ * the payload is unreadable — every caller treats that as "factory bundle".
308
+ */
309
+ private readSlotState(): Record<string, any> | null {
310
+ if (!NativeScaleBunOta) return null;
311
+ try {
312
+ return JSON.parse(NativeScaleBunOta.getSlotState());
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ private hydrateCurrentBundleFromSlots(state: Record<string, any> | null): void {
285
319
  try {
286
- const state = JSON.parse(NativeScaleBunOta.getSlotState());
287
320
  const current = state?.current;
288
321
  if (!current?.sha256) return;
289
322
 
@@ -298,6 +331,7 @@ export class OtaOrchestrator {
298
331
  this.currentBundle = {
299
332
  id: record.bundleId,
300
333
  version: record.version,
334
+ releaseId: record.releaseId ?? undefined,
301
335
  sha256: record.sha256,
302
336
  } as OtaBundlePayload;
303
337
  __DEV__ && logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
@@ -335,49 +369,50 @@ export class OtaOrchestrator {
335
369
  if (!this.currentBundle) return;
336
370
 
337
371
  const running = readRunningBundleMarker();
338
- const expected = this.readInstallExpectation();
372
+ const record = this.readInstallRecord();
339
373
 
340
- // Stale record from an earlier install the slot has moved on since.
341
- if (expected && expected.bundleId !== this.currentBundle.id) {
374
+ // No record at all installed by an SDK that predates install records, or
375
+ // local storage was cleared. NOTHING can be concluded here and nothing is
376
+ // reported: the marker is a random per-publish token, never the bundle id,
377
+ // so comparing the two would flag a false mismatch on every healthy launch.
378
+ if (!record) return;
379
+
380
+ // The record describes a bundle that is no longer the active one (a revert,
381
+ // or a bundle staged by another path). It cannot verify this launch, and
382
+ // keeping it would make the next launch mis-report what is running.
383
+ if (record.sha256 !== this.currentBundle.sha256) {
342
384
  this.clearInstallExpectation();
343
385
  return;
344
386
  }
345
387
 
346
- if (expected) {
347
- if (running === expected.identityToken) {
348
- // Proven: the bundle we installed is the bundle executing.
349
- __DEV__ && logger.debug('[OTA] Install verified running bundle matches what was installed.');
350
- this.clearInstallExpectation();
351
- return;
352
- }
353
-
354
- logger.error(
355
- `[OTA] INSTALL DID NOT TAKE EFFECT bundle ${this.currentBundle.id} was installed and ` +
356
- `carries a known identity marker, but the running bundle reports ` +
357
- `${running ?? 'no marker at all'}. The app is executing different code than the slot ` +
358
- 'manager believes. Check that the host app resolves the OTA bundle path at launch ' +
359
- '(see the ScaleBunOta integration for your React Native version).',
360
- );
361
- otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
362
- error: `install_not_effective — expected ${expected.identityToken}, running ${running ?? 'none'}`,
363
- });
364
- // Deliberately NOT cleared: the condition is still true on the next boot
365
- // and should keep reporting until the integration is fixed. Clearing here
366
- // would make a permanently broken install look like a one-off.
388
+ // Already proven on an earlier launch. The token is retired once verified
389
+ // while the REST of the record stays — it is also the sha256 -> bundleId map
390
+ // that `hydrateCurrentBundleFromSlots` reads to report what this device is
391
+ // running. Wiping the whole record here is what made every launch after the
392
+ // first send `currentBundleId: undefined`, which the server reads as "not on
393
+ // this bundle" and answers by serving the same bundle again, forever.
394
+ if (!record.identityToken) return;
395
+
396
+ if (running === record.identityToken) {
397
+ __DEV__ && logger.debug('[OTA] Install verified running bundle matches what was installed.');
398
+ this.retireIdentityToken(record);
367
399
  return;
368
400
  }
369
401
 
370
- if (running && running !== this.currentBundle.id) {
371
- // No recorded expectation (installed by an older SDK), but the running
372
- // marker disagrees with the active slot outright. Still conclusive.
373
- logger.error(
374
- `[OTA] BUNDLE MISMATCH slot says ${this.currentBundle.id} is active but the ` +
375
- `running bundle identifies as ${running}.`,
376
- );
377
- otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
378
- error: `bundle_identity_mismatch — running ${running}`,
379
- });
380
- }
402
+ logger.error(
403
+ `[OTA] INSTALL DID NOT TAKE EFFECT bundle ${this.currentBundle.id} was installed and ` +
404
+ `carries a known identity marker, but the running bundle reports ` +
405
+ `${running ?? 'no marker at all'}. The app is executing different code than the slot ` +
406
+ 'manager believes. Check that the host app resolves the OTA bundle path at launch ' +
407
+ '(see the ScaleBunOta integration for your React Native version).',
408
+ );
409
+ otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
410
+ releaseId: this.currentBundle.releaseId,
411
+ error: `install_not_effective — expected ${record.identityToken}, running ${running ?? 'none'}`,
412
+ });
413
+ // Deliberately NOT retired: the condition is still true on the next boot and
414
+ // should keep reporting until the integration is fixed. Retiring the token
415
+ // here would make a permanently broken install look like a one-off.
381
416
  }
382
417
 
383
418
  // ── Install expectation ────────────────────────────────────────────────────
@@ -394,6 +429,11 @@ export class OtaOrchestrator {
394
429
  JSON.stringify({
395
430
  bundleId: bundle.id,
396
431
  version: bundle.version,
432
+ // Carried so telemetry emitted on a LATER launch (a boot-guard
433
+ // rollback, an ineffective install) can still be attributed to the
434
+ // release, not merely the bundle. The check response is long gone by
435
+ // then; this record is the only thing that remembers.
436
+ releaseId: bundle.releaseId ?? null,
397
437
  // The join key back to the native slot, which records sha256 and
398
438
  // nothing else identifying.
399
439
  sha256: bundle.sha256,
@@ -408,6 +448,7 @@ export class OtaOrchestrator {
408
448
  private readInstallRecord(): {
409
449
  bundleId: string;
410
450
  version: number;
451
+ releaseId?: string | null;
411
452
  sha256: string;
412
453
  identityToken: string | null;
413
454
  } | null {
@@ -423,11 +464,35 @@ export class OtaOrchestrator {
423
464
  }
424
465
  }
425
466
 
426
- private readInstallExpectation(): { bundleId: string; identityToken: string } | null {
427
- const record = this.readInstallRecord();
428
- return record && record.identityToken
429
- ? { bundleId: record.bundleId, identityToken: record.identityToken }
430
- : null;
467
+ /**
468
+ * Drop the identity token once the install has been proven, keeping the rest
469
+ * of the record.
470
+ *
471
+ * The record does two jobs: it proves an install took effect (once), and it
472
+ * maps the native slot's sha256 back to a bundle id (for the life of that
473
+ * bundle). Only the first job is finished after a successful verification, so
474
+ * only the token is retired.
475
+ */
476
+ private retireIdentityToken(record: {
477
+ bundleId: string;
478
+ version: number;
479
+ releaseId?: string | null;
480
+ sha256: string;
481
+ }): void {
482
+ try {
483
+ this.storage().set(
484
+ OtaOrchestrator.INSTALL_EXPECTATION_KEY,
485
+ JSON.stringify({
486
+ bundleId: record.bundleId,
487
+ version: record.version,
488
+ releaseId: record.releaseId ?? null,
489
+ sha256: record.sha256,
490
+ identityToken: null,
491
+ }),
492
+ );
493
+ } catch {
494
+ /* non-fatal */
495
+ }
431
496
  }
432
497
 
433
498
  private clearInstallExpectation(): void {
@@ -448,19 +513,50 @@ export class OtaOrchestrator {
448
513
  * If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
449
514
  * and no 'current' OTA bundle, the native layer already reverted.
450
515
  */
451
- private checkBootGuardRecovery(): void {
452
- if (!NativeScaleBunOta) return;
453
-
516
+ private checkBootGuardRecovery(state: Record<string, any> | null): void {
454
517
  try {
455
- const stateJson = NativeScaleBunOta.getSlotState();
456
- const state = JSON.parse(stateJson);
518
+ if (!state?.bootGuardReverted) return;
519
+
520
+ // WHICH bundle was rolled back, and it has to come from the install
521
+ // record. The obvious source — `state.previous` — is wrong twice over:
522
+ // slot meta.json carries only `{sha256, installedAt}` so it has no bundle
523
+ // id to read, and `revert()` deletes the previous slot as its last step,
524
+ // so by the time this runs there is no previous meta at all. The event
525
+ // therefore reported `bundleId: 'unknown'` on every rollback, and the
526
+ // backend drops any ota_event whose bundleId does not resolve to a bundle
527
+ // the app owns — so the crash-guard's own rollback signal never arrived.
528
+ //
529
+ // Native now stamps the sha256 of the bundle it reverted AWAY FROM into
530
+ // the revert record; the install record maps that back to a bundle id.
531
+ const record = this.readInstallRecord();
532
+ const revertedSha: string | undefined = state.bootGuardRevertedSha256;
533
+ const matchesRecord = !!record && (!revertedSha || record.sha256 === revertedSha);
534
+
535
+ // Report the reason native actually determined — a hash mismatch and a
536
+ // crash loop are different incidents and were being collapsed into one.
537
+ const reason: string = state.bootGuardRevertReason || 'boot_crash_guard';
457
538
 
458
- if (state.bootGuardReverted) {
459
- logger.warn('[OTA] Boot guard fired — app was reverted to previous bundle');
460
- otaEventEmitter.emitSimple('AUTO_ROLLBACK', state.previous?.bundleId ?? 'unknown', {
461
- reason: 'boot_guard_crash_loop_detected',
539
+ if (matchesRecord && record) {
540
+ logger.warn(
541
+ `[OTA] Boot guard fired — reverted away from bundle ${record.bundleId} ` +
542
+ `(v${record.version}); reason: ${reason}`,
543
+ );
544
+ otaEventEmitter.emitSimple('AUTO_ROLLBACK', record.bundleId, {
545
+ releaseId: record.releaseId ?? undefined,
546
+ version: record.version,
547
+ reason,
462
548
  });
549
+ // The record describes a bundle this device is no longer running.
550
+ this.clearInstallExpectation();
551
+ return;
463
552
  }
553
+
554
+ // No usable record (older SDK, cleared storage). Say so rather than
555
+ // emitting an event the server is obliged to discard.
556
+ logger.warn(
557
+ `[OTA] Boot guard fired (reason: ${reason}) but the rolled-back bundle could not be ` +
558
+ 'identified locally — no install record. The rollback is not reported to the server.',
559
+ );
464
560
  } catch {
465
561
  // Slot state parsing failed — non-fatal
466
562
  }
@@ -591,6 +687,23 @@ export class OtaOrchestrator {
591
687
  }
592
688
 
593
689
  __DEV__ && logger.debug('[OTA] Sync started…');
690
+
691
+ // The funnel's denominator, for devices already on an OTA bundle. Emitted
692
+ // before the request, so a check that fails outright still counts as a
693
+ // check — the only CHECK rows before this came from a server-side geo
694
+ // side-effect that is skipped whenever the request carries no country.
695
+ //
696
+ // Only when a current bundle is known: every ota_event must name a bundle
697
+ // the app owns or the server drops it, and a device still on the binary's
698
+ // factory bundle has no such id to give. Those devices are counted
699
+ // server-side when they are offered something.
700
+ if (this.currentBundle?.id) {
701
+ otaEventEmitter.emitSimple('CHECK', this.currentBundle.id, {
702
+ releaseId: this.currentBundle.releaseId,
703
+ version: this.currentBundle.version,
704
+ });
705
+ }
706
+
594
707
  const checkRes = await this.checkForUpdate(params);
595
708
 
596
709
  if (checkRes.action === 'NONE') {
@@ -603,7 +716,9 @@ export class OtaOrchestrator {
603
716
  // Handle server-initiated rollback (Sprint 4)
604
717
  if (checkRes.action === 'ROLLBACK') {
605
718
  logger.warn('[OTA] Server requested ROLLBACK — reverting to previous bundle');
606
- otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown');
719
+ otaEventEmitter.emitSimple('MANUAL_ROLLBACK', this.currentBundle?.id ?? 'unknown', {
720
+ releaseId: this.currentBundle?.releaseId,
721
+ });
607
722
  const reverted = await NativeScaleBunOta.revertToPrevious();
608
723
  if (reverted) {
609
724
  this.currentBundle = null;
@@ -623,6 +738,15 @@ export class OtaOrchestrator {
623
738
  const bundle = checkRes.bundle;
624
739
  let patchUsed = false;
625
740
 
741
+ // The offer itself. Everything downstream (download, install, activation)
742
+ // is a conversion against this, so without it the top of the funnel was
743
+ // unmeasurable and a rollout that never reached devices looked identical
744
+ // to one whose devices all declined to download.
745
+ otaEventEmitter.emitSimple('OFFERED', bundle.id, {
746
+ releaseId: bundle.releaseId,
747
+ version: bundle.version,
748
+ });
749
+
626
750
  // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
627
751
  // Before anything touches the disk. SHA-256 proves the bytes arrived
628
752
  // intact; only the signature proves they came from you. Checking after
@@ -634,6 +758,7 @@ export class OtaOrchestrator {
634
758
  );
635
759
  if (!signatureOutcome.ok) {
636
760
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
761
+ releaseId: bundle.releaseId,
637
762
  error: `Signature check failed: ${signatureOutcome.reason}`,
638
763
  version: bundle.version,
639
764
  });
@@ -645,7 +770,10 @@ export class OtaOrchestrator {
645
770
  }
646
771
 
647
772
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
648
- otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, { version: bundle.version });
773
+ otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, {
774
+ releaseId: bundle.releaseId,
775
+ version: bundle.version,
776
+ });
649
777
  const downloadStart = Date.now();
650
778
  __DEV__ && logger.debug(`[OTA] Downloading update v${bundle.version}…`);
651
779
 
@@ -750,6 +878,7 @@ export class OtaOrchestrator {
750
878
  postProgress(0, 'FAILED');
751
879
  logger.error('[OTA] Staging bundle failed after retries');
752
880
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
881
+ releaseId: bundle.releaseId,
753
882
  error: 'Staging failed — SHA-256 mismatch or download error',
754
883
  version: bundle.version,
755
884
  });
@@ -760,6 +889,7 @@ export class OtaOrchestrator {
760
889
 
761
890
  const downloadDuration = Date.now() - downloadStart;
762
891
  otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
892
+ releaseId: bundle.releaseId,
763
893
  version: bundle.version,
764
894
  durationMs: downloadDuration,
765
895
  patchUsed,
@@ -771,6 +901,7 @@ export class OtaOrchestrator {
771
901
  if (!applied) {
772
902
  logger.error('[OTA] Applying update failed');
773
903
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
904
+ releaseId: bundle.releaseId,
774
905
  error: 'Atomic slot swap failed',
775
906
  version: bundle.version,
776
907
  });
@@ -791,7 +922,10 @@ export class OtaOrchestrator {
791
922
  // it is skipped when there is no marker to compare.
792
923
  this.recordInstallExpectation(bundle);
793
924
 
794
- otaEventEmitter.emitSimple('INSTALLED', bundle.id, { version: bundle.version });
925
+ otaEventEmitter.emitSimple('INSTALLED', bundle.id, {
926
+ releaseId: bundle.releaseId,
927
+ version: bundle.version,
928
+ });
795
929
  __DEV__ && logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
796
930
 
797
931
  // Deliver telemetry BEFORE a restart tears the JS runtime down —
@@ -863,6 +997,19 @@ export class OtaOrchestrator {
863
997
  NativeScaleBunOta?.markHealthy();
864
998
  __DEV__ && logger.info('[OTA] Boot guard cleared — bundle marked healthy ✓');
865
999
  this.healthyTimer = null;
1000
+
1001
+ // The honest activation signal. INSTALLED is emitted optimistically,
1002
+ // BEFORE the bundle has ever executed, so counting it as adoption
1003
+ // credits bundles that were installed and then crash-reverted. This
1004
+ // fires only once the bundle has actually booted and survived.
1005
+ const running = this.currentBundle;
1006
+ if (running?.id) {
1007
+ otaEventEmitter.emitSimple('BOOT_SUCCESS', running.id, {
1008
+ releaseId: running.releaseId,
1009
+ version: running.version,
1010
+ durationMs: healthyMs,
1011
+ });
1012
+ }
866
1013
  });
867
1014
  }, healthyMs);
868
1015
  } catch {
@@ -8,42 +8,38 @@
8
8
 
9
9
  /**
10
10
  * ═══════════════════════════════════════════════════════════════════════════
11
- * OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
11
+ * OTA-TELEMETRY-SPEC — status (spec §84-86)
12
12
  * ═══════════════════════════════════════════════════════════════════════════
13
- * These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
14
- * (new optional fields, new enum members, new no-throw emit calls); old installed
15
- * clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
16
- * DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
17
- * (Metro cannot exercise the boot-guard) — this file only carries the contract
18
- * types + this spec, not the runtime wiring.
13
+ * Items 1-3 are DONE. All were additive and backward-compatible: old installed
14
+ * clients simply omit the new fields and never emit the new types, and the
15
+ * backend ingests nullable columns. Because they alter device runtime behaviour,
16
+ * verify against a real RN build + device kill-test before release — Metro
17
+ * cannot exercise the boot guard.
19
18
  *
20
- * 1. releaseId end-to-end (CRITICALroot of the dashboard funnel mismatch)
21
- * Backend already serves it: OtaBundlePayload.releaseId (done, this file +
22
- * ota-check.service.ts). SDK TODO:
23
- * - store payload.releaseId on the downloaded/installed bundle state, and
24
- * - set `releaseId: bundle.releaseId` in the delivery mapper
25
- * OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
26
- * Then ota_events carry releaseId and the funnel keys by release.
19
+ * 1. releaseId end-to-end — DONE. `recordInstallExpectation` persists the
20
+ * release id (so a rollback reported launches later can still name it),
21
+ * `hydrateCurrentBundleFromSlots` restores it, every emit site passes it, and
22
+ * `deliverOtaEvents` maps it onto the wire item.
27
23
  *
28
- * 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
29
- * - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
30
- * before the fetch. ('CHECK' type already existsno emit site today.)
31
- * - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
32
- * 'DOWNLOAD' (~:619) before download begins.
24
+ * 2. CHECK + OFFERED DONE. CHECK is emitted at the top of `sync()` for devices
25
+ * already on an OTA bundle (a device on the factory bundle has no owned
26
+ * bundle id to name, and the server drops rows it cannot resolve those are
27
+ * counted server-side instead). OFFERED is emitted on `action === 'DOWNLOAD'`
28
+ * before any bytes move.
33
29
  *
34
- * 3. Emit BOOT_SUCCESS (honest activation signal)
35
- * Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
36
- * markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
37
- * optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
38
- * but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
39
- * add 'VERIFIED' after signature check (~:635).
30
+ * 3. BOOT_SUCCESS DONE. Emitted from the boot-guard heartbeat once the bundle
31
+ * has actually booted and survived to `healthyAfterMs`. INSTALLED is kept and
32
+ * still means "staged + swapped", but activation should be measured on
33
+ * BOOT_SUCCESS: INSTALLED fires before the bundle has run, so it credits
34
+ * bundles that installed and then crash-reverted.
40
35
  *
41
- * 4. Stamp the running OTA bundle onto session/crash telemetry
42
- * SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
43
- * Add optional otaBundleId?/otaBundleVersion? (distinct fields do NOT
44
- * overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
45
- * session start (SessionManager.ts ~:451), so crashes attribute to the
46
- * running bundle/version (release-health crash impact).
36
+ * 4. Stamp the running OTA bundle onto session/crash telemetry — NOT DONE.
37
+ * `SessionMetadata.bundleId` is the NATIVE app package id, not the OTA bundle,
38
+ * so crashes cannot currently be attributed to the bundle that produced them
39
+ * (release-health crash impact). Needs optional otaBundleId?/otaBundleVersion?
40
+ * (distinct fields do NOT overload bundleId) sourced from
41
+ * `otaOrchestrator.getCurrentBundle()` at session start, plus the matching
42
+ * backend columns and a Prisma migration.
47
43
  *
48
44
  * Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
49
45
  * reason); add a new optional field if the two must be distinguished.
@@ -149,14 +145,16 @@ export interface OtaPatchPayload {
149
145
  }
150
146
 
151
147
  // ── Adoption telemetry (rides the unified batch envelope as kind:'ota_event') ──
152
- export type OtaEventType =
153
- | 'CHECK'
154
- | 'DOWNLOAD_STARTED'
155
- | 'DOWNLOAD_COMPLETE'
156
- | 'INSTALLED'
157
- | 'APPLY_FAILED'
158
- | 'AUTO_ROLLBACK'
159
- | 'MANUAL_ROLLBACK';
148
+ //
149
+ // The event vocabulary is declared ONCE, in OtaEventEmitter — the module that
150
+ // emits it. This file used to carry a second, shorter copy under the same name,
151
+ // so `OtaEventItem.type` and the emitter's events could disagree while both
152
+ // type-checked, and adding a funnel step meant remembering to edit two unions.
153
+ // `DOWNLOAD_PROGRESS` is emitted for UI only and never queued for upload, so it
154
+ // is excluded here rather than re-listing the rest.
155
+ import type { OtaEventType as EmittedOtaEventType } from './OtaEventEmitter';
156
+
157
+ export type OtaEventType = Exclude<EmittedOtaEventType, 'DOWNLOAD_PROGRESS'>;
160
158
 
161
159
  export interface OtaEventItem {
162
160
  kind: 'ota_event';
@@ -115,7 +115,13 @@ export function useOtaUpdate(options: UseOtaUpdateOptions): UseOtaUpdateReturn {
115
115
  setIsSyncing(true);
116
116
  setDownloadProgress(0);
117
117
  try {
118
- const result = await otaOrchestrator.sync(options);
118
+ // Read through the ref, not the closed-over `options`. The dependency list
119
+ // below cannot name the targeting fields (`attributes` and `segmentIds` are
120
+ // fresh object/array identities on every render, so listing them would
121
+ // rebuild this callback each time), which left a host that changed
122
+ // `channelName` or `lifecycleStage` syncing against the values captured on
123
+ // first render. The ref is assigned on every render, so it is always current.
124
+ const result = await otaOrchestrator.sync(optionsRef.current);
119
125
  setSyncResult(result);
120
126
 
121
127
  // Handle mandatory update blocking
@@ -131,7 +137,10 @@ export function useOtaUpdate(options: UseOtaUpdateOptions): UseOtaUpdateReturn {
131
137
  } finally {
132
138
  setIsSyncing(false);
133
139
  }
134
- }, [options.apiUrl, options.clientKey, options.appVersion, options.installationId, options.autoRestart]);
140
+ // Empty: everything this reads comes from `optionsRef`, so the callback has a
141
+ // stable identity and never needs rebuilding. A host can safely pass it to a
142
+ // memoised child or an effect dependency list.
143
+ }, []);
135
144
 
136
145
  const restart = useCallback(() => {
137
146
  setMandatoryUpdatePending(false);
@@ -93,31 +93,33 @@ export class JourneyEventPipeline {
93
93
  journeyId?: string;
94
94
  /** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
95
95
  frameId?: string;
96
+ timestamp?: number;
96
97
  },
97
98
  ): JourneyEvent | null {
98
99
  try {
99
100
  const key = `${type}:${opts?.subtype ?? ''}`;
100
- const now = Date.now();
101
- if (key === this.lastEventKey && (now - this.lastEventTs) < this.config.dedupeWindowMs) {
101
+ const receivedAt = Date.now();
102
+ const occurredAt = opts?.timestamp ?? receivedAt;
103
+ if (key === this.lastEventKey && (receivedAt - this.lastEventTs) < this.config.dedupeWindowMs) {
102
104
  // Within dedup window — allow high-confidence native events to
103
105
  // REPLACE a prior low-confidence JS event for the same gesture.
104
106
  // This prevents the race where JS fires first and the pipeline
105
107
  // drops the native event that has more accurate coordinates.
106
108
  const incomingConfidence = (opts?.payload as any)?.confidence;
107
109
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
108
- this._replaceLastEvent(key, now, opts);
110
+ this._replaceLastEvent(key, receivedAt, opts);
109
111
  }
110
112
  return null;
111
113
  }
112
114
  this.lastEventKey = key;
113
- this.lastEventTs = now;
115
+ this.lastEventTs = receivedAt;
114
116
  this.lastEventConfidence = (opts?.payload as any)?.confidence ?? null;
115
117
 
116
118
  const event: JourneyEvent = {
117
119
  eventId: generateEventId(),
118
120
  sessionId: this.sessionId,
119
121
  journeyId: opts?.journeyId,
120
- ts: now,
122
+ ts: occurredAt,
121
123
  type,
122
124
  subtype: opts?.subtype,
123
125
  severity: opts?.severity ?? inferSeverity(type),