@goliapkg/sentori-react-native 1.0.0-rc.8 → 1.0.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/bin/sentori-rn-upload-source-bundle.cjs +193 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +9 -0
- package/lib/capture.js.map +1 -1
- package/lib/heartbeat.d.ts +9 -0
- package/lib/heartbeat.d.ts.map +1 -0
- package/lib/heartbeat.js +140 -0
- package/lib/heartbeat.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +6 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +18 -0
- package/lib/init.js.map +1 -1
- package/lib/install-id.d.ts +17 -0
- package/lib/install-id.d.ts.map +1 -0
- package/lib/install-id.js +125 -0
- package/lib/install-id.js.map +1 -0
- package/lib/navigation.d.ts +1 -0
- package/lib/navigation.d.ts.map +1 -1
- package/lib/navigation.js +20 -0
- package/lib/navigation.js.map +1 -1
- package/lib/replay.d.ts +27 -9
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +209 -167
- package/lib/replay.js.map +1 -1
- package/lib/report-security.d.ts +40 -0
- package/lib/report-security.d.ts.map +1 -0
- package/lib/report-security.js +159 -0
- package/lib/report-security.js.map +1 -0
- package/lib/track.d.ts +34 -0
- package/lib/track.d.ts.map +1 -0
- package/lib/track.js +98 -0
- package/lib/track.js.map +1 -0
- package/lib/transport.d.ts +15 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +23 -0
- package/lib/transport.js.map +1 -1
- package/lib/trust-score.d.ts +20 -0
- package/lib/trust-score.d.ts.map +1 -0
- package/lib/trust-score.js +151 -0
- package/lib/trust-score.js.map +1 -0
- package/package.json +6 -2
- package/src/__tests__/install-id.test.ts +60 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/__tests__/report-security.test.ts +106 -0
- package/src/__tests__/track.test.ts +91 -0
- package/src/capture.ts +8 -0
- package/src/heartbeat.ts +158 -0
- package/src/index.ts +24 -0
- package/src/init.ts +23 -0
- package/src/install-id.ts +146 -0
- package/src/navigation.ts +26 -0
- package/src/replay.ts +258 -176
- package/src/report-security.ts +165 -0
- package/src/track.ts +114 -0
- package/src/transport.ts +35 -0
- package/src/trust-score.ts +176 -0
package/src/replay.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
//
|
|
1
|
+
// rc.9 — wireframe Session Replay v2 encoding.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Replaces rc.8's "one full snapshot per tick" with keyframe + delta:
|
|
4
|
+
// - Native walker still emits a full snapshot string every tick.
|
|
5
|
+
// - JS parses the snapshot, builds a fingerprint→node map, and either
|
|
6
|
+
// emits a keyframe (cold start / every KEYFRAME_INTERVAL_MS / when
|
|
7
|
+
// the delta would be bigger than a fresh key) OR a delta against
|
|
8
|
+
// the previous emit's reconstructed state.
|
|
9
|
+
// - Static-UI ticks produce zero-delta heartbeats which we drop.
|
|
8
10
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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.
|
|
11
|
+
// At 4 Hz capture / 4 s keyframes the wire bytes drop ~50 % vs rc.8 at
|
|
12
|
+
// 1 Hz for the same 60 s pre-error window, and the dashboard player
|
|
13
|
+
// cross-fades between captures at 24 fps so playback reads as motion
|
|
14
|
+
// rather than a 1 Hz slideshow.
|
|
15
|
+
//
|
|
16
|
+
// Wire schema: docs/replay-encoding-v2.md.
|
|
19
17
|
|
|
20
18
|
import { startSpan } from '@goliapkg/sentori-core';
|
|
21
19
|
|
|
@@ -24,61 +22,81 @@ import { describeWireframeNative } from './native';
|
|
|
24
22
|
|
|
25
23
|
declare const __DEV__: boolean | undefined;
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/** Default capture interval (2 Hz). Override via `replay.hz`. rc.10
|
|
26
|
+
* rolled this back from rc.9's 4 Hz default: iOS sim measured 1 ms
|
|
27
|
+
* per tick on a thin dev panel but extrapolation to a 200-node
|
|
28
|
+
* Insight-class UI on Android pushes JS-thread occupancy past 1 %,
|
|
29
|
+
* which violates the "几乎不能造成性能抖动" rule. Apps that want
|
|
30
|
+
* smoother playback motion can opt into `replay.hz: 4` explicitly. */
|
|
31
|
+
const TICK_INTERVAL_MS = 500;
|
|
32
|
+
|
|
33
|
+
/** How often to emit a fresh keyframe — caps reconstruction chain
|
|
34
|
+
* length and lets the player re-sync after a dropped line. */
|
|
35
|
+
const KEYFRAME_INTERVAL_MS = 4_000;
|
|
36
|
+
|
|
37
|
+
/** When the delta against the previous frame would carry ≥ this
|
|
38
|
+
* fraction of the current node count, prefer a fresh keyframe —
|
|
39
|
+
* emits roughly the same bytes but doesn't grow the chain. */
|
|
40
|
+
const DELTA_TO_KEYFRAME_RATIO = 0.4;
|
|
41
|
+
|
|
42
|
+
/** Floor under which the keyframe-vs-delta ratio heuristic does
|
|
43
|
+
* not apply. Trivial UIs (≤ 10 nodes — boot splash, dev panel,
|
|
44
|
+
* tests) shouldn't drop to keyframes on every change. */
|
|
45
|
+
const KEYFRAME_RATIO_MIN_NODES = 10;
|
|
46
|
+
|
|
47
|
+
/** Replay window kept in the ring buffer. captureException drains. */
|
|
48
|
+
const REPLAY_WINDOW_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/** Hard ceiling on ring item count — defence against a wedged tick
|
|
51
|
+
* clock filling memory; under normal capture rates we evict by time
|
|
52
|
+
* long before this fires. */
|
|
53
|
+
const MAX_RING_ITEMS = 1000;
|
|
54
|
+
|
|
55
|
+
/** Floor on tick period. < 100 ms the native view-tree walk dominates
|
|
56
|
+
* the JS thread on mid-tier Android. */
|
|
57
|
+
const MIN_TICK_PERIOD_MS = 100;
|
|
58
|
+
|
|
59
|
+
type Node = {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
w: number;
|
|
63
|
+
h: number;
|
|
64
|
+
kind?: string;
|
|
65
|
+
text?: string;
|
|
66
|
+
color?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type NativeFrame = { ts: number; width: number; height: number; nodes: Node[] };
|
|
29
70
|
|
|
30
|
-
|
|
31
|
-
* dominates the JS thread on mid-tier Android, especially with mask
|
|
32
|
-
* consultation. The default is 1 Hz; the option exists for
|
|
33
|
-
* benchmarking, not for production. */
|
|
34
|
-
const MIN_TICK_PERIOD_MS = 250;
|
|
71
|
+
type RingItem = { ts: number; line: string };
|
|
35
72
|
|
|
36
|
-
let _ring:
|
|
73
|
+
let _ring: RingItem[] = [];
|
|
37
74
|
let _timer: ReturnType<typeof setInterval> | null = null;
|
|
38
75
|
let _running = false;
|
|
39
76
|
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
* We only check against the most-recently-pushed snapshot, not the
|
|
48
|
-
* whole ring — that's cheap (one string comparison per tick) and
|
|
49
|
-
* catches the dominant case (idle screens). True content changes
|
|
50
|
-
* fall through and push as before.
|
|
51
|
-
*
|
|
52
|
-
* Budget verification on the iOS showcase (apps/ios-showcase): 60
|
|
53
|
-
* frames at ~120 bytes each → ≈ 7 KB raw NDJSON, well under the
|
|
54
|
-
* 500 KB attachment cap. Heavier RN apps with 200+ visible nodes
|
|
55
|
-
* per frame can land in the 400 KB band; future work in v1.x adds
|
|
56
|
-
* native gzip on upload if real-world traffic ever pushes the cap.
|
|
57
|
-
*/
|
|
58
|
-
let _lastPushed: null | string = null;
|
|
59
|
-
|
|
60
|
-
/** Native module ref, resolved once on first start. Caching here
|
|
61
|
-
* avoids the cost of `requireNativeModule('Sentori')` on every
|
|
62
|
-
* capture tick (Metro's require cache makes this cheap, but the
|
|
63
|
-
* per-tick string lookup and possible throw still cost more than
|
|
64
|
-
* reading a closed-over variable). */
|
|
77
|
+
/** Last emit's reconstructed state — fingerprint → node. Null until
|
|
78
|
+
* the first keyframe lands; reset on drain so the next session
|
|
79
|
+
* starts with a fresh keyframe. */
|
|
80
|
+
let _lastFrameState: Map<string, Node> | null = null;
|
|
81
|
+
let _lastKeyframeTs = 0;
|
|
82
|
+
|
|
65
83
|
let _nativeMod: ReplayNativeModule | null = null;
|
|
66
84
|
|
|
67
85
|
export type ReplayOptions = {
|
|
68
86
|
mode?: 'off' | 'wireframe';
|
|
69
|
-
/** Ticks per second. Default
|
|
87
|
+
/** Ticks per second. Default 2. Opt into 4 (or 8) for
|
|
88
|
+
* motion-heavy apps where playback smoothness matters more than
|
|
89
|
+
* the marginal CPU saving. */
|
|
70
90
|
hz?: number;
|
|
91
|
+
/** Keyframe cadence in ms. Default 4000. */
|
|
92
|
+
keyframeMs?: number;
|
|
71
93
|
};
|
|
72
94
|
|
|
95
|
+
let _keyframeIntervalMs = KEYFRAME_INTERVAL_MS;
|
|
96
|
+
|
|
73
97
|
export function startReplay(opts: ReplayOptions): void {
|
|
74
98
|
if (_running) return;
|
|
75
99
|
if (opts.mode !== 'wireframe') return;
|
|
76
|
-
// v0.9.10 — gate via expo-modules-core's registry (same path the
|
|
77
|
-
// screenshot capture uses). The previous `isNativeModuleLinked`
|
|
78
|
-
// check looked at the legacy `RN.NativeModules` map, but the
|
|
79
|
-
// Sentori module is registered through expo-modules-core; the
|
|
80
|
-
// legacy map never sees it, so this branch returned "not linked"
|
|
81
|
-
// forever even with the pod correctly attached (Insight 2026-05-17).
|
|
82
100
|
const info = describeWireframeNative();
|
|
83
101
|
if (!info.bound) {
|
|
84
102
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -99,23 +117,17 @@ export function startReplay(opts: ReplayOptions): void {
|
|
|
99
117
|
}
|
|
100
118
|
_running = true;
|
|
101
119
|
_nativeMod = loadNativeReplay();
|
|
102
|
-
|
|
120
|
+
_keyframeIntervalMs = opts.keyframeMs ?? KEYFRAME_INTERVAL_MS;
|
|
121
|
+
const hz = opts.hz ?? 2;
|
|
122
|
+
const period = Math.max(MIN_TICK_PERIOD_MS, Math.round(1000 / hz));
|
|
103
123
|
_timer = setInterval(() => {
|
|
104
124
|
captureTick();
|
|
105
125
|
}, period);
|
|
106
|
-
// v0.9.12 — Insight 2026-05-17 report: 0.9.11 emitted the
|
|
107
|
-
// "starting bound=true" line then went silent. Root cause was the
|
|
108
|
-
// `.unref?.()` call that used to live here. Hermes 0.81 doesn't
|
|
109
|
-
// ship a Timer object with the Node-style `unref` method, and the
|
|
110
|
-
// optional-chained call ended up dereferencing a `prototype`
|
|
111
|
-
// property on `undefined` — throwing synchronously inside
|
|
112
|
-
// startReplay, which RN's bridge swallowed silently. Net effect:
|
|
113
|
-
// setInterval registered, captureTick never invoked. Drop the
|
|
114
|
-
// call; replay tick lifecycle is bound to the app process, no
|
|
115
|
-
// event-loop tweak needed.
|
|
116
126
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
117
127
|
// eslint-disable-next-line no-console
|
|
118
|
-
console.warn(
|
|
128
|
+
console.warn(
|
|
129
|
+
'[sentori] replay: scheduled tick period=', period, 'ms keyframe=', _keyframeIntervalMs, 'ms',
|
|
130
|
+
);
|
|
119
131
|
}
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -129,6 +141,9 @@ export function stopReplay(): void {
|
|
|
129
141
|
_emptyTickCount = 0;
|
|
130
142
|
_emptyTickLogStride = 1;
|
|
131
143
|
_firstTickLogged = false;
|
|
144
|
+
_okTickCount = 0;
|
|
145
|
+
_thinTickCount = 0;
|
|
146
|
+
_thinTickLogStride = 1;
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
let _emptyTickCount = 0;
|
|
@@ -138,30 +153,15 @@ let _thinTickLogStride = 1;
|
|
|
138
153
|
let _okTickCount = 0;
|
|
139
154
|
let _firstTickLogged = false;
|
|
140
155
|
|
|
141
|
-
/** Anything below this many nodes is suspicious — likely the
|
|
142
|
-
* walker bailed early (zero-size parent, masked root, etc.).
|
|
143
|
-
* Insight 2026-05-18 verify event saw 800-node payloads on some
|
|
144
|
-
* ticks and 1-3-node payloads on others; this threshold flags
|
|
145
|
-
* the latter without spamming on small-but-valid screens. */
|
|
146
156
|
const THIN_RESULT_NODES = 6;
|
|
147
157
|
|
|
148
158
|
function captureTick(): void {
|
|
149
159
|
if (!_running) return;
|
|
150
|
-
// v0.9.12 — UNCONDITIONAL first-tick log. Proves the setInterval
|
|
151
|
-
// callback is firing at all, before any other code that could
|
|
152
|
-
// throw. 0.9.11's diagnostic was inside a `else if (snapshot==null)`
|
|
153
|
-
// branch that could only surface AFTER the native call returned;
|
|
154
|
-
// useless when the bug is that the tick body never enters.
|
|
155
160
|
if (typeof __DEV__ !== 'undefined' && __DEV__ && !_firstTickLogged) {
|
|
156
161
|
// eslint-disable-next-line no-console
|
|
157
162
|
console.warn('[sentori] replay tick: FIRST INVOCATION');
|
|
158
163
|
_firstTickLogged = true;
|
|
159
164
|
}
|
|
160
|
-
// 0.9.11 called startSpan OUTSIDE the catch block. If
|
|
161
|
-
// `@goliapkg/sentori-core` failed to initialise (or startSpan
|
|
162
|
-
// threw for any other reason on the first tick) the whole tick
|
|
163
|
-
// callback died silently. Wrap so worst case is "no span for this
|
|
164
|
-
// tick" not "no ticks for the session".
|
|
165
165
|
let tickSpan: ReturnType<typeof startSpan> | null = null;
|
|
166
166
|
try {
|
|
167
167
|
tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
|
|
@@ -170,71 +170,32 @@ function captureTick(): void {
|
|
|
170
170
|
}
|
|
171
171
|
try {
|
|
172
172
|
const maskIds = readMaskIds();
|
|
173
|
-
const
|
|
174
|
-
if (typeof
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
_emptyTickCount = 0;
|
|
185
|
-
_emptyTickLogStride = 1;
|
|
186
|
-
|
|
187
|
-
// v1.0.0-rc.3 — Insight 2026-05-18 report: some ticks land
|
|
188
|
-
// valid non-empty JSON but with only the root View + 1-2 wrappers
|
|
189
|
-
// (the Android zero-size-bails-subtree bug, now fixed natively;
|
|
190
|
-
// this log catches similar regressions). Cheap node-count parse
|
|
191
|
-
// — we only look at one digit-level character class.
|
|
192
|
-
_okTickCount += 1;
|
|
173
|
+
const snapshotJson = _nativeMod?.captureWireframe?.(maskIds);
|
|
174
|
+
if (typeof snapshotJson !== 'string' || snapshotJson.length === 0) {
|
|
175
|
+
handleEmptyTick(snapshotJson);
|
|
176
|
+
tickSpan?.finish({ status: 'ok' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let snapshot: NativeFrame;
|
|
181
|
+
try {
|
|
182
|
+
snapshot = JSON.parse(snapshotJson) as NativeFrame;
|
|
183
|
+
} catch (e) {
|
|
193
184
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
194
|
-
const nodeCount = countNodesQuick(snapshot);
|
|
195
|
-
const sizeBytes = snapshot.length;
|
|
196
|
-
const isThin = nodeCount < THIN_RESULT_NODES;
|
|
197
|
-
if (isThin) {
|
|
198
|
-
_thinTickCount += 1;
|
|
199
|
-
if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
|
|
200
|
-
// eslint-disable-next-line no-console
|
|
201
|
-
console.warn(
|
|
202
|
-
`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${sizeBytes} (thin ticks so far: ${_thinTickCount})`,
|
|
203
|
-
);
|
|
204
|
-
_thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
_thinTickCount = 0;
|
|
208
|
-
_thinTickLogStride = 1;
|
|
209
|
-
}
|
|
210
|
-
// First good tick logs the shape so devs see it once.
|
|
211
|
-
if (_okTickCount === 1) {
|
|
212
|
-
// eslint-disable-next-line no-console
|
|
213
|
-
console.warn(
|
|
214
|
-
`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${sizeBytes}`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
} else if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
219
|
-
// v0.9.11 — Insight 2026-05-17 Finding 6: tick fires hundreds
|
|
220
|
-
// of times but ring stays empty → native returned null/empty.
|
|
221
|
-
// Log on a back-off schedule (1st, 10th, 100th, …) so the
|
|
222
|
-
// diagnostic is visible without spamming Metro at 1 Hz for a
|
|
223
|
-
// 15-minute session.
|
|
224
|
-
_emptyTickCount += 1;
|
|
225
|
-
if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
|
|
226
185
|
// eslint-disable-next-line no-console
|
|
227
|
-
console.warn(
|
|
228
|
-
'[sentori] replay tick: native returned',
|
|
229
|
-
snapshot === null
|
|
230
|
-
? 'null'
|
|
231
|
-
: typeof snapshot === 'string'
|
|
232
|
-
? `empty (length=${snapshot.length})`
|
|
233
|
-
: typeof snapshot,
|
|
234
|
-
`(empty ticks so far: ${_emptyTickCount})`,
|
|
235
|
-
);
|
|
236
|
-
_emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
|
|
186
|
+
console.warn('[sentori] replay tick: native JSON parse failed', e);
|
|
237
187
|
}
|
|
188
|
+
tickSpan?.finish({ status: 'error' });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_emptyTickCount = 0;
|
|
193
|
+
_emptyTickLogStride = 1;
|
|
194
|
+
|
|
195
|
+
encodeAndPush(snapshot);
|
|
196
|
+
|
|
197
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
198
|
+
diagnosticForTick(snapshot, snapshotJson.length);
|
|
238
199
|
}
|
|
239
200
|
tickSpan?.finish({ status: 'ok' });
|
|
240
201
|
} catch (e) {
|
|
@@ -247,27 +208,140 @@ function captureTick(): void {
|
|
|
247
208
|
}
|
|
248
209
|
}
|
|
249
210
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
211
|
+
function encodeAndPush(snapshot: NativeFrame): void {
|
|
212
|
+
const currentState = new Map<string, Node>();
|
|
213
|
+
for (const n of snapshot.nodes) currentState.set(fingerprint(n), n);
|
|
214
|
+
|
|
215
|
+
const ts = snapshot.ts;
|
|
216
|
+
const isCold = _lastFrameState === null;
|
|
217
|
+
const keyframeOverdue = ts - _lastKeyframeTs >= _keyframeIntervalMs;
|
|
218
|
+
|
|
219
|
+
let line: string;
|
|
220
|
+
|
|
221
|
+
if (isCold || keyframeOverdue) {
|
|
222
|
+
line = encodeKeyframe(snapshot);
|
|
223
|
+
_lastKeyframeTs = ts;
|
|
224
|
+
} else {
|
|
225
|
+
const delta = computeDelta(_lastFrameState as Map<string, Node>, currentState);
|
|
226
|
+
const totalChanged = delta.added.length + delta.changed.length + delta.removed.length;
|
|
227
|
+
if (totalChanged === 0) {
|
|
228
|
+
// No-op heartbeat — drop. Keep _lastFrameState as-is (identical).
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
currentState.size >= KEYFRAME_RATIO_MIN_NODES &&
|
|
233
|
+
totalChanged >= currentState.size * DELTA_TO_KEYFRAME_RATIO
|
|
234
|
+
) {
|
|
235
|
+
// Big screen transition on a substantial UI — emit a fresh
|
|
236
|
+
// keyframe so reconstruction doesn't carry a near-rewrite delta.
|
|
237
|
+
line = encodeKeyframe(snapshot);
|
|
238
|
+
_lastKeyframeTs = ts;
|
|
239
|
+
} else {
|
|
240
|
+
line = JSON.stringify({
|
|
241
|
+
ts,
|
|
242
|
+
kind: 'delta',
|
|
243
|
+
added: delta.added,
|
|
244
|
+
changed: delta.changed,
|
|
245
|
+
removed: delta.removed,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_ring.push({ ts, line });
|
|
251
|
+
evictRing(ts);
|
|
252
|
+
_lastFrameState = currentState;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function encodeKeyframe(snapshot: NativeFrame): string {
|
|
256
|
+
return JSON.stringify({
|
|
257
|
+
ts: snapshot.ts,
|
|
258
|
+
kind: 'key',
|
|
259
|
+
width: snapshot.width,
|
|
260
|
+
height: snapshot.height,
|
|
261
|
+
nodes: snapshot.nodes,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function evictRing(nowTs: number): void {
|
|
266
|
+
const cutoff = nowTs - REPLAY_WINDOW_MS;
|
|
267
|
+
while (_ring.length > 0 && _ring[0]!.ts < cutoff) _ring.shift();
|
|
268
|
+
while (_ring.length > MAX_RING_ITEMS) _ring.shift();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Fingerprint integer-rounds before joining so sub-pixel jitter from
|
|
272
|
+
* RN's Fabric layout (occasionally floats) doesn't break stable
|
|
273
|
+
* matching across ticks. */
|
|
274
|
+
function fingerprint(n: Node): string {
|
|
275
|
+
return `${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type Delta = { added: Node[]; changed: Node[]; removed: Pick<Node, 'x' | 'y' | 'w' | 'h'>[] };
|
|
279
|
+
|
|
280
|
+
export function computeDelta(prev: Map<string, Node>, curr: Map<string, Node>): Delta {
|
|
281
|
+
const added: Node[] = [];
|
|
282
|
+
const changed: Node[] = [];
|
|
283
|
+
const removed: Pick<Node, 'x' | 'y' | 'w' | 'h'>[] = [];
|
|
284
|
+
for (const [fp, node] of curr) {
|
|
285
|
+
const p = prev.get(fp);
|
|
286
|
+
if (!p) {
|
|
287
|
+
added.push(node);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
(p.kind ?? '') !== (node.kind ?? '') ||
|
|
292
|
+
(p.color ?? '') !== (node.color ?? '') ||
|
|
293
|
+
(p.text ?? '') !== (node.text ?? '')
|
|
294
|
+
) {
|
|
295
|
+
changed.push(node);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const [fp, node] of prev) {
|
|
299
|
+
if (!curr.has(fp)) removed.push({ x: node.x, y: node.y, w: node.w, h: node.h });
|
|
300
|
+
}
|
|
301
|
+
return { added, changed, removed };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleEmptyTick(snapshot: unknown): void {
|
|
305
|
+
if (typeof __DEV__ === 'undefined' || !__DEV__) return;
|
|
306
|
+
_emptyTickCount += 1;
|
|
307
|
+
if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
|
|
308
|
+
// eslint-disable-next-line no-console
|
|
309
|
+
console.warn(
|
|
310
|
+
'[sentori] replay tick: native returned',
|
|
311
|
+
snapshot === null
|
|
312
|
+
? 'null'
|
|
313
|
+
: typeof snapshot === 'string'
|
|
314
|
+
? `empty (length=${snapshot.length})`
|
|
315
|
+
: typeof snapshot,
|
|
316
|
+
`(empty ticks so far: ${_emptyTickCount})`,
|
|
317
|
+
);
|
|
318
|
+
_emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function diagnosticForTick(snapshot: NativeFrame, snapshotBytes: number): void {
|
|
323
|
+
_okTickCount += 1;
|
|
324
|
+
const nodeCount = snapshot.nodes.length;
|
|
325
|
+
const isThin = nodeCount < THIN_RESULT_NODES;
|
|
326
|
+
if (isThin) {
|
|
327
|
+
_thinTickCount += 1;
|
|
328
|
+
if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
|
|
329
|
+
// eslint-disable-next-line no-console
|
|
330
|
+
console.warn(
|
|
331
|
+
`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${snapshotBytes} (thin ticks so far: ${_thinTickCount})`,
|
|
332
|
+
);
|
|
333
|
+
_thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
_thinTickCount = 0;
|
|
337
|
+
_thinTickLogStride = 1;
|
|
338
|
+
}
|
|
339
|
+
if (_okTickCount === 1) {
|
|
340
|
+
// eslint-disable-next-line no-console
|
|
341
|
+
console.warn(
|
|
342
|
+
`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${snapshotBytes}`,
|
|
343
|
+
);
|
|
269
344
|
}
|
|
270
|
-
return count;
|
|
271
345
|
}
|
|
272
346
|
|
|
273
347
|
function readMaskIds(): string[] {
|
|
@@ -296,28 +370,36 @@ function loadNativeReplay(): ReplayNativeModule | null {
|
|
|
296
370
|
}
|
|
297
371
|
}
|
|
298
372
|
|
|
299
|
-
/** rc.4 — surface "is replay subsystem alive" so the captureException
|
|
300
|
-
* debug log can label `wantReplay` alongside `wantScreenshot` /
|
|
301
|
-
* `wantSessionTrail`. Insight 2026-05-18 verify flagged that the
|
|
302
|
-
* pre-rc.4 log line was missing `wantReplay`, leaving the failure
|
|
303
|
-
* shape ambiguous (config-off vs. ring-empty vs. attach-failed). */
|
|
304
373
|
export function isReplayRunning(): boolean {
|
|
305
374
|
return _running;
|
|
306
375
|
}
|
|
307
376
|
|
|
308
|
-
/** Drain the ring as NDJSON (
|
|
309
|
-
* when the ring is empty.
|
|
310
|
-
* replay starts fresh. */
|
|
377
|
+
/** Drain the ring as NDJSON (keyframe or delta per line). Empty
|
|
378
|
+
* string when the ring is empty. Resets state so the next session's
|
|
379
|
+
* replay starts with a fresh keyframe. */
|
|
311
380
|
export function drainReplay(): string {
|
|
312
381
|
if (_ring.length === 0) return '';
|
|
313
|
-
const out = _ring.join('\n');
|
|
382
|
+
const out = _ring.map((r) => r.line).join('\n');
|
|
314
383
|
_ring = [];
|
|
315
|
-
|
|
384
|
+
_lastFrameState = null;
|
|
385
|
+
_lastKeyframeTs = 0;
|
|
316
386
|
return out;
|
|
317
387
|
}
|
|
318
388
|
|
|
319
389
|
export function __resetReplayForTests(): void {
|
|
320
390
|
stopReplay();
|
|
321
391
|
_ring = [];
|
|
322
|
-
|
|
392
|
+
_lastFrameState = null;
|
|
393
|
+
_lastKeyframeTs = 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** rc.9 — test seam. Lets unit tests drive the encoder without a
|
|
397
|
+
* native module; pretends we received `frameJson` on the tick. */
|
|
398
|
+
export function __feedTickForTests(frameJson: string): void {
|
|
399
|
+
if (!_running) {
|
|
400
|
+
// Simulate "running" without an actual setInterval — caller drives.
|
|
401
|
+
_running = true;
|
|
402
|
+
}
|
|
403
|
+
const snapshot = JSON.parse(frameJson) as NativeFrame;
|
|
404
|
+
encodeAndPush(snapshot);
|
|
323
405
|
}
|