Performance trace, load test and chapter read-ahead
A switchable trace (DATA/perf/trace.on or PERF_TRACE=1) records every request's phases (stat, open, first read, disk vs network wait), browser resource timings, page-turn waits and flow stalls, a cold storage probe, event-loop lag, scans and scraper calls as JSONL. tools/perf-report.js says which phase slow loads spend their time in; tools/perf-bench.js simulates readers. READ_AHEAD (default on) reads an opened chapter's pages, then the next chapter's, into the OS cache so a NAS's cold reads stop reaching the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea8a5b40e7
commit
896726a51b
16 files changed
+893
-8
No files matched your search
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Reader-side half of the performance trace (docs/performance.md). Only started when the server says its
|
||||
* trace is on (`perf: true` in /api/chapter). It reports, in batches to POST api/perf:
|
||||
* - `res` the browser's resource timing of every library image: queueing, connection, time to first byte,
|
||||
* download, size, the server's own `stat` time and whether the connection was reused;
|
||||
* - `view` a page turn (page/book mode): how long the new page(s) took to appear, and whether they were
|
||||
* preloaded already;
|
||||
* - `stall` flow mode: a page scrolled into view before its image had loaded, and how long it stayed empty.
|
||||
*/
|
||||
window.PerfTrace = {
|
||||
start(pageName) {
|
||||
let queue = [], on = true;
|
||||
const push = e => { if (on) queue.push(e); };
|
||||
const rel = url => { try { return decodeURIComponent(new URL(url).pathname); } catch { return String(url); } };
|
||||
const flush = beacon => {
|
||||
if (!queue.length || !on) return;
|
||||
const body = JSON.stringify({ page: pageName, events: queue.splice(0, 300) });
|
||||
if (beacon && navigator.sendBeacon) navigator.sendBeacon('api/perf', new Blob([body], { type: 'application/json' }));
|
||||
else fetch('api/perf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
|
||||
.then(r => r.json()).then(r => { if (r.on === false) on = false; }).catch(() => {});
|
||||
};
|
||||
setInterval(() => flush(false), 5000);
|
||||
addEventListener('pagehide', () => flush(true));
|
||||
push({ e: 'hello', ua: navigator.userAgent, conn: navigator.connection ? navigator.connection.effectiveType : '',
|
||||
dpr: devicePixelRatio, vw: innerWidth, vh: innerHeight });
|
||||
|
||||
if (window.PerformanceObserver) {
|
||||
new PerformanceObserver(list => {
|
||||
for (const r of list.getEntries()) {
|
||||
if (!/\/lib\//.test(r.name)) continue;
|
||||
const st = (r.serverTiming || []).find(x => x.name === 'stat');
|
||||
push({ e: 'res', url: rel(r.name), at: r.startTime,
|
||||
queue: r.requestStart ? r.requestStart - r.startTime : null, // waiting for a free connection
|
||||
connect: r.connectEnd - r.connectStart, dns: r.domainLookupEnd - r.domainLookupStart,
|
||||
ttfb: r.responseStart && r.requestStart ? r.responseStart - r.requestStart : null,
|
||||
download: r.responseStart ? r.responseEnd - r.responseStart : null,
|
||||
total: r.duration, size: r.transferSize, body: r.encodedBodySize, server: st ? st.duration : null,
|
||||
proto: r.nextHopProtocol || '', cached: r.transferSize === 0 && r.encodedBodySize > 0 });
|
||||
}
|
||||
}).observe({ type: 'resource', buffered: true });
|
||||
}
|
||||
|
||||
return {
|
||||
// Called right after a page turn put new <img> elements on screen.
|
||||
view(imgs, info) {
|
||||
const t0 = performance.now(), waiting = imgs.filter(img => !img.complete);
|
||||
const event = { e: 'view', ...info, n: imgs.length, preloaded: !waiting.length };
|
||||
if (!waiting.length) { push({ ...event, wait: 0 }); return; }
|
||||
Promise.all(waiting.map(img => (img.decode ? img.decode() : Promise.resolve()).catch(() => {})))
|
||||
.then(() => requestAnimationFrame(() => push({ ...event, wait: performance.now() - t0,
|
||||
url: rel(waiting[0].currentSrc || waiting[0].src) })));
|
||||
},
|
||||
// Flow mode: watch pages enter the real viewport (not the preload margin) before their image is there.
|
||||
watchFlow(root, els) {
|
||||
if (!window.IntersectionObserver) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
for (const en of entries) {
|
||||
const img = en.target.firstChild;
|
||||
if (!en.isIntersecting || (img.complete && img.naturalWidth)) continue;
|
||||
const t0 = performance.now(), hadSrc = !!img.getAttribute('src');
|
||||
const done = () => push({ e: 'stall', wait: performance.now() - t0, hadSrc, url: rel(img.currentSrc || img.src) });
|
||||
img.addEventListener('load', done, { once: true });
|
||||
io.unobserve(en.target);
|
||||
}
|
||||
}, { root });
|
||||
for (const el of els) io.observe(el);
|
||||
return io;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user