@goliapkg/sentori-react-native 0.9.0 → 0.9.2

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.
@@ -0,0 +1,99 @@
1
+ // v0.9.6 #4 — JS thread long-task monitor.
2
+ //
3
+ // What it does: a setInterval(50ms) tick checks how much wall-clock
4
+ // passed vs the requested interval. Excess = the JS thread was busy
5
+ // doing something else for at least that much. When the excess
6
+ // crosses 200ms (a "long task" per Chrome's PerformanceObserver
7
+ // threshold) we emit a `sentori.longtask` span so the dashboard's
8
+ // trace waterfall shows where the JS thread stalled.
9
+ //
10
+ // What it does NOT do: capture the stack DURING the long task. JS
11
+ // is single-threaded — by the time our tick runs, the busy code is
12
+ // already gone. The span carries the duration + nearest navigation
13
+ // context (via active span) so triage still has a route to blame.
14
+ //
15
+ // Why this and not a real Hermes sampler: Hermes ships a sampling
16
+ // profiler but accessing it from JS requires RN-internal bridges
17
+ // that vary per RN minor. We could swizzle / vendor headers but
18
+ // that's an Insight-build-config burden. The long-task monitor
19
+ // gets ~80% of the practical value (find slow renders, expensive
20
+ // reducers, accidental sync work in render path) with zero native
21
+ // code + zero RN-version sensitivity.
22
+ //
23
+ // Pairs naturally with +S4 (pre-crash sentinel, RAF frame budget,
24
+ // fires at the slow-frame threshold) — long-task monitor fires
25
+ // further down the slowness scale.
26
+
27
+ import { startSpan } from '@goliapkg/sentori-core';
28
+
29
+ const TICK_INTERVAL_MS = 50;
30
+ const LONGTASK_THRESHOLD_MS = 200; // > 200ms blocking = a longtask
31
+ const MAX_EMITS_PER_MIN = 60;
32
+
33
+ let _timer: ReturnType<typeof setInterval> | null = null;
34
+ let _lastTick = 0;
35
+ let _emitWindowStart = 0;
36
+ let _emitsThisWindow = 0;
37
+
38
+ export type LongTaskMonitorOptions = {
39
+ enabled: boolean;
40
+ /** Threshold ms above which a tick lag becomes a longtask span.
41
+ * Default 200ms. Lower → noisier. */
42
+ thresholdMs?: number;
43
+ };
44
+
45
+ export function startLongTaskMonitor(opts: LongTaskMonitorOptions): void {
46
+ if (_timer !== null) return;
47
+ if (!opts.enabled) return;
48
+ const threshold = opts.thresholdMs ?? LONGTASK_THRESHOLD_MS;
49
+ _lastTick = Date.now();
50
+ _emitWindowStart = _lastTick;
51
+ _emitsThisWindow = 0;
52
+ _timer = setInterval(() => {
53
+ tick(threshold);
54
+ }, TICK_INTERVAL_MS);
55
+ (_timer as unknown as { unref?: () => void }).unref?.();
56
+ }
57
+
58
+ export function stopLongTaskMonitor(): void {
59
+ if (_timer !== null) {
60
+ clearInterval(_timer);
61
+ _timer = null;
62
+ }
63
+ }
64
+
65
+ function tick(threshold: number): void {
66
+ const now = Date.now();
67
+ const elapsed = now - _lastTick;
68
+ _lastTick = now;
69
+ const lag = elapsed - TICK_INTERVAL_MS;
70
+ if (lag <= threshold) return;
71
+
72
+ // Rate-limit emits: at most MAX_EMITS_PER_MIN per minute so a
73
+ // pathological scroll-block-storm doesn't generate 1000 spans.
74
+ if (now - _emitWindowStart >= 60_000) {
75
+ _emitWindowStart = now;
76
+ _emitsThisWindow = 0;
77
+ }
78
+ if (_emitsThisWindow >= MAX_EMITS_PER_MIN) return;
79
+ _emitsThisWindow += 1;
80
+
81
+ const span = startSpan('sentori.longtask', {
82
+ name: 'js.longtask',
83
+ startNowMs: now - lag,
84
+ tags: {
85
+ 'profile.kind': 'longtask',
86
+ 'profile.duration_ms': String(Math.round(lag)),
87
+ 'profile.tick_interval_ms': String(TICK_INTERVAL_MS),
88
+ },
89
+ });
90
+ span.finish({ endNowMs: now, status: 'ok' });
91
+ }
92
+
93
+ /** Test-only. */
94
+ export function __resetLongTaskMonitorForTests(): void {
95
+ stopLongTaskMonitor();
96
+ _lastTick = 0;
97
+ _emitWindowStart = 0;
98
+ _emitsThisWindow = 0;
99
+ }
package/src/native.ts CHANGED
@@ -34,6 +34,18 @@ type SentoriNativeModule = {
34
34
  getFrameCounters?: () => null | { frozen: number; slow: number }
35
35
  /** Reset counters on navigation transition (called by useTraceNavigation). */
36
36
  resetFrameCounters?: () => void
37
+ /**
38
+ * v0.9.5 #8 — read the most-recent native exception recorded by
39
+ * `SentoriNativeExceptionBridge` within the last 1 s. Used by the
40
+ * JS-side capture path to attach native stack info to a JSError
41
+ * that RN wrapped from a swallowed NSException / Java Exception.
42
+ */
43
+ getRecentNativeException?: () => null | {
44
+ ageMs: number
45
+ name: string
46
+ reason: string
47
+ stack: string[]
48
+ }
37
49
  /**
38
50
  * v0.7.3 — JS-triggered screenshot with consumer-supplied mask IDs.
39
51
  * `maskedIds` are RN `nativeID` strings; native walks the view
@@ -187,6 +199,22 @@ export function resetNativeFrameCounters(): void {
187
199
  }
188
200
  }
189
201
 
202
+ /** v0.9.5 #8 — fetch the most-recent native exception from
203
+ * SentoriNativeExceptionBridge (within last ~1 s). null if none or
204
+ * bridge not linked. */
205
+ export function getRecentNativeException(): null | {
206
+ ageMs: number
207
+ name: string
208
+ reason: string
209
+ stack: string[]
210
+ } {
211
+ try {
212
+ return native()?.getRecentNativeException?.() ?? null
213
+ } catch {
214
+ return null
215
+ }
216
+ }
217
+
190
218
  /**
191
219
  * v0.7.3 — drives the native screenshot path. JS side passes the
192
220
  * current list of mask `nativeID`s (read from the consumer's
package/src/replay.ts ADDED
@@ -0,0 +1,123 @@
1
+ // v0.9.6 #2 — wireframe Session Replay (SDK side).
2
+ //
3
+ // 60-slot ring buffer of native-captured wireframe snapshots. Each
4
+ // tick calls into `SentoriReplayCapture.captureWireframe(maskIds)`
5
+ // which walks the iOS UIView / Android View hierarchy and returns
6
+ // one JSON string per snapshot. captureException flushes the ring
7
+ // as a `replay` attachment (NDJSON: one snapshot per line).
8
+ //
9
+ // Why wireframe and not raster:
10
+ // • Storage: 80 nodes × ~80 bytes ≈ 6 KB per snapshot vs ~50 KB
11
+ // for a downsampled JPEG. 60-slot ring ≈ 400 KB raw / ~80 KB
12
+ // gzipped — fits comfortably in the 500 KB attachment cap.
13
+ // • Privacy: no pixels means no accidental PII leaks; mask
14
+ // registry decides what text to replace with "***".
15
+ // • Replay fidelity: less faithful to pixels but enough to see
16
+ // which screen the user was on and what was on it. Dashboard
17
+ // player renders SVG rects — denser-looking than a 1 Hz
18
+ // screenshot strip.
19
+
20
+ import { startSpan } from '@goliapkg/sentori-core';
21
+
22
+ import { getRegisteredMaskQuery } from './mask';
23
+ import { isNativeModuleLinked } from './native-loader';
24
+
25
+ const TICK_INTERVAL_MS = 1000;
26
+ const RING_SIZE = 60;
27
+
28
+ let _ring: string[] = [];
29
+ let _timer: ReturnType<typeof setInterval> | null = null;
30
+ let _running = false;
31
+
32
+ export type ReplayOptions = {
33
+ mode?: 'off' | 'wireframe';
34
+ /** Ticks per second. Default 1. */
35
+ hz?: number;
36
+ };
37
+
38
+ export function startReplay(opts: ReplayOptions): void {
39
+ if (_running) return;
40
+ if (opts.mode !== 'wireframe') return;
41
+ // Native replay needs the Sentori native module linked. Defensive
42
+ // — same pattern as other native peers — for Expo Go / unlinked
43
+ // builds.
44
+ if (!isNativeModuleLinked('Sentori') && !isNativeModuleLinked('SentoriModule')) {
45
+ // Falls back silently. Replay rings stay empty; captureException
46
+ // simply doesn't attach a replay.
47
+ return;
48
+ }
49
+ _running = true;
50
+ const period = Math.max(250, Math.floor(1000 / (opts.hz ?? 1)));
51
+ _timer = setInterval(() => {
52
+ captureTick();
53
+ }, period);
54
+ (_timer as unknown as { unref?: () => void }).unref?.();
55
+ }
56
+
57
+ export function stopReplay(): void {
58
+ _running = false;
59
+ if (_timer !== null) {
60
+ clearInterval(_timer);
61
+ _timer = null;
62
+ }
63
+ }
64
+
65
+ function captureTick(): void {
66
+ if (!_running) return;
67
+ const tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
68
+ try {
69
+ const maskIds = readMaskIds();
70
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
71
+ const nativeMod = loadNativeReplay();
72
+ const snapshot = nativeMod?.captureWireframe?.(maskIds);
73
+ if (typeof snapshot === 'string' && snapshot.length > 0) {
74
+ _ring.push(snapshot);
75
+ while (_ring.length > RING_SIZE) _ring.shift();
76
+ }
77
+ tickSpan.finish({ status: 'ok' });
78
+ } catch (e) {
79
+ if (e instanceof Error) tickSpan.setTag('error.message', e.message);
80
+ tickSpan.finish({ status: 'error' });
81
+ }
82
+ }
83
+
84
+ function readMaskIds(): string[] {
85
+ const q = getRegisteredMaskQuery();
86
+ if (!q) return [];
87
+ try {
88
+ return q();
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ type ReplayNativeModule = {
95
+ captureWireframe?: (maskedIds: string[]) => null | string;
96
+ };
97
+
98
+ function loadNativeReplay(): ReplayNativeModule | null {
99
+ try {
100
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
101
+ const core = require('expo-modules-core') as {
102
+ requireNativeModule: <T>(name: string) => T;
103
+ };
104
+ return core.requireNativeModule<ReplayNativeModule>('Sentori');
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Drain the ring as NDJSON (one snapshot per line). Empty string
111
+ * when the ring is empty. Also clears the ring so the next session's
112
+ * replay starts fresh. */
113
+ export function drainReplay(): string {
114
+ if (_ring.length === 0) return '';
115
+ const out = _ring.join('\n');
116
+ _ring = [];
117
+ return out;
118
+ }
119
+
120
+ export function __resetReplayForTests(): void {
121
+ stopReplay();
122
+ _ring = [];
123
+ }