@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.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.
package/src/native.ts CHANGED
@@ -67,6 +67,31 @@ 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
+ }
87
+ /**
88
+ * v1.0.0-rc.2 — diagnostic mirror of probeWireframe for the
89
+ * screenshot path. Returned shape is best-effort cross-platform —
90
+ * Android carries `trackedActivity` / `decorViewFound` / dims,
91
+ * iOS carries `windowFound` / `rootViewControllerFound` / `bounds*`.
92
+ * Callers should treat unknown keys as missing.
93
+ */
94
+ probeScreenshot?: () => Record<string, unknown>
70
95
  /**
71
96
  * Phase 22 sub-D / sub-E: cross-platform main-thread watchdog.
72
97
  * Android: 5 s / 1 s defaults (matches the OS ANR threshold).
@@ -295,10 +320,121 @@ export async function captureNativeScreenshotWithMask(
295
320
  export function describeWireframeNative(): {
296
321
  bound: boolean
297
322
  hasCaptureWireframe: boolean
323
+ hasProbeWireframe: boolean
298
324
  } {
299
325
  const n = native()
300
326
  return {
301
327
  bound: n !== null,
302
328
  hasCaptureWireframe: Boolean(n?.captureWireframe),
329
+ hasProbeWireframe: Boolean(n?.probeWireframe),
330
+ }
331
+ }
332
+
333
+ /**
334
+ * v0.9.12 — JS entry to the native `probeWireframe` diagnostic. Safe
335
+ * to call before the first replay tick — returns the not-yet-called
336
+ * sentinel. When the ring stays empty, this is the single call that
337
+ * answers "why" without redeploying the pod.
338
+ *
339
+ * path meaning
340
+ * ─────────────── ───────────────────────────────────────────────
341
+ * none(not-yet…) captureWireframe has never run yet
342
+ * scene.fg.key iOS: resolved via foregroundActive scene's key window
343
+ * scene.fg.first iOS: foregroundActive scene's first window (no key)
344
+ * scene.fgi.first iOS: foregroundInactive scene mid-transition
345
+ * scene.any.first iOS: had to fall back to any window
346
+ * legacy.first iOS: legacy UIApplication.windows path
347
+ * none iOS: no UIWindow reachable at the tick instant
348
+ * activity.null Android: no resumed Activity registered
349
+ * decorView.null Android: activity has no decor view yet
350
+ * root.zero-size Android: decorView size <= 0 (mid-layout)
351
+ * activity.resumed Android: ok
352
+ */
353
+ export function probeNativeWireframe(): {
354
+ available: boolean
355
+ lastNodes: number
356
+ lastPath: string
357
+ sceneCount: number
358
+ windowCount: number
359
+ } {
360
+ const n = native()
361
+ if (!n || typeof n.probeWireframe !== 'function') {
362
+ return {
363
+ available: false,
364
+ lastNodes: 0,
365
+ lastPath: 'native.unavailable',
366
+ sceneCount: 0,
367
+ windowCount: 0,
368
+ }
369
+ }
370
+ try {
371
+ const r = n.probeWireframe()
372
+ return {
373
+ available: true,
374
+ lastNodes: typeof r?.lastNodes === 'number' ? r.lastNodes : 0,
375
+ lastPath: typeof r?.lastPath === 'string' ? r.lastPath : 'unknown',
376
+ sceneCount: typeof r?.sceneCount === 'number' ? r.sceneCount : 0,
377
+ windowCount: typeof r?.windowCount === 'number' ? r.windowCount : 0,
378
+ }
379
+ } catch (e) {
380
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
381
+ // eslint-disable-next-line no-console
382
+ console.warn('[sentori] probeWireframe threw', e)
383
+ }
384
+ return {
385
+ available: false,
386
+ lastNodes: 0,
387
+ lastPath: 'native.threw',
388
+ sceneCount: 0,
389
+ windowCount: 0,
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * v1.0.0-rc.2 — JS entry to the `probeScreenshot` native diagnostic.
396
+ * Same shape contract as [probeNativeWireframe]: returns a flat
397
+ * key/value bag the consumer can ship back as-is when screenshot
398
+ * capture returns null.
399
+ *
400
+ * path meaning
401
+ * ───────────────────────── ───────────────────────────────────────
402
+ * none(not-yet-called) captureScreenshot has never run
403
+ * ok capture succeeded
404
+ * activity.null Android: foreground tracker had no Activity
405
+ * window.null Android/iOS: Activity/scene has no window
406
+ * decorView.null Android: window had no decor view
407
+ * decorView.zero-size Android: decorView size <= 0 (mid-layout)
408
+ * api.unsupported Android: API < 24 (no PixelCopy)
409
+ * pixelCopy.notSuccess Android: PixelCopy completed but reported failure
410
+ * pixelCopy.threw:<class> Android: PixelCopy threw mid-request
411
+ * render.failed iOS: UIGraphicsImageRenderer returned nil
412
+ * empty iOS: walked tree but no view+screenshot output
413
+ *
414
+ * On Android the result also carries `trackedSource` (lifecycle.created /
415
+ * lifecycle.resumed / reflection.activityThread / manual.setActivity)
416
+ * so callers can tell whether the SDK back-filled via reflection.
417
+ */
418
+ export function probeNativeScreenshot(): {
419
+ available: boolean
420
+ lastPath: string
421
+ raw: Record<string, unknown>
422
+ } {
423
+ const n = native()
424
+ if (!n || typeof n.probeScreenshot !== 'function') {
425
+ return { available: false, lastPath: 'native.unavailable', raw: {} }
426
+ }
427
+ try {
428
+ const r = n.probeScreenshot()
429
+ const raw =
430
+ r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : {}
431
+ const lastPath = typeof raw.lastPath === 'string' ? raw.lastPath : 'unknown'
432
+ return { available: true, lastPath, raw }
433
+ } catch (e) {
434
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
435
+ // eslint-disable-next-line no-console
436
+ console.warn('[sentori] probeScreenshot threw', e)
437
+ }
438
+ return { available: false, lastPath: 'native.threw', raw: {} }
303
439
  }
304
440
  }
package/src/replay.ts CHANGED
@@ -37,6 +37,26 @@ let _ring: string[] = [];
37
37
  let _timer: ReturnType<typeof setInterval> | null = null;
38
38
  let _running = false;
39
39
 
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
+
40
60
  /** Native module ref, resolved once on first start. Caching here
41
61
  * avoids the cost of `requireNativeModule('Sentori')` on every
42
62
  * capture tick (Metro's require cache makes this cheap, but the
@@ -83,7 +103,20 @@ export function startReplay(opts: ReplayOptions): void {
83
103
  _timer = setInterval(() => {
84
104
  captureTick();
85
105
  }, period);
86
- (_timer as unknown as { unref?: () => void }).unref?.();
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
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
117
+ // eslint-disable-next-line no-console
118
+ console.warn('[sentori] replay: scheduled tick period=', period, 'ms');
119
+ }
87
120
  }
88
121
 
89
122
  export function stopReplay(): void {
@@ -95,20 +128,49 @@ export function stopReplay(): void {
95
128
  _nativeMod = null;
96
129
  _emptyTickCount = 0;
97
130
  _emptyTickLogStride = 1;
131
+ _firstTickLogged = false;
98
132
  }
99
133
 
100
134
  let _emptyTickCount = 0;
101
135
  let _emptyTickLogStride = 1;
136
+ let _firstTickLogged = false;
102
137
 
103
138
  function captureTick(): void {
104
139
  if (!_running) return;
105
- const tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
140
+ // v0.9.12 UNCONDITIONAL first-tick log. Proves the setInterval
141
+ // callback is firing at all, before any other code that could
142
+ // throw. 0.9.11's diagnostic was inside a `else if (snapshot==null)`
143
+ // branch that could only surface AFTER the native call returned;
144
+ // useless when the bug is that the tick body never enters.
145
+ if (typeof __DEV__ !== 'undefined' && __DEV__ && !_firstTickLogged) {
146
+ // eslint-disable-next-line no-console
147
+ console.warn('[sentori] replay tick: FIRST INVOCATION');
148
+ _firstTickLogged = true;
149
+ }
150
+ // 0.9.11 called startSpan OUTSIDE the catch block. If
151
+ // `@goliapkg/sentori-core` failed to initialise (or startSpan
152
+ // threw for any other reason on the first tick) the whole tick
153
+ // callback died silently. Wrap so worst case is "no span for this
154
+ // tick" not "no ticks for the session".
155
+ let tickSpan: ReturnType<typeof startSpan> | null = null;
156
+ try {
157
+ tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
158
+ } catch {
159
+ // never fatal
160
+ }
106
161
  try {
107
162
  const maskIds = readMaskIds();
108
163
  const snapshot = _nativeMod?.captureWireframe?.(maskIds);
109
164
  if (typeof snapshot === 'string' && snapshot.length > 0) {
110
- _ring.push(snapshot);
111
- while (_ring.length > RING_SIZE) _ring.shift();
165
+ // v0.9.13 — skip pushing if the frame is identical to the last
166
+ // pushed one. See _lastPushed comment for the rationale.
167
+ if (snapshot !== _lastPushed) {
168
+ _ring.push(snapshot);
169
+ _lastPushed = snapshot;
170
+ while (_ring.length > RING_SIZE) {
171
+ _ring.shift();
172
+ }
173
+ }
112
174
  _emptyTickCount = 0;
113
175
  _emptyTickLogStride = 1;
114
176
  } else if (typeof __DEV__ !== 'undefined' && __DEV__) {
@@ -132,10 +194,14 @@ function captureTick(): void {
132
194
  _emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
133
195
  }
134
196
  }
135
- tickSpan.finish({ status: 'ok' });
197
+ tickSpan?.finish({ status: 'ok' });
136
198
  } catch (e) {
137
- if (e instanceof Error) tickSpan.setTag('error.message', e.message);
138
- tickSpan.finish({ status: 'error' });
199
+ if (e instanceof Error) tickSpan?.setTag('error.message', e.message);
200
+ tickSpan?.finish({ status: 'error' });
201
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
202
+ // eslint-disable-next-line no-console
203
+ console.warn('[sentori] replay tick: threw', e);
204
+ }
139
205
  }
140
206
  }
141
207
 
@@ -172,10 +238,12 @@ export function drainReplay(): string {
172
238
  if (_ring.length === 0) return '';
173
239
  const out = _ring.join('\n');
174
240
  _ring = [];
241
+ _lastPushed = null;
175
242
  return out;
176
243
  }
177
244
 
178
245
  export function __resetReplayForTests(): void {
179
246
  stopReplay();
180
247
  _ring = [];
248
+ _lastPushed = null;
181
249
  }