@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.
Files changed (60) hide show
  1. package/bin/sentori-rn-upload-source-bundle.cjs +193 -0
  2. package/lib/capture.d.ts.map +1 -1
  3. package/lib/capture.js +9 -0
  4. package/lib/capture.js.map +1 -1
  5. package/lib/heartbeat.d.ts +9 -0
  6. package/lib/heartbeat.d.ts.map +1 -0
  7. package/lib/heartbeat.js +140 -0
  8. package/lib/heartbeat.js.map +1 -0
  9. package/lib/index.d.ts +15 -0
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +15 -0
  12. package/lib/index.js.map +1 -1
  13. package/lib/init.d.ts +6 -0
  14. package/lib/init.d.ts.map +1 -1
  15. package/lib/init.js +18 -0
  16. package/lib/init.js.map +1 -1
  17. package/lib/install-id.d.ts +17 -0
  18. package/lib/install-id.d.ts.map +1 -0
  19. package/lib/install-id.js +125 -0
  20. package/lib/install-id.js.map +1 -0
  21. package/lib/navigation.d.ts +1 -0
  22. package/lib/navigation.d.ts.map +1 -1
  23. package/lib/navigation.js +20 -0
  24. package/lib/navigation.js.map +1 -1
  25. package/lib/replay.d.ts +27 -9
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +209 -167
  28. package/lib/replay.js.map +1 -1
  29. package/lib/report-security.d.ts +40 -0
  30. package/lib/report-security.d.ts.map +1 -0
  31. package/lib/report-security.js +159 -0
  32. package/lib/report-security.js.map +1 -0
  33. package/lib/track.d.ts +34 -0
  34. package/lib/track.d.ts.map +1 -0
  35. package/lib/track.js +98 -0
  36. package/lib/track.js.map +1 -0
  37. package/lib/transport.d.ts +15 -0
  38. package/lib/transport.d.ts.map +1 -1
  39. package/lib/transport.js +23 -0
  40. package/lib/transport.js.map +1 -1
  41. package/lib/trust-score.d.ts +20 -0
  42. package/lib/trust-score.d.ts.map +1 -0
  43. package/lib/trust-score.js +151 -0
  44. package/lib/trust-score.js.map +1 -0
  45. package/package.json +6 -2
  46. package/src/__tests__/install-id.test.ts +60 -0
  47. package/src/__tests__/replay-encoding.test.ts +237 -0
  48. package/src/__tests__/report-security.test.ts +106 -0
  49. package/src/__tests__/track.test.ts +91 -0
  50. package/src/capture.ts +8 -0
  51. package/src/heartbeat.ts +158 -0
  52. package/src/index.ts +24 -0
  53. package/src/init.ts +23 -0
  54. package/src/install-id.ts +146 -0
  55. package/src/navigation.ts +26 -0
  56. package/src/replay.ts +258 -176
  57. package/src/report-security.ts +165 -0
  58. package/src/track.ts +114 -0
  59. package/src/transport.ts +35 -0
  60. package/src/trust-score.ts +176 -0
package/src/replay.ts CHANGED
@@ -1,21 +1,19 @@
1
- // v0.9.6 #2 — wireframe Session Replay (SDK side).
1
+ // rc.9 — wireframe Session Replay v2 encoding.
2
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).
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
- // 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.
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
- const TICK_INTERVAL_MS = 1000;
28
- const RING_SIZE = 60;
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
- /** Floor on tick period. < 250 ms (4 Hz) the native view-tree walk
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: string[] = [];
73
+ let _ring: RingItem[] = [];
37
74
  let _timer: ReturnType<typeof setInterval> | null = null;
38
75
  let _running = false;
39
76
 
40
- /**
41
- * v0.9.13 frame-level delta encoding: when the new snapshot matches
42
- * the last one byte-for-byte (static UI, no animation, off-screen
43
- * app), skip pushing it. The ring stays meaningful (one frame =
44
- * one *change*), the attachment shrinks proportionally, and a real
45
- * idle phase no longer evicts a useful pre-error frame.
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 1. */
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
- const period = Math.max(MIN_TICK_PERIOD_MS, Math.floor(TICK_INTERVAL_MS / (opts.hz ?? 1)));
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('[sentori] replay: scheduled tick period=', period, 'ms');
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 snapshot = _nativeMod?.captureWireframe?.(maskIds);
174
- if (typeof snapshot === 'string' && snapshot.length > 0) {
175
- // v0.9.13 — skip pushing if the frame is identical to the last
176
- // pushed one. See _lastPushed comment for the rationale.
177
- if (snapshot !== _lastPushed) {
178
- _ring.push(snapshot);
179
- _lastPushed = snapshot;
180
- while (_ring.length > RING_SIZE) {
181
- _ring.shift();
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
- * Approximate node-count parse — counts occurrences of the
252
- * `"x":` key in the serialised payload. Every node JSON object
253
- * starts with `{"x":<n>,"y":<n>,"w":<n>,"h":<n>...}` so the
254
- * occurrence count matches the array length without paying for a
255
- * full `JSON.parse`. Cheap enough to run inside the 1 Hz tick.
256
- */
257
- function countNodesQuick(payload: string): number {
258
- let count = 0;
259
- let i = 0;
260
- // Skip the outer {"ts":..,"width":..,"height":..,"nodes":[
261
- // and count `"x":` thereafter. The outer payload doesn't contain
262
- // a top-level "x" key so any match must be a node.
263
- const needle = '"x":';
264
- while (true) {
265
- const at = payload.indexOf(needle, i);
266
- if (at < 0) break;
267
- count += 1;
268
- i = at + needle.length;
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 (one snapshot per line). Empty string
309
- * when the ring is empty. Also clears the ring so the next session's
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
- _lastPushed = null;
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
- _lastPushed = null;
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
  }