@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.
- package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
- package/ios/SentoriModule.swift +15 -0
- package/ios/SentoriReplayCapture.swift +135 -10
- package/ios/SentoriScreenshotCapture.swift +69 -3
- package/lib/base64.d.ts +25 -0
- package/lib/base64.d.ts.map +1 -0
- package/lib/base64.js +30 -0
- package/lib/base64.js.map +1 -0
- package/lib/capture.d.ts +20 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +45 -21
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.bak +64 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +68 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +115 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +28 -4
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +242 -65
- package/lib/replay.js.map +1 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +16 -0
- package/lib/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/base64.test.ts +55 -0
- package/src/__tests__/capture-replay.test.ts +150 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/base64.ts +29 -0
- package/src/capture.ts +56 -22
- package/src/index.ts +3 -0
- package/src/native.ts +177 -0
- package/src/replay.ts +294 -70
- 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
|
-
//
|
|
1
|
+
// rc.9 — wireframe Session Replay v2 encoding.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Replaces rc.8's "one full snapshot per tick" with keyframe + delta:
|
|
4
|
+
// - Native walker still emits a full snapshot string every tick.
|
|
5
|
+
// - JS parses the snapshot, builds a fingerprint→node map, and either
|
|
6
|
+
// emits a keyframe (cold start / every KEYFRAME_INTERVAL_MS / when
|
|
7
|
+
// the delta would be bigger than a fresh key) OR a delta against
|
|
8
|
+
// the previous emit's reconstructed state.
|
|
9
|
+
// - Static-UI ticks produce zero-delta heartbeats which we drop.
|
|
8
10
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// • Replay fidelity: less faithful to pixels but enough to see
|
|
16
|
-
// which screen the user was on and what was on it. Dashboard
|
|
17
|
-
// player renders SVG rects — denser-looking than a 1 Hz
|
|
18
|
-
// screenshot strip.
|
|
11
|
+
// At 4 Hz capture / 4 s keyframes the wire bytes drop ~50 % vs rc.8 at
|
|
12
|
+
// 1 Hz for the same 60 s pre-error window, and the dashboard player
|
|
13
|
+
// cross-fades between captures at 24 fps so playback reads as motion
|
|
14
|
+
// rather than a 1 Hz slideshow.
|
|
15
|
+
//
|
|
16
|
+
// Wire schema: docs/replay-encoding-v2.md.
|
|
19
17
|
|
|
20
18
|
import { startSpan } from '@goliapkg/sentori-core';
|
|
21
19
|
|
|
@@ -24,41 +22,81 @@ import { describeWireframeNative } from './native';
|
|
|
24
22
|
|
|
25
23
|
declare const __DEV__: boolean | undefined;
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/** Default capture interval (2 Hz). Override via `replay.hz`. rc.10
|
|
26
|
+
* rolled this back from rc.9's 4 Hz default: iOS sim measured 1 ms
|
|
27
|
+
* per tick on a thin dev panel but extrapolation to a 200-node
|
|
28
|
+
* Insight-class UI on Android pushes JS-thread occupancy past 1 %,
|
|
29
|
+
* which violates the "几乎不能造成性能抖动" rule. Apps that want
|
|
30
|
+
* smoother playback motion can opt into `replay.hz: 4` explicitly. */
|
|
31
|
+
const TICK_INTERVAL_MS = 500;
|
|
32
|
+
|
|
33
|
+
/** How often to emit a fresh keyframe — caps reconstruction chain
|
|
34
|
+
* length and lets the player re-sync after a dropped line. */
|
|
35
|
+
const KEYFRAME_INTERVAL_MS = 4_000;
|
|
36
|
+
|
|
37
|
+
/** When the delta against the previous frame would carry ≥ this
|
|
38
|
+
* fraction of the current node count, prefer a fresh keyframe —
|
|
39
|
+
* emits roughly the same bytes but doesn't grow the chain. */
|
|
40
|
+
const DELTA_TO_KEYFRAME_RATIO = 0.4;
|
|
41
|
+
|
|
42
|
+
/** Floor under which the keyframe-vs-delta ratio heuristic does
|
|
43
|
+
* not apply. Trivial UIs (≤ 10 nodes — boot splash, dev panel,
|
|
44
|
+
* tests) shouldn't drop to keyframes on every change. */
|
|
45
|
+
const KEYFRAME_RATIO_MIN_NODES = 10;
|
|
46
|
+
|
|
47
|
+
/** Replay window kept in the ring buffer. captureException drains. */
|
|
48
|
+
const REPLAY_WINDOW_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/** Hard ceiling on ring item count — defence against a wedged tick
|
|
51
|
+
* clock filling memory; under normal capture rates we evict by time
|
|
52
|
+
* long before this fires. */
|
|
53
|
+
const MAX_RING_ITEMS = 1000;
|
|
54
|
+
|
|
55
|
+
/** Floor on tick period. < 100 ms the native view-tree walk dominates
|
|
56
|
+
* the JS thread on mid-tier Android. */
|
|
57
|
+
const MIN_TICK_PERIOD_MS = 100;
|
|
58
|
+
|
|
59
|
+
type Node = {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
w: number;
|
|
63
|
+
h: number;
|
|
64
|
+
kind?: string;
|
|
65
|
+
text?: string;
|
|
66
|
+
color?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type NativeFrame = { ts: number; width: number; height: number; nodes: Node[] };
|
|
29
70
|
|
|
30
|
-
|
|
31
|
-
* dominates the JS thread on mid-tier Android, especially with mask
|
|
32
|
-
* consultation. The default is 1 Hz; the option exists for
|
|
33
|
-
* benchmarking, not for production. */
|
|
34
|
-
const MIN_TICK_PERIOD_MS = 250;
|
|
71
|
+
type RingItem = { ts: number; line: string };
|
|
35
72
|
|
|
36
|
-
let _ring:
|
|
73
|
+
let _ring: RingItem[] = [];
|
|
37
74
|
let _timer: ReturnType<typeof setInterval> | null = null;
|
|
38
75
|
let _running = false;
|
|
39
76
|
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
|
109
|
-
if (typeof
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
200
|
+
tickSpan?.finish({ status: 'ok' });
|
|
136
201
|
} catch (e) {
|
|
137
|
-
if (e instanceof Error) tickSpan
|
|
138
|
-
tickSpan
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
};
|