@goliapkg/sentori-react-native 1.0.0-rc.9 → 1.0.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 (59) hide show
  1. package/bin/sentori-rn-upload-source-bundle.cjs +193 -0
  2. package/lib/capture.d.ts.map +1 -1
  3. package/lib/capture.js +9 -0
  4. package/lib/capture.js.map +1 -1
  5. package/lib/heartbeat.d.ts +9 -0
  6. package/lib/heartbeat.d.ts.map +1 -0
  7. package/lib/heartbeat.js +140 -0
  8. package/lib/heartbeat.js.map +1 -0
  9. package/lib/index.d.ts +15 -0
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +15 -0
  12. package/lib/index.js.map +1 -1
  13. package/lib/init.d.ts +6 -0
  14. package/lib/init.d.ts.map +1 -1
  15. package/lib/init.js +18 -0
  16. package/lib/init.js.map +1 -1
  17. package/lib/install-id.d.ts +17 -0
  18. package/lib/install-id.d.ts.map +1 -0
  19. package/lib/install-id.js +125 -0
  20. package/lib/install-id.js.map +1 -0
  21. package/lib/navigation.d.ts +1 -0
  22. package/lib/navigation.d.ts.map +1 -1
  23. package/lib/navigation.js +20 -0
  24. package/lib/navigation.js.map +1 -1
  25. package/lib/replay.d.ts +3 -1
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +8 -3
  28. package/lib/replay.js.map +1 -1
  29. package/lib/report-security.d.ts +40 -0
  30. package/lib/report-security.d.ts.map +1 -0
  31. package/lib/report-security.js +159 -0
  32. package/lib/report-security.js.map +1 -0
  33. package/lib/track.d.ts +34 -0
  34. package/lib/track.d.ts.map +1 -0
  35. package/lib/track.js +98 -0
  36. package/lib/track.js.map +1 -0
  37. package/lib/transport.d.ts +15 -0
  38. package/lib/transport.d.ts.map +1 -1
  39. package/lib/transport.js +23 -0
  40. package/lib/transport.js.map +1 -1
  41. package/lib/trust-score.d.ts +20 -0
  42. package/lib/trust-score.d.ts.map +1 -0
  43. package/lib/trust-score.js +151 -0
  44. package/lib/trust-score.js.map +1 -0
  45. package/package.json +6 -2
  46. package/src/__tests__/install-id.test.ts +60 -0
  47. package/src/__tests__/report-security.test.ts +106 -0
  48. package/src/__tests__/track.test.ts +91 -0
  49. package/src/capture.ts +8 -0
  50. package/src/heartbeat.ts +158 -0
  51. package/src/index.ts +24 -0
  52. package/src/init.ts +23 -0
  53. package/src/install-id.ts +146 -0
  54. package/src/navigation.ts +26 -0
  55. package/src/replay.ts +11 -4
  56. package/src/report-security.ts +165 -0
  57. package/src/track.ts +114 -0
  58. package/src/transport.ts +35 -0
  59. package/src/trust-score.ts +176 -0
@@ -0,0 +1,91 @@
1
+ // v1.1 chunk B — analytics `track` buffer + flush.
2
+
3
+ import {
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ mock,
10
+ } from 'bun:test';
11
+
12
+ import { __resetForTests as resetConfig, setConfig } from '../config';
13
+ import { setUser } from '../capture';
14
+ import {
15
+ __peekTrackBuffer,
16
+ __resetTrackForTests,
17
+ flushTrack,
18
+ track,
19
+ } from '../track';
20
+
21
+ const originalFetch = globalThis.fetch;
22
+
23
+ describe('track buffer', () => {
24
+ beforeEach(() => {
25
+ resetConfig();
26
+ __resetTrackForTests();
27
+ setConfig({
28
+ token: 'st_pk_test',
29
+ release: 'app@1.0.0+1',
30
+ environment: 'test',
31
+ ingestUrl: 'http://localhost:8080',
32
+ enabled: true,
33
+ });
34
+ });
35
+
36
+ afterEach(() => {
37
+ globalThis.fetch = originalFetch;
38
+ setUser(null);
39
+ });
40
+
41
+ it('buffers calls and tags them with release + environment', () => {
42
+ track('checkout.started', { cart: 42 });
43
+ track('$pageview', undefined, 'Cart');
44
+ const buf = __peekTrackBuffer();
45
+ expect(buf.length).toBe(2);
46
+ expect(buf[0].name).toBe('checkout.started');
47
+ expect(buf[0].release).toBe('app@1.0.0+1');
48
+ expect(buf[0].environment).toBe('test');
49
+ expect(buf[0].props).toEqual({ cart: 42 });
50
+ expect(buf[1].name).toBe('$pageview');
51
+ expect(buf[1].route).toBe('Cart');
52
+ });
53
+
54
+ it('attaches the current user id when set', () => {
55
+ setUser({ id: 'u_abc' });
56
+ track('signed_in');
57
+ const buf = __peekTrackBuffer();
58
+ expect(buf[0].userId).toBe('u_abc');
59
+ });
60
+
61
+ it('drops oversized names + over-cap prop bags silently', () => {
62
+ track('x'.repeat(201));
63
+ const tooManyProps: Record<string, number> = {};
64
+ for (let i = 0; i < 41; i += 1) tooManyProps[`k${i}`] = i;
65
+ track('big-bag', tooManyProps);
66
+ expect(__peekTrackBuffer().length).toBe(0);
67
+ });
68
+
69
+ it('flushTrack drains the buffer and POSTs the batch envelope', async () => {
70
+ const calls: { body: string; url: string }[] = [];
71
+ globalThis.fetch = mock(async (url: unknown, init: unknown) => {
72
+ calls.push({
73
+ body: String((init as { body?: unknown })?.body ?? ''),
74
+ url: String(url),
75
+ });
76
+ return new Response('{}', { status: 202 });
77
+ }) as unknown as typeof fetch;
78
+
79
+ track('a');
80
+ track('b');
81
+ await flushTrack();
82
+
83
+ expect(calls.length).toBe(1);
84
+ expect(calls[0].url).toBe('http://localhost:8080/v1/track:batch');
85
+ const parsed = JSON.parse(calls[0].body) as {
86
+ events: Array<{ name: string }>;
87
+ };
88
+ expect(parsed.events.map((e) => e.name)).toEqual(['a', 'b']);
89
+ expect(__peekTrackBuffer().length).toBe(0);
90
+ });
91
+ });
package/src/capture.ts CHANGED
@@ -18,6 +18,7 @@ import { parseStack } from './stack';
18
18
  import { getTrailBuffer } from './trail';
19
19
  import { enqueue, sendUserReport, uploadAttachment } from './transport';
20
20
  import { uuidV7 } from './uuid';
21
+ import { peekInstallId } from './install-id';
21
22
  import { getCachedNetworkType } from './netinfo';
22
23
  import { getRecentNativeException } from './native';
23
24
  import type { App, AttachmentMeta, Device, Event, SentoriError, Tags, User } from './types';
@@ -459,6 +460,13 @@ const collectDevice = (): Device => {
459
460
  const device: Device = { os, osVersion };
460
461
  if (locale) device.locale = locale;
461
462
  if (networkType) device.networkType = networkType;
463
+ // v1.1 chunk S1 — stable per-install id. Read sync from the
464
+ // in-memory cache populated by the install-id module's first
465
+ // resolve (kicked off from init()). `null` before init resolves —
466
+ // the field is omitted in that case rather than blocking event
467
+ // assembly, which sits on the JS thread.
468
+ const installId = peekInstallId();
469
+ if (installId) device.installId = installId;
462
470
  return device;
463
471
  };
464
472
 
@@ -0,0 +1,158 @@
1
+ // Analytics v1 — concurrent-user heartbeat.
2
+ //
3
+ // Once per minute, while the app is in the foreground, POST a tiny
4
+ // `{ sessionId, userId?, release, route?, os?, ts }` body to
5
+ // `/v1/heartbeat`. The server keeps a per-project Valkey ZSET keyed
6
+ // by member (user.id when set, sessionId otherwise) and the
7
+ // dashboard's `Live` page reads the set to render concurrent-user
8
+ // count + per-dim breakdowns.
9
+ //
10
+ // Iron-rule budget (CLAUDE.md):
11
+ // - 1 POST / min foreground only — never fires when backgrounded
12
+ // - ~200 B body
13
+ // - fire-and-forget; never blocks the JS thread, no retries
14
+ // - bounce suppression: if AppState flaps active → inactive → active
15
+ // inside 30 s, we still fire at most one heartbeat per 30 s
16
+ //
17
+ // The heartbeat is independent of the session ping in
18
+ // `session-tracker.ts`. Session pings fire only at session close
19
+ // (transport-batched); the heartbeat exists *during* the session to
20
+ // signal presence.
21
+
22
+ import { getConfig } from './config';
23
+ import { getUser } from './capture';
24
+ import { getLastRoute } from './navigation';
25
+ import { uuidV7 } from './uuid';
26
+
27
+ declare const __DEV__: boolean | undefined;
28
+
29
+ const DEFAULT_INTERVAL_MS = 60_000;
30
+ const MIN_GAP_MS = 30_000;
31
+
32
+ type AppStateLike = {
33
+ addEventListener: (
34
+ event: 'change',
35
+ handler: (state: string) => void
36
+ ) => { remove: () => void };
37
+ currentState?: string;
38
+ };
39
+
40
+ let _running = false;
41
+ let _timer: ReturnType<typeof setInterval> | null = null;
42
+ let _appStateSub: null | { remove: () => void } = null;
43
+ let _sessionId: null | string = null;
44
+ let _lastBeatTs = 0;
45
+ let _intervalMs = DEFAULT_INTERVAL_MS;
46
+
47
+ export type HeartbeatOptions = {
48
+ /** Override the default 60 s interval. Floor 10 s — anything below
49
+ * trips the perf rule and the server's rate-limit anyway. */
50
+ intervalMs?: number;
51
+ };
52
+
53
+ export function startHeartbeat(opts: HeartbeatOptions = {}): void {
54
+ if (_running) return;
55
+ _running = true;
56
+ _intervalMs = Math.max(10_000, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
57
+ _sessionId = uuidV7();
58
+
59
+ // AppState gate — only beat while app is in the foreground.
60
+ let AppState: AppStateLike | undefined;
61
+ try {
62
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
63
+ AppState = (require('react-native') as { AppState?: AppStateLike }).AppState;
64
+ } catch {
65
+ // Not in RN runtime (tests). The interval still runs; the gate
66
+ // is just permissive. Suppression below still applies.
67
+ AppState = undefined;
68
+ }
69
+
70
+ const isForeground = (): boolean => {
71
+ if (!AppState) return true;
72
+ return (AppState.currentState ?? 'active') === 'active';
73
+ };
74
+
75
+ const beat = () => {
76
+ if (!_running) return;
77
+ if (!isForeground()) return;
78
+ const now = Date.now();
79
+ if (now - _lastBeatTs < MIN_GAP_MS) return;
80
+ _lastBeatTs = now;
81
+ void send();
82
+ };
83
+
84
+ // First beat as soon as we start (so the dashboard sees the user
85
+ // immediately, not 60 s after launch). Subsequent fires on the
86
+ // interval. AppState transitions can poke an immediate beat too —
87
+ // an active resume is a meaningful presence event.
88
+ beat();
89
+ _timer = setInterval(beat, _intervalMs);
90
+
91
+ if (AppState && typeof AppState.addEventListener === 'function') {
92
+ _appStateSub = AppState.addEventListener('change', (state) => {
93
+ if (state === 'active') beat();
94
+ });
95
+ }
96
+ }
97
+
98
+ export function stopHeartbeat(): void {
99
+ _running = false;
100
+ if (_timer !== null) {
101
+ clearInterval(_timer);
102
+ _timer = null;
103
+ }
104
+ if (_appStateSub) {
105
+ _appStateSub.remove();
106
+ _appStateSub = null;
107
+ }
108
+ _sessionId = null;
109
+ _lastBeatTs = 0;
110
+ }
111
+
112
+ async function send(): Promise<void> {
113
+ const config = getConfig();
114
+ if (!config) return;
115
+ const user = getUser();
116
+ const body: Record<string, unknown> = {
117
+ sessionId: _sessionId ?? '',
118
+ release: config.release,
119
+ ts: Date.now(),
120
+ };
121
+ if (user?.id) body.userId = user.id;
122
+ const route = getLastRoute();
123
+ if (route) body.route = route;
124
+ const os = readOsString();
125
+ if (os) body.os = os;
126
+
127
+ try {
128
+ await fetch(`${config.ingestUrl}/v1/heartbeat`, {
129
+ body: JSON.stringify(body),
130
+ headers: {
131
+ Authorization: `Bearer ${config.token}`,
132
+ 'Content-Type': 'application/json',
133
+ },
134
+ method: 'POST',
135
+ });
136
+ } catch (e) {
137
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
138
+ // eslint-disable-next-line no-console
139
+ console.warn('[sentori] heartbeat failed (best-effort)', e);
140
+ }
141
+ }
142
+ }
143
+
144
+ function readOsString(): null | string {
145
+ try {
146
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
147
+ const RN = require('react-native') as {
148
+ Platform: { OS: string; Version: string | number };
149
+ };
150
+ return `${RN.Platform.OS} ${RN.Platform.Version}`;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ export function __resetHeartbeatForTests(): void {
157
+ stopHeartbeat();
158
+ }
package/src/index.ts CHANGED
@@ -25,7 +25,11 @@ import {
25
25
  } from './mobile-vitals';
26
26
  import { bindState, recordState, unbindState } from './state-snapshots';
27
27
  import { startMoment } from '@goliapkg/sentori-core';
28
+ import { getInstallId } from './install-id';
28
29
  import { flushMetrics, recordMetric } from './metrics';
30
+ import { linkFederatedIdentity, reportPinMismatch, reportSecurity } from './report-security';
31
+ import { flushTrack, track } from './track';
32
+ import { queryTrustScore } from './trust-score';
29
33
  import { RageTapCapture } from './rage-tap';
30
34
  import {
31
35
  endSession,
@@ -44,6 +48,13 @@ export const sentori = {
44
48
  sendUserFeedback,
45
49
  recordMetric,
46
50
  flushMetrics,
51
+ track,
52
+ flushTrack,
53
+ getInstallId,
54
+ reportSecurity,
55
+ reportPinMismatch,
56
+ queryTrustScore,
57
+ linkFederatedIdentity,
47
58
  measureFn,
48
59
  startMoment,
49
60
  bindState,
@@ -87,6 +98,19 @@ export {
87
98
  } from './feature-flags';
88
99
  export { clearMaskQuery, registerMaskQuery } from './mask';
89
100
  export { flushMetrics, recordMetric } from './metrics';
101
+ export { flushTrack, track, type TrackEvent, type TrackProps } from './track';
102
+ export { getInstallId, peekInstallId } from './install-id';
103
+ export {
104
+ linkFederatedIdentity,
105
+ reportPinMismatch,
106
+ reportSecurity,
107
+ type SecurityReportData,
108
+ } from './report-security';
109
+ export {
110
+ queryTrustScore,
111
+ type TrustScore,
112
+ type TrustSignal,
113
+ } from './trust-score';
90
114
  export { measureFn } from './measure';
91
115
  export {
92
116
  getColdStartMs,
package/src/init.ts CHANGED
@@ -8,9 +8,12 @@ import {
8
8
  markLaunchCompleted,
9
9
  runLaunchCrashGuard,
10
10
  } from './launch-crash-guard';
11
+ import { getInstallId } from './install-id';
11
12
  import { startMetricsTimer } from './metrics';
13
+ import { startTrackTimer } from './track';
12
14
  import { drainNativePending, markNativeJsBridgeReady, setNativeConfig } from './native';
13
15
  import { getColdStartMs } from './mobile-vitals';
16
+ import { startHeartbeat, type HeartbeatOptions } from './heartbeat';
14
17
  import { startSpan } from '@goliapkg/sentori-core';
15
18
  import { startControlChannel } from './control-channel';
16
19
  import { startLongTaskMonitor } from './long-task-monitor';
@@ -92,6 +95,11 @@ export type InitOptions = {
92
95
  * longTaskMonitor (≥200ms outliers) — sample profiler 看 idle
93
96
  * 分布、long-task 看 outliers。 */
94
97
  sampleProfiler?: boolean | { flushMs?: number; sampleMs?: number };
98
+ /** Analytics v1 — live-presence heartbeat. Foreground 1/min
99
+ * default; opt out with `false`, or pass an options object
100
+ * to tune. Powers the Audience > Live dashboard.
101
+ * Iron-rule budget: < 1 KB / min, < 1 ms / call. */
102
+ heartbeat?: boolean | HeartbeatOptions;
95
103
  /** v0.9.0 #3 — launch-crash loop guard. When two consecutive
96
104
  * launches don't reach `markLaunchCompleted()` (typical of an
97
105
  * OTA update with a fatal bug), invoke the host callback with
@@ -193,6 +201,13 @@ export const init = (options: InitOptions): void => {
193
201
  startNetworkTypeWatch();
194
202
  // v0.8.3 — drain custom-metric ring every 30 s.
195
203
  startMetricsTimer();
204
+ // v1.1 chunk B — drain `sentori.track()` ring every 30 s.
205
+ startTrackTimer();
206
+ // v1.1 chunk S1 — warm the install-id cache. Fire-and-forget;
207
+ // any event captured before the first resolve simply omits
208
+ // `device.installId`. Subsequent captures pick it up via the
209
+ // sync `peekInstallId()` read in collectDevice().
210
+ void getInstallId();
196
211
  // v0.9.1 +S4 — pre-crash sentinel. Off by default; opt-in via
197
212
  // `capture.preCrashSentinel: true`.
198
213
  if (options.capture?.preCrashSentinel === true) {
@@ -240,6 +255,14 @@ export const init = (options: InitOptions): void => {
240
255
  startSession();
241
256
  installLifecycleHandler();
242
257
  }
258
+ // Analytics v1 — live-presence heartbeat. Foreground 1/min by
259
+ // default; pass `capture.heartbeat = false` to opt out, or
260
+ // `{ intervalMs: 30_000 }` to override. The default budget is well
261
+ // under the "几乎不能造成性能抖动" rule (CLAUDE.md).
262
+ const heartbeatOpt = capture.heartbeat;
263
+ if (heartbeatOpt !== false) {
264
+ startHeartbeat(typeof heartbeatOpt === 'object' ? heartbeatOpt : {});
265
+ }
243
266
 
244
267
  // Drain events persisted from previous session (best-effort):
245
268
  // - native crashes from <Documents>/sentori/pending/*.json
@@ -0,0 +1,146 @@
1
+ // v1.1 chunk S1 — stable per-install identifier.
2
+ //
3
+ // `getInstallId()` returns a UUIDv7 generated on first call and
4
+ // persisted to device storage. The id survives app restarts and, on
5
+ // iOS specifically, app reinstalls (because the Keychain backend is
6
+ // preserved across uninstall — that's the whole point).
7
+ //
8
+ // Storage tier order:
9
+ // 1. `react-native-keychain` (optional peer; iOS + Android Keystore)
10
+ // 2. AsyncStorage (host already needs this for launch-crash-guard /
11
+ // offline queue, so we don't add a hard new peer dep)
12
+ // 3. Process-memory only (no persistence — covers SSR / tests)
13
+ //
14
+ // Opaque to the host. The id is NOT tied to a `setUser` call; the
15
+ // server doesn't auto-correlate to user identity. It exists purely
16
+ // as a stable device key so the security posture engine (S3) can
17
+ // score per-install signals independently of authentication state.
18
+
19
+ import { uuidV7 } from '@goliapkg/sentori-core';
20
+
21
+ import { isAnyNativeModuleLinked } from './native-loader';
22
+
23
+ const KEYCHAIN_SERVICE = 'sentori.install-id';
24
+ const ASYNC_STORAGE_KEY = '@sentori/install-id';
25
+
26
+ type AsyncStorageLike = {
27
+ getItem: (key: string) => Promise<null | string>;
28
+ setItem: (key: string, value: string) => Promise<void>;
29
+ };
30
+
31
+ type KeychainModule = {
32
+ getGenericPassword: (options: {
33
+ service: string;
34
+ }) => Promise<false | { password: string; username: string }>;
35
+ setGenericPassword: (
36
+ username: string,
37
+ password: string,
38
+ options: { service: string },
39
+ ) => Promise<unknown>;
40
+ };
41
+
42
+ let _cached: null | string = null;
43
+ let _inflight: null | Promise<string> = null;
44
+
45
+ function loadKeychain(): KeychainModule | null {
46
+ // react-native-keychain is the recommended secure-storage peer.
47
+ // Optional: if the host hasn't installed it we fall through to
48
+ // AsyncStorage. The Keychain backend is what gives the iOS
49
+ // "survives reinstall" guarantee.
50
+ try {
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const mod = require('react-native-keychain') as KeychainModule;
53
+ if (typeof mod.getGenericPassword !== 'function') return null;
54
+ return mod;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function loadAsyncStorage(): AsyncStorageLike | null {
61
+ if (!isAnyNativeModuleLinked(['RNCAsyncStorage', 'AsyncStorageModule'])) {
62
+ return null;
63
+ }
64
+ try {
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const mod = require('@react-native-async-storage/async-storage') as {
67
+ default?: AsyncStorageLike;
68
+ };
69
+ return mod.default ?? (mod as unknown as AsyncStorageLike);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve the device's stable install id, generating one if absent.
77
+ * Cached in memory after first resolve so subsequent calls are sync-
78
+ * adjacent (no storage I/O). Idempotent under concurrent calls — a
79
+ * second caller during the first resolve awaits the same promise.
80
+ */
81
+ export async function getInstallId(): Promise<string> {
82
+ if (_cached !== null) return _cached;
83
+ if (_inflight !== null) return _inflight;
84
+ _inflight = (async () => {
85
+ try {
86
+ const kc = loadKeychain();
87
+ if (kc) {
88
+ const existing = await kc
89
+ .getGenericPassword({ service: KEYCHAIN_SERVICE })
90
+ .catch(() => false as const);
91
+ if (existing && existing.password) {
92
+ _cached = existing.password;
93
+ return existing.password;
94
+ }
95
+ const fresh = uuidV7();
96
+ await kc
97
+ .setGenericPassword('sentori', fresh, { service: KEYCHAIN_SERVICE })
98
+ .catch(() => undefined);
99
+ _cached = fresh;
100
+ return fresh;
101
+ }
102
+ const storage = loadAsyncStorage();
103
+ if (storage) {
104
+ const existing = await storage
105
+ .getItem(ASYNC_STORAGE_KEY)
106
+ .catch(() => null);
107
+ if (existing) {
108
+ _cached = existing;
109
+ return existing;
110
+ }
111
+ const fresh = uuidV7();
112
+ await storage.setItem(ASYNC_STORAGE_KEY, fresh).catch(() => undefined);
113
+ _cached = fresh;
114
+ return fresh;
115
+ }
116
+ // No storage available — generate but don't persist. The id is
117
+ // still stable for the lifetime of the process which is good
118
+ // enough for tests / SSR / no-native-modules contexts.
119
+ const fresh = uuidV7();
120
+ _cached = fresh;
121
+ return fresh;
122
+ } finally {
123
+ _inflight = null;
124
+ }
125
+ })();
126
+ return _inflight;
127
+ }
128
+
129
+ /** Sync read of the currently-cached install id. `null` before the
130
+ * first `getInstallId()` resolves. Use this in hot paths (event
131
+ * payload assembly) that can't await storage; callers should kick
132
+ * off `getInstallId()` once at startup to warm the cache. */
133
+ export function peekInstallId(): null | string {
134
+ return _cached;
135
+ }
136
+
137
+ export function __resetInstallIdForTests(): void {
138
+ _cached = null;
139
+ _inflight = null;
140
+ }
141
+
142
+ /** For tests + advanced operator flows that need to seed an id from
143
+ * an external secure-storage migration. */
144
+ export function __setInstallIdForTests(id: string): void {
145
+ _cached = id;
146
+ }
package/src/navigation.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  getNativeFrameCounters,
29
29
  resetNativeFrameCounters,
30
30
  } from './native';
31
+ import { track } from './track';
31
32
  import { captureStep } from './trail';
32
33
 
33
34
  /** Minimal contract: anything with `addListener('state', cb)` and
@@ -38,6 +39,17 @@ export type NavigationRefLike = {
38
39
  getCurrentRoute: () => { name: string } | undefined;
39
40
  };
40
41
 
42
+ /** Process-global last-known route — populated by the navigation
43
+ * span path below, read by the analytics heartbeat so its per-tick
44
+ * payload includes the screen the user is on right now. Initial null
45
+ * when no nav has happened yet (app just launched, dev-launcher,
46
+ * splash). */
47
+ let _lastRoute: null | string = null;
48
+
49
+ export function getLastRoute(): null | string {
50
+ return _lastRoute;
51
+ }
52
+
41
53
  /**
42
54
  * Subscribe to react-navigation state changes and emit a
43
55
  * `react.navigation` span per screen (including the initial one),
@@ -85,6 +97,7 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
85
97
  openSpanRef.current = span;
86
98
  setActiveSpan(span);
87
99
  lastRouteRef.current = to;
100
+ _lastRoute = to;
88
101
  lastRouteEnteredAtRef.current = Date.now();
89
102
  // v0.8.0-b — dwell on the previous screen surfaces in the
90
103
  // session trail. The leaving span's `durationMs` already
@@ -98,6 +111,19 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
98
111
  type: 'navigation',
99
112
  },
100
113
  });
114
+ // v1.1 chunk B — auto-pageview. Pushed into the track ring
115
+ // alongside the navigation span so the analytics path sees
116
+ // every screen entry without app code needing to wire its own
117
+ // pageview calls. `from`/`dwellMsPrev` ride in props so the
118
+ // Behavior view (chunk D) can reconstruct the user journey
119
+ // even when only the track stream is queried.
120
+ track(
121
+ '$pageview',
122
+ prevDwellMs !== null
123
+ ? { from: from ?? null, dwellMsPrev: prevDwellMs }
124
+ : { from: from ?? null },
125
+ to,
126
+ );
101
127
  };
102
128
 
103
129
  const finishOpenSpanWithDwell = (): null | number => {
package/src/replay.ts CHANGED
@@ -22,8 +22,13 @@ import { describeWireframeNative } from './native';
22
22
 
23
23
  declare const __DEV__: boolean | undefined;
24
24
 
25
- /** Default capture interval (4 Hz). Override via `replay.hz`. */
26
- const TICK_INTERVAL_MS = 250;
25
+ /** Default capture interval (2 Hz). Override via `replay.hz`. rc.10
26
+ * rolled this back from rc.9's 4 Hz default: iOS sim measured 1 ms
27
+ * per tick on a thin dev panel but extrapolation to a 200-node
28
+ * Insight-class UI on Android pushes JS-thread occupancy past 1 %,
29
+ * which violates the "几乎不能造成性能抖动" rule. Apps that want
30
+ * smoother playback motion can opt into `replay.hz: 4` explicitly. */
31
+ const TICK_INTERVAL_MS = 500;
27
32
 
28
33
  /** How often to emit a fresh keyframe — caps reconstruction chain
29
34
  * length and lets the player re-sync after a dropped line. */
@@ -79,7 +84,9 @@ let _nativeMod: ReplayNativeModule | null = null;
79
84
 
80
85
  export type ReplayOptions = {
81
86
  mode?: 'off' | 'wireframe';
82
- /** Ticks per second. Default 4. */
87
+ /** Ticks per second. Default 2. Opt into 4 (or 8) for
88
+ * motion-heavy apps where playback smoothness matters more than
89
+ * the marginal CPU saving. */
83
90
  hz?: number;
84
91
  /** Keyframe cadence in ms. Default 4000. */
85
92
  keyframeMs?: number;
@@ -111,7 +118,7 @@ export function startReplay(opts: ReplayOptions): void {
111
118
  _running = true;
112
119
  _nativeMod = loadNativeReplay();
113
120
  _keyframeIntervalMs = opts.keyframeMs ?? KEYFRAME_INTERVAL_MS;
114
- const hz = opts.hz ?? 4;
121
+ const hz = opts.hz ?? 2;
115
122
  const period = Math.max(MIN_TICK_PERIOD_MS, Math.round(1000 / hz));
116
123
  _timer = setInterval(() => {
117
124
  captureTick();