@goliapkg/sentori-react-native 1.3.0 → 2.1.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/README.md +63 -3
- package/lib/config.d.ts +5 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/feedback.d.ts +2 -0
- package/lib/feedback.d.ts.map +1 -0
- package/lib/feedback.js +17 -0
- package/lib/feedback.js.map +1 -0
- 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/init.d.ts +18 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +39 -0
- package/lib/init.js.map +1 -1
- package/lib/metrics.d.ts +2 -4
- package/lib/metrics.d.ts.map +1 -1
- package/lib/metrics.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/track.d.ts.map +1 -1
- package/lib/track.js +9 -0
- package/lib/track.js.map +1 -1
- 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 +8 -3
- package/src/__tests__/runtime-metrics-instruments.test.ts +109 -0
- package/src/__tests__/runtime-metrics-network.test.ts +78 -0
- package/src/config.ts +5 -0
- package/src/feedback.ts +21 -0
- package/src/handlers/network.ts +11 -0
- package/src/init.ts +61 -0
- package/src/metrics.ts +3 -1
- 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/track.ts +9 -0
- package/src/transport.ts +39 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
__peekRuntimeMetricsSize,
|
|
5
|
+
__resetRuntimeMetricsForTests,
|
|
6
|
+
drainRuntimeMetricsForFlush,
|
|
7
|
+
} from '@goliapkg/sentori-core';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
__forceEmitFpsForTests,
|
|
11
|
+
__pushSampleForTests,
|
|
12
|
+
__resetFpsInstrumentForTests,
|
|
13
|
+
startFpsInstrument,
|
|
14
|
+
stopFpsInstrument,
|
|
15
|
+
} from '../runtime-metrics-fps';
|
|
16
|
+
import {
|
|
17
|
+
__forceHeapTickForTests,
|
|
18
|
+
__peekHeapInstrumentState,
|
|
19
|
+
startHeapInstrument,
|
|
20
|
+
stopHeapInstrument,
|
|
21
|
+
} from '../runtime-metrics-heap';
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
__resetRuntimeMetricsForTests();
|
|
25
|
+
__resetFpsInstrumentForTests();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
stopFpsInstrument();
|
|
30
|
+
stopHeapInstrument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('fps: pushes p50 + p95 emits into the runtime metrics ring', () => {
|
|
34
|
+
// 300 samples at a steady 16.67 ms cadence ≈ 60 fps.
|
|
35
|
+
for (let i = 0; i < 300; i++) {
|
|
36
|
+
__pushSampleForTests(16.67);
|
|
37
|
+
}
|
|
38
|
+
__forceEmitFpsForTests();
|
|
39
|
+
|
|
40
|
+
const drained = drainRuntimeMetricsForFlush();
|
|
41
|
+
expect(drained.length).toBe(2);
|
|
42
|
+
const names = drained.map((m) => m.name).sort();
|
|
43
|
+
expect(names).toEqual(['runtime.fps.p50', 'runtime.fps.p95']);
|
|
44
|
+
// Both should land near 60 fps.
|
|
45
|
+
const p50 = drained.find((m) => m.name === 'runtime.fps.p50')!;
|
|
46
|
+
expect(p50.value).toBeGreaterThanOrEqual(58);
|
|
47
|
+
expect(p50.value).toBeLessThanOrEqual(62);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('fps: mixed-cadence run separates p50 from p95 sensibly', () => {
|
|
51
|
+
// 270 fast frames (60 fps) + 30 stutters (30 fps). p95 should
|
|
52
|
+
// land at the stutter zone.
|
|
53
|
+
for (let i = 0; i < 270; i++) __pushSampleForTests(16.67);
|
|
54
|
+
for (let i = 0; i < 30; i++) __pushSampleForTests(33.33);
|
|
55
|
+
__forceEmitFpsForTests();
|
|
56
|
+
|
|
57
|
+
const drained = drainRuntimeMetricsForFlush();
|
|
58
|
+
const p50 = drained.find((m) => m.name === 'runtime.fps.p50')!.value;
|
|
59
|
+
const p95 = drained.find((m) => m.name === 'runtime.fps.p95')!.value;
|
|
60
|
+
// p50 stays at the smooth-frame zone.
|
|
61
|
+
expect(p50).toBeGreaterThanOrEqual(58);
|
|
62
|
+
// p95 (worst-95th of Δt → slowest 5%) should drop into the
|
|
63
|
+
// 30 fps zone.
|
|
64
|
+
expect(p95).toBeLessThanOrEqual(35);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('fps: start is idempotent — second call is a no-op', () => {
|
|
68
|
+
startFpsInstrument();
|
|
69
|
+
// Calling start twice mustn't spin up a second rAF loop. We
|
|
70
|
+
// don't directly observe that here (no two-loop probe), but
|
|
71
|
+
// we assert start doesn't throw + state stays running once.
|
|
72
|
+
expect(() => startFpsInstrument()).not.toThrow();
|
|
73
|
+
stopFpsInstrument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('heap: start is a no-op on hosts without performance.memory', () => {
|
|
77
|
+
// The default test runtime (bun) doesn't expose
|
|
78
|
+
// performance.memory, so start should silently skip and
|
|
79
|
+
// never schedule a timer.
|
|
80
|
+
startHeapInstrument();
|
|
81
|
+
expect(__peekHeapInstrumentState().running).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('heap: force tick is a no-op on hosts without performance.memory', () => {
|
|
85
|
+
__forceHeapTickForTests();
|
|
86
|
+
// No metric should land in the ring.
|
|
87
|
+
expect(__peekRuntimeMetricsSize()).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('heap: emits when performance.memory is shimmed', () => {
|
|
91
|
+
// Shim performance.memory so the read path exercises.
|
|
92
|
+
const g = globalThis as {
|
|
93
|
+
performance?: { memory?: { usedJSHeapSize?: number; totalJSHeapSize?: number } };
|
|
94
|
+
};
|
|
95
|
+
const before = g.performance;
|
|
96
|
+
g.performance = {
|
|
97
|
+
...(before ?? {}),
|
|
98
|
+
memory: { usedJSHeapSize: 12_345_678, totalJSHeapSize: 23_456_789 },
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
__forceHeapTickForTests();
|
|
102
|
+
const drained = drainRuntimeMetricsForFlush();
|
|
103
|
+
const names = drained.map((m) => m.name).sort();
|
|
104
|
+
expect(names).toEqual(['runtime.heap.total_bytes', 'runtime.heap.used_bytes']);
|
|
105
|
+
expect(drained.find((m) => m.name === 'runtime.heap.used_bytes')!.value).toBe(12_345_678);
|
|
106
|
+
} finally {
|
|
107
|
+
g.performance = before;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
__resetRuntimeMetricsForTests,
|
|
5
|
+
drainRuntimeMetricsForFlush,
|
|
6
|
+
} from '@goliapkg/sentori-core';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
__forceNetworkEmitForTests,
|
|
10
|
+
__peekNetworkCountersForTests,
|
|
11
|
+
__resetNetworkBytesForTests,
|
|
12
|
+
estimateRequestBytes,
|
|
13
|
+
estimateResponseBytes,
|
|
14
|
+
recordNetworkBytes,
|
|
15
|
+
} from '../runtime-metrics-network';
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
__resetRuntimeMetricsForTests();
|
|
19
|
+
__resetNetworkBytesForTests();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => __resetNetworkBytesForTests());
|
|
23
|
+
|
|
24
|
+
test('recordNetworkBytes: cumulates sent + received separately', () => {
|
|
25
|
+
recordNetworkBytes(100, 200);
|
|
26
|
+
recordNetworkBytes(50, 0);
|
|
27
|
+
recordNetworkBytes(0, 25);
|
|
28
|
+
const { sent, received } = __peekNetworkCountersForTests();
|
|
29
|
+
expect(sent).toBe(150);
|
|
30
|
+
expect(received).toBe(225);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('force emit: flushes both counters into the ring + resets', () => {
|
|
34
|
+
recordNetworkBytes(1234, 5678);
|
|
35
|
+
__forceNetworkEmitForTests();
|
|
36
|
+
|
|
37
|
+
const drained = drainRuntimeMetricsForFlush();
|
|
38
|
+
expect(drained.length).toBe(2);
|
|
39
|
+
const names = drained.map((m) => m.name).sort();
|
|
40
|
+
expect(names).toEqual(['runtime.network.bytes_received', 'runtime.network.bytes_sent']);
|
|
41
|
+
expect(drained.find((m) => m.name === 'runtime.network.bytes_sent')!.value).toBe(1234);
|
|
42
|
+
expect(drained.find((m) => m.name === 'runtime.network.bytes_received')!.value).toBe(5678);
|
|
43
|
+
|
|
44
|
+
const { sent, received } = __peekNetworkCountersForTests();
|
|
45
|
+
expect(sent).toBe(0);
|
|
46
|
+
expect(received).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('force emit: skips zero counters (no empty metric emit)', () => {
|
|
50
|
+
__forceNetworkEmitForTests();
|
|
51
|
+
expect(drainRuntimeMetricsForFlush().length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('estimateRequestBytes: handles string body', () => {
|
|
55
|
+
expect(estimateRequestBytes({ body: 'hello world' })).toBe(11);
|
|
56
|
+
expect(estimateRequestBytes()).toBe(0);
|
|
57
|
+
expect(estimateRequestBytes({})).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('estimateRequestBytes: reads byteLength from ArrayBuffer-like body', () => {
|
|
61
|
+
const buf = new Uint8Array([1, 2, 3, 4, 5]);
|
|
62
|
+
expect(estimateRequestBytes({ body: buf })).toBe(5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('estimateResponseBytes: parses content-length header', () => {
|
|
66
|
+
const h = new Headers({ 'content-length': '1024' });
|
|
67
|
+
expect(estimateResponseBytes(h)).toBe(1024);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('estimateResponseBytes: returns 0 when header missing (chunked / stripped)', () => {
|
|
71
|
+
expect(estimateResponseBytes(new Headers())).toBe(0);
|
|
72
|
+
expect(estimateResponseBytes(null)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('estimateResponseBytes: returns 0 on unparseable header (undercount-safe)', () => {
|
|
76
|
+
const h = new Headers({ 'content-length': 'nope' });
|
|
77
|
+
expect(estimateResponseBytes(h)).toBe(0);
|
|
78
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -36,6 +36,11 @@ export type Config = {
|
|
|
36
36
|
* session-trail buffer and uploads it as a `sessionTrail`
|
|
37
37
|
* attachment. Defaults to false. */
|
|
38
38
|
sessionTrailEnabled: boolean;
|
|
39
|
+
/** v2.0 W3 — when true, every `track(name, props)` call also
|
|
40
|
+
* pushes a `{ type: 'track', data: { name, props } }` breadcrumb
|
|
41
|
+
* so a subsequent capture carries the customer journey. Defaults
|
|
42
|
+
* to false to preserve v1 customer breadcrumb shape on upgrade. */
|
|
43
|
+
trackAutoBreadcrumb: boolean;
|
|
39
44
|
/** v2.3 — Sentori console output gate.
|
|
40
45
|
*
|
|
41
46
|
* Default `warn`: SDK is silent on host's console unless
|
package/src/feedback.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// v2.0 W3 — `feedback` subpath. The feedback widget pulls in a
|
|
2
|
+
// component tree (react + tsx) and an internal viewer route, so
|
|
3
|
+
// importing it eagerly from the SDK's top-level barrel pays a
|
|
4
|
+
// bundle cost every consumer pays — even ones that never render
|
|
5
|
+
// the widget.
|
|
6
|
+
//
|
|
7
|
+
// Subpath import `@goliapkg/sentori-react-native/feedback`
|
|
8
|
+
// resolves to just the widget surface. Hosts that don't render
|
|
9
|
+
// the feedback button pay zero bundle delta; hosts that do reach
|
|
10
|
+
// for it import via this module and get exactly what they need.
|
|
11
|
+
//
|
|
12
|
+
// Top-level (`index.ts`) still re-exports `FeedbackButton` for one
|
|
13
|
+
// more release cycle so v1 callsites don't break on upgrade. v3
|
|
14
|
+
// will drop the top-level re-export — see
|
|
15
|
+
// `docs/recipes/v1-to-v2-migration.md` for the deprecation timeline.
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
FeedbackButton,
|
|
19
|
+
type FeedbackButtonHandle,
|
|
20
|
+
type FeedbackButtonProps,
|
|
21
|
+
} from './feedback-widget'
|
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/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';
|
|
@@ -73,6 +81,24 @@ export type InitOptions = {
|
|
|
73
81
|
* the buffer is sealed and uploaded as a `sessionTrail`
|
|
74
82
|
* attachment. Defaults to false. */
|
|
75
83
|
sessionTrail?: boolean;
|
|
84
|
+
/** v2.0 W3 — when `true`, every `sentori.track(name, props)`
|
|
85
|
+
* also pushes a `{ type: 'track', data: { name, props } }`
|
|
86
|
+
* breadcrumb so a subsequent `captureException` /
|
|
87
|
+
* `captureMessage` carries the customer journey leading up to
|
|
88
|
+
* the failure. Defaults to `false` to preserve v1 customer
|
|
89
|
+
* breadcrumb shape on upgrade; recommended `true` for new
|
|
90
|
+
* integrations. See `docs/recipes/track-and-metrics.md`. */
|
|
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;
|
|
76
102
|
/** v0.9.1 +S4 — pre-crash sentinel. Subscribes to JS-thread
|
|
77
103
|
* frame timing; when ≥ 50% of a 60-frame window misses the
|
|
78
104
|
* budget (default 32 ms / < 30 fps), emits a `kind: nearCrash`
|
|
@@ -197,6 +223,11 @@ export const init = (options: InitOptions): void => {
|
|
|
197
223
|
traceSampleRate: options.sample?.traces ?? options.sampling?.traces ?? null,
|
|
198
224
|
messageSampleRate: options.sample?.messages ?? options.sampling?.messages ?? null,
|
|
199
225
|
sessionTrailEnabled: options.capture?.sessionTrail === true,
|
|
226
|
+
// v2.0 W3 — when true, every `track()` also pushes a
|
|
227
|
+
// `type: 'track'` breadcrumb so a subsequent captureException
|
|
228
|
+
// carries the customer journey. Defaults false to preserve v1
|
|
229
|
+
// breadcrumb shape on upgrade.
|
|
230
|
+
trackAutoBreadcrumb: options.capture?.trackAutoBreadcrumb === true,
|
|
200
231
|
});
|
|
201
232
|
|
|
202
233
|
// Tell the native crash handler about the config so the JSON it writes
|
|
@@ -236,6 +267,36 @@ export const init = (options: InitOptions): void => {
|
|
|
236
267
|
startMetricsTimer();
|
|
237
268
|
// v1.1 chunk B — drain `sentori.track()` ring every 30 s.
|
|
238
269
|
startTrackTimer();
|
|
270
|
+
// v2.1 W2 — runtime metrics auto-instrument. Defaults on; host
|
|
271
|
+
// can opt out with `capture: { runtimeMetrics: false }`. The
|
|
272
|
+
// ring + emit live in `@goliapkg/sentori-core`; we own the
|
|
273
|
+
// 30 s flusher + the cold-start one-shot here. FPS / heap /
|
|
274
|
+
// route-nav / network instruments ship in W2 part 3 (each
|
|
275
|
+
// gated on its own per-tick perf budget CI test).
|
|
276
|
+
if (options.capture?.runtimeMetrics !== false) {
|
|
277
|
+
markColdStartT0();
|
|
278
|
+
startRuntimeMetricsTimer();
|
|
279
|
+
// Defer the emit one tick so React's first paint settles
|
|
280
|
+
// before we stamp "cold-start ended". 0-delay setTimeout puts
|
|
281
|
+
// the call after the current microtask queue drains.
|
|
282
|
+
setTimeout(emitColdStart, 0);
|
|
283
|
+
// FPS via rAF (per-tick budget < 0.5 ms — see
|
|
284
|
+
// runtime-metrics-fps.ts header). Heap is a 30 s polling
|
|
285
|
+
// tick; no-op when performance.memory isn't exposed (most
|
|
286
|
+
// RN engines today; we ship the wiring anyway so the same
|
|
287
|
+
// SDK works on web targets via @goliapkg/sentori-javascript).
|
|
288
|
+
startFpsInstrument();
|
|
289
|
+
startHeapInstrument();
|
|
290
|
+
// Network bytes counters drain every 30 s. The counters
|
|
291
|
+
// themselves are incremented inline by handlers/network.ts
|
|
292
|
+
// on every fetch round-trip — see the recordNetworkBytes
|
|
293
|
+
// call site there. No-op when `capture.network: false` is
|
|
294
|
+
// set (no fetch patch → no counter increments).
|
|
295
|
+
startNetworkBytesInstrument();
|
|
296
|
+
// Route-nav dwell timing emits inline from `navigation.ts`'s
|
|
297
|
+
// useTraceNavigation state listener — no extra start call
|
|
298
|
+
// needed; the host already mounts that hook for tracing.
|
|
299
|
+
}
|
|
239
300
|
// v1.1 chunk S1 — warm the install-id cache. Fire-and-forget;
|
|
240
301
|
// any event captured before the first resolve simply omits
|
|
241
302
|
// `device.installId`. Subsequent captures pick it up via the
|
package/src/metrics.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// outgoing connection pool. Batching makes the SDK safe to use as a
|
|
12
12
|
// cheap counter primitive.
|
|
13
13
|
|
|
14
|
+
import type { SpanContextLike } from '@goliapkg/sentori-core';
|
|
15
|
+
|
|
14
16
|
import { getConfig, isInitialized } from './config';
|
|
15
17
|
import { sendMetricsBatch } from './transport';
|
|
16
18
|
|
|
@@ -43,7 +45,7 @@ export function recordMetric(
|
|
|
43
45
|
name: string,
|
|
44
46
|
value: number,
|
|
45
47
|
tags?: Record<string, string>,
|
|
46
|
-
opts?: { parent?:
|
|
48
|
+
opts?: { parent?: SpanContextLike },
|
|
47
49
|
): void {
|
|
48
50
|
if (!isInitialized()) return;
|
|
49
51
|
if (typeof name !== 'string' || name.length === 0 || name.length > 200) return;
|
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
|
+
}
|