@goliapkg/sentori-react-native 2.0.0 → 2.2.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/lib/capture.d.ts +6 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +49 -2
- package/lib/capture.js.map +1 -1
- package/lib/compat/sentry.d.ts +14 -0
- package/lib/compat/sentry.d.ts.map +1 -1
- package/lib/compat/sentry.js +6 -0
- package/lib/compat/sentry.js.map +1 -1
- package/lib/config.d.ts +12 -18
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +7 -0
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +17 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +37 -0
- package/lib/init.js.map +1 -1
- package/lib/navigation.d.ts.map +1 -1
- package/lib/navigation.js +14 -1
- package/lib/navigation.js.map +1 -1
- package/lib/runtime-metrics-fps.d.ts +19 -0
- package/lib/runtime-metrics-fps.d.ts.map +1 -0
- package/lib/runtime-metrics-fps.js +128 -0
- package/lib/runtime-metrics-fps.js.map +1 -0
- package/lib/runtime-metrics-heap.d.ts +13 -0
- package/lib/runtime-metrics-heap.d.ts.map +1 -0
- package/lib/runtime-metrics-heap.js +67 -0
- package/lib/runtime-metrics-heap.js.map +1 -0
- package/lib/runtime-metrics-network.d.ts +23 -0
- package/lib/runtime-metrics-network.d.ts.map +1 -0
- package/lib/runtime-metrics-network.js +99 -0
- package/lib/runtime-metrics-network.js.map +1 -0
- package/lib/runtime-metrics.d.ts +43 -0
- package/lib/runtime-metrics.d.ts.map +1 -0
- package/lib/runtime-metrics.js +115 -0
- package/lib/runtime-metrics.js.map +1 -0
- package/lib/transport.d.ts +18 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +31 -0
- package/lib/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/before-send.test.ts +72 -0
- package/src/__tests__/compat-sentry.test.ts +121 -0
- package/src/__tests__/runtime-metrics-instruments.test.ts +109 -0
- package/src/__tests__/runtime-metrics-network.test.ts +78 -0
- package/src/capture.ts +48 -2
- package/src/compat/sentry.ts +8 -0
- package/src/config.ts +12 -15
- package/src/handlers/network.ts +11 -0
- package/src/index.ts +14 -0
- package/src/init.ts +58 -0
- package/src/navigation.ts +14 -1
- package/src/runtime-metrics-fps.ts +137 -0
- package/src/runtime-metrics-heap.ts +78 -0
- package/src/runtime-metrics-network.ts +103 -0
- package/src/runtime-metrics.ts +122 -0
- package/src/transport.ts +39 -0
package/src/config.ts
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
import type { LogLevel } from '@goliapkg/sentori-core';
|
|
1
|
+
import type { BeforeSendHook, LogLevel, ReadyInfo } from '@goliapkg/sentori-core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* v2.3 — `ReadyInfo` is shared across SDKs via `@goliapkg/sentori-core`
|
|
5
|
+
* so a host that switches from web to RN reads the same shape. The RN
|
|
6
|
+
* SDK always populates `native` + `coldStartMs`; the core type marks
|
|
7
|
+
* both optional for the web SDK's benefit (web has no native module).
|
|
7
8
|
*/
|
|
8
|
-
export type ReadyInfo
|
|
9
|
-
/** npm version string of @goliapkg/sentori-react-native */
|
|
10
|
-
sdkVersion: string;
|
|
11
|
-
/** Milliseconds between RN cold-start signal and SDK init
|
|
12
|
-
* completion. May be undefined if native module wasn't bound. */
|
|
13
|
-
coldStartMs?: number;
|
|
14
|
-
/** Native module status. `bound: false` means screenshot /
|
|
15
|
-
* wireframe / native crash capture won't fire — useful for
|
|
16
|
-
* host to know if e.g. they forgot to autolink. */
|
|
17
|
-
native: { bound: boolean; methods: string[] };
|
|
18
|
-
};
|
|
9
|
+
export type { ReadyInfo };
|
|
19
10
|
|
|
20
11
|
export type Config = {
|
|
21
12
|
token: string;
|
|
@@ -56,6 +47,12 @@ export type Config = {
|
|
|
56
47
|
* the native-module bind status + cold-start timing. Host
|
|
57
48
|
* wraps any host-side logging here. */
|
|
58
49
|
onReady?: (info: ReadyInfo) => void;
|
|
50
|
+
/** v2.3 — host-side mutate-or-drop hook called once per event
|
|
51
|
+
* just before transport enqueue. Return the event to send it,
|
|
52
|
+
* `null` to drop. Sync only. If the hook throws or returns a
|
|
53
|
+
* non-event, SDK falls back to the un-mutated event and emits
|
|
54
|
+
* one one-shot warn. */
|
|
55
|
+
beforeSend?: BeforeSendHook;
|
|
59
56
|
};
|
|
60
57
|
|
|
61
58
|
let _config: Config | null = null;
|
package/src/handlers/network.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { normalizeUrl, startSpan } from '@goliapkg/sentori-core';
|
|
|
2
2
|
|
|
3
3
|
import { addBreadcrumb } from '../breadcrumbs';
|
|
4
4
|
import { getConfig } from '../config';
|
|
5
|
+
// v2.1 W2 — bytes counters drive the runtime.network.{sent,received}
|
|
6
|
+
// metrics. Cheap two-add per request, no allocation.
|
|
7
|
+
import {
|
|
8
|
+
estimateRequestBytes,
|
|
9
|
+
estimateResponseBytes,
|
|
10
|
+
recordNetworkBytes,
|
|
11
|
+
} from '../runtime-metrics-network';
|
|
5
12
|
|
|
6
13
|
let _installed = false;
|
|
7
14
|
let _graphqlEnabled = true;
|
|
@@ -88,6 +95,10 @@ function patchFetch(): void {
|
|
|
88
95
|
const resp = await original(input, reqInit);
|
|
89
96
|
span.setTag('http.status', String(resp.status));
|
|
90
97
|
span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
|
|
98
|
+
// v2.1 W2 — bytes accounting. Sent estimated from
|
|
99
|
+
// init.body; received read from response Content-Length
|
|
100
|
+
// header (0 when missing / chunked — undercount-safe).
|
|
101
|
+
recordNetworkBytes(estimateRequestBytes(init), estimateResponseBytes(resp.headers));
|
|
91
102
|
addBreadcrumb({
|
|
92
103
|
type: 'net',
|
|
93
104
|
data: gqlOp
|
package/src/index.ts
CHANGED
|
@@ -121,6 +121,20 @@ export type {
|
|
|
121
121
|
CaptureMessageOptions,
|
|
122
122
|
MessageLevel,
|
|
123
123
|
} from '@goliapkg/sentori-core';
|
|
124
|
+
/** v2.3 — logger surface re-exported from core so hosts can
|
|
125
|
+
* `import { setLogLevel } from '@goliapkg/sentori-react-native'`
|
|
126
|
+
* per design §3 ("Production override"). `setLogTransport` lets
|
|
127
|
+
* hosts route Sentori-internal lines into their own log
|
|
128
|
+
* aggregator (Datadog, etc.). */
|
|
129
|
+
export {
|
|
130
|
+
getLogLevel,
|
|
131
|
+
type LogLevel,
|
|
132
|
+
logger,
|
|
133
|
+
type LogTransport,
|
|
134
|
+
setLogLevel,
|
|
135
|
+
setLogTransport,
|
|
136
|
+
} from '@goliapkg/sentori-core';
|
|
137
|
+
export type { ReadyInfo } from './config';
|
|
124
138
|
export { ErrorBoundary } from './error-boundary';
|
|
125
139
|
export { FeedbackButton, type FeedbackButtonHandle, type FeedbackButtonProps } from './feedback-widget';
|
|
126
140
|
export {
|
package/src/init.ts
CHANGED
|
@@ -12,6 +12,14 @@ import {
|
|
|
12
12
|
} from './launch-crash-guard';
|
|
13
13
|
import { getInstallId } from './install-id';
|
|
14
14
|
import { startMetricsTimer } from './metrics';
|
|
15
|
+
import {
|
|
16
|
+
emitColdStart,
|
|
17
|
+
markColdStartT0,
|
|
18
|
+
startRuntimeMetricsTimer,
|
|
19
|
+
} from './runtime-metrics';
|
|
20
|
+
import { startFpsInstrument } from './runtime-metrics-fps';
|
|
21
|
+
import { startHeapInstrument } from './runtime-metrics-heap';
|
|
22
|
+
import { startNetworkBytesInstrument } from './runtime-metrics-network';
|
|
15
23
|
import { startTrackTimer } from './track';
|
|
16
24
|
import { drainNativePending, markNativeJsBridgeReady, setNativeConfig } from './native';
|
|
17
25
|
import { getColdStartMs } from './mobile-vitals';
|
|
@@ -81,6 +89,16 @@ export type InitOptions = {
|
|
|
81
89
|
* breadcrumb shape on upgrade; recommended `true` for new
|
|
82
90
|
* integrations. See `docs/recipes/track-and-metrics.md`. */
|
|
83
91
|
trackAutoBreadcrumb?: boolean;
|
|
92
|
+
/** v2.1 W2 — auto-instrument runtime metrics (FPS, JS heap,
|
|
93
|
+
* cold-start, route nav timing, network bytes). Drains the
|
|
94
|
+
* shared `@goliapkg/sentori-core` ring to
|
|
95
|
+
* `/v1/runtime-metrics:batch` every 30 s. Defaults to `true`
|
|
96
|
+
* in W2 part 2 — cold-start only, ~6 emits per session
|
|
97
|
+
* per device, ~zero main-thread cost. Higher-cost instruments
|
|
98
|
+
* (FPS / route-nav) land in W2 part 3 with per-tick perf
|
|
99
|
+
* budget tests as stop-ship gates per
|
|
100
|
+
* `.claude/CLAUDE.md` performance bedrock. */
|
|
101
|
+
runtimeMetrics?: boolean;
|
|
84
102
|
/** v0.9.1 +S4 — pre-crash sentinel. Subscribes to JS-thread
|
|
85
103
|
* frame timing; when ≥ 50% of a 60-frame window misses the
|
|
86
104
|
* budget (default 32 ms / < 30 fps), emits a `kind: nearCrash`
|
|
@@ -158,6 +176,13 @@ export type InitOptions = {
|
|
|
158
176
|
* SDK is live instead of scanning the console. The `ReadyInfo`
|
|
159
177
|
* carries native-module bind status + cold-start timing. */
|
|
160
178
|
onReady?: (info: ReadyInfo) => void;
|
|
179
|
+
/** v2.3 — mutate-or-drop hook on each outbound event (sync).
|
|
180
|
+
* Return the event to ship it (possibly mutated), or `null` to
|
|
181
|
+
* drop. See `BeforeSendHook` for the throwing / non-event
|
|
182
|
+
* fallback policy. Used for host-side PII scrubbing the SDK
|
|
183
|
+
* can't do automatically; server-side privacy_lab still runs
|
|
184
|
+
* regardless. */
|
|
185
|
+
beforeSend?: import('@goliapkg/sentori-core').BeforeSendHook;
|
|
161
186
|
};
|
|
162
187
|
|
|
163
188
|
const DEFAULT_INGEST_URL = 'https://ingest.sentori.golia.jp';
|
|
@@ -210,6 +235,9 @@ export const init = (options: InitOptions): void => {
|
|
|
210
235
|
// carries the customer journey. Defaults false to preserve v1
|
|
211
236
|
// breadcrumb shape on upgrade.
|
|
212
237
|
trackAutoBreadcrumb: options.capture?.trackAutoBreadcrumb === true,
|
|
238
|
+
// v2.3 — host-side beforeSend hook (sync). Stored on Config so
|
|
239
|
+
// capture.ts can pull it without re-resolving the InitOptions.
|
|
240
|
+
beforeSend: options.beforeSend,
|
|
213
241
|
});
|
|
214
242
|
|
|
215
243
|
// Tell the native crash handler about the config so the JSON it writes
|
|
@@ -249,6 +277,36 @@ export const init = (options: InitOptions): void => {
|
|
|
249
277
|
startMetricsTimer();
|
|
250
278
|
// v1.1 chunk B — drain `sentori.track()` ring every 30 s.
|
|
251
279
|
startTrackTimer();
|
|
280
|
+
// v2.1 W2 — runtime metrics auto-instrument. Defaults on; host
|
|
281
|
+
// can opt out with `capture: { runtimeMetrics: false }`. The
|
|
282
|
+
// ring + emit live in `@goliapkg/sentori-core`; we own the
|
|
283
|
+
// 30 s flusher + the cold-start one-shot here. FPS / heap /
|
|
284
|
+
// route-nav / network instruments ship in W2 part 3 (each
|
|
285
|
+
// gated on its own per-tick perf budget CI test).
|
|
286
|
+
if (options.capture?.runtimeMetrics !== false) {
|
|
287
|
+
markColdStartT0();
|
|
288
|
+
startRuntimeMetricsTimer();
|
|
289
|
+
// Defer the emit one tick so React's first paint settles
|
|
290
|
+
// before we stamp "cold-start ended". 0-delay setTimeout puts
|
|
291
|
+
// the call after the current microtask queue drains.
|
|
292
|
+
setTimeout(emitColdStart, 0);
|
|
293
|
+
// FPS via rAF (per-tick budget < 0.5 ms — see
|
|
294
|
+
// runtime-metrics-fps.ts header). Heap is a 30 s polling
|
|
295
|
+
// tick; no-op when performance.memory isn't exposed (most
|
|
296
|
+
// RN engines today; we ship the wiring anyway so the same
|
|
297
|
+
// SDK works on web targets via @goliapkg/sentori-javascript).
|
|
298
|
+
startFpsInstrument();
|
|
299
|
+
startHeapInstrument();
|
|
300
|
+
// Network bytes counters drain every 30 s. The counters
|
|
301
|
+
// themselves are incremented inline by handlers/network.ts
|
|
302
|
+
// on every fetch round-trip — see the recordNetworkBytes
|
|
303
|
+
// call site there. No-op when `capture.network: false` is
|
|
304
|
+
// set (no fetch patch → no counter increments).
|
|
305
|
+
startNetworkBytesInstrument();
|
|
306
|
+
// Route-nav dwell timing emits inline from `navigation.ts`'s
|
|
307
|
+
// useTraceNavigation state listener — no extra start call
|
|
308
|
+
// needed; the host already mounts that hook for tracing.
|
|
309
|
+
}
|
|
252
310
|
// v1.1 chunk S1 — warm the install-id cache. Fire-and-forget;
|
|
253
311
|
// any event captured before the first resolve simply omits
|
|
254
312
|
// `device.installId`. Subsequent captures pick it up via the
|
package/src/navigation.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { useEffect, useRef } from 'react';
|
|
24
24
|
|
|
25
|
-
import { setActiveSpan, startSpan, type SpanHandle } from '@goliapkg/sentori-core';
|
|
25
|
+
import { emitMetric, setActiveSpan, startSpan, type SpanHandle } from '@goliapkg/sentori-core';
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
getNativeFrameCounters,
|
|
@@ -161,6 +161,19 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
|
|
|
161
161
|
const prev = lastRouteRef.current;
|
|
162
162
|
if (next === null || next === prev) return;
|
|
163
163
|
const dwellMs = finishOpenSpanWithDwell();
|
|
164
|
+
// v2.1 W2 — emit dwell as a runtime metric so the BI panel
|
|
165
|
+
// can rank screens by how long users sat on them. Tagged
|
|
166
|
+
// with `from` + `to` so a release-over-release diff can spot
|
|
167
|
+
// a 2x dwell regression on Onboarding etc. emit is cheap +
|
|
168
|
+
// bounded by the core ring cap; if `capture.runtimeMetrics:
|
|
169
|
+
// false` the flush is off but the ring still records up to
|
|
170
|
+
// the cap before dropping oldest.
|
|
171
|
+
if (typeof dwellMs === 'number' && dwellMs >= 0) {
|
|
172
|
+
emitMetric('runtime.route_nav_ms', dwellMs, {
|
|
173
|
+
from: prev ?? '',
|
|
174
|
+
to: next ?? '',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
164
177
|
openScreenSpan(prev, next, dwellMs);
|
|
165
178
|
});
|
|
166
179
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// v2.1 W2 part 3 — FPS auto-instrument.
|
|
2
|
+
//
|
|
3
|
+
// Rolling 5 s window of inter-frame Δt measured via rAF; emit
|
|
4
|
+
// runtime.fps.p50 + runtime.fps.p95 every 5 s.
|
|
5
|
+
//
|
|
6
|
+
// Perf bedrock (.claude/CLAUDE.md):
|
|
7
|
+
// • per-tick cost must be < 0.5 ms on a Pixel-5-equivalent
|
|
8
|
+
// bench → stop-ship gate in W2 part 4 CI
|
|
9
|
+
// • main-thread sustained < 1 %
|
|
10
|
+
// • no allocation inside the rAF callback hot path
|
|
11
|
+
//
|
|
12
|
+
// Implementation choices:
|
|
13
|
+
// • single-frame Δt = perf.now() - prev — cheap, no allocation
|
|
14
|
+
// • rolling window stored in a fixed-size Float32Array (no
|
|
15
|
+
// splice / shift — circular buffer write index)
|
|
16
|
+
// • percentile computed by copying-and-sorting only at emit
|
|
17
|
+
// time (every 5 s, ~300 floats) — no per-tick sort
|
|
18
|
+
//
|
|
19
|
+
// Bail-outs:
|
|
20
|
+
// • if requestAnimationFrame isn't available (rare RN host),
|
|
21
|
+
// never start the loop
|
|
22
|
+
// • if perf.now() is missing (older RN engines), fall back to
|
|
23
|
+
// Date.now() — coarser but better than crashing
|
|
24
|
+
|
|
25
|
+
import { emitMetric } from '@goliapkg/sentori-core';
|
|
26
|
+
|
|
27
|
+
const TICK_FRAMES_BEFORE_EMIT = 300; // ~5 s at 60 fps
|
|
28
|
+
const SAMPLE_CAP = 600; // safety: 10 s at 60 fps caps memory
|
|
29
|
+
|
|
30
|
+
let _running = false;
|
|
31
|
+
let _samples = new Float32Array(SAMPLE_CAP);
|
|
32
|
+
let _write = 0;
|
|
33
|
+
let _count = 0;
|
|
34
|
+
let _prev = 0;
|
|
35
|
+
|
|
36
|
+
function now(): number {
|
|
37
|
+
// Engines without perf.now() (very old RN runtimes / minimal
|
|
38
|
+
// JSCore builds) fall back to wall-clock. Date.now() has 1 ms
|
|
39
|
+
// resolution so FPS readings cap at 1000; good enough.
|
|
40
|
+
const p = (globalThis as { performance?: { now?: () => number } }).performance;
|
|
41
|
+
return p?.now ? p.now() : Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rafTick(t: number): void {
|
|
45
|
+
if (!_running) return;
|
|
46
|
+
if (_prev !== 0) {
|
|
47
|
+
const dt = t - _prev;
|
|
48
|
+
if (dt > 0 && dt < 1000) {
|
|
49
|
+
_samples[_write] = dt;
|
|
50
|
+
_write = (_write + 1) % SAMPLE_CAP;
|
|
51
|
+
_count += 1;
|
|
52
|
+
if (_count >= TICK_FRAMES_BEFORE_EMIT) {
|
|
53
|
+
emitWindow();
|
|
54
|
+
_count = 0;
|
|
55
|
+
_write = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
_prev = t;
|
|
60
|
+
requestAnimationFrame(rafTick);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emitWindow(): void {
|
|
64
|
+
// Copy + sort. ~300 floats, well under 1 ms even on a slow phone.
|
|
65
|
+
// Allocation lives outside the per-tick hot path — only at emit.
|
|
66
|
+
const n = _count;
|
|
67
|
+
if (n === 0) return;
|
|
68
|
+
const slice = new Float32Array(n);
|
|
69
|
+
for (let i = 0; i < n; i++) slice[i] = _samples[i]!;
|
|
70
|
+
slice.sort();
|
|
71
|
+
const p50dt = percentile(slice, 0.5);
|
|
72
|
+
const p95dt = percentile(slice, 0.95);
|
|
73
|
+
// FPS = 1000 ms / per-frame Δt.
|
|
74
|
+
const fpsP50 = p50dt > 0 ? Math.round(1000 / p50dt) : 0;
|
|
75
|
+
// p95 of Δt is the *slowest* 95 % — i.e. when frames are bad.
|
|
76
|
+
// p95 FPS conventionally means "5 % worst frames as fps".
|
|
77
|
+
const fpsP5Slow = p95dt > 0 ? Math.round(1000 / p95dt) : 0;
|
|
78
|
+
emitMetric('runtime.fps.p50', fpsP50);
|
|
79
|
+
emitMetric('runtime.fps.p95', fpsP5Slow);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function percentile(sorted: Float32Array, q: number): number {
|
|
83
|
+
if (sorted.length === 0) return 0;
|
|
84
|
+
// Discrete percentile — index = ceil(q * n) - 1, clamped.
|
|
85
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
|
|
86
|
+
return sorted[idx]!;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Idempotent start. Safe to call multiple times; only the first
|
|
90
|
+
* call kicks off the rAF loop. */
|
|
91
|
+
export function startFpsInstrument(): void {
|
|
92
|
+
if (_running) return;
|
|
93
|
+
if (typeof requestAnimationFrame !== 'function') return;
|
|
94
|
+
_running = true;
|
|
95
|
+
_prev = 0;
|
|
96
|
+
_count = 0;
|
|
97
|
+
_write = 0;
|
|
98
|
+
requestAnimationFrame(rafTick);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stop the loop. Used by tests + by hosts that want to opt out
|
|
102
|
+
* mid-session. */
|
|
103
|
+
export function stopFpsInstrument(): void {
|
|
104
|
+
_running = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test-only state inspection. */
|
|
108
|
+
export function __peekFpsInstrumentState(): {
|
|
109
|
+
count: number;
|
|
110
|
+
running: boolean;
|
|
111
|
+
} {
|
|
112
|
+
return { count: _count, running: _running };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Test-only force-emit (skips the every-300-frames gate). */
|
|
116
|
+
export function __forceEmitFpsForTests(): void {
|
|
117
|
+
emitWindow();
|
|
118
|
+
_count = 0;
|
|
119
|
+
_write = 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test-only sample injection so we can assert without spinning
|
|
123
|
+
* rAF in the test runner. */
|
|
124
|
+
export function __pushSampleForTests(dt: number): void {
|
|
125
|
+
_samples[_write] = dt;
|
|
126
|
+
_write = (_write + 1) % SAMPLE_CAP;
|
|
127
|
+
_count += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Test-only reset between runs. */
|
|
131
|
+
export function __resetFpsInstrumentForTests(): void {
|
|
132
|
+
_running = false;
|
|
133
|
+
_samples = new Float32Array(SAMPLE_CAP);
|
|
134
|
+
_write = 0;
|
|
135
|
+
_count = 0;
|
|
136
|
+
_prev = 0;
|
|
137
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// v2.1 W2 part 3 — JS heap auto-instrument.
|
|
2
|
+
//
|
|
3
|
+
// Polls `performance.memory.usedJSHeapSize` every 30 s, emits
|
|
4
|
+
// runtime.heap.used_bytes. Web-only metric, RN best-effort —
|
|
5
|
+
// the V8/Hermes value depends on which engine RN is wired to:
|
|
6
|
+
// • Hermes (RN ≥ 0.70 default): exposes a per-isolate count
|
|
7
|
+
// when memoryReporter is enabled (Hermes 0.12+)
|
|
8
|
+
// • JSC (legacy / explicit opt-in): no native equivalent
|
|
9
|
+
// • Web (sdk-javascript wires this same module): Chromium
|
|
10
|
+
// gates it behind a high-resolution-timer flag in some
|
|
11
|
+
// contexts; treat missing field as a silent no-op
|
|
12
|
+
//
|
|
13
|
+
// Cost: one number read + one emit every 30 s. Negligible.
|
|
14
|
+
// The timer .unref()s so Node tests / CLI hosts exit cleanly.
|
|
15
|
+
|
|
16
|
+
import { emitMetric } from '@goliapkg/sentori-core';
|
|
17
|
+
|
|
18
|
+
const TICK_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
type MemoryShape = {
|
|
21
|
+
usedJSHeapSize?: number;
|
|
22
|
+
totalJSHeapSize?: number;
|
|
23
|
+
jsHeapSizeLimit?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let _timer: null | ReturnType<typeof setInterval> = null;
|
|
27
|
+
|
|
28
|
+
function readHeap(): MemoryShape | null {
|
|
29
|
+
const perf = (globalThis as { performance?: { memory?: MemoryShape } }).performance;
|
|
30
|
+
return perf?.memory ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tickOnce(): void {
|
|
34
|
+
const m = readHeap();
|
|
35
|
+
if (!m) return;
|
|
36
|
+
if (typeof m.usedJSHeapSize === 'number' && Number.isFinite(m.usedJSHeapSize)) {
|
|
37
|
+
emitMetric('runtime.heap.used_bytes', m.usedJSHeapSize);
|
|
38
|
+
}
|
|
39
|
+
// Total + limit help capacity-plan but cost an extra ~16 B
|
|
40
|
+
// per row downstream — keep them only when present.
|
|
41
|
+
if (typeof m.totalJSHeapSize === 'number' && Number.isFinite(m.totalJSHeapSize)) {
|
|
42
|
+
emitMetric('runtime.heap.total_bytes', m.totalJSHeapSize);
|
|
43
|
+
}
|
|
44
|
+
if (typeof m.jsHeapSizeLimit === 'number' && Number.isFinite(m.jsHeapSizeLimit)) {
|
|
45
|
+
emitMetric('runtime.heap.limit_bytes', m.jsHeapSizeLimit);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Idempotent start. No-op on hosts that don't expose
|
|
50
|
+
* performance.memory — checked once at start so we don't pay
|
|
51
|
+
* for the detect on every tick. */
|
|
52
|
+
export function startHeapInstrument(): void {
|
|
53
|
+
if (_timer !== null) return;
|
|
54
|
+
if (!readHeap()) return;
|
|
55
|
+
// One immediate sample at start so the first dashboard render
|
|
56
|
+
// doesn't sit empty for 30 s.
|
|
57
|
+
tickOnce();
|
|
58
|
+
_timer = setInterval(tickOnce, TICK_MS);
|
|
59
|
+
(_timer as unknown as { unref?: () => void }).unref?.();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stop. Idempotent. */
|
|
63
|
+
export function stopHeapInstrument(): void {
|
|
64
|
+
if (_timer !== null) {
|
|
65
|
+
clearInterval(_timer);
|
|
66
|
+
_timer = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test-only force tick (skips the 30 s wait). */
|
|
71
|
+
export function __forceHeapTickForTests(): void {
|
|
72
|
+
tickOnce();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Test-only state. */
|
|
76
|
+
export function __peekHeapInstrumentState(): { running: boolean } {
|
|
77
|
+
return { running: _timer !== null };
|
|
78
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// v2.1 W2 part 4 — network bytes auto-instrument.
|
|
2
|
+
//
|
|
3
|
+
// Two counters (sent / received bytes) incremented by the existing
|
|
4
|
+
// fetch patch on every request/response, drained every 30 s as
|
|
5
|
+
// runtime.network.bytes_sent + runtime.network.bytes_received.
|
|
6
|
+
//
|
|
7
|
+
// Best-effort: not every Response exposes `content-length`
|
|
8
|
+
// (chunked encoding, server stripped it, gzipped without a
|
|
9
|
+
// pre-decode header). For those we report 0 — undercounts vs.
|
|
10
|
+
// over-attributing arbitrary numbers. Request body size is
|
|
11
|
+
// estimated only when init.body is a string or has `.byteLength`;
|
|
12
|
+
// FormData / Blob / ReadableStream are not measured.
|
|
13
|
+
//
|
|
14
|
+
// XHR is NOT instrumented here — the existing patchXhr() in
|
|
15
|
+
// handlers/network.ts does the trace span work, and XHR usage in
|
|
16
|
+
// 2026-era RN apps is rare enough that the engineering cost of
|
|
17
|
+
// a second patch isn't justified by the missing data. If a host
|
|
18
|
+
// app shows up with significant XHR traffic we add it in a patch
|
|
19
|
+
// release.
|
|
20
|
+
|
|
21
|
+
import { emitMetric } from '@goliapkg/sentori-core';
|
|
22
|
+
|
|
23
|
+
const TICK_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
let _bytesSent = 0;
|
|
26
|
+
let _bytesReceived = 0;
|
|
27
|
+
let _timer: null | ReturnType<typeof setInterval> = null;
|
|
28
|
+
|
|
29
|
+
/** Called from handlers/network.ts on every outbound request.
|
|
30
|
+
* Cheap — two adds, no allocation. */
|
|
31
|
+
export function recordNetworkBytes(sent: number, received: number): void {
|
|
32
|
+
if (sent > 0) _bytesSent += sent;
|
|
33
|
+
if (received > 0) _bytesReceived += received;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function estimateBodyBytes(body: BodyInit | null | undefined): number {
|
|
37
|
+
if (body == null) return 0;
|
|
38
|
+
if (typeof body === 'string') return body.length;
|
|
39
|
+
// ArrayBuffer / Uint8Array / ArrayBufferView all carry byteLength.
|
|
40
|
+
const maybe = body as { byteLength?: number };
|
|
41
|
+
if (typeof maybe.byteLength === 'number') return maybe.byteLength;
|
|
42
|
+
// FormData / Blob / ReadableStream — not measured.
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Estimate request bytes from a fetch init. Used by the fetch
|
|
47
|
+
* patch in handlers/network.ts. */
|
|
48
|
+
export function estimateRequestBytes(init?: RequestInit): number {
|
|
49
|
+
return estimateBodyBytes(init?.body);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read response bytes from the Content-Length header. Returns
|
|
53
|
+
* 0 if missing or unparseable — undercount-safe. */
|
|
54
|
+
export function estimateResponseBytes(headers: Headers | undefined | null): number {
|
|
55
|
+
if (!headers) return 0;
|
|
56
|
+
const v = headers.get?.('content-length');
|
|
57
|
+
if (!v) return 0;
|
|
58
|
+
const n = parseInt(v, 10);
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emit(): void {
|
|
63
|
+
if (_bytesSent > 0) {
|
|
64
|
+
emitMetric('runtime.network.bytes_sent', _bytesSent);
|
|
65
|
+
_bytesSent = 0;
|
|
66
|
+
}
|
|
67
|
+
if (_bytesReceived > 0) {
|
|
68
|
+
emitMetric('runtime.network.bytes_received', _bytesReceived);
|
|
69
|
+
_bytesReceived = 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Idempotent start — second call is a no-op. */
|
|
74
|
+
export function startNetworkBytesInstrument(): void {
|
|
75
|
+
if (_timer !== null) return;
|
|
76
|
+
_timer = setInterval(emit, TICK_MS);
|
|
77
|
+
(_timer as unknown as { unref?: () => void }).unref?.();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Stop the periodic emit. Idempotent. */
|
|
81
|
+
export function stopNetworkBytesInstrument(): void {
|
|
82
|
+
if (_timer !== null) {
|
|
83
|
+
clearInterval(_timer);
|
|
84
|
+
_timer = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test-only: force one emit tick + reset counters. */
|
|
89
|
+
export function __forceNetworkEmitForTests(): void {
|
|
90
|
+
emit();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Test-only: peek raw counter state without resetting. */
|
|
94
|
+
export function __peekNetworkCountersForTests(): { sent: number; received: number } {
|
|
95
|
+
return { sent: _bytesSent, received: _bytesReceived };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Test-only: reset for clean test runs. */
|
|
99
|
+
export function __resetNetworkBytesForTests(): void {
|
|
100
|
+
stopNetworkBytesInstrument();
|
|
101
|
+
_bytesSent = 0;
|
|
102
|
+
_bytesReceived = 0;
|
|
103
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// v2.1 W2 part 2 — RN runtime metrics flusher + cold-start
|
|
2
|
+
// instrument.
|
|
3
|
+
//
|
|
4
|
+
// The buffer + emit primitives live in @goliapkg/sentori-core
|
|
5
|
+
// (runtime-metrics.ts). This module owns:
|
|
6
|
+
// • the periodic flush timer (30 s, coalesced w/ event flush)
|
|
7
|
+
// • the rebuffer-on-failure recovery path
|
|
8
|
+
// • the cold-start auto-instrument (one-shot at init)
|
|
9
|
+
//
|
|
10
|
+
// Other auto-instrument modules (FPS / heap / route-nav / network
|
|
11
|
+
// bytes) ship in the W2 part 3+ chunks; each is its own file with
|
|
12
|
+
// its own per-tick perf budget test gated as stop-ship in CI.
|
|
13
|
+
//
|
|
14
|
+
// NEVER rule: every public surface here is wrapped in safeFn
|
|
15
|
+
// boundaries by the SDK init layer; the flush timer itself
|
|
16
|
+
// catches all rejections + reports via the circuit breaker.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
drainRuntimeMetricsForFlush,
|
|
20
|
+
emitMetric,
|
|
21
|
+
rebufferRuntimeMetrics,
|
|
22
|
+
reportInternal,
|
|
23
|
+
} from '@goliapkg/sentori-core';
|
|
24
|
+
|
|
25
|
+
import { getConfig, isInitialized } from './config';
|
|
26
|
+
import { sendRuntimeMetricsBatch } from './transport';
|
|
27
|
+
|
|
28
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
let _timer: null | ReturnType<typeof setInterval> = null;
|
|
31
|
+
let _coldStartT0: null | number = null;
|
|
32
|
+
let _coldStartEmitted = false;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Drain core's runtime-metrics ring and POST to
|
|
36
|
+
* /v1/runtime-metrics:batch. Rebuffers on failure so the next
|
|
37
|
+
* tick retries; sustained outages spill into the ring's drop
|
|
38
|
+
* counter which reports to the SDK self-report channel.
|
|
39
|
+
*
|
|
40
|
+
* Returns when the round-trip settles (success or failure). Per
|
|
41
|
+
* the NEVER rule, never throws — failure is logged + rebuffered,
|
|
42
|
+
* the resolved promise's value is undefined.
|
|
43
|
+
*/
|
|
44
|
+
export async function flushRuntimeMetrics(): Promise<void> {
|
|
45
|
+
if (!isInitialized()) return;
|
|
46
|
+
const config = getConfig();
|
|
47
|
+
if (!config) return;
|
|
48
|
+
const batch = drainRuntimeMetricsForFlush();
|
|
49
|
+
if (batch.length === 0) return;
|
|
50
|
+
const ok = await sendRuntimeMetricsBatch(config.ingestUrl, config.token, batch);
|
|
51
|
+
if (!ok) {
|
|
52
|
+
rebufferRuntimeMetrics(batch);
|
|
53
|
+
reportInternal('runtime-metrics.flush', new Error('runtime-metrics POST failed'));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start the 30 s flush timer. Called once from `init()`. Idempotent
|
|
59
|
+
* — repeated calls are a no-op so users that call
|
|
60
|
+
* `Sentori.init({ metrics: true })` more than once (HMR, fast
|
|
61
|
+
* refresh) don't get multiple timers.
|
|
62
|
+
*/
|
|
63
|
+
export function startRuntimeMetricsTimer(): void {
|
|
64
|
+
if (_timer !== null) return;
|
|
65
|
+
_timer = setInterval(() => {
|
|
66
|
+
void flushRuntimeMetrics();
|
|
67
|
+
}, FLUSH_INTERVAL_MS);
|
|
68
|
+
// Don't keep Node alive solely for this timer. RN's setInterval
|
|
69
|
+
// is a NoopRef so this is harmless there; Node + CLI tests
|
|
70
|
+
// benefit so the process can exit cleanly.
|
|
71
|
+
(_timer as unknown as { unref?: () => void }).unref?.();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Capture the wall-clock at `init()` so cold-start instrumentation
|
|
76
|
+
* has a t0. Called from init() before any other auto-instrument
|
|
77
|
+
* fires. Returns the captured t0 in millis (callers that want to
|
|
78
|
+
* stash + use it later can hold the value).
|
|
79
|
+
*/
|
|
80
|
+
export function markColdStartT0(): number {
|
|
81
|
+
if (_coldStartT0 === null) {
|
|
82
|
+
_coldStartT0 = Date.now();
|
|
83
|
+
}
|
|
84
|
+
return _coldStartT0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Emit one `runtime.cold_start_ms` metric point. Idempotent per
|
|
89
|
+
* session — the second + later calls are a no-op so route-nav
|
|
90
|
+
* instrument (W2 part 3) can call this safely without worrying
|
|
91
|
+
* about double-counting.
|
|
92
|
+
*
|
|
93
|
+
* Hosts that want a more interactive-pixel-perfect cold-start
|
|
94
|
+
* boundary (TTI vs. raw init→render) can call
|
|
95
|
+
* `markTimeToFullDisplay()` and we emit that separately as
|
|
96
|
+
* `runtime.time_to_full_display_ms` (existing v1 instrument).
|
|
97
|
+
*/
|
|
98
|
+
export function emitColdStart(): void {
|
|
99
|
+
if (_coldStartEmitted) return;
|
|
100
|
+
if (_coldStartT0 === null) return;
|
|
101
|
+
const ms = Date.now() - _coldStartT0;
|
|
102
|
+
if (ms <= 0 || ms > 60_000) {
|
|
103
|
+
// Implausible — likely the host called emit before init or
|
|
104
|
+
// we got into a freeze-then-resume situation. Drop silently.
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
emitMetric('runtime.cold_start_ms', ms);
|
|
108
|
+
_coldStartEmitted = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Test-only escape hatch: stop the timer + reset cold-start state
|
|
113
|
+
* so vitest / bun:test teardown doesn't leak intervals across runs.
|
|
114
|
+
*/
|
|
115
|
+
export function __resetRuntimeMetricsRnForTests(): void {
|
|
116
|
+
if (_timer !== null) {
|
|
117
|
+
clearInterval(_timer);
|
|
118
|
+
_timer = null;
|
|
119
|
+
}
|
|
120
|
+
_coldStartT0 = null;
|
|
121
|
+
_coldStartEmitted = false;
|
|
122
|
+
}
|