@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.
Files changed (59) hide show
  1. package/README.md +63 -3
  2. package/lib/config.d.ts +5 -0
  3. package/lib/config.d.ts.map +1 -1
  4. package/lib/config.js.map +1 -1
  5. package/lib/feedback.d.ts +2 -0
  6. package/lib/feedback.d.ts.map +1 -0
  7. package/lib/feedback.js +17 -0
  8. package/lib/feedback.js.map +1 -0
  9. package/lib/handlers/network.d.ts.map +1 -1
  10. package/lib/handlers/network.js +7 -0
  11. package/lib/handlers/network.js.map +1 -1
  12. package/lib/init.d.ts +18 -0
  13. package/lib/init.d.ts.map +1 -1
  14. package/lib/init.js +39 -0
  15. package/lib/init.js.map +1 -1
  16. package/lib/metrics.d.ts +2 -4
  17. package/lib/metrics.d.ts.map +1 -1
  18. package/lib/metrics.js.map +1 -1
  19. package/lib/navigation.d.ts.map +1 -1
  20. package/lib/navigation.js +14 -1
  21. package/lib/navigation.js.map +1 -1
  22. package/lib/runtime-metrics-fps.d.ts +19 -0
  23. package/lib/runtime-metrics-fps.d.ts.map +1 -0
  24. package/lib/runtime-metrics-fps.js +128 -0
  25. package/lib/runtime-metrics-fps.js.map +1 -0
  26. package/lib/runtime-metrics-heap.d.ts +13 -0
  27. package/lib/runtime-metrics-heap.d.ts.map +1 -0
  28. package/lib/runtime-metrics-heap.js +67 -0
  29. package/lib/runtime-metrics-heap.js.map +1 -0
  30. package/lib/runtime-metrics-network.d.ts +23 -0
  31. package/lib/runtime-metrics-network.d.ts.map +1 -0
  32. package/lib/runtime-metrics-network.js +99 -0
  33. package/lib/runtime-metrics-network.js.map +1 -0
  34. package/lib/runtime-metrics.d.ts +43 -0
  35. package/lib/runtime-metrics.d.ts.map +1 -0
  36. package/lib/runtime-metrics.js +115 -0
  37. package/lib/runtime-metrics.js.map +1 -0
  38. package/lib/track.d.ts.map +1 -1
  39. package/lib/track.js +9 -0
  40. package/lib/track.js.map +1 -1
  41. package/lib/transport.d.ts +18 -0
  42. package/lib/transport.d.ts.map +1 -1
  43. package/lib/transport.js +31 -0
  44. package/lib/transport.js.map +1 -1
  45. package/package.json +8 -3
  46. package/src/__tests__/runtime-metrics-instruments.test.ts +109 -0
  47. package/src/__tests__/runtime-metrics-network.test.ts +78 -0
  48. package/src/config.ts +5 -0
  49. package/src/feedback.ts +21 -0
  50. package/src/handlers/network.ts +11 -0
  51. package/src/init.ts +61 -0
  52. package/src/metrics.ts +3 -1
  53. package/src/navigation.ts +14 -1
  54. package/src/runtime-metrics-fps.ts +137 -0
  55. package/src/runtime-metrics-heap.ts +78 -0
  56. package/src/runtime-metrics-network.ts +103 -0
  57. package/src/runtime-metrics.ts +122 -0
  58. package/src/track.ts +9 -0
  59. 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
@@ -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'
@@ -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?: { spanId: string; traceId: string } },
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
+ }