@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
@@ -253,6 +253,11 @@ class ScaleBunFacade {
253
253
  logger.warn('[ScaleBun] A signing key is pinned but no signature verifier is available. ' + 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' + 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' + 'code needed), or supply `ota.verifySignature`.');
254
254
  }
255
255
  }
256
+ // `healthyAfterMs` is the field BootGuardConfig actually declares.
257
+ // This passed `healthyTimeoutMs`, which nothing reads, so a host that
258
+ // tuned the boot-guard window silently got the 10s default instead.
259
+ // Both spellings are accepted so the older one keeps working.
260
+ const healthyAfterMs = typeof ota.healthyAfterMs === 'number' ? ota.healthyAfterMs : typeof ota.healthyTimeoutMs === 'number' ? ota.healthyTimeoutMs : undefined;
256
261
  otaOrchestrator.init({
257
262
  ...(signingRequested ? {
258
263
  signature: {
@@ -262,15 +267,123 @@ class ScaleBunFacade {
262
267
  verifier
263
268
  }
264
269
  } : {}),
265
- ...(typeof ota.healthyTimeoutMs === 'number' ? {
266
- healthyTimeoutMs: ota.healthyTimeoutMs
270
+ ...(healthyAfterMs !== undefined ? {
271
+ healthyAfterMs
267
272
  } : {})
268
273
  });
269
274
  logger.info('[ScaleBun] OTA enabled from init config.');
275
+
276
+ // Run the checks the config asked for. Until this existed, `ota.enabled`
277
+ // initialised the orchestrator and then never checked anything: the
278
+ // documented `checkOnForeground` and `channelOverride` options were read
279
+ // by no code at all, and an app following the documented config received
280
+ // updates only if it ALSO drove `useOtaUpdate` or the CodePush shim by
281
+ // hand. Nothing logged, because "no update available" and "never asked"
282
+ // look identical from the outside.
283
+ this._startOtaChecks(ota);
270
284
  } catch (err) {
271
285
  logger.warn(`[ScaleBun] OTA init failed: ${err?.message ?? err}`);
272
286
  }
273
287
  }
288
+
289
+ /** Guards against overlapping config-driven OTA checks. */
290
+ _otaCheckInFlight = false;
291
+ /** Wall clock of the last config-driven check, for the foreground floor. */
292
+ _otaLastCheckAt = 0;
293
+ _otaForegroundListener = null;
294
+
295
+ /**
296
+ * Minimum gap between config-driven checks.
297
+ *
298
+ * A foreground transition is cheap to trigger — app switchers, permission
299
+ * dialogs and share sheets all produce one — so an unthrottled check would
300
+ * put a request on the hot path every time the user glanced away. Ten
301
+ * minutes is well below any realistic release cadence and well above that
302
+ * noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
303
+ * which is never throttled.
304
+ */
305
+ static OTA_MIN_CHECK_INTERVAL_MS = 10 * 60 * 1000;
306
+
307
+ /**
308
+ * Drive OTA checks from init config: once at startup, then on each
309
+ * foreground when `checkOnForeground` is on (the schema default).
310
+ *
311
+ * `appVersion` is resolved from the native bridge rather than asked of the
312
+ * integrator, because it gates the server's `targetAppVersion` semver check
313
+ * — sending a wrong or invented value is worse than sending none, and there
314
+ * is no honest default. If it cannot be resolved, the check is skipped with
315
+ * a warning instead of guessing.
316
+ */
317
+ _startOtaChecks(ota) {
318
+ // Never in a debug build. `ScaleBunOtaModule.getJSBundleFile()` returns null
319
+ // there on purpose so Metro keeps ownership of the bundle — so a bundle
320
+ // downloaded in dev is installed into a slot that will never be loaded, and
321
+ // the identity check on the next launch then correctly observes that the
322
+ // running code is not what was installed and reports APPLY_FAILED. Checking
323
+ // at all in dev buys nothing and manufactures that false alarm. A developer
324
+ // testing the OTA path drives `useOtaUpdate().sync()` explicitly.
325
+ if (__DEV__) {
326
+ logger.info('[ScaleBun] OTA checks are skipped in debug builds (Metro owns the bundle).');
327
+ return;
328
+ }
329
+ const runCheck = async trigger => {
330
+ if (this._otaCheckInFlight) return;
331
+ if (trigger === 'foreground' && Date.now() - this._otaLastCheckAt < ScaleBunFacade.OTA_MIN_CHECK_INTERVAL_MS) {
332
+ return;
333
+ }
334
+ const clientKey = this._clientKey;
335
+ const apiUrl = this._apiBaseUrl;
336
+ if (!clientKey || !apiUrl) return;
337
+ this._otaCheckInFlight = true;
338
+ try {
339
+ const info = await bridgeAdapter.getDeviceInfo();
340
+ const appVersion = info?.appVersion;
341
+ if (!appVersion) {
342
+ logger.warn('[ScaleBun] OTA check skipped — the app version could not be read from the ' + 'native bridge. Rebuild the native app, or drive checks yourself with ' + 'useOtaUpdate({ appVersion }).');
343
+ return;
344
+ }
345
+ this._otaLastCheckAt = Date.now();
346
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
347
+ const {
348
+ otaOrchestrator
349
+ } = require('../features/ota/OtaOrchestrator');
350
+ await otaOrchestrator.sync({
351
+ apiUrl,
352
+ clientKey,
353
+ appVersion,
354
+ // The documented option, finally connected. Omitted means the
355
+ // server's `default` channel, exactly as before.
356
+ channelName: typeof ota.channelOverride === 'string' ? ota.channelOverride : undefined
357
+ // Never forced from config: the release's own installMode
358
+ // decides when the app restarts, and yanking the screen out
359
+ // from under a user is not a decision this switch should make.
360
+ });
361
+ } catch (err) {
362
+ logger.warn(`[ScaleBun] OTA check failed: ${err?.message ?? err}`);
363
+ } finally {
364
+ this._otaCheckInFlight = false;
365
+ }
366
+ };
367
+ void runCheck('startup');
368
+ if (ota.checkOnForeground === false) return;
369
+ if (this._otaForegroundListener) return; // idempotent across repeated init()
370
+ try {
371
+ // Through `appLifecycle`, not a second AppState subscription: the SDK
372
+ // already owns one and fanning out from it keeps every consumer on the
373
+ // same transition sequence.
374
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
375
+ const {
376
+ appLifecycle
377
+ } = require('../core/lifecycle/appLifecycle');
378
+ this._otaForegroundListener = state => {
379
+ if (state === 'active') void runCheck('foreground');
380
+ };
381
+ appLifecycle.addListener(this._otaForegroundListener);
382
+ } catch {
383
+ // Lifecycle unavailable (tests, exotic hosts) — the startup check stands.
384
+ this._otaForegroundListener = null;
385
+ }
386
+ }
274
387
  _autoEnableDebug(debugConfig) {
275
388
  try {
276
389
  const dbgConfig = buildDebugConfigFromInitConfig(debugConfig);
@@ -125,7 +125,7 @@ export declare class EventTracker {
125
125
  * Persisted so it survives the click→install→open gap. Call BEFORE start() ideally.
126
126
  */
127
127
  setAttributionClickId(clickId: string): void;
128
- track(eventName: string, properties?: Record<string, any>): void;
128
+ track(eventName: string, properties?: Record<string, any>, timestamp?: number): void;
129
129
  trackPurchase(input: {
130
130
  revenue: number;
131
131
  currency: string;
@@ -18,9 +18,11 @@ export type AutomaticEventName = 'app_foregrounded' | 'app_backgrounded' | 'scre
18
18
  export interface AutomaticEvent {
19
19
  name: AutomaticEventName;
20
20
  properties: Record<string, unknown>;
21
+ /** Original observation time. Delivery can be delayed while native coordinates resolve. */
22
+ timestamp?: number;
21
23
  }
22
24
  type AutomaticEventListener = (event: AutomaticEvent) => void;
23
- export declare function emitAutomaticEvent(name: AutomaticEventName, properties?: Record<string, unknown>): void;
25
+ export declare function emitAutomaticEvent(name: AutomaticEventName, properties?: Record<string, unknown>, timestamp?: number): void;
24
26
  export declare function subscribeAutomaticEvents(listener: AutomaticEventListener): () => void;
25
27
  /** Test-only reset; intentionally not exported from the package entry point. */
26
28
  export declare function resetAutomaticEventsForTests(): void;
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export declare const SDK_VERSION = "1.10.7";
11
+ export declare const SDK_VERSION = "1.11.1";
7
12
  //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,21 @@
1
+ /** One physical interaction, shared by replay and analytics projections. */
2
+ export declare const INTERACTION_PROTOCOL_VERSION = 1;
3
+ export type InteractionStateStatus = 'captured_empty' | 'captured_nonempty' | 'not_captured' | 'not_instrumented';
4
+ export interface InteractionStartContext {
5
+ interactionId: string;
6
+ occurredAt: number;
7
+ x: number;
8
+ y: number;
9
+ target?: string;
10
+ targetId?: string;
11
+ screenName?: string;
12
+ ui?: string;
13
+ stateStatus: InteractionStateStatus;
14
+ emitAutomaticAnalytics: boolean;
15
+ }
16
+ export declare function generateInteractionId(): string;
17
+ /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
18
+ export declare function nearestInteractionStart<T extends InteractionStartContext>(starts: readonly T[], occurredAt: number, toleranceMs?: number): T | undefined;
19
+ /** Analytics is a projection of the same evidence; no second click is invented. */
20
+ export declare function automaticInteractionProperties(payload: Record<string, unknown>, screenName: string | undefined, canonicalMirror: boolean): Record<string, unknown>;
21
+ //# sourceMappingURL=interactionProtocol.d.ts.map
@@ -5,10 +5,24 @@
5
5
  *
6
6
  * Sprint 5 deliverable (S5-NAT-2, S5-NAT-3).
7
7
  */
8
- export type OtaEventType = 'CHECK' | 'DOWNLOAD_STARTED' | 'DOWNLOAD_PROGRESS' | 'DOWNLOAD_COMPLETE' | 'INSTALLED' | 'APPLY_FAILED' | 'AUTO_ROLLBACK' | 'MANUAL_ROLLBACK';
8
+ export type OtaEventType =
9
+ /** A check was made against the server. The denominator of the funnel. */
10
+ 'CHECK'
11
+ /** The server answered with a bundle for this device — the offer, before any bytes move. */
12
+ | 'OFFERED' | 'DOWNLOAD_STARTED' | 'DOWNLOAD_PROGRESS' | 'DOWNLOAD_COMPLETE'
13
+ /** Staged and swapped. Emitted optimistically, BEFORE the bundle has booted. */
14
+ | 'INSTALLED'
15
+ /** The bundle booted and survived to the healthy mark — the honest activation signal. */
16
+ | 'BOOT_SUCCESS' | 'APPLY_FAILED' | 'AUTO_ROLLBACK' | 'MANUAL_ROLLBACK';
9
17
  export interface OtaEvent {
10
18
  type: OtaEventType;
11
19
  bundleId: string;
20
+ /**
21
+ * The release this bundle was served as. Optional because a bundle installed
22
+ * by an older SDK has no recorded release; present on everything emitted by a
23
+ * current one, so the delivery funnel can key by release rather than bundle.
24
+ */
25
+ releaseId?: string;
12
26
  version?: number;
13
27
  /** 0–100 for DOWNLOAD_PROGRESS */
14
28
  progress?: number;
@@ -15,8 +15,13 @@ export interface BootGuardConfig {
15
15
  */
16
16
  healthyAfterMs?: number;
17
17
  /**
18
- * Maximum consecutive boot-guard reverts before the SDK stops trying
19
- * OTA bundles and pins to the factory bundle. Default: 2.
18
+ * @deprecated Not honoured, and never was nothing in JS reads this field.
19
+ *
20
+ * The boot-attempt limit lives in native code, where the counter it bounds is
21
+ * incremented (`MAX_BOOT_ATTEMPTS` in `SlotManager.kt` / `OtaSlotManager.swift`,
22
+ * both 2). Setting it here has no effect; the field is kept only so existing
23
+ * call sites keep compiling. Change the limit natively, or file a request for
24
+ * it to be plumbed through `initOutbox`-style native config.
20
25
  */
21
26
  maxRevertAttempts?: number;
22
27
  }
@@ -51,6 +56,11 @@ export declare class OtaOrchestrator {
51
56
  * Read the active slot back into `currentBundle` so the next check reports
52
57
  * what this device is genuinely running.
53
58
  */
59
+ /**
60
+ * Parse the native slot state once. Returns null when the module is absent or
61
+ * the payload is unreadable — every caller treats that as "factory bundle".
62
+ */
63
+ private readSlotState;
54
64
  private hydrateCurrentBundleFromSlots;
55
65
  /**
56
66
  * Compare the bundle the slot manager believes is active against the identity
@@ -71,7 +81,16 @@ export declare class OtaOrchestrator {
71
81
  private static readonly INSTALL_EXPECTATION_KEY;
72
82
  private recordInstallExpectation;
73
83
  private readInstallRecord;
74
- private readInstallExpectation;
84
+ /**
85
+ * Drop the identity token once the install has been proven, keeping the rest
86
+ * of the record.
87
+ *
88
+ * The record does two jobs: it proves an install took effect (once), and it
89
+ * maps the native slot's sha256 back to a bundle id (for the life of that
90
+ * bundle). Only the first job is finished after a successful verification, so
91
+ * only the token is retired.
92
+ */
93
+ private retireIdentityToken;
75
94
  private clearInstallExpectation;
76
95
  private storage;
77
96
  /**
@@ -7,42 +7,38 @@
7
7
  */
8
8
  /**
9
9
  * ═══════════════════════════════════════════════════════════════════════════
10
- * OTA-TELEMETRY-SPEC — remaining telemetry fixes (from the 2026-08 audit)
10
+ * OTA-TELEMETRY-SPEC — status (spec §84-86)
11
11
  * ═══════════════════════════════════════════════════════════════════════════
12
- * These close the SDK half of spec §84-86. All are ADDITIVE + backward-compatible
13
- * (new optional fields, new enum members, new no-throw emit calls); old installed
14
- * clients omit them and the backend ingests nullable/unknown fields. Each ALTERS
15
- * DEVICE RUNTIME BEHAVIOR, so land them behind a real RN build + device/kill-test
16
- * (Metro cannot exercise the boot-guard) — this file only carries the contract
17
- * types + this spec, not the runtime wiring.
12
+ * Items 1-3 are DONE. All were additive and backward-compatible: old installed
13
+ * clients simply omit the new fields and never emit the new types, and the
14
+ * backend ingests nullable columns. Because they alter device runtime behaviour,
15
+ * verify against a real RN build + device kill-test before release — Metro
16
+ * cannot exercise the boot guard.
18
17
  *
19
- * 1. releaseId end-to-end (CRITICALroot of the dashboard funnel mismatch)
20
- * Backend already serves it: OtaBundlePayload.releaseId (done, this file +
21
- * ota-check.service.ts). SDK TODO:
22
- * - store payload.releaseId on the downloaded/installed bundle state, and
23
- * - set `releaseId: bundle.releaseId` in the delivery mapper
24
- * OtaOrchestrator.ts deliverOtaEvents (~:105) — currently only bundleId.
25
- * Then ota_events carry releaseId and the funnel keys by release.
18
+ * 1. releaseId end-to-end — DONE. `recordInstallExpectation` persists the
19
+ * release id (so a rollback reported launches later can still name it),
20
+ * `hydrateCurrentBundleFromSlots` restores it, every emit site passes it, and
21
+ * `deliverOtaEvents` maps it onto the wire item.
26
22
  *
27
- * 2. Emit CHECK + OFFERED (funnel top is currently unmeasurable)
28
- * - CHECK: emit at the start of checkForUpdate (OtaOrchestrator.ts ~:480),
29
- * before the fetch. ('CHECK' type already existsno emit site today.)
30
- * - OFFERED: add 'OFFERED' to OtaEventType, emit when checkRes.action ===
31
- * 'DOWNLOAD' (~:619) before download begins.
23
+ * 2. CHECK + OFFERED DONE. CHECK is emitted at the top of `sync()` for devices
24
+ * already on an OTA bundle (a device on the factory bundle has no owned
25
+ * bundle id to name, and the server drops rows it cannot resolve those are
26
+ * counted server-side instead). OFFERED is emitted on `action === 'DOWNLOAD'`
27
+ * before any bytes move.
32
28
  *
33
- * 3. Emit BOOT_SUCCESS (honest activation signal)
34
- * Add 'BOOT_SUCCESS' to OtaEventType and emit it from the boot-guard
35
- * markHealthy path (OtaOrchestrator.ts ~:863). INSTALLED (~:794) is emitted
36
- * optimistically BEFORE the bundle boots; keep it (it means "staged+swapped")
37
- * but let the dashboard measure real activation on BOOT_SUCCESS. Optionally
38
- * add 'VERIFIED' after signature check (~:635).
29
+ * 3. BOOT_SUCCESS DONE. Emitted from the boot-guard heartbeat once the bundle
30
+ * has actually booted and survived to `healthyAfterMs`. INSTALLED is kept and
31
+ * still means "staged + swapped", but activation should be measured on
32
+ * BOOT_SUCCESS: INSTALLED fires before the bundle has run, so it credits
33
+ * bundles that installed and then crash-reverted.
39
34
  *
40
- * 4. Stamp the running OTA bundle onto session/crash telemetry
41
- * SessionMetadata.bundleId is the NATIVE app package id, not the OTA bundle.
42
- * Add optional otaBundleId?/otaBundleVersion? (distinct fields do NOT
43
- * overload bundleId) sourced from otaOrchestrator.getCurrentBundle() at
44
- * session start (SessionManager.ts ~:451), so crashes attribute to the
45
- * running bundle/version (release-health crash impact).
35
+ * 4. Stamp the running OTA bundle onto session/crash telemetry — NOT DONE.
36
+ * `SessionMetadata.bundleId` is the NATIVE app package id, not the OTA bundle,
37
+ * so crashes cannot currently be attributed to the bundle that produced them
38
+ * (release-health crash impact). Needs optional otaBundleId?/otaBundleVersion?
39
+ * (distinct fields do NOT overload bundleId) sourced from
40
+ * `otaOrchestrator.getCurrentBundle()` at session start, plus the matching
41
+ * backend columns and a Prisma migration.
46
42
  *
47
43
  * Do NOT repurpose errorCode (it already collapses failure-error vs rollback-
48
44
  * reason); add a new optional field if the two must be distinguished.
@@ -126,7 +122,8 @@ export interface OtaPatchPayload {
126
122
  sha256: string;
127
123
  baseBundleId: string;
128
124
  }
129
- export type OtaEventType = 'CHECK' | 'DOWNLOAD_STARTED' | 'DOWNLOAD_COMPLETE' | 'INSTALLED' | 'APPLY_FAILED' | 'AUTO_ROLLBACK' | 'MANUAL_ROLLBACK';
125
+ import type { OtaEventType as EmittedOtaEventType } from './OtaEventEmitter';
126
+ export type OtaEventType = Exclude<EmittedOtaEventType, 'DOWNLOAD_PROGRESS'>;
130
127
  export interface OtaEventItem {
131
128
  kind: 'ota_event';
132
129
  bundleId: string;
@@ -50,6 +50,7 @@ export declare class JourneyEventPipeline {
50
50
  journeyId?: string;
51
51
  /** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
52
52
  frameId?: string;
53
+ timestamp?: number;
53
54
  }): JourneyEvent | null;
54
55
  /**
55
56
  * Anchor a crash event — protect nearby events from eviction.
@@ -17,6 +17,7 @@ import type { DebugTransport } from '../../debug/transport';
17
17
  import type { SessionMetadata, SessionReplayConfig, JourneyEventType, JourneyEventSeverity, JourneyEventSource, CrashAnchor, NativeSyncMetadata, SyncDecisionCallback } from './sessionTypes';
18
18
  import { type NativeCaptureFn } from './ReplayCaptureManager';
19
19
  import type { BackendSessionAdapter } from './BackendSessionAdapter';
20
+ import { type InteractionStateStatus } from '../journey/interactionProtocol';
20
21
  export interface SessionManagerConfig {
21
22
  sessionReplay?: Partial<SessionReplayConfig>;
22
23
  syncPolicy?: SyncDecisionCallback;
@@ -43,15 +44,6 @@ export declare class SessionManager {
43
44
  * lane even when no replay recording is active. Additive, opt-in (default off).
44
45
  */
45
46
  private _captureInteractionHeatmap;
46
- /**
47
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
48
- * ON by default, an unbounded one-event-per-gesture stream could materially
49
- * inflate ingest volume. We cap emitted interactions per analytics-session
50
- * window (finalize-scoped per foreground): the first N gestures define the
51
- * hotspot shape; the long tail is dropped. Resets when the window changes.
52
- */
53
- private _heatmapWindowSessionId;
54
- private _heatmapWindowCount;
55
47
  private session;
56
48
  private active;
57
49
  private timeoutTimer;
@@ -207,6 +199,8 @@ export declare class SessionManager {
207
199
  traceId?: string;
208
200
  source?: JourneyEventSource;
209
201
  journeyId?: string;
202
+ /** Original observation time; interaction capture may resolve asynchronously. */
203
+ timestamp?: number;
210
204
  }): void;
211
205
  /**
212
206
  * Anchor a crash in the session context.
@@ -223,7 +217,10 @@ export declare class SessionManager {
223
217
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
224
218
  * Also triggers frame capture for desktop-initiated recordings.
225
219
  */
226
- onUserAction(subtype: string, payload?: Record<string, unknown>): void;
220
+ onUserAction(subtype: string, payload?: Record<string, unknown>, context?: {
221
+ screen?: string;
222
+ timestamp?: number;
223
+ }): void;
227
224
  /**
228
225
  * Detailed gesture detection — called by ScaleBunDebugRoot touch handlers.
229
226
  * Emits a USER_ACTION event with gesture-specific subtype and payload.
@@ -263,6 +260,14 @@ export declare class SessionManager {
263
260
  screenWidth?: number;
264
261
  screenHeight?: number;
265
262
  platform?: string;
263
+ interactionId?: string;
264
+ interactionProtocol?: number;
265
+ occurredAt?: number;
266
+ ui?: string;
267
+ stateStatus?: InteractionStateStatus;
268
+ targetId?: string;
269
+ screenName?: string;
270
+ emitAutomaticAnalytics?: boolean;
266
271
  }): void;
267
272
  /**
268
273
  * Manual frame capture — triggered by Desktop "Capture Step" button.
@@ -82,6 +82,33 @@ declare class ScaleBunFacade {
82
82
  * than letting the app discover it as a silent no-update condition.
83
83
  */
84
84
  private _maybeStartOta;
85
+ /** Guards against overlapping config-driven OTA checks. */
86
+ private _otaCheckInFlight;
87
+ /** Wall clock of the last config-driven check, for the foreground floor. */
88
+ private _otaLastCheckAt;
89
+ private _otaForegroundListener;
90
+ /**
91
+ * Minimum gap between config-driven checks.
92
+ *
93
+ * A foreground transition is cheap to trigger — app switchers, permission
94
+ * dialogs and share sheets all produce one — so an unthrottled check would
95
+ * put a request on the hot path every time the user glanced away. Ten
96
+ * minutes is well below any realistic release cadence and well above that
97
+ * noise. A host that wants a check on demand calls `useOtaUpdate().sync()`,
98
+ * which is never throttled.
99
+ */
100
+ private static readonly OTA_MIN_CHECK_INTERVAL_MS;
101
+ /**
102
+ * Drive OTA checks from init config: once at startup, then on each
103
+ * foreground when `checkOnForeground` is on (the schema default).
104
+ *
105
+ * `appVersion` is resolved from the native bridge rather than asked of the
106
+ * integrator, because it gates the server's `targetAppVersion` semver check
107
+ * — sending a wrong or invented value is worse than sending none, and there
108
+ * is no honest default. If it cannot be resolved, the check is skipped with
109
+ * a warning instead of guessing.
110
+ */
111
+ private _startOtaChecks;
85
112
  private _autoEnableDebug;
86
113
  /** Boot the Phase 1 envelope tracking lane if an appId is configured. */
87
114
  private _maybeStartEventTracker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "1.10.7",
3
+ "version": "1.11.1",
4
4
  "description": "React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -121,7 +121,7 @@
121
121
  "@babel/runtime": "^7.25.0",
122
122
  "@jridgewell/sourcemap-codec": "1.5.5",
123
123
  "@jridgewell/trace-mapping": "0.3.31",
124
- "@scalebun/cli": "^1.10.2"
124
+ "@scalebun/cli": "^1.11.0"
125
125
  },
126
126
  "codegenConfig": {
127
127
  "name": "ScaleBunSpec",
@@ -154,6 +154,7 @@
154
154
  "watch": "bob build --watch",
155
155
  "test:screens": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/screen-detection.test.ts",
156
156
  "test:uistate": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/ui-state.test.ts",
157
- "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json"
157
+ "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
158
+ "test:protocol": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/interaction-protocol.test.ts"
158
159
  }
159
160
  }
@@ -172,7 +172,7 @@ export class EventTracker {
172
172
  this.started = true;
173
173
  if (this.cfg.automaticEventTracking) {
174
174
  this.automaticEventsUnsubscribe = subscribeAutomaticEvents((event) => {
175
- this.track(event.name, event.properties);
175
+ this.track(event.name, event.properties, event.timestamp);
176
176
  });
177
177
  }
178
178
  if (this.cfg.autoLifecycleEvents) {
@@ -284,9 +284,9 @@ export class EventTracker {
284
284
 
285
285
  // ─── tracking ────────────────────────────────────────────────────────────
286
286
 
287
- track(eventName: string, properties?: Record<string, any>): void {
287
+ track(eventName: string, properties?: Record<string, any>, timestamp?: number): void {
288
288
  try {
289
- this.enqueue(this.buildEnvelope(eventName, properties));
289
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
290
290
  try { this.cfg.onEvent?.(eventName); } catch { /* no-throw */ }
291
291
  } catch (err) {
292
292
  logger.warn(`[ScaleBun.events] track failed: ${(err as Error)?.message}`);
@@ -408,7 +408,7 @@ export class EventTracker {
408
408
 
409
409
  // ─── internals ─────────────────────────────────────────────────────────────
410
410
 
411
- private buildEnvelope(eventName: string, properties?: Record<string, any>): Envelope {
411
+ private buildEnvelope(eventName: string, properties?: Record<string, any>, timestamp?: number): Envelope {
412
412
  const ctx = this.cfg.context ?? {};
413
413
  let canonicalSessionId: string | null | undefined;
414
414
  try {
@@ -419,7 +419,7 @@ export class EventTracker {
419
419
  const env: Envelope = {
420
420
  event_id: uuid(),
421
421
  event_name: eventName,
422
- event_time: Date.now(),
422
+ event_time: timestamp ?? Date.now(),
423
423
  app_id: this.cfg.appId,
424
424
  platform: this.cfg.platform ?? resolveEventPlatform(),
425
425
  installation_id: this.installationId,
@@ -39,6 +39,8 @@ export type AutomaticEventName =
39
39
  export interface AutomaticEvent {
40
40
  name: AutomaticEventName;
41
41
  properties: Record<string, unknown>;
42
+ /** Original observation time. Delivery can be delayed while native coordinates resolve. */
43
+ timestamp?: number;
42
44
  }
43
45
 
44
46
  type AutomaticEventListener = (event: AutomaticEvent) => void;
@@ -60,10 +62,12 @@ function compactProperties(
60
62
  export function emitAutomaticEvent(
61
63
  name: AutomaticEventName,
62
64
  properties?: Record<string, unknown>,
65
+ timestamp?: number,
63
66
  ): void {
64
67
  const event: AutomaticEvent = {
65
68
  name,
66
69
  properties: compactProperties(properties),
70
+ timestamp,
67
71
  };
68
72
  if (listeners.size === 0) {
69
73
  pending.push(event);
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export const SDK_VERSION = '1.10.7';
11
+ export const SDK_VERSION = '1.11.1';