@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
@@ -41,16 +41,16 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
41
41
  import { redactBody } from '../../debug/redaction';
42
42
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
43
43
  import { resolvePlatformOS } from '../../core/context/device';
44
+ import { emitAutomaticEvent } from '../../analytics/automaticEvents';
45
+ import {
46
+ INTERACTION_PROTOCOL_VERSION,
47
+ automaticInteractionProperties,
48
+ generateInteractionId,
49
+ type InteractionStateStatus,
50
+ } from '../journey/interactionProtocol';
44
51
 
45
52
  // ─── Types ──────────────────────────────────────────────────────────────────
46
53
 
47
- /**
48
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
49
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
50
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
51
- */
52
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
53
-
54
54
  export interface SessionManagerConfig {
55
55
  sessionReplay?: Partial<SessionReplayConfig>;
56
56
  syncPolicy?: SyncDecisionCallback;
@@ -84,16 +84,6 @@ export class SessionManager {
84
84
  * lane even when no replay recording is active. Additive, opt-in (default off).
85
85
  */
86
86
  private _captureInteractionHeatmap = false;
87
- /**
88
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
89
- * ON by default, an unbounded one-event-per-gesture stream could materially
90
- * inflate ingest volume. We cap emitted interactions per analytics-session
91
- * window (finalize-scoped per foreground): the first N gestures define the
92
- * hotspot shape; the long tail is dropped. Resets when the window changes.
93
- */
94
- private _heatmapWindowSessionId: string | null = null;
95
- private _heatmapWindowCount = 0;
96
-
97
87
  private session: Session | null = null;
98
88
  private active = false;
99
89
  private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
@@ -740,21 +730,16 @@ export class SessionManager {
740
730
  * injected sensitive keys (e.g. a label) are stripped before transport.
741
731
  * - Never throws.
742
732
  */
743
- private _emitInteractionToAnalytics(gestureType: string, payload: Record<string, unknown>): void {
744
- if (!this._captureInteractionHeatmap) return;
745
- if (this.active) return; // recording lane already carries this tap
733
+ private _emitInteractionToAnalytics(
734
+ gestureType: string,
735
+ payload: Record<string, unknown>,
736
+ occurredAt: number,
737
+ screenName?: string,
738
+ ): boolean {
739
+ if (!this._captureInteractionHeatmap) return false;
740
+ if (this.active) return false; // recording lane already carries this tap
746
741
  const adapter = this._backendTransport;
747
- if (!adapter) return;
748
-
749
- // Per-foreground sampling cap. Reset the counter when the analytics
750
- // session window rolls over, then drop anything past the cap.
751
- const windowId = adapter.analyticsSessionId ?? '';
752
- if (windowId !== this._heatmapWindowSessionId) {
753
- this._heatmapWindowSessionId = windowId;
754
- this._heatmapWindowCount = 0;
755
- }
756
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
757
- this._heatmapWindowCount++;
742
+ if (!adapter) return false;
758
743
 
759
744
  try {
760
745
  // Normalize coords in place (same logic as the recording lane).
@@ -782,16 +767,18 @@ export class SessionManager {
782
767
  eventId: generateEventId(),
783
768
  // sessionId is (re)stamped by the analytics lane at flush time.
784
769
  sessionId: adapter.analyticsSessionId ?? '',
785
- ts: Date.now(),
770
+ ts: occurredAt,
786
771
  type: 'USER_ACTION',
787
772
  subtype: `gesture:${gestureType}`,
788
- screen: this._lastKnownScreen ?? undefined,
773
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
789
774
  payload: safePayload,
790
775
  source: 'user',
791
776
  };
792
777
  adapter.trackEvent(event);
778
+ return true;
793
779
  } catch (err) {
794
780
  logger.error('[SessionManager] heatmap analytics emit failed:', err);
781
+ return false;
795
782
  }
796
783
  }
797
784
 
@@ -810,6 +797,8 @@ export class SessionManager {
810
797
  traceId?: string;
811
798
  source?: JourneyEventSource;
812
799
  journeyId?: string;
800
+ /** Original observation time; interaction capture may resolve asynchronously. */
801
+ timestamp?: number;
813
802
  },
814
803
  ): void {
815
804
  if (!this.active) return;
@@ -1046,13 +1035,18 @@ export class SessionManager {
1046
1035
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
1047
1036
  * Also triggers frame capture for desktop-initiated recordings.
1048
1037
  */
1049
- onUserAction(subtype: string, payload?: Record<string, unknown>): void {
1038
+ onUserAction(
1039
+ subtype: string,
1040
+ payload?: Record<string, unknown>,
1041
+ context?: { screen?: string; timestamp?: number },
1042
+ ): void {
1050
1043
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1051
1044
  this.emitEvent('USER_ACTION', {
1052
1045
  subtype,
1053
- screen: this._lastKnownScreen ?? undefined,
1046
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
1054
1047
  payload,
1055
1048
  source: 'user',
1049
+ timestamp: context?.timestamp,
1056
1050
  });
1057
1051
  // NOTE: Do NOT call captureManager.onInteraction() here.
1058
1052
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -1103,11 +1097,22 @@ export class SessionManager {
1103
1097
  screenWidth?: number;
1104
1098
  screenHeight?: number;
1105
1099
  platform?: string;
1100
+ interactionId?: string;
1101
+ interactionProtocol?: number;
1102
+ occurredAt?: number;
1103
+ ui?: string;
1104
+ stateStatus?: InteractionStateStatus;
1105
+ targetId?: string;
1106
+ screenName?: string;
1107
+ emitAutomaticAnalytics?: boolean;
1106
1108
  }): void {
1107
1109
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
1108
1110
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
1109
1111
  // with no coords never collides with a real origin tap).
1110
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
1112
+ const suppliedInteractionId = details?.interactionId;
1113
+ const dedupSig = suppliedInteractionId
1114
+ ? `id:${suppliedInteractionId}`
1115
+ : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
1111
1116
  const nowTs = Date.now();
1112
1117
  if (
1113
1118
  dedupSig === this._lastGestureSig &&
@@ -1122,7 +1127,15 @@ export class SessionManager {
1122
1127
  // Build the canonical gesture payload ONCE so the recording lane and the
1123
1128
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
1124
1129
  // gestureType, etc.). Same object shape that was previously inlined.
1130
+ const interactionId = suppliedInteractionId ?? generateInteractionId();
1131
+ const occurredAt = details?.occurredAt ?? Date.now();
1132
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
1125
1133
  const payload: Record<string, unknown> = {
1134
+ interaction_id: interactionId,
1135
+ interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
1136
+ state_status: stateStatus,
1137
+ ui: details?.ui,
1138
+ target_id: details?.targetId,
1126
1139
  gestureType,
1127
1140
  x: details?.x,
1128
1141
  y: details?.y,
@@ -1167,7 +1180,27 @@ export class SessionManager {
1167
1180
  // when the flag is OFF, no backend transport is attached, or recording is
1168
1181
  // active (the recording path below already carries this tap). Uses a fresh
1169
1182
  // payload clone so analytics normalization never mutates the recording one.
1170
- this._emitInteractionToAnalytics(gestureType, { ...payload });
1183
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(
1184
+ gestureType,
1185
+ { ...payload },
1186
+ occurredAt,
1187
+ details?.screenName,
1188
+ );
1189
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1190
+ if (details?.emitAutomaticAnalytics) {
1191
+ try {
1192
+ const safePayload = redactBody(payload) as Record<string, unknown>;
1193
+ emitAutomaticEvent(
1194
+ 'element_interacted',
1195
+ automaticInteractionProperties(
1196
+ safePayload,
1197
+ details?.screenName ?? this._lastKnownScreen ?? undefined,
1198
+ replayCarrier,
1199
+ ),
1200
+ occurredAt,
1201
+ );
1202
+ } catch { /* automatic projection must never affect interaction capture */ }
1203
+ }
1171
1204
 
1172
1205
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1173
1206
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
@@ -1178,7 +1211,11 @@ export class SessionManager {
1178
1211
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1179
1212
  // first — so enriching the label cannot change heatmap gesture classification.
1180
1213
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1181
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1214
+ this.onUserAction(
1215
+ target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`,
1216
+ payload,
1217
+ { screen: details?.screenName, timestamp: occurredAt },
1218
+ );
1182
1219
  }
1183
1220
 
1184
1221
  /**
@@ -327,6 +327,17 @@ class ScaleBunFacade {
327
327
  );
328
328
  }
329
329
  }
330
+ // `healthyAfterMs` is the field BootGuardConfig actually declares.
331
+ // This passed `healthyTimeoutMs`, which nothing reads, so a host that
332
+ // tuned the boot-guard window silently got the 10s default instead.
333
+ // Both spellings are accepted so the older one keeps working.
334
+ const healthyAfterMs =
335
+ typeof ota.healthyAfterMs === 'number'
336
+ ? ota.healthyAfterMs
337
+ : typeof ota.healthyTimeoutMs === 'number'
338
+ ? ota.healthyTimeoutMs
339
+ : undefined;
340
+
330
341
  otaOrchestrator.init({
331
342
  ...(signingRequested
332
343
  ? {
@@ -338,16 +349,129 @@ class ScaleBunFacade {
338
349
  },
339
350
  }
340
351
  : {}),
341
- ...(typeof ota.healthyTimeoutMs === 'number'
342
- ? { healthyTimeoutMs: ota.healthyTimeoutMs }
343
- : {}),
352
+ ...(healthyAfterMs !== undefined ? { healthyAfterMs } : {}),
344
353
  });
345
354
  logger.info('[ScaleBun] OTA enabled from init config.');
355
+
356
+ // Run the checks the config asked for. Until this existed, `ota.enabled`
357
+ // initialised the orchestrator and then never checked anything: the
358
+ // documented `checkOnForeground` and `channelOverride` options were read
359
+ // by no code at all, and an app following the documented config received
360
+ // updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
361
+ // hand. Nothing logged, because "no update available" and "never asked"
362
+ // look identical from the outside.
363
+ this._startOtaChecks(ota);
346
364
  } catch (err: any) {
347
365
  logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
348
366
  }
349
367
  }
350
368
 
369
+ /** Guards against overlapping config-driven OTA checks. */
370
+ private _otaCheckInFlight = false;
371
+ /** Wall clock of the last config-driven check, for the foreground floor. */
372
+ private _otaLastCheckAt = 0;
373
+ private _otaForegroundListener: ((state: string) => void) | null = null;
374
+
375
+ /**
376
+ * Minimum gap between config-driven checks.
377
+ *
378
+ * A foreground transition is cheap to trigger — app switchers, permission
379
+ * dialogs and share sheets all produce one — so an unthrottled check would
380
+ * put a request on the hot path every time the user glanced away. Ten
381
+ * minutes is well below any realistic release cadence and well above that
382
+ * noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
383
+ * which is never throttled.
384
+ */
385
+ private static readonly OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
386
+
387
+ /**
388
+ * Drive OTA checks from init config: once at startup, then on each
389
+ * foreground when `checkOnForeground` is on (the schema default).
390
+ *
391
+ * `appVersion` is resolved from the native bridge rather than asked of the
392
+ * integrator, because it gates the server's `targetAppVersion` semver check
393
+ * — sending a wrong or invented value is worse than sending none, and there
394
+ * is no honest default. If it cannot be resolved, the check is skipped with
395
+ * a warning instead of guessing.
396
+ */
397
+ private _startOtaChecks(ota: any): void {
398
+ // Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
399
+ // there on purpose so Metro keeps ownership of the bundle — so a bundle
400
+ // downloaded in dev is installed into a slot that will never be loaded, and
401
+ // the identity check on the next launch then correctly observes that the
402
+ // running code is not what was installed and reports APPLY_FAILED. Checking
403
+ // at all in dev buys nothing and manufactures that false alarm. A developer
404
+ // testing the OTA path drives `useOtaUpdate().sync()` explicitly.
405
+ if (__DEV__) {
406
+ logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
407
+ return;
408
+ }
409
+
410
+ const runCheck = async (trigger: 'startup' | 'foreground'): Promise<void> => {
411
+ if (this._otaCheckInFlight) return;
412
+ if (
413
+ trigger === 'foreground' &&
414
+ Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS
415
+ ) {
416
+ return;
417
+ }
418
+ const clientKey = this._clientKey;
419
+ const apiUrl = this._apiBaseUrl;
420
+ if (!clientKey || !apiUrl) return;
421
+
422
+ this._otaCheckInFlight = true;
423
+ try {
424
+ const info = await bridgeAdapter.getDeviceInfo();
425
+ const appVersion = (info as { appVersion?: string } | null)?.appVersion;
426
+ if (!appVersion) {
427
+ logger.warn(
428
+ '[ScaleBun] OTA check skipped — the app version could not be read from the ' +
429
+ 'native bridge. Rebuild the native app, or drive checks yourself with ' +
430
+ 'useOtaUpdate({ appVersion }).',
431
+ );
432
+ return;
433
+ }
434
+ this._otaLastCheckAt = Date.now();
435
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
436
+ const { otaOrchestrator } = require('../features/ota/OtaOrchestrator');
437
+ await otaOrchestrator.sync({
438
+ apiUrl,
439
+ clientKey,
440
+ appVersion,
441
+ // The documented option, finally connected. Omitted means the
442
+ // server's `default` channel, exactly as before.
443
+ channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined,
444
+ // Never forced from config: the release's own installMode
445
+ // decides when the app restarts, and yanking the screen out
446
+ // from under a user is not a decision this switch should make.
447
+ });
448
+ } catch (err: any) {
449
+ logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
450
+ } finally {
451
+ this._otaCheckInFlight = false;
452
+ }
453
+ };
454
+
455
+ void runCheck('startup');
456
+
457
+ if (ota.checkOnForeground === false) return;
458
+ if (this._otaForegroundListener) return; // idempotent across repeated init()
459
+ try {
460
+ // Through `appLifecycle`, not a second AppState subscription: the SDK
461
+ // already owns one and fanning out from it keeps every consumer on the
462
+ // same transition sequence.
463
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
464
+ const { appLifecycle } = require('../core/lifecycle/appLifecycle');
465
+ this._otaForegroundListener = (state: string) => {
466
+ if (state === 'active') void runCheck('foreground');
467
+ };
468
+ appLifecycle.addListener(this._otaForegroundListener);
469
+ } catch {
470
+ // Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
471
+ this._otaForegroundListener = null;
472
+ }
473
+ }
474
+
351
475
  private _autoEnableDebug(debugConfig: DebugConnectionConfig): void {
352
476
  try {
353
477
  const dbgConfig = buildDebugConfigFromInitConfig(debugConfig);