@scalebun/react-native 1.3.3 → 1.5.0

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 (97) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BsPatch.kt +182 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +88 -13
  3. package/android/src/oldarch/java/com/scalebun/rn/crash/ScaleBunCrashSpec.kt +16 -0
  4. package/bin/scalebun.js +55 -3
  5. package/dist/scalebun.full.js +190 -110
  6. package/dist/scalebun.full.js.map +1 -1
  7. package/dist/scalebun.slim.js +188 -109
  8. package/dist/scalebun.slim.js.map +1 -1
  9. package/ios/Ota/BsPatch.swift +180 -0
  10. package/ios/Ota/OtaSlotManager.swift +59 -2
  11. package/ios/Ota/ScaleBunOtaModule.swift +89 -10
  12. package/lib/commonjs/analytics/EventTracker.js +26 -4
  13. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  14. package/lib/commonjs/analytics/eventLane.js +23 -0
  15. package/lib/commonjs/analytics/eventLane.js.map +1 -1
  16. package/lib/commonjs/core/constants/version.js +1 -1
  17. package/lib/commonjs/core/context/device.js +18 -0
  18. package/lib/commonjs/core/context/device.js.map +1 -1
  19. package/lib/commonjs/core/id/installationId.js +55 -0
  20. package/lib/commonjs/core/id/installationId.js.map +1 -0
  21. package/lib/commonjs/debug/bootstrap.js +14 -1
  22. package/lib/commonjs/debug/bootstrap.js.map +1 -1
  23. package/lib/commonjs/debug/transport.js +2 -1
  24. package/lib/commonjs/debug/transport.js.map +1 -1
  25. package/lib/commonjs/features/journey/journeyManager.js +2 -1
  26. package/lib/commonjs/features/journey/journeyManager.js.map +1 -1
  27. package/lib/commonjs/features/ota/OtaOrchestrator.js +2 -30
  28. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  29. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +27 -3
  30. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  31. package/lib/commonjs/features/performance/collectors/JsStallCollector.js +20 -1
  32. package/lib/commonjs/features/performance/collectors/JsStallCollector.js.map +1 -1
  33. package/lib/commonjs/features/session/BackendSessionAdapter.js +8 -0
  34. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  35. package/lib/commonjs/public/ScaleBunFacade.js +10 -2
  36. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  37. package/lib/module/analytics/EventTracker.js +26 -4
  38. package/lib/module/analytics/EventTracker.js.map +1 -1
  39. package/lib/module/analytics/eventLane.js +22 -0
  40. package/lib/module/analytics/eventLane.js.map +1 -1
  41. package/lib/module/core/constants/version.js +1 -1
  42. package/lib/module/core/context/device.js +17 -0
  43. package/lib/module/core/context/device.js.map +1 -1
  44. package/lib/module/core/id/installationId.js +50 -0
  45. package/lib/module/core/id/installationId.js.map +1 -0
  46. package/lib/module/debug/bootstrap.js +14 -1
  47. package/lib/module/debug/bootstrap.js.map +1 -1
  48. package/lib/module/debug/transport.js +2 -1
  49. package/lib/module/debug/transport.js.map +1 -1
  50. package/lib/module/features/journey/journeyManager.js +2 -1
  51. package/lib/module/features/journey/journeyManager.js.map +1 -1
  52. package/lib/module/features/ota/OtaOrchestrator.js +1 -29
  53. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  54. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +27 -3
  55. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  56. package/lib/module/features/performance/collectors/JsStallCollector.js +20 -1
  57. package/lib/module/features/performance/collectors/JsStallCollector.js.map +1 -1
  58. package/lib/module/features/session/BackendSessionAdapter.js +8 -0
  59. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  60. package/lib/module/public/ScaleBunFacade.js +12 -4
  61. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  62. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  63. package/lib/typescript/analytics/eventLane.d.ts +2 -0
  64. package/lib/typescript/analytics/eventLane.d.ts.map +1 -1
  65. package/lib/typescript/core/constants/version.d.ts +1 -1
  66. package/lib/typescript/core/context/device.d.ts +13 -0
  67. package/lib/typescript/core/context/device.d.ts.map +1 -1
  68. package/lib/typescript/core/id/installationId.d.ts +20 -0
  69. package/lib/typescript/core/id/installationId.d.ts.map +1 -0
  70. package/lib/typescript/debug/bootstrap.d.ts +18 -0
  71. package/lib/typescript/debug/bootstrap.d.ts.map +1 -1
  72. package/lib/typescript/debug/transport.d.ts.map +1 -1
  73. package/lib/typescript/features/journey/journeyManager.d.ts.map +1 -1
  74. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  75. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +14 -0
  76. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  77. package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts +5 -0
  78. package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts.map +1 -1
  79. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  80. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  81. package/lib/typescript/public/types.d.ts +1 -1
  82. package/lib/typescript/public/types.d.ts.map +1 -1
  83. package/package.json +3 -2
  84. package/src/analytics/EventTracker.ts +26 -4
  85. package/src/analytics/eventLane.ts +22 -0
  86. package/src/core/constants/version.ts +1 -1
  87. package/src/core/context/device.ts +17 -0
  88. package/src/core/id/installationId.ts +52 -0
  89. package/src/debug/bootstrap.ts +38 -1
  90. package/src/debug/transport.ts +2 -1
  91. package/src/features/journey/journeyManager.ts +2 -1
  92. package/src/features/ota/OtaOrchestrator.ts +1 -30
  93. package/src/features/performance/collectors/FrameMetricsCollector.ts +32 -3
  94. package/src/features/performance/collectors/JsStallCollector.ts +20 -1
  95. package/src/features/session/BackendSessionAdapter.ts +8 -0
  96. package/src/public/ScaleBunFacade.ts +12 -4
  97. package/src/public/types.ts +1 -1
@@ -19,7 +19,9 @@
19
19
  */
20
20
 
21
21
  import { createStorageBackend, StorageBackend } from '../storage/StorageBackend';
22
+ import { resolveEventPlatform } from '../core/context/device';
22
23
  import { getDeviceId } from '../core/id/deviceId';
24
+ import { SDK_VERSION } from '../core/constants/version';
23
25
  import { logger } from '../core/logger/internalLogger';
24
26
  import { setNativeActiveSession } from '../features/crash/nativeCrashBridge';
25
27
  import { hmacSha256Hex } from '../crypto/hmacSha256';
@@ -141,7 +143,7 @@ export class EventTracker {
141
143
  constructor(config: EventTrackerConfig) {
142
144
  this.storage = createStorageBackend();
143
145
  this.cfg = {
144
- platform: 'react_native',
146
+ platform: resolveEventPlatform(),
145
147
  flushIntervalMs: DEFAULT_FLUSH_MS,
146
148
  batchSize: DEFAULT_BATCH,
147
149
  autoLifecycleEvents: true,
@@ -315,7 +317,27 @@ export class EventTracker {
315
317
  const effectiveBatch = clampBatchSize(this.cfg.batchSize, DEFAULT_BATCH);
316
318
  const batch = this.queue.slice(0, effectiveBatch);
317
319
  try {
318
- // Sign the exact bytes we send so the backend can tamper-detect the payload.
320
+ // WHAT THIS SIGNATURE IS, AND WHAT IT IS NOT.
321
+ //
322
+ // This used to be described as letting "the backend tamper-detect
323
+ // the payload", which overstates it to the point of being wrong.
324
+ // The HMAC key is `clientKey` — the same value sent beside it in
325
+ // `x-scalebun-client-key`, and a value that ships inside every
326
+ // installed app, extractable by anyone who unzips an APK. A MAC
327
+ // whose key travels with the message and is public by construction
328
+ // proves nothing about who sent it: anyone can compute a valid one.
329
+ //
330
+ // What it DOES buy is integrity in transit against non-adversarial
331
+ // corruption, and a cheap filter for junk traffic. Treat `clientKey`
332
+ // as a publishable identifier (like a Stripe publishable key), never
333
+ // as a trust boundary. Authenticity that actually holds has to come
334
+ // from something the client cannot forge — server-side rate limits
335
+ // and anomaly detection per key, short-lived device tokens, or
336
+ // platform attestation (Play Integrity / App Attest).
337
+ //
338
+ // Contrast with OTA bundle signing (features/ota/signature.ts),
339
+ // which is asymmetric: the private half never leaves the publisher,
340
+ // so a valid signature there IS proof of origin.
319
341
  const payload = JSON.stringify({ events: batch });
320
342
  // Wave 2 (§2F.2): a stable idempotency key over the batch's event ids so a
321
343
  // retry after a timeout/disconnect replays the first response instead of
@@ -380,14 +402,14 @@ export class EventTracker {
380
402
  event_name: eventName,
381
403
  event_time: Date.now(),
382
404
  app_id: this.cfg.appId,
383
- platform: this.cfg.platform ?? 'react_native',
405
+ platform: this.cfg.platform ?? resolveEventPlatform(),
384
406
  installation_id: this.installationId,
385
407
  anonymous_id: this.anonymousId,
386
408
  session_id: canonicalSessionId || this.sessionId,
387
409
  device_id: getDeviceId(),
388
410
  // Phase 4: experiment stamps ride on every event; explicit props override.
389
411
  properties: { ...this.stamps, ...(properties ?? {}) },
390
- context: { sdk_version: ctx.sdk_version ?? '0.1.0', ...ctx },
412
+ context: { sdk_version: ctx.sdk_version ?? SDK_VERSION, ...ctx },
391
413
  };
392
414
  if (this.userId) env.user_id = this.userId;
393
415
  if (this.cfg.appVersion) env.app_version = this.cfg.appVersion;
@@ -24,3 +24,25 @@ export function isSessionLaneEvent(name: string): boolean {
24
24
  export function resolveEventLane(name: string): EventLane {
25
25
  return isSessionLaneEvent(name) ? 'session' : 'analytics';
26
26
  }
27
+
28
+ /**
29
+ * Signals that stay IN-PROCESS: they fan out to the engage trigger engine, the perf collectors and
30
+ * the desktop debug stream, but are NOT uploaded as session events — because each one already has
31
+ * its own dedicated ingestion lane carrying strictly more data.
32
+ *
33
+ * `$network_request` is the founding member: NetworkFeature writes every request to the
34
+ * `network_requests` table via reportNetworkRequest (method/status/timing/sizes/headers), and ALSO
35
+ * tracked it — which uploaded a second copy as a USER_ACTION session event that nothing reads. The
36
+ * dashboard's inspector explicitly filters that echo out as noise, the heatmap route catalog
37
+ * documents it as pollution, and measured sessions carried ~15% of their event volume in it.
38
+ * (`$perf_event` was the same pattern, removed at its emit site — d81e0ab.)
39
+ *
40
+ * Rule for adding here: the signal must have a dedicated lane that every consumer reads. A signal
41
+ * whose ONLY record is the session event stream must never be added.
42
+ */
43
+ const INTERNAL_ONLY_SIGNALS = new Set(['$network_request']);
44
+
45
+ /** Should this tracked event be uploaded to the session-events lane at all? */
46
+ export function isUploadedSessionEvent(name: string): boolean {
47
+ return !INTERNAL_ONLY_SIGNALS.has(name);
48
+ }
@@ -3,4 +3,4 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.3.3';
6
+ export const SDK_VERSION = '1.5.0';
@@ -144,3 +144,20 @@ export function mergeDeviceContext(
144
144
  ): Record<string, unknown> {
145
145
  return { ...(configured ?? {}), ...detected };
146
146
  }
147
+
148
+ /**
149
+ * The analytics envelope lane's platform value, AUTO-DETECTED.
150
+ *
151
+ * The init config's `platform` field was documented as "Default: react_native" — so every
152
+ * integration that did not hand-write `platform: 'ios' | 'android'` stamped the literal
153
+ * `react_native` on all its analytics events, and the dashboard's platform filters (which split
154
+ * ios/android) silently excluded them. Asking the developer for a value the runtime knows is an
155
+ * integration tax with a failure mode; `Platform.OS` answers it exactly.
156
+ *
157
+ * `react_native` remains only for the genuinely-unknown case (RN unloadable, or an OS the wire
158
+ * enum does not carry) — an honest "RN, platform untold" rather than a confident wrong answer.
159
+ */
160
+ export function resolveEventPlatform(): 'ios' | 'android' | 'web' | 'react_native' {
161
+ const os = resolvePlatformOS();
162
+ return os === 'ios' || os === 'android' || os === 'web' ? os : 'react_native';
163
+ }
@@ -0,0 +1,52 @@
1
+ import { createStorageBackend } from '../../storage/StorageBackend';
2
+ import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
3
+
4
+ /**
5
+ * The SDK's canonical per-install id.
6
+ *
7
+ * ONE install, ONE id, every lane. This is deliberately NOT `getDeviceId()` —
8
+ * that is a separate value under a separate storage key, and the backend schema
9
+ * annotates the two as different ("stable per-install id from the SDK (≠
10
+ * deviceId)").
11
+ *
12
+ * It matters because the backend joins a bundle's OTA installs to that device's
13
+ * sessions through this value. OTA events have always been keyed by it; the
14
+ * session lane did not send it at all, so on the server the two could only be
15
+ * connected for devices that happened to have registered for push. Release
16
+ * health then reported "no data" for bundles that were running perfectly well —
17
+ * or crashing badly.
18
+ *
19
+ * Lives here rather than inside one feature so the OTA orchestrator and the
20
+ * session adapter cannot drift to two different ids.
21
+ */
22
+ export function resolveInstallationId(): string {
23
+ try {
24
+ const storage = createStorageBackend();
25
+ const existing = storage.get(K_INSTALLATION_ID);
26
+ if (existing) return existing;
27
+ const id = `inst-${randomSegment()}${randomSegment()}`;
28
+ storage.set(K_INSTALLATION_ID, id);
29
+ return id;
30
+ } catch {
31
+ // Storage unavailable (tests, exotic hosts): a per-process id keeps the
32
+ // lanes functional; it stabilizes on the first run where storage works.
33
+ return `inst-ephemeral-${randomSegment()}`;
34
+ }
35
+ }
36
+
37
+ /** 8 chars of base36 entropy, crypto-backed when the runtime offers it. */
38
+ function randomSegment(): string {
39
+ try {
40
+ const g = globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } };
41
+ if (g.crypto?.getRandomValues) {
42
+ const bytes = g.crypto.getRandomValues(new Uint8Array(6));
43
+ return Array.from(bytes)
44
+ .map((b) => b.toString(36).padStart(2, '0'))
45
+ .join('')
46
+ .slice(0, 8);
47
+ }
48
+ } catch {
49
+ // fall through to Math.random
50
+ }
51
+ return Math.random().toString(36).slice(2, 10).padEnd(8, '0');
52
+ }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { DebugTransport, type DebugTransportConfig } from './transport';
18
18
  import { DEFAULT_WS_PORT, DEBUG_READY_EMIT_DELAY_MS } from '../core/constants/timings';
19
+ import { SDK_VERSION } from '../core/constants/version';
19
20
  import { DebugEventStream } from './stream';
20
21
  import { CommandRegistry } from './commands';
21
22
  import { PerfMonitor } from './perf';
@@ -66,6 +67,24 @@ export interface DebugConfig {
66
67
  /** @internal */
67
68
  _performanceDisabled?: boolean;
68
69
  enableProfiler?: boolean;
70
+ /**
71
+ * Permit the debug channel in a RELEASE build. Default false, and you
72
+ * almost certainly want it that way.
73
+ *
74
+ * The transport is an unencrypted `ws://` socket that carries captured
75
+ * console output, network traffic and the shared `secret` itself in
76
+ * cleartext, readable by anyone on the same Wi-Fi. That is an acceptable
77
+ * trade for a developer on their own machine and not acceptable in an app
78
+ * on a user's phone.
79
+ *
80
+ * Until now the only thing standing between those two cases was that the
81
+ * desktop tooling is excluded from bundles by default — a BUILD-time
82
+ * opt-out, not a release check. Anyone who set `devTools: true` to debug
83
+ * locally and shipped without reverting it published an app that opens
84
+ * that socket in production. This flag makes that a deliberate act rather
85
+ * than an oversight.
86
+ */
87
+ allowInRelease?: boolean;
69
88
  }
70
89
 
71
90
  // ─── Singleton State ────────────────────────────────────────────────────────
@@ -114,6 +133,24 @@ export function enableDebug(config: DebugConfig): void {
114
133
  return;
115
134
  }
116
135
 
136
+ // Refuse in release builds. See `allowInRelease` on DebugConfig: this
137
+ // channel is plaintext ws:// carrying console output, network traffic
138
+ // and the shared secret, and the only previous guard was that the
139
+ // tooling is excluded from bundles by default — which a developer
140
+ // disables to debug locally and then forgets. Warn rather than fail
141
+ // silently, because a debug connection that does nothing with no
142
+ // explanation is its own long afternoon.
143
+ if (!__DEV__ && !config.allowInRelease) {
144
+ logger.warn(
145
+ '[ScaleBun] Debug connection refused in a release build. The debug ' +
146
+ 'transport is unencrypted (ws://) and carries captured traffic and the ' +
147
+ 'shared secret in cleartext, so it is development-only by default. If ' +
148
+ 'you genuinely need it in a release build, set ' +
149
+ '`allowInRelease: true` — and do not ship that to users.',
150
+ );
151
+ return;
152
+ }
153
+
117
154
  // The desktop-debugger tooling can be excluded from a build via
118
155
  // `withScaleBun(config, { features: { devTools: false } })`, which resolves these modules
119
156
  // to an empty stub. Detect that explicitly: without this check the first `new
@@ -262,7 +299,7 @@ export function enableDebug(config: DebugConfig): void {
262
299
  journeyEnabled: _journeyConfig.enabled,
263
300
  canScreenshot: true,
264
301
  navMode: _autoScreenDetector ? 'auto' : 'beacon',
265
- sdkVersion: '1.0.0',
302
+ sdkVersion: SDK_VERSION,
266
303
  });
267
304
  __DEV__ && logger.debug('Emitted journey:debug_ready event to desktop');
268
305
  } catch { /* no-throw */ }
@@ -30,6 +30,7 @@ import {
30
30
  TRANSPORT_RECONNECT_MAX_MS,
31
31
  TRANSPORT_HIGH_LATENCY_WARN_MS,
32
32
  } from '../core/constants/timings';
33
+ import { SDK_VERSION } from '../core/constants/version';
33
34
 
34
35
  // ─── Types ──────────────────────────────────────────────────────────────────
35
36
 
@@ -272,7 +273,7 @@ export class DebugTransport {
272
273
  timestamp: Date.now(),
273
274
  sequence: this.nextSequence(),
274
275
  payload: {
275
- sdkVersion: '0.1.0',
276
+ sdkVersion: SDK_VERSION,
276
277
  appName: this.config.appName,
277
278
  appVersion: this.config.appVersion,
278
279
  bundleId: this.config.bundleId,
@@ -17,6 +17,7 @@ import type {
17
17
  } from './journeyTypes';
18
18
  import { DEFAULT_JOURNEY_CONFIG } from './journeyTypes';
19
19
  import { GestureBuffer } from './gestureBuffer';
20
+ import { SDK_VERSION } from '../../core/constants/version';
20
21
  import { GestureDetector } from './gestureDetector';
21
22
  import { NavDetector } from './navDetector';
22
23
  import { ScreenshotHelper } from './screenshotHelper';
@@ -435,7 +436,7 @@ export class JourneyManager {
435
436
  journeyEnabled: this.config.enabled,
436
437
  canScreenshot: this.screenshotHelper.canCapture,
437
438
  navMode: this.navDetector.navMode,
438
- sdkVersion: '0.1.0',
439
+ sdkVersion: SDK_VERSION,
439
440
  };
440
441
  this._emitEvent(JOURNEY_EVENTS.DEBUG_READY, payload as unknown as Record<string, unknown>);
441
442
  }
@@ -13,7 +13,7 @@ import type { OtaCheckRequest, OtaCheckResponse, OtaBundlePayload } from './OtaT
13
13
  import { otaEventEmitter } from './OtaEventEmitter';
14
14
  import { getDeviceCountry, prefetchDeviceCountry } from './geoCountry';
15
15
  import { deviceTargetingAttributes } from './deviceAttributes';
16
- import { K_INSTALLATION_ID } from '../../analytics/EventTracker';
16
+ import { resolveInstallationId } from '../../core/id/installationId';
17
17
  import { verifyBundleSignature, type SignatureConfig } from './signature';
18
18
  import { detectOtaEnvironment, type OtaEnvironment } from './environment';
19
19
  import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
@@ -71,35 +71,6 @@ function subscribeNativeProgress(
71
71
  /** POST at most once per this interval; first and terminal ticks always go. */
72
72
  const PROGRESS_POST_INTERVAL_MS = 1_000;
73
73
 
74
- /**
75
- * P8 — the SDK's canonical per-install id, shared with the analytics lane.
76
- *
77
- * OTA used to REQUIRE a caller-supplied `installationId`, and the documented
78
- * example passed `deviceId` — so OTA telemetry lived in its own identity
79
- * namespace, disconnected from the id every other lane reports. Every join
80
- * from OTA data to CDP data (blast radius, MRR-at-risk, churn bands) was a
81
- * cross-namespace join that matched by luck.
82
- *
83
- * Reads (or mints, for OTA-only apps that never initialize analytics) the same
84
- * persisted value `EventTracker` uses, via the same storage backend and the
85
- * same exported key — one install, one id, every lane.
86
- */
87
- function resolveInstallationId(): string {
88
- try {
89
- const storage = createStorageBackend();
90
- const existing = storage.get(K_INSTALLATION_ID);
91
- if (existing) return existing;
92
- const id = `inst-${Math.random().toString(36).slice(2, 10)}${Math.random()
93
- .toString(36)
94
- .slice(2, 10)}`;
95
- storage.set(K_INSTALLATION_ID, id);
96
- return id;
97
- } catch {
98
- // Storage unavailable (tests, exotic hosts): a per-process id keeps sync
99
- // functional; it will stabilize on the first run where storage works.
100
- return `inst-ephemeral-${Math.random().toString(36).slice(2, 10)}`;
101
- }
102
- }
103
74
 
104
75
  /**
105
76
  * Deliver queued OTA lifecycle events to the backend's batch endpoint.
@@ -29,6 +29,21 @@ export class FrameMetricsCollector {
29
29
  private appStateSubscription: NativeEventSubscription | null = null;
30
30
  /** True while the native sampler is actually running (i.e. armed AND foregrounded). */
31
31
  private sampling = false;
32
+ /** Timestamp of the last emitted window — drives the healthy-window heartbeat below. */
33
+ private lastEmitTs = 0;
34
+
35
+ /**
36
+ * HEALTHY windows emit at a heartbeat, not per report interval.
37
+ *
38
+ * The native sampler reports every `reportIntervalMs` (5 s), and every window became a stored
39
+ * row — on a session hours long that is 720 rows/hour of "still 60 fps", which is how real
40
+ * sessions hit the dashboard's 2,000-row display cap with nothing to say. A window that shows
41
+ * ANY jank (dropped/frozen frames, meaningful slow-frame count, an FPS dip) always emits; a
42
+ * healthy window emits only when the heartbeat has elapsed, keeping the FPS series alive for
43
+ * the charts at 1/12th the volume. Capture cadence is unchanged — only identical-looking
44
+ * healthy rows are elided, and the first window after ANY emit gap states the recovery.
45
+ */
46
+ private static readonly HEALTHY_HEARTBEAT_MS = 60_000;
32
47
 
33
48
  constructor(
34
49
  config: PerformanceConfig,
@@ -161,10 +176,24 @@ export class FrameMetricsCollector {
161
176
  ? Math.round((actualFrames / expectedFrames) * 60)
162
177
  : 60;
163
178
 
179
+ const clampedFps = Math.min(60, Math.max(0, estimatedFps));
180
+
181
+ // Healthy-window heartbeat — see HEALTHY_HEARTBEAT_MS. Jank always emits.
182
+ const now = Date.now();
183
+ const healthy =
184
+ droppedFrames === 0 &&
185
+ frozenFrames === 0 &&
186
+ slowFrames <= 2 &&
187
+ clampedFps >= 55;
188
+ if (healthy && this.lastEmitTs !== 0 && now - this.lastEmitTs < FrameMetricsCollector.HEALTHY_HEARTBEAT_MS) {
189
+ return;
190
+ }
191
+ this.lastEmitTs = now;
192
+
164
193
  const event: FrameMetricsEvent = {
165
- id: `frames-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
194
+ id: `frames-${now}-${Math.random().toString(36).substring(2, 8)}`,
166
195
  type: 'frame_metrics',
167
- timestamp: Date.now(),
196
+ timestamp: now,
168
197
  schemaVersion: PERF_SCHEMA_VERSION,
169
198
  context: this.contextProvider(),
170
199
  windowMs,
@@ -172,7 +201,7 @@ export class FrameMetricsCollector {
172
201
  droppedFrames,
173
202
  slowFrames,
174
203
  frozenFrames,
175
- estimatedFps: Math.min(60, Math.max(0, estimatedFps)),
204
+ estimatedFps: clampedFps,
176
205
  maxFrameTimeMs,
177
206
  };
178
207
 
@@ -37,6 +37,11 @@ export class JsStallCollector {
37
37
  private maxStallMs = 0;
38
38
  private totalStallMs = 0;
39
39
  private recentStalls: Array<{ timestamp: number; durationMs: number }> = [];
40
+ /** Per-run caps on INDIVIDUAL stall events — see the emit site. Summaries are uncapped. */
41
+ private static readonly EMITTED_STALLS_MAX = 200;
42
+ private static readonly CRITICAL_OVERFLOW_MAX = 100;
43
+ private emittedStalls = 0;
44
+ private emittedCriticalOverflow = 0;
40
45
 
41
46
  constructor(
42
47
  config: PerformanceConfig,
@@ -121,9 +126,23 @@ export class JsStallCollector {
121
126
  this.recentStalls = this.recentStalls.slice(-MAX_STALL_RECORDS / 2);
122
127
  }
123
128
 
124
- // Emit individual stall events for severe stalls (>100ms)
129
+ // Emit individual stall events for severe stalls (>100ms) — per-run capped.
130
+ //
131
+ // On a chronically janky device this fired per stall for the whole session: hours of the
132
+ // same diagnosis, thousands of rows, and the session's perf tab pinned at its display cap.
133
+ // The first EMITTED_STALLS_MAX stalls carry the individual detail; after that only
134
+ // criticals (>500 ms) still emit, themselves capped, and everything keeps counting into
135
+ // the periodic summary (stallCount/maxStallMs/totalStallMs) so no stall goes unmeasured —
136
+ // only the thousandth copy of the same story goes unshipped.
125
137
  if (durationMs > 100) {
126
138
  const severity = this._classifySeverity(durationMs);
139
+ if (this.emittedStalls >= JsStallCollector.EMITTED_STALLS_MAX) {
140
+ if (severity !== 'critical') return;
141
+ if (this.emittedCriticalOverflow >= JsStallCollector.CRITICAL_OVERFLOW_MAX) return;
142
+ this.emittedCriticalOverflow++;
143
+ } else {
144
+ this.emittedStalls++;
145
+ }
127
146
  const event: JsStallEvent = {
128
147
  id: `stall-${timestamp}-${Math.random().toString(36).substring(2, 8)}`,
129
148
  type: 'js_stall',
@@ -26,6 +26,7 @@
26
26
  import { logger } from '../../core/logger/internalLogger';
27
27
  import { base64ToBytes } from '../../core/encoding/base64';
28
28
  import { ENDPOINTS } from '../../transport/http/endpoints';
29
+ import { resolveInstallationId } from '../../core/id/installationId';
29
30
  import { SDK_VERSION } from '../../core/constants/version';
30
31
  import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
31
32
  import { getReplaySdkNative } from '../replay/bridge/nativeModule';
@@ -262,6 +263,13 @@ export class BackendSessionAdapter implements SessionTransport {
262
263
  const payload = {
263
264
  sessionId: session.sessionId,
264
265
  deviceId: session.deviceId,
266
+ // The canonical per-install id — the SAME value OTA events are
267
+ // reported under, and a DIFFERENT value from deviceId. The
268
+ // backend stores it on the Device so release health can join a
269
+ // bundle's installs to that device's sessions. Without it, crash
270
+ // impact for a release resolves to "no data" for every device
271
+ // that never registered for push.
272
+ installationId: resolveInstallationId(),
265
273
  startedAt: session.startedAt,
266
274
  // Link the recording row to the always-on analytics row so the
267
275
  // dashboard can join them. track() events land on the analytics
@@ -20,7 +20,7 @@ import { PersistentQueue } from '../pipeline/queue/persistentQueue';
20
20
  import { MemoryBackend, createStorageBackend } from '../storage/StorageBackend';
21
21
  import { HttpClient } from '../transport/http/httpClient';
22
22
  import { getDeviceId } from '../core/id/deviceId';
23
- import { mergeDeviceContext } from '../core/context/device';
23
+ import { mergeDeviceContext, resolveEventPlatform } from '../core/context/device';
24
24
  import { coreContainer } from '../core/di/container';
25
25
  import { flushQueue } from '../pipeline/queue/flushQueue';
26
26
  import {
@@ -52,7 +52,7 @@ import {
52
52
  emitAutomaticEvent,
53
53
  type AutomaticEventName,
54
54
  } from '../analytics/automaticEvents';
55
- import { resolveEventLane } from '../analytics/eventLane';
55
+ import { resolveEventLane, isUploadedSessionEvent } from '../analytics/eventLane';
56
56
  import { ConfigManager } from '../config/ConfigManager';
57
57
  import { SDK_VERSION } from '../core/constants/version';
58
58
  import { EngageTransport, type SubmitStage } from '../features/engage/EngageTransport';
@@ -347,7 +347,8 @@ class ScaleBunFacade {
347
347
  appId,
348
348
  clientKey,
349
349
  apiBaseUrl,
350
- platform: rawConfig?.platform ?? 'react_native',
350
+ // Auto-detected (Platform.OS) unless the integration overrides it — see resolveEventPlatform.
351
+ platform: rawConfig?.platform ?? resolveEventPlatform(),
351
352
  appVersion: rawConfig?.appVersion,
352
353
  osVersion: rawConfig?.osVersion,
353
354
  autoLifecycleEvents: rawConfig?.autoLifecycleEvents !== false,
@@ -1224,7 +1225,14 @@ class ScaleBunFacade {
1224
1225
  engageSignals.emit(name, payload);
1225
1226
 
1226
1227
  const adapter = SessionManager.getExistingInstance()?.getBackendTransport();
1227
- if (this._clientKey && adapter) {
1228
+ // Internal-only signals ($network_request, ) fan out to engage/perf/debug below but
1229
+ // are never uploaded as session events — their data already travels on a dedicated
1230
+ // lane (see INTERNAL_ONLY_SIGNALS in analytics/eventLane). The uploaded copy was pure
1231
+ // duplication: ~15% of measured mobile event volume, filtered out as noise by every
1232
+ // dashboard reader.
1233
+ if (!isUploadedSessionEvent(name)) {
1234
+ // fall through to the in-process consumers
1235
+ } else if (this._clientKey && adapter) {
1228
1236
  // SaaS mode → always-on analytics ingestion lane (NOT /sdk/events).
1229
1237
  // sessionId is stamped by the lane at flush time, so the call-time
1230
1238
  // value is a placeholder; endpoint id == body sessionId by construction.
@@ -334,7 +334,7 @@ export interface SimplifiedInitConfig {
334
334
  appId?: string;
335
335
  /** SDK client key (SaaS mode). Also used to authenticate the envelope lane. */
336
336
  clientKey?: string;
337
- /** Platform override: ios | android | web | react_native. Default: react_native. */
337
+ /** Platform OVERRIDE auto-detected from Platform.OS when omitted (integrations should omit it). */
338
338
  platform?: 'ios' | 'android' | 'web' | 'react_native';
339
339
  /**
340
340
  * Enable the Phase 1 envelope tracking lane (first_open/app_open/session_start/