@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.
- package/android/src/main/java/com/scalebun/rn/ota/BsPatch.kt +182 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +88 -13
- package/android/src/oldarch/java/com/scalebun/rn/crash/ScaleBunCrashSpec.kt +16 -0
- package/bin/scalebun.js +55 -3
- package/dist/scalebun.full.js +190 -110
- package/dist/scalebun.full.js.map +1 -1
- package/dist/scalebun.slim.js +188 -109
- package/dist/scalebun.slim.js.map +1 -1
- package/ios/Ota/BsPatch.swift +180 -0
- package/ios/Ota/OtaSlotManager.swift +59 -2
- package/ios/Ota/ScaleBunOtaModule.swift +89 -10
- package/lib/commonjs/analytics/EventTracker.js +26 -4
- package/lib/commonjs/analytics/EventTracker.js.map +1 -1
- package/lib/commonjs/analytics/eventLane.js +23 -0
- package/lib/commonjs/analytics/eventLane.js.map +1 -1
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/core/context/device.js +18 -0
- package/lib/commonjs/core/context/device.js.map +1 -1
- package/lib/commonjs/core/id/installationId.js +55 -0
- package/lib/commonjs/core/id/installationId.js.map +1 -0
- package/lib/commonjs/debug/bootstrap.js +14 -1
- package/lib/commonjs/debug/bootstrap.js.map +1 -1
- package/lib/commonjs/debug/transport.js +2 -1
- package/lib/commonjs/debug/transport.js.map +1 -1
- package/lib/commonjs/features/journey/journeyManager.js +2 -1
- package/lib/commonjs/features/journey/journeyManager.js.map +1 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +2 -30
- package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +27 -3
- package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
- package/lib/commonjs/features/performance/collectors/JsStallCollector.js +20 -1
- package/lib/commonjs/features/performance/collectors/JsStallCollector.js.map +1 -1
- package/lib/commonjs/features/session/BackendSessionAdapter.js +8 -0
- package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/commonjs/public/ScaleBunFacade.js +10 -2
- package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
- package/lib/module/analytics/EventTracker.js +26 -4
- package/lib/module/analytics/EventTracker.js.map +1 -1
- package/lib/module/analytics/eventLane.js +22 -0
- package/lib/module/analytics/eventLane.js.map +1 -1
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/core/context/device.js +17 -0
- package/lib/module/core/context/device.js.map +1 -1
- package/lib/module/core/id/installationId.js +50 -0
- package/lib/module/core/id/installationId.js.map +1 -0
- package/lib/module/debug/bootstrap.js +14 -1
- package/lib/module/debug/bootstrap.js.map +1 -1
- package/lib/module/debug/transport.js +2 -1
- package/lib/module/debug/transport.js.map +1 -1
- package/lib/module/features/journey/journeyManager.js +2 -1
- package/lib/module/features/journey/journeyManager.js.map +1 -1
- package/lib/module/features/ota/OtaOrchestrator.js +1 -29
- package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/module/features/performance/collectors/FrameMetricsCollector.js +27 -3
- package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
- package/lib/module/features/performance/collectors/JsStallCollector.js +20 -1
- package/lib/module/features/performance/collectors/JsStallCollector.js.map +1 -1
- package/lib/module/features/session/BackendSessionAdapter.js +8 -0
- package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/module/public/ScaleBunFacade.js +12 -4
- package/lib/module/public/ScaleBunFacade.js.map +1 -1
- package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
- package/lib/typescript/analytics/eventLane.d.ts +2 -0
- package/lib/typescript/analytics/eventLane.d.ts.map +1 -1
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/core/context/device.d.ts +13 -0
- package/lib/typescript/core/context/device.d.ts.map +1 -1
- package/lib/typescript/core/id/installationId.d.ts +20 -0
- package/lib/typescript/core/id/installationId.d.ts.map +1 -0
- package/lib/typescript/debug/bootstrap.d.ts +18 -0
- package/lib/typescript/debug/bootstrap.d.ts.map +1 -1
- package/lib/typescript/debug/transport.d.ts.map +1 -1
- package/lib/typescript/features/journey/journeyManager.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
- package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +14 -0
- package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
- package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts +5 -0
- package/lib/typescript/features/performance/collectors/JsStallCollector.d.ts.map +1 -1
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
- package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
- package/lib/typescript/public/types.d.ts +1 -1
- package/lib/typescript/public/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/analytics/EventTracker.ts +26 -4
- package/src/analytics/eventLane.ts +22 -0
- package/src/core/constants/version.ts +1 -1
- package/src/core/context/device.ts +17 -0
- package/src/core/id/installationId.ts +52 -0
- package/src/debug/bootstrap.ts +38 -1
- package/src/debug/transport.ts +2 -1
- package/src/features/journey/journeyManager.ts +2 -1
- package/src/features/ota/OtaOrchestrator.ts +1 -30
- package/src/features/performance/collectors/FrameMetricsCollector.ts +32 -3
- package/src/features/performance/collectors/JsStallCollector.ts +20 -1
- package/src/features/session/BackendSessionAdapter.ts +8 -0
- package/src/public/ScaleBunFacade.ts +12 -4
- 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:
|
|
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
|
-
//
|
|
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 ??
|
|
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 ??
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/debug/bootstrap.ts
CHANGED
|
@@ -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:
|
|
302
|
+
sdkVersion: SDK_VERSION,
|
|
266
303
|
});
|
|
267
304
|
__DEV__ && logger.debug('Emitted journey:debug_ready event to desktop');
|
|
268
305
|
} catch { /* no-throw */ }
|
package/src/debug/transport.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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 {
|
|
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-${
|
|
194
|
+
id: `frames-${now}-${Math.random().toString(36).substring(2, 8)}`,
|
|
166
195
|
type: 'frame_metrics',
|
|
167
|
-
timestamp:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/public/types.ts
CHANGED
|
@@ -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
|
|
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/
|