@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.10

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 (41) hide show
  1. package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
  2. package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
  3. package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
  4. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
  5. package/ios/SentoriModule.swift +15 -0
  6. package/ios/SentoriReplayCapture.swift +135 -10
  7. package/ios/SentoriScreenshotCapture.swift +69 -3
  8. package/lib/base64.d.ts +25 -0
  9. package/lib/base64.d.ts.map +1 -0
  10. package/lib/base64.js +30 -0
  11. package/lib/base64.js.map +1 -0
  12. package/lib/capture.d.ts +20 -1
  13. package/lib/capture.d.ts.map +1 -1
  14. package/lib/capture.js +45 -21
  15. package/lib/capture.js.map +1 -1
  16. package/lib/index.d.ts +2 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +2 -1
  19. package/lib/index.js.bak +64 -0
  20. package/lib/index.js.map +1 -1
  21. package/lib/native.d.ts +68 -0
  22. package/lib/native.d.ts.map +1 -1
  23. package/lib/native.js +115 -0
  24. package/lib/native.js.map +1 -1
  25. package/lib/replay.d.ts +28 -4
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +242 -65
  28. package/lib/replay.js.map +1 -1
  29. package/lib/transport.d.ts.map +1 -1
  30. package/lib/transport.js +16 -0
  31. package/lib/transport.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/__tests__/base64.test.ts +55 -0
  34. package/src/__tests__/capture-replay.test.ts +150 -0
  35. package/src/__tests__/replay-encoding.test.ts +237 -0
  36. package/src/base64.ts +29 -0
  37. package/src/capture.ts +56 -22
  38. package/src/index.ts +3 -0
  39. package/src/native.ts +177 -0
  40. package/src/replay.ts +294 -70
  41. package/src/transport.ts +31 -0
package/src/native.ts CHANGED
@@ -67,6 +67,44 @@ type SentoriNativeModule = {
67
67
  * snapshot string or null on failure.
68
68
  */
69
69
  captureWireframe?: (maskedIds: string[]) => null | string
70
+ /**
71
+ * v0.9.12 — diagnostic readout for the wireframe path. Cheap
72
+ * synchronous call that returns the path the last `captureWireframe`
73
+ * tick took plus scene/window counts at that moment. Used by the
74
+ * example app's debug button and the Insight verify flow to tell
75
+ * "no window resolvable" from "window walked but tree empty" without
76
+ * shipping a new pod.
77
+ */
78
+ probeWireframe?: () => {
79
+ lastPath: string
80
+ lastNodes: number
81
+ sceneCount?: number
82
+ windowCount?: number
83
+ trackedSource?: string
84
+ trackedActivity?: string
85
+ decorViewFound?: boolean
86
+ /** v1.0.0-rc.3: deepest descendant reached by the walker. If
87
+ * this is 2-3 the walker bailed early; if it matches the
88
+ * expected view-tree depth (~20-40 for RN apps) the capture
89
+ * is healthy. */
90
+ lastDepthMax?: number
91
+ /** v1.0.0-rc.3: byte length of the last serialised payload. */
92
+ lastSizeBytes?: number
93
+ /** v1.0.0-rc.3: lifetime counters — totalTicks is the number
94
+ * of times native captureWireframe ran; totalEmptyResultTicks
95
+ * is the subset that came back null or empty. Ratio surfaces
96
+ * intermittent failures (e.g. 200/240 ≈ 80% failure rate). */
97
+ totalTicks?: number
98
+ totalEmptyResultTicks?: number
99
+ }
100
+ /**
101
+ * v1.0.0-rc.2 — diagnostic mirror of probeWireframe for the
102
+ * screenshot path. Returned shape is best-effort cross-platform —
103
+ * Android carries `trackedActivity` / `decorViewFound` / dims,
104
+ * iOS carries `windowFound` / `rootViewControllerFound` / `bounds*`.
105
+ * Callers should treat unknown keys as missing.
106
+ */
107
+ probeScreenshot?: () => Record<string, unknown>
70
108
  /**
71
109
  * Phase 22 sub-D / sub-E: cross-platform main-thread watchdog.
72
110
  * Android: 5 s / 1 s defaults (matches the OS ANR threshold).
@@ -295,10 +333,149 @@ export async function captureNativeScreenshotWithMask(
295
333
  export function describeWireframeNative(): {
296
334
  bound: boolean
297
335
  hasCaptureWireframe: boolean
336
+ hasProbeWireframe: boolean
298
337
  } {
299
338
  const n = native()
300
339
  return {
301
340
  bound: n !== null,
302
341
  hasCaptureWireframe: Boolean(n?.captureWireframe),
342
+ hasProbeWireframe: Boolean(n?.probeWireframe),
343
+ }
344
+ }
345
+
346
+ /**
347
+ * v0.9.12 — JS entry to the native `probeWireframe` diagnostic. Safe
348
+ * to call before the first replay tick — returns the not-yet-called
349
+ * sentinel. When the ring stays empty, this is the single call that
350
+ * answers "why" without redeploying the pod.
351
+ *
352
+ * path meaning
353
+ * ─────────────── ───────────────────────────────────────────────
354
+ * none(not-yet…) captureWireframe has never run yet
355
+ * scene.fg.key iOS: resolved via foregroundActive scene's key window
356
+ * scene.fg.first iOS: foregroundActive scene's first window (no key)
357
+ * scene.fgi.first iOS: foregroundInactive scene mid-transition
358
+ * scene.any.first iOS: had to fall back to any window
359
+ * legacy.first iOS: legacy UIApplication.windows path
360
+ * none iOS: no UIWindow reachable at the tick instant
361
+ * activity.null Android: no resumed Activity registered
362
+ * decorView.null Android: activity has no decor view yet
363
+ * root.zero-size Android: decorView size <= 0 (mid-layout)
364
+ * activity.resumed Android: ok
365
+ */
366
+ export function probeNativeWireframe(): {
367
+ available: boolean
368
+ lastNodes: number
369
+ lastPath: string
370
+ sceneCount: number
371
+ windowCount: number
372
+ /** v1.0.0-rc.3: max recursion depth reached by the walker on
373
+ * the last tick. Healthy on an RN app: 20-40. If it's 2-3 the
374
+ * walker bailed early (zero-size parent, masked root). */
375
+ lastDepthMax: number
376
+ /** v1.0.0-rc.3: byte length of the last serialised payload. */
377
+ lastSizeBytes: number
378
+ /** v1.0.0-rc.3: lifetime totals. `totalEmptyResultTicks /
379
+ * totalTicks` is the failure rate. */
380
+ totalTicks: number
381
+ totalEmptyResultTicks: number
382
+ raw: Record<string, unknown>
383
+ } {
384
+ const n = native()
385
+ if (!n || typeof n.probeWireframe !== 'function') {
386
+ return {
387
+ available: false,
388
+ lastDepthMax: 0,
389
+ lastNodes: 0,
390
+ lastPath: 'native.unavailable',
391
+ lastSizeBytes: 0,
392
+ raw: {},
393
+ sceneCount: 0,
394
+ totalEmptyResultTicks: 0,
395
+ totalTicks: 0,
396
+ windowCount: 0,
397
+ }
398
+ }
399
+ try {
400
+ const r = n.probeWireframe()
401
+ const raw = (r ?? {}) as Record<string, unknown>
402
+ return {
403
+ available: true,
404
+ lastDepthMax: typeof r?.lastDepthMax === 'number' ? r.lastDepthMax : 0,
405
+ lastNodes: typeof r?.lastNodes === 'number' ? r.lastNodes : 0,
406
+ lastPath: typeof r?.lastPath === 'string' ? r.lastPath : 'unknown',
407
+ lastSizeBytes: typeof r?.lastSizeBytes === 'number' ? r.lastSizeBytes : 0,
408
+ raw,
409
+ sceneCount: typeof r?.sceneCount === 'number' ? r.sceneCount : 0,
410
+ totalEmptyResultTicks:
411
+ typeof r?.totalEmptyResultTicks === 'number' ? r.totalEmptyResultTicks : 0,
412
+ totalTicks: typeof r?.totalTicks === 'number' ? r.totalTicks : 0,
413
+ windowCount: typeof r?.windowCount === 'number' ? r.windowCount : 0,
414
+ }
415
+ } catch (e) {
416
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
417
+ // eslint-disable-next-line no-console
418
+ console.warn('[sentori] probeWireframe threw', e)
419
+ }
420
+ return {
421
+ available: false,
422
+ lastDepthMax: 0,
423
+ lastNodes: 0,
424
+ lastPath: 'native.threw',
425
+ lastSizeBytes: 0,
426
+ raw: {},
427
+ sceneCount: 0,
428
+ totalEmptyResultTicks: 0,
429
+ totalTicks: 0,
430
+ windowCount: 0,
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * v1.0.0-rc.2 — JS entry to the `probeScreenshot` native diagnostic.
437
+ * Same shape contract as [probeNativeWireframe]: returns a flat
438
+ * key/value bag the consumer can ship back as-is when screenshot
439
+ * capture returns null.
440
+ *
441
+ * path meaning
442
+ * ───────────────────────── ───────────────────────────────────────
443
+ * none(not-yet-called) captureScreenshot has never run
444
+ * ok capture succeeded
445
+ * activity.null Android: foreground tracker had no Activity
446
+ * window.null Android/iOS: Activity/scene has no window
447
+ * decorView.null Android: window had no decor view
448
+ * decorView.zero-size Android: decorView size <= 0 (mid-layout)
449
+ * api.unsupported Android: API < 24 (no PixelCopy)
450
+ * pixelCopy.notSuccess Android: PixelCopy completed but reported failure
451
+ * pixelCopy.threw:<class> Android: PixelCopy threw mid-request
452
+ * render.failed iOS: UIGraphicsImageRenderer returned nil
453
+ * empty iOS: walked tree but no view+screenshot output
454
+ *
455
+ * On Android the result also carries `trackedSource` (lifecycle.created /
456
+ * lifecycle.resumed / reflection.activityThread / manual.setActivity)
457
+ * so callers can tell whether the SDK back-filled via reflection.
458
+ */
459
+ export function probeNativeScreenshot(): {
460
+ available: boolean
461
+ lastPath: string
462
+ raw: Record<string, unknown>
463
+ } {
464
+ const n = native()
465
+ if (!n || typeof n.probeScreenshot !== 'function') {
466
+ return { available: false, lastPath: 'native.unavailable', raw: {} }
467
+ }
468
+ try {
469
+ const r = n.probeScreenshot()
470
+ const raw =
471
+ r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : {}
472
+ const lastPath = typeof raw.lastPath === 'string' ? raw.lastPath : 'unknown'
473
+ return { available: true, lastPath, raw }
474
+ } catch (e) {
475
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
476
+ // eslint-disable-next-line no-console
477
+ console.warn('[sentori] probeScreenshot threw', e)
478
+ }
479
+ return { available: false, lastPath: 'native.threw', raw: {} }
303
480
  }
304
481
  }
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,41 +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
- /** Native module ref, resolved once on first start. Caching here
41
- * avoids the cost of `requireNativeModule('Sentori')` on every
42
- * capture tick (Metro's require cache makes this cheap, but the
43
- * per-tick string lookup and possible throw still cost more than
44
- * 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
+
45
83
  let _nativeMod: ReplayNativeModule | null = null;
46
84
 
47
85
  export type ReplayOptions = {
48
86
  mode?: 'off' | 'wireframe';
49
- /** 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. */
50
90
  hz?: number;
91
+ /** Keyframe cadence in ms. Default 4000. */
92
+ keyframeMs?: number;
51
93
  };
52
94
 
95
+ let _keyframeIntervalMs = KEYFRAME_INTERVAL_MS;
96
+
53
97
  export function startReplay(opts: ReplayOptions): void {
54
98
  if (_running) return;
55
99
  if (opts.mode !== 'wireframe') return;
56
- // v0.9.10 — gate via expo-modules-core's registry (same path the
57
- // screenshot capture uses). The previous `isNativeModuleLinked`
58
- // check looked at the legacy `RN.NativeModules` map, but the
59
- // Sentori module is registered through expo-modules-core; the
60
- // legacy map never sees it, so this branch returned "not linked"
61
- // forever even with the pod correctly attached (Insight 2026-05-17).
62
100
  const info = describeWireframeNative();
63
101
  if (!info.bound) {
64
102
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
@@ -79,11 +117,18 @@ export function startReplay(opts: ReplayOptions): void {
79
117
  }
80
118
  _running = true;
81
119
  _nativeMod = loadNativeReplay();
82
- 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));
83
123
  _timer = setInterval(() => {
84
124
  captureTick();
85
125
  }, period);
86
- (_timer as unknown as { unref?: () => void }).unref?.();
126
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
127
+ // eslint-disable-next-line no-console
128
+ console.warn(
129
+ '[sentori] replay: scheduled tick period=', period, 'ms keyframe=', _keyframeIntervalMs, 'ms',
130
+ );
131
+ }
87
132
  }
88
133
 
89
134
  export function stopReplay(): void {
@@ -95,47 +140,207 @@ export function stopReplay(): void {
95
140
  _nativeMod = null;
96
141
  _emptyTickCount = 0;
97
142
  _emptyTickLogStride = 1;
143
+ _firstTickLogged = false;
144
+ _okTickCount = 0;
145
+ _thinTickCount = 0;
146
+ _thinTickLogStride = 1;
98
147
  }
99
148
 
100
149
  let _emptyTickCount = 0;
101
150
  let _emptyTickLogStride = 1;
151
+ let _thinTickCount = 0;
152
+ let _thinTickLogStride = 1;
153
+ let _okTickCount = 0;
154
+ let _firstTickLogged = false;
155
+
156
+ const THIN_RESULT_NODES = 6;
102
157
 
103
158
  function captureTick(): void {
104
159
  if (!_running) return;
105
- const tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
160
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && !_firstTickLogged) {
161
+ // eslint-disable-next-line no-console
162
+ console.warn('[sentori] replay tick: FIRST INVOCATION');
163
+ _firstTickLogged = true;
164
+ }
165
+ let tickSpan: ReturnType<typeof startSpan> | null = null;
166
+ try {
167
+ tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
168
+ } catch {
169
+ // never fatal
170
+ }
106
171
  try {
107
172
  const maskIds = readMaskIds();
108
- const snapshot = _nativeMod?.captureWireframe?.(maskIds);
109
- if (typeof snapshot === 'string' && snapshot.length > 0) {
110
- _ring.push(snapshot);
111
- while (_ring.length > RING_SIZE) _ring.shift();
112
- _emptyTickCount = 0;
113
- _emptyTickLogStride = 1;
114
- } else if (typeof __DEV__ !== 'undefined' && __DEV__) {
115
- // v0.9.11 — Insight 2026-05-17 Finding 6: tick fires hundreds
116
- // of times but ring stays empty → native returned null/empty.
117
- // Log on a back-off schedule (1st, 10th, 100th, …) so the
118
- // diagnostic is visible without spamming Metro at 1 Hz for a
119
- // 15-minute session.
120
- _emptyTickCount += 1;
121
- if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
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) {
184
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
122
185
  // eslint-disable-next-line no-console
123
- console.warn(
124
- '[sentori] replay tick: native returned',
125
- snapshot === null
126
- ? 'null'
127
- : typeof snapshot === 'string'
128
- ? `empty (length=${snapshot.length})`
129
- : typeof snapshot,
130
- `(empty ticks so far: ${_emptyTickCount})`,
131
- );
132
- _emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
186
+ console.warn('[sentori] replay tick: native JSON parse failed', e);
133
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);
134
199
  }
135
- tickSpan.finish({ status: 'ok' });
200
+ tickSpan?.finish({ status: 'ok' });
136
201
  } catch (e) {
137
- if (e instanceof Error) tickSpan.setTag('error.message', e.message);
138
- tickSpan.finish({ status: 'error' });
202
+ if (e instanceof Error) tickSpan?.setTag('error.message', e.message);
203
+ tickSpan?.finish({ status: 'error' });
204
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
205
+ // eslint-disable-next-line no-console
206
+ console.warn('[sentori] replay tick: threw', e);
207
+ }
208
+ }
209
+ }
210
+
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
+ );
139
344
  }
140
345
  }
141
346
 
@@ -165,17 +370,36 @@ function loadNativeReplay(): ReplayNativeModule | null {
165
370
  }
166
371
  }
167
372
 
168
- /** Drain the ring as NDJSON (one snapshot per line). Empty string
169
- * when the ring is empty. Also clears the ring so the next session's
170
- * replay starts fresh. */
373
+ export function isReplayRunning(): boolean {
374
+ return _running;
375
+ }
376
+
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. */
171
380
  export function drainReplay(): string {
172
381
  if (_ring.length === 0) return '';
173
- const out = _ring.join('\n');
382
+ const out = _ring.map((r) => r.line).join('\n');
174
383
  _ring = [];
384
+ _lastFrameState = null;
385
+ _lastKeyframeTs = 0;
175
386
  return out;
176
387
  }
177
388
 
178
389
  export function __resetReplayForTests(): void {
179
390
  stopReplay();
180
391
  _ring = [];
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);
181
405
  }
package/src/transport.ts CHANGED
@@ -391,6 +391,19 @@ export const uploadAttachment = async (
391
391
  // UploadResponse; non-JSON bodies fall through to null.
392
392
  if (resp.status < 200 || resp.status >= 300) {
393
393
  noteAttachmentFailure(eventId, kind, `http_${resp.status}`);
394
+ // rc.6 — surface the status in dev so Insight-style triage
395
+ // doesn't have to guess between 413/422/500. Pre-rc.6 only
396
+ // the breadcrumb carried the reason; logcat only saw the
397
+ // generic `upload returned null` line.
398
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
399
+ // eslint-disable-next-line no-console
400
+ console.warn(
401
+ '[sentori] attachment upload non-2xx',
402
+ 'eventId=', eventId,
403
+ 'kind=', kind,
404
+ 'status=', resp.status,
405
+ );
406
+ }
394
407
  return null;
395
408
  }
396
409
  const j = (await resp.json().catch(() => null)) as null | {
@@ -401,6 +414,15 @@ export const uploadAttachment = async (
401
414
  };
402
415
  if (!j || !j.refId) {
403
416
  noteAttachmentFailure(eventId, kind, 'bad_response_body');
417
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
418
+ // eslint-disable-next-line no-console
419
+ console.warn(
420
+ '[sentori] attachment upload bad-response-body',
421
+ 'eventId=', eventId,
422
+ 'kind=', kind,
423
+ 'status=', resp.status,
424
+ );
425
+ }
404
426
  return null;
405
427
  }
406
428
  return {
@@ -413,6 +435,15 @@ export const uploadAttachment = async (
413
435
  } catch (e) {
414
436
  const reason = e instanceof Error ? `fetch_${e.name}` : 'fetch_unknown';
415
437
  noteAttachmentFailure(eventId, kind, reason);
438
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
439
+ // eslint-disable-next-line no-console
440
+ console.warn(
441
+ '[sentori] attachment upload fetch threw',
442
+ 'eventId=', eventId,
443
+ 'kind=', kind,
444
+ 'reason=', reason,
445
+ );
446
+ }
416
447
  return null;
417
448
  }
418
449
  };