@goliapkg/sentori-react-native 1.0.0-rc.7 → 1.0.0-rc.9
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/SentoriReplayCapture.kt +39 -14
- package/lib/replay.d.ts +25 -9
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +204 -167
- package/lib/replay.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/replay.ts +251 -176
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
package com.sentori
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.graphics.drawable.BitmapDrawable
|
|
4
5
|
import android.graphics.drawable.ColorDrawable
|
|
5
6
|
import android.graphics.drawable.Drawable
|
|
6
7
|
import android.graphics.drawable.GradientDrawable
|
|
7
8
|
import android.graphics.drawable.LayerDrawable
|
|
9
|
+
import android.graphics.drawable.StateListDrawable
|
|
8
10
|
import android.view.View
|
|
9
11
|
import android.view.ViewGroup
|
|
10
12
|
import android.widget.EditText
|
|
@@ -93,9 +95,18 @@ object SentoriReplayCapture {
|
|
|
93
95
|
totalEmptyResultTicks++
|
|
94
96
|
return null
|
|
95
97
|
}
|
|
96
|
-
|
|
98
|
+
// rc.8 — anchor the walk at android.R.id.content, NOT
|
|
99
|
+
// window.decorView. decorView includes the StatusBarBackground
|
|
100
|
+
// and NavigationBarBackground sibling views the PhoneWindow
|
|
101
|
+
// injects (full display width, positioned in absolute window
|
|
102
|
+
// coords). Insight 2026-05-18 saw those bleed into the
|
|
103
|
+
// wireframe as horizontal grey bars stretching beyond the
|
|
104
|
+
// viewport width. Anchoring at the content FrameLayout drops
|
|
105
|
+
// them while keeping the app's React tree intact.
|
|
106
|
+
val decor = activity.window?.decorView
|
|
107
|
+
val root = decor?.findViewById<View>(android.R.id.content)
|
|
97
108
|
if (root == null) {
|
|
98
|
-
lastDiagPath = "decorView.null"
|
|
109
|
+
lastDiagPath = if (decor == null) "decorView.null" else "contentView.null"
|
|
99
110
|
totalEmptyResultTicks++
|
|
100
111
|
return null
|
|
101
112
|
}
|
|
@@ -217,18 +228,21 @@ object SentoriReplayCapture {
|
|
|
217
228
|
kindEmitted = true
|
|
218
229
|
}
|
|
219
230
|
view.background != null -> {
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
// rc.8 — backgrounds backed by a BitmapDrawable
|
|
232
|
+
// (a View whose backgroundImage / drawable
|
|
233
|
+
// resource is a raster) emit as `image` kind so
|
|
234
|
+
// the dashboard renders them as a media region,
|
|
235
|
+
// not as a grey rect. Everything else stays
|
|
236
|
+
// `rect` and tries to extract a fill colour.
|
|
237
|
+
val bg = view.background
|
|
238
|
+
if (bg is BitmapDrawable) {
|
|
239
|
+
node.put("kind", "image")
|
|
240
|
+
} else {
|
|
241
|
+
node.put("kind", "rect")
|
|
242
|
+
val color = extractDrawableColor(bg)
|
|
243
|
+
if (color != null && (color shr 24 and 0xff) != 0) {
|
|
244
|
+
node.put("color", colorToHex(color))
|
|
245
|
+
}
|
|
232
246
|
}
|
|
233
247
|
kindEmitted = true
|
|
234
248
|
}
|
|
@@ -283,6 +297,17 @@ object SentoriReplayCapture {
|
|
|
283
297
|
val csl = drawable.color
|
|
284
298
|
csl?.defaultColor
|
|
285
299
|
}
|
|
300
|
+
is StateListDrawable -> {
|
|
301
|
+
// rc.8 — Pressable / TouchableOpacity wrap their child
|
|
302
|
+
// in a StateListDrawable (default state + pressed
|
|
303
|
+
// state). `.current` returns the currently-applied
|
|
304
|
+
// state's drawable, which during a normal capture
|
|
305
|
+
// is the unpressed visual — exactly what we want.
|
|
306
|
+
// AnimatedStateListDrawable extends StateListDrawable
|
|
307
|
+
// so it inherits this branch.
|
|
308
|
+
extractDrawableColor(drawable.current)
|
|
309
|
+
}
|
|
310
|
+
is BitmapDrawable -> null
|
|
286
311
|
is LayerDrawable -> {
|
|
287
312
|
for (i in 0 until drawable.numberOfLayers) {
|
|
288
313
|
val inner = drawable.getDrawable(i)
|
package/lib/replay.d.ts
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
|
+
type Node = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
w: number;
|
|
5
|
+
h: number;
|
|
6
|
+
kind?: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
};
|
|
1
10
|
export type ReplayOptions = {
|
|
2
11
|
mode?: 'off' | 'wireframe';
|
|
3
|
-
/** Ticks per second. Default
|
|
12
|
+
/** Ticks per second. Default 4. */
|
|
4
13
|
hz?: number;
|
|
14
|
+
/** Keyframe cadence in ms. Default 4000. */
|
|
15
|
+
keyframeMs?: number;
|
|
5
16
|
};
|
|
6
17
|
export declare function startReplay(opts: ReplayOptions): void;
|
|
7
18
|
export declare function stopReplay(): void;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
type Delta = {
|
|
20
|
+
added: Node[];
|
|
21
|
+
changed: Node[];
|
|
22
|
+
removed: Pick<Node, 'x' | 'y' | 'w' | 'h'>[];
|
|
23
|
+
};
|
|
24
|
+
export declare function computeDelta(prev: Map<string, Node>, curr: Map<string, Node>): Delta;
|
|
13
25
|
export declare function isReplayRunning(): boolean;
|
|
14
|
-
/** Drain the ring as NDJSON (
|
|
15
|
-
* when the ring is empty.
|
|
16
|
-
* replay starts fresh. */
|
|
26
|
+
/** Drain the ring as NDJSON (keyframe or delta per line). Empty
|
|
27
|
+
* string when the ring is empty. Resets state so the next session's
|
|
28
|
+
* replay starts with a fresh keyframe. */
|
|
17
29
|
export declare function drainReplay(): string;
|
|
18
30
|
export declare function __resetReplayForTests(): void;
|
|
31
|
+
/** rc.9 — test seam. Lets unit tests drive the encoder without a
|
|
32
|
+
* native module; pretends we received `frameJson` on the tick. */
|
|
33
|
+
export declare function __feedTickForTests(frameJson: string): void;
|
|
34
|
+
export {};
|
|
19
35
|
//# sourceMappingURL=replay.d.ts.map
|
package/lib/replay.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAqDA,KAAK,IAAI,GAAG;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAkBF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,KAAK,GAAG,WAAW,CAAC;IAC3B,mCAAmC;IACnC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAIF,wBAAgB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAmCrD;AAED,wBAAgB,UAAU,IAAI,IAAI,CAajC;AAmID,KAAK,KAAK,GAAG;IAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAAC,OAAO,EAAE,IAAI,EAAE,CAAC;IAAC,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,EAAE,CAAA;CAAE,CAAC;AAE9F,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,KAAK,CAsBpF;AAuED,wBAAgB,eAAe,IAAI,OAAO,CAEzC;AAED;;2CAE2C;AAC3C,wBAAgB,WAAW,IAAI,MAAM,CAOpC;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C;AAED;mEACmE;AACnE,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAO1D"}
|
package/lib/replay.js
CHANGED
|
@@ -1,70 +1,59 @@
|
|
|
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
|
import { startSpan } from '@goliapkg/sentori-core';
|
|
20
18
|
import { getRegisteredMaskQuery } from './mask';
|
|
21
19
|
import { describeWireframeNative } from './native';
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
/** Default capture interval (4 Hz). Override via `replay.hz`. */
|
|
21
|
+
const TICK_INTERVAL_MS = 250;
|
|
22
|
+
/** How often to emit a fresh keyframe — caps reconstruction chain
|
|
23
|
+
* length and lets the player re-sync after a dropped line. */
|
|
24
|
+
const KEYFRAME_INTERVAL_MS = 4_000;
|
|
25
|
+
/** When the delta against the previous frame would carry ≥ this
|
|
26
|
+
* fraction of the current node count, prefer a fresh keyframe —
|
|
27
|
+
* emits roughly the same bytes but doesn't grow the chain. */
|
|
28
|
+
const DELTA_TO_KEYFRAME_RATIO = 0.4;
|
|
29
|
+
/** Floor under which the keyframe-vs-delta ratio heuristic does
|
|
30
|
+
* not apply. Trivial UIs (≤ 10 nodes — boot splash, dev panel,
|
|
31
|
+
* tests) shouldn't drop to keyframes on every change. */
|
|
32
|
+
const KEYFRAME_RATIO_MIN_NODES = 10;
|
|
33
|
+
/** Replay window kept in the ring buffer. captureException drains. */
|
|
34
|
+
const REPLAY_WINDOW_MS = 60_000;
|
|
35
|
+
/** Hard ceiling on ring item count — defence against a wedged tick
|
|
36
|
+
* clock filling memory; under normal capture rates we evict by time
|
|
37
|
+
* long before this fires. */
|
|
38
|
+
const MAX_RING_ITEMS = 1000;
|
|
39
|
+
/** Floor on tick period. < 100 ms the native view-tree walk dominates
|
|
40
|
+
* the JS thread on mid-tier Android. */
|
|
41
|
+
const MIN_TICK_PERIOD_MS = 100;
|
|
29
42
|
let _ring = [];
|
|
30
43
|
let _timer = null;
|
|
31
44
|
let _running = false;
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* idle phase no longer evicts a useful pre-error frame.
|
|
38
|
-
*
|
|
39
|
-
* We only check against the most-recently-pushed snapshot, not the
|
|
40
|
-
* whole ring — that's cheap (one string comparison per tick) and
|
|
41
|
-
* catches the dominant case (idle screens). True content changes
|
|
42
|
-
* fall through and push as before.
|
|
43
|
-
*
|
|
44
|
-
* Budget verification on the iOS showcase (apps/ios-showcase): 60
|
|
45
|
-
* frames at ~120 bytes each → ≈ 7 KB raw NDJSON, well under the
|
|
46
|
-
* 500 KB attachment cap. Heavier RN apps with 200+ visible nodes
|
|
47
|
-
* per frame can land in the 400 KB band; future work in v1.x adds
|
|
48
|
-
* native gzip on upload if real-world traffic ever pushes the cap.
|
|
49
|
-
*/
|
|
50
|
-
let _lastPushed = null;
|
|
51
|
-
/** Native module ref, resolved once on first start. Caching here
|
|
52
|
-
* avoids the cost of `requireNativeModule('Sentori')` on every
|
|
53
|
-
* capture tick (Metro's require cache makes this cheap, but the
|
|
54
|
-
* per-tick string lookup and possible throw still cost more than
|
|
55
|
-
* reading a closed-over variable). */
|
|
45
|
+
/** Last emit's reconstructed state — fingerprint → node. Null until
|
|
46
|
+
* the first keyframe lands; reset on drain so the next session
|
|
47
|
+
* starts with a fresh keyframe. */
|
|
48
|
+
let _lastFrameState = null;
|
|
49
|
+
let _lastKeyframeTs = 0;
|
|
56
50
|
let _nativeMod = null;
|
|
51
|
+
let _keyframeIntervalMs = KEYFRAME_INTERVAL_MS;
|
|
57
52
|
export function startReplay(opts) {
|
|
58
53
|
if (_running)
|
|
59
54
|
return;
|
|
60
55
|
if (opts.mode !== 'wireframe')
|
|
61
56
|
return;
|
|
62
|
-
// v0.9.10 — gate via expo-modules-core's registry (same path the
|
|
63
|
-
// screenshot capture uses). The previous `isNativeModuleLinked`
|
|
64
|
-
// check looked at the legacy `RN.NativeModules` map, but the
|
|
65
|
-
// Sentori module is registered through expo-modules-core; the
|
|
66
|
-
// legacy map never sees it, so this branch returned "not linked"
|
|
67
|
-
// forever even with the pod correctly attached (Insight 2026-05-17).
|
|
68
57
|
const info = describeWireframeNative();
|
|
69
58
|
if (!info.bound) {
|
|
70
59
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
@@ -79,23 +68,15 @@ export function startReplay(opts) {
|
|
|
79
68
|
}
|
|
80
69
|
_running = true;
|
|
81
70
|
_nativeMod = loadNativeReplay();
|
|
82
|
-
|
|
71
|
+
_keyframeIntervalMs = opts.keyframeMs ?? KEYFRAME_INTERVAL_MS;
|
|
72
|
+
const hz = opts.hz ?? 4;
|
|
73
|
+
const period = Math.max(MIN_TICK_PERIOD_MS, Math.round(1000 / hz));
|
|
83
74
|
_timer = setInterval(() => {
|
|
84
75
|
captureTick();
|
|
85
76
|
}, period);
|
|
86
|
-
// v0.9.12 — Insight 2026-05-17 report: 0.9.11 emitted the
|
|
87
|
-
// "starting bound=true" line then went silent. Root cause was the
|
|
88
|
-
// `.unref?.()` call that used to live here. Hermes 0.81 doesn't
|
|
89
|
-
// ship a Timer object with the Node-style `unref` method, and the
|
|
90
|
-
// optional-chained call ended up dereferencing a `prototype`
|
|
91
|
-
// property on `undefined` — throwing synchronously inside
|
|
92
|
-
// startReplay, which RN's bridge swallowed silently. Net effect:
|
|
93
|
-
// setInterval registered, captureTick never invoked. Drop the
|
|
94
|
-
// call; replay tick lifecycle is bound to the app process, no
|
|
95
|
-
// event-loop tweak needed.
|
|
96
77
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
97
78
|
// eslint-disable-next-line no-console
|
|
98
|
-
console.warn('[sentori] replay: scheduled tick period=', period, 'ms');
|
|
79
|
+
console.warn('[sentori] replay: scheduled tick period=', period, 'ms keyframe=', _keyframeIntervalMs, 'ms');
|
|
99
80
|
}
|
|
100
81
|
}
|
|
101
82
|
export function stopReplay() {
|
|
@@ -108,6 +89,9 @@ export function stopReplay() {
|
|
|
108
89
|
_emptyTickCount = 0;
|
|
109
90
|
_emptyTickLogStride = 1;
|
|
110
91
|
_firstTickLogged = false;
|
|
92
|
+
_okTickCount = 0;
|
|
93
|
+
_thinTickCount = 0;
|
|
94
|
+
_thinTickLogStride = 1;
|
|
111
95
|
}
|
|
112
96
|
let _emptyTickCount = 0;
|
|
113
97
|
let _emptyTickLogStride = 1;
|
|
@@ -115,30 +99,15 @@ let _thinTickCount = 0;
|
|
|
115
99
|
let _thinTickLogStride = 1;
|
|
116
100
|
let _okTickCount = 0;
|
|
117
101
|
let _firstTickLogged = false;
|
|
118
|
-
/** Anything below this many nodes is suspicious — likely the
|
|
119
|
-
* walker bailed early (zero-size parent, masked root, etc.).
|
|
120
|
-
* Insight 2026-05-18 verify event saw 800-node payloads on some
|
|
121
|
-
* ticks and 1-3-node payloads on others; this threshold flags
|
|
122
|
-
* the latter without spamming on small-but-valid screens. */
|
|
123
102
|
const THIN_RESULT_NODES = 6;
|
|
124
103
|
function captureTick() {
|
|
125
104
|
if (!_running)
|
|
126
105
|
return;
|
|
127
|
-
// v0.9.12 — UNCONDITIONAL first-tick log. Proves the setInterval
|
|
128
|
-
// callback is firing at all, before any other code that could
|
|
129
|
-
// throw. 0.9.11's diagnostic was inside a `else if (snapshot==null)`
|
|
130
|
-
// branch that could only surface AFTER the native call returned;
|
|
131
|
-
// useless when the bug is that the tick body never enters.
|
|
132
106
|
if (typeof __DEV__ !== 'undefined' && __DEV__ && !_firstTickLogged) {
|
|
133
107
|
// eslint-disable-next-line no-console
|
|
134
108
|
console.warn('[sentori] replay tick: FIRST INVOCATION');
|
|
135
109
|
_firstTickLogged = true;
|
|
136
110
|
}
|
|
137
|
-
// 0.9.11 called startSpan OUTSIDE the catch block. If
|
|
138
|
-
// `@goliapkg/sentori-core` failed to initialise (or startSpan
|
|
139
|
-
// threw for any other reason on the first tick) the whole tick
|
|
140
|
-
// callback died silently. Wrap so worst case is "no span for this
|
|
141
|
-
// tick" not "no ticks for the session".
|
|
142
111
|
let tickSpan = null;
|
|
143
112
|
try {
|
|
144
113
|
tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
|
|
@@ -148,64 +117,29 @@ function captureTick() {
|
|
|
148
117
|
}
|
|
149
118
|
try {
|
|
150
119
|
const maskIds = readMaskIds();
|
|
151
|
-
const
|
|
152
|
-
if (typeof
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
_emptyTickCount = 0;
|
|
163
|
-
_emptyTickLogStride = 1;
|
|
164
|
-
// v1.0.0-rc.3 — Insight 2026-05-18 report: some ticks land
|
|
165
|
-
// valid non-empty JSON but with only the root View + 1-2 wrappers
|
|
166
|
-
// (the Android zero-size-bails-subtree bug, now fixed natively;
|
|
167
|
-
// this log catches similar regressions). Cheap node-count parse
|
|
168
|
-
// — we only look at one digit-level character class.
|
|
169
|
-
_okTickCount += 1;
|
|
170
|
-
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
171
|
-
const nodeCount = countNodesQuick(snapshot);
|
|
172
|
-
const sizeBytes = snapshot.length;
|
|
173
|
-
const isThin = nodeCount < THIN_RESULT_NODES;
|
|
174
|
-
if (isThin) {
|
|
175
|
-
_thinTickCount += 1;
|
|
176
|
-
if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
|
|
177
|
-
// eslint-disable-next-line no-console
|
|
178
|
-
console.warn(`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${sizeBytes} (thin ticks so far: ${_thinTickCount})`);
|
|
179
|
-
_thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
_thinTickCount = 0;
|
|
184
|
-
_thinTickLogStride = 1;
|
|
185
|
-
}
|
|
186
|
-
// First good tick logs the shape so devs see it once.
|
|
187
|
-
if (_okTickCount === 1) {
|
|
188
|
-
// eslint-disable-next-line no-console
|
|
189
|
-
console.warn(`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${sizeBytes}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
120
|
+
const snapshotJson = _nativeMod?.captureWireframe?.(maskIds);
|
|
121
|
+
if (typeof snapshotJson !== 'string' || snapshotJson.length === 0) {
|
|
122
|
+
handleEmptyTick(snapshotJson);
|
|
123
|
+
tickSpan?.finish({ status: 'ok' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let snapshot;
|
|
127
|
+
try {
|
|
128
|
+
snapshot = JSON.parse(snapshotJson);
|
|
192
129
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// of times but ring stays empty → native returned null/empty.
|
|
196
|
-
// Log on a back-off schedule (1st, 10th, 100th, …) so the
|
|
197
|
-
// diagnostic is visible without spamming Metro at 1 Hz for a
|
|
198
|
-
// 15-minute session.
|
|
199
|
-
_emptyTickCount += 1;
|
|
200
|
-
if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
201
132
|
// eslint-disable-next-line no-console
|
|
202
|
-
console.warn('[sentori] replay tick: native
|
|
203
|
-
? 'null'
|
|
204
|
-
: typeof snapshot === 'string'
|
|
205
|
-
? `empty (length=${snapshot.length})`
|
|
206
|
-
: typeof snapshot, `(empty ticks so far: ${_emptyTickCount})`);
|
|
207
|
-
_emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
|
|
133
|
+
console.warn('[sentori] replay tick: native JSON parse failed', e);
|
|
208
134
|
}
|
|
135
|
+
tickSpan?.finish({ status: 'error' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
_emptyTickCount = 0;
|
|
139
|
+
_emptyTickLogStride = 1;
|
|
140
|
+
encodeAndPush(snapshot);
|
|
141
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
142
|
+
diagnosticForTick(snapshot, snapshotJson.length);
|
|
209
143
|
}
|
|
210
144
|
tickSpan?.finish({ status: 'ok' });
|
|
211
145
|
}
|
|
@@ -219,28 +153,124 @@ function captureTick() {
|
|
|
219
153
|
}
|
|
220
154
|
}
|
|
221
155
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
156
|
+
function encodeAndPush(snapshot) {
|
|
157
|
+
const currentState = new Map();
|
|
158
|
+
for (const n of snapshot.nodes)
|
|
159
|
+
currentState.set(fingerprint(n), n);
|
|
160
|
+
const ts = snapshot.ts;
|
|
161
|
+
const isCold = _lastFrameState === null;
|
|
162
|
+
const keyframeOverdue = ts - _lastKeyframeTs >= _keyframeIntervalMs;
|
|
163
|
+
let line;
|
|
164
|
+
if (isCold || keyframeOverdue) {
|
|
165
|
+
line = encodeKeyframe(snapshot);
|
|
166
|
+
_lastKeyframeTs = ts;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const delta = computeDelta(_lastFrameState, currentState);
|
|
170
|
+
const totalChanged = delta.added.length + delta.changed.length + delta.removed.length;
|
|
171
|
+
if (totalChanged === 0) {
|
|
172
|
+
// No-op heartbeat — drop. Keep _lastFrameState as-is (identical).
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (currentState.size >= KEYFRAME_RATIO_MIN_NODES &&
|
|
176
|
+
totalChanged >= currentState.size * DELTA_TO_KEYFRAME_RATIO) {
|
|
177
|
+
// Big screen transition on a substantial UI — emit a fresh
|
|
178
|
+
// keyframe so reconstruction doesn't carry a near-rewrite delta.
|
|
179
|
+
line = encodeKeyframe(snapshot);
|
|
180
|
+
_lastKeyframeTs = ts;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
line = JSON.stringify({
|
|
184
|
+
ts,
|
|
185
|
+
kind: 'delta',
|
|
186
|
+
added: delta.added,
|
|
187
|
+
changed: delta.changed,
|
|
188
|
+
removed: delta.removed,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
_ring.push({ ts, line });
|
|
193
|
+
evictRing(ts);
|
|
194
|
+
_lastFrameState = currentState;
|
|
195
|
+
}
|
|
196
|
+
function encodeKeyframe(snapshot) {
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
ts: snapshot.ts,
|
|
199
|
+
kind: 'key',
|
|
200
|
+
width: snapshot.width,
|
|
201
|
+
height: snapshot.height,
|
|
202
|
+
nodes: snapshot.nodes,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function evictRing(nowTs) {
|
|
206
|
+
const cutoff = nowTs - REPLAY_WINDOW_MS;
|
|
207
|
+
while (_ring.length > 0 && _ring[0].ts < cutoff)
|
|
208
|
+
_ring.shift();
|
|
209
|
+
while (_ring.length > MAX_RING_ITEMS)
|
|
210
|
+
_ring.shift();
|
|
211
|
+
}
|
|
212
|
+
/** Fingerprint integer-rounds before joining so sub-pixel jitter from
|
|
213
|
+
* RN's Fabric layout (occasionally floats) doesn't break stable
|
|
214
|
+
* matching across ticks. */
|
|
215
|
+
function fingerprint(n) {
|
|
216
|
+
return `${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`;
|
|
217
|
+
}
|
|
218
|
+
export function computeDelta(prev, curr) {
|
|
219
|
+
const added = [];
|
|
220
|
+
const changed = [];
|
|
221
|
+
const removed = [];
|
|
222
|
+
for (const [fp, node] of curr) {
|
|
223
|
+
const p = prev.get(fp);
|
|
224
|
+
if (!p) {
|
|
225
|
+
added.push(node);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if ((p.kind ?? '') !== (node.kind ?? '') ||
|
|
229
|
+
(p.color ?? '') !== (node.color ?? '') ||
|
|
230
|
+
(p.text ?? '') !== (node.text ?? '')) {
|
|
231
|
+
changed.push(node);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const [fp, node] of prev) {
|
|
235
|
+
if (!curr.has(fp))
|
|
236
|
+
removed.push({ x: node.x, y: node.y, w: node.w, h: node.h });
|
|
237
|
+
}
|
|
238
|
+
return { added, changed, removed };
|
|
239
|
+
}
|
|
240
|
+
function handleEmptyTick(snapshot) {
|
|
241
|
+
if (typeof __DEV__ === 'undefined' || !__DEV__)
|
|
242
|
+
return;
|
|
243
|
+
_emptyTickCount += 1;
|
|
244
|
+
if (_emptyTickCount === 1 || _emptyTickCount === _emptyTickLogStride) {
|
|
245
|
+
// eslint-disable-next-line no-console
|
|
246
|
+
console.warn('[sentori] replay tick: native returned', snapshot === null
|
|
247
|
+
? 'null'
|
|
248
|
+
: typeof snapshot === 'string'
|
|
249
|
+
? `empty (length=${snapshot.length})`
|
|
250
|
+
: typeof snapshot, `(empty ticks so far: ${_emptyTickCount})`);
|
|
251
|
+
_emptyTickLogStride = Math.max(_emptyTickLogStride * 10, 10);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function diagnosticForTick(snapshot, snapshotBytes) {
|
|
255
|
+
_okTickCount += 1;
|
|
256
|
+
const nodeCount = snapshot.nodes.length;
|
|
257
|
+
const isThin = nodeCount < THIN_RESULT_NODES;
|
|
258
|
+
if (isThin) {
|
|
259
|
+
_thinTickCount += 1;
|
|
260
|
+
if (_thinTickCount === 1 || _thinTickCount === _thinTickLogStride) {
|
|
261
|
+
// eslint-disable-next-line no-console
|
|
262
|
+
console.warn(`[sentori] replay tick: thin result nodes=${nodeCount} sizeBytes=${snapshotBytes} (thin ticks so far: ${_thinTickCount})`);
|
|
263
|
+
_thinTickLogStride = Math.max(_thinTickLogStride * 10, 10);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
_thinTickCount = 0;
|
|
268
|
+
_thinTickLogStride = 1;
|
|
269
|
+
}
|
|
270
|
+
if (_okTickCount === 1) {
|
|
271
|
+
// eslint-disable-next-line no-console
|
|
272
|
+
console.warn(`[sentori] replay tick: first ok — nodes=${nodeCount} sizeBytes=${snapshotBytes}`);
|
|
242
273
|
}
|
|
243
|
-
return count;
|
|
244
274
|
}
|
|
245
275
|
function readMaskIds() {
|
|
246
276
|
const q = getRegisteredMaskQuery();
|
|
@@ -263,28 +293,35 @@ function loadNativeReplay() {
|
|
|
263
293
|
return null;
|
|
264
294
|
}
|
|
265
295
|
}
|
|
266
|
-
/** rc.4 — surface "is replay subsystem alive" so the captureException
|
|
267
|
-
* debug log can label `wantReplay` alongside `wantScreenshot` /
|
|
268
|
-
* `wantSessionTrail`. Insight 2026-05-18 verify flagged that the
|
|
269
|
-
* pre-rc.4 log line was missing `wantReplay`, leaving the failure
|
|
270
|
-
* shape ambiguous (config-off vs. ring-empty vs. attach-failed). */
|
|
271
296
|
export function isReplayRunning() {
|
|
272
297
|
return _running;
|
|
273
298
|
}
|
|
274
|
-
/** Drain the ring as NDJSON (
|
|
275
|
-
* when the ring is empty.
|
|
276
|
-
* replay starts fresh. */
|
|
299
|
+
/** Drain the ring as NDJSON (keyframe or delta per line). Empty
|
|
300
|
+
* string when the ring is empty. Resets state so the next session's
|
|
301
|
+
* replay starts with a fresh keyframe. */
|
|
277
302
|
export function drainReplay() {
|
|
278
303
|
if (_ring.length === 0)
|
|
279
304
|
return '';
|
|
280
|
-
const out = _ring.join('\n');
|
|
305
|
+
const out = _ring.map((r) => r.line).join('\n');
|
|
281
306
|
_ring = [];
|
|
282
|
-
|
|
307
|
+
_lastFrameState = null;
|
|
308
|
+
_lastKeyframeTs = 0;
|
|
283
309
|
return out;
|
|
284
310
|
}
|
|
285
311
|
export function __resetReplayForTests() {
|
|
286
312
|
stopReplay();
|
|
287
313
|
_ring = [];
|
|
288
|
-
|
|
314
|
+
_lastFrameState = null;
|
|
315
|
+
_lastKeyframeTs = 0;
|
|
316
|
+
}
|
|
317
|
+
/** rc.9 — test seam. Lets unit tests drive the encoder without a
|
|
318
|
+
* native module; pretends we received `frameJson` on the tick. */
|
|
319
|
+
export function __feedTickForTests(frameJson) {
|
|
320
|
+
if (!_running) {
|
|
321
|
+
// Simulate "running" without an actual setInterval — caller drives.
|
|
322
|
+
_running = true;
|
|
323
|
+
}
|
|
324
|
+
const snapshot = JSON.parse(frameJson);
|
|
325
|
+
encodeAndPush(snapshot);
|
|
289
326
|
}
|
|
290
327
|
//# sourceMappingURL=replay.js.map
|