@goliapkg/sentori-react-native 0.9.1 → 0.9.3
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/android/src/main/java/com/sentori/SentoriCrashHandler.kt +3 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +5 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +153 -0
- package/ios/SentoriModule.swift +5 -0
- package/ios/SentoriReplayCapture.swift +147 -0
- package/lib/capture.d.ts +3 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +29 -0
- package/lib/capture.js.map +1 -1
- package/lib/control-channel.d.ts +6 -0
- package/lib/control-channel.d.ts.map +1 -0
- package/lib/control-channel.js +89 -0
- package/lib/control-channel.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/init.d.ts +16 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +21 -0
- package/lib/init.js.map +1 -1
- package/lib/long-task-monitor.d.ts +11 -0
- package/lib/long-task-monitor.d.ts.map +1 -0
- package/lib/long-task-monitor.js +88 -0
- package/lib/long-task-monitor.js.map +1 -0
- package/lib/replay.d.ts +13 -0
- package/lib/replay.d.ts.map +1 -0
- package/lib/replay.js +111 -0
- package/lib/replay.js.map +1 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +8 -0
- package/lib/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/capture.ts +35 -0
- package/src/control-channel.ts +90 -0
- package/src/init.ts +31 -0
- package/src/long-task-monitor.ts +99 -0
- package/src/replay.ts +123 -0
- package/src/transport.ts +8 -0
|
@@ -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/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
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { drainSpans } from '@goliapkg/sentori-core';
|
|
2
2
|
|
|
3
3
|
import { getConfig } from './config';
|
|
4
|
+
import { isLiveMode } from './control-channel';
|
|
4
5
|
import { isAnyNativeModuleLinked } from './native-loader';
|
|
5
6
|
import type { Event } from './types';
|
|
6
7
|
|
|
@@ -25,6 +26,13 @@ const SDK_VERSION = '0.0.0';
|
|
|
25
26
|
|
|
26
27
|
export const enqueue = (event: Event): void => {
|
|
27
28
|
_queue.push(event);
|
|
29
|
+
// v1.1 +S7 升级 — when the dashboard has armed live-debug for the
|
|
30
|
+
// current user, flush immediately instead of waiting for the 5 s
|
|
31
|
+
// batch interval. Dashboard sees each event with sub-second latency.
|
|
32
|
+
if (isLiveMode()) {
|
|
33
|
+
void flush();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
28
36
|
if (_queue.length >= BATCH_SIZE) {
|
|
29
37
|
void flush();
|
|
30
38
|
} else if (!_flushTimer) {
|