Files
sovereign_browser_rust/www/js/perf-probe.js
T

370 lines
14 KiB
JavaScript

/*
* perf-probe.js — per-tab performance probe for sovereign_browser
*
* Injected by WebKitUserContentManager into every non-sovereign:// page
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
* - resource entries (PerformanceObserver 'resource') -> net counts
* - requestAnimationFrame cadence -> fps
* - patched setTimeout/setInterval -> active timer count + top intervals
* - patched addEventListener -> event listener count
* - patched Worker -> worker count
* - performance.memory -> heap used
* - document.getElementsByTagName('*').length -> DOM node count
*
* Batches a report every 1s and POSTs it to:
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
*
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
* tag injected before this script runs (see inject_perf_probe() in
* nostr_bridge.c / main.c). If absent, the report is dropped.
*
* The probe is a no-op on sovereign:// pages (the injector skips them)
* to avoid self-noise and recursion.
*/
(function () {
'use strict';
// Honor the skip flag set by the preamble on sovereign:// / about:
// pages. The skip flag is re-evaluated on every navigation (the
// preamble runs at document_start for each new document), so we also
// reset our installed flag here to allow re-injection after a
// navigation from an internal page to a normal one.
if (window.__sbPerfProbeSkip) return;
if (window.__sbPerfProbe) return; // already running in this document
window.__sbPerfProbe = true;
// Tab index is injected by the browser via window.__sbPerfTabIndex
// (set by the preamble that runs before this script). Read it once.
var TAB_INDEX = window.__sbPerfTabIndex;
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
// Capture the native timers early — the heartbeat (below) needs
// origSetTimeout before the timer-patching block runs.
var origSetTimeout = window.setTimeout;
var origSetInterval = window.setInterval;
var origClearTimeout = window.clearTimeout;
var origClearInterval = window.clearInterval;
// ── Long tasks ──────────────────────────────────────────────────
var longTasks = []; // last 30s of {t, duration, name}
var longTasksLastSec = []; // tasks in the current 1s window
var longTaskSources = {}; // name -> {total_ms, count}
var longTaskSupported = false;
if (typeof PerformanceObserver !== 'undefined') {
try {
var ltObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
var rec = {
t: performance.now(),
duration: e.duration,
name: e.name || 'unknown',
attribution: (e.attribution && e.attribution[0])
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
: ''
};
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = rec.name || 'unknown';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += rec.duration;
longTaskSources[key].count++;
});
});
ltObs.observe({ entryTypes: ['longtask'] });
longTaskSupported = true;
} catch (e) { /* longtask not supported */ }
}
// ── Main-thread blockage fallback ───────────────────────────────
// WebKitGTK does not fire 'longtask' entries, so we measure main-
// thread blockage directly: schedule a 0ms heartbeat and measure
// how late it fires. If the main thread is blocked by a busy loop,
// the heartbeat is delayed. The total delay in a 1s window is a
// proxy for cpu_busy_percent.
var heartbeatDelayMs = 0; // accumulated delay in the current window
var heartbeatLast = performance.now();
function heartbeat() {
var now = performance.now();
var expected = heartbeatLast + 50; // we schedule every 50ms
var delay = now - expected;
if (delay > 5) { // ignore small scheduler jitter
heartbeatDelayMs += delay;
// Record as a synthetic long task if > 50ms.
if (delay > 50) {
var rec = { t: now, duration: delay, name: 'main-thread-block',
attribution: '' };
longTasks.push(rec);
longTasksLastSec.push(rec);
var key = 'main-thread-block';
if (!longTaskSources[key]) {
longTaskSources[key] = { total_ms: 0, count: 0 };
}
longTaskSources[key].total_ms += delay;
longTaskSources[key].count++;
}
}
heartbeatLast = now;
origSetTimeout.call(window, heartbeat, 50);
}
// Always start the heartbeat. On engines where longtask fires
// reliably, the heartbeat delay will be near-zero (long tasks are
// captured by the observer). On WebKitGTK, where longtask is
// accepted but never fires, the heartbeat is the sole source of
// cpu_busy measurement. The report() function takes the max of
// both, so there's no double-counting.
origSetTimeout.call(window, heartbeat, 50);
// ── Resource entries (network) ────────────────────────────────
var netRequestsLastSec = [];
var netEndpoints = {}; // url-prefix -> count
if (typeof PerformanceObserver !== 'undefined') {
try {
var resObs = new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) {
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
var key = e.name.split('?')[0];
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
});
});
resObs.observe({ entryTypes: ['resource'] });
} catch (e) { /* resource not supported */ }
}
// ── FPS via requestAnimationFrame ───────────────────────────────
var rafFrames = 0;
var fps = 0;
function rafLoop(ts) {
rafFrames++;
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
var activeTimers = {}; // id -> {code, interval_ms, count}
var timerTop = {}; // "code|interval" -> count
var nextTimerId = 1;
function labelFor(fn) {
try {
var s = (fn && fn.name) ? fn.name : 'anonymous';
// Truncate long inline-function bodies.
if (s.length > 40) s = s.slice(0, 40);
return s;
} catch (e) { return 'anonymous'; }
}
// origSetTimeout / origSetInterval / origClearTimeout /
// origClearInterval were captured at the top of the IIFE (before the
// heartbeat was installed), so they reference the native timers.
window.setTimeout = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
var key = label + '|0';
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
delete activeTimers[id];
return fn.apply(this, args);
};
return origSetTimeout.call(this, wrapped, delay);
};
window.setInterval = function (fn, delay) {
var id = nextTimerId++;
var label = labelFor(fn);
var interval = delay | 0;
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
var key = label + '|' + interval;
timerTop[key] = (timerTop[key] || 0) + 1;
var args = Array.prototype.slice.call(arguments, 2);
var wrapped = function () {
if (activeTimers[id]) activeTimers[id].count++;
return fn.apply(this, args);
};
return origSetInterval.call(this, wrapped, delay);
};
window.clearTimeout = function (id) {
delete activeTimers[id];
return origClearTimeout.call(this, id);
};
window.clearInterval = function (id) {
delete activeTimers[id];
return origClearInterval.call(this, id);
};
// ── Event listener count (patched addEventListener) ─────────────
var eventListenerCount = 0;
var origAEL = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
// Skip our own internal listeners (tagged with __sbInternal).
if (!fn || !fn.__sbInternal) eventListenerCount++;
return origAEL.call(this, type, fn, opts);
};
// ── Worker count (patched Worker constructor) ───────────────────
var workerCount = 0;
var OrigWorker = window.Worker;
if (OrigWorker) {
window.Worker = function (url, opts) {
workerCount++;
return new OrigWorker(url, opts);
};
window.Worker.prototype = OrigWorker.prototype;
}
// ── In-flight network (patched fetch + XHR.open) ────────────────
var netInFlight = 0;
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function () {
netInFlight++;
var p = origFetch.apply(this, arguments);
var dec = function () { if (netInFlight > 0) netInFlight--; };
p.then(dec, dec);
return p;
};
}
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
netInFlight++;
var onEnd = function () {
if (netInFlight > 0) netInFlight--;
};
onEnd.__sbInternal = true;
this.addEventListener('loadend', onEnd);
return origXhrOpen.apply(this, arguments);
};
// ── Reporting ───────────────────────────────────────────────────
function report() {
// Trim long-task timeline to last 30s.
var now = performance.now();
while (longTasks.length && (now - longTasks[0].t) > 30000) {
longTasks.shift();
}
// cpu_busy_percent: sum of long-task durations in the last 1s
// window. If the longtask PerformanceObserver isn't supported
// (WebKitGTK), fall back to the heartbeat-delay measurement.
var busyMs = 0;
for (var i = 0; i < longTasksLastSec.length; i++) {
busyMs += longTasksLastSec[i].duration;
}
// If the heartbeat measured more blockage than the longtask
// observer (e.g. on WebKitGTK where longtask never fires), use
// the heartbeat measurement.
if (heartbeatDelayMs > busyMs) {
busyMs = heartbeatDelayMs;
}
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
if (cpuBusy > 100) cpuBusy = 100;
heartbeatDelayMs = 0; // reset for next window
// Top timer sources.
var timerTopArr = [];
Object.keys(timerTop).forEach(function (k) {
var parts = k.split('|');
timerTopArr.push({
code: parts[0],
interval_ms: parseInt(parts[1], 10) || 0,
count: timerTop[k]
});
});
timerTopArr.sort(function (a, b) { return b.count - a.count; });
timerTopArr = timerTopArr.slice(0, 5);
// Top network endpoints (last window).
var netTopArr = [];
Object.keys(netEndpoints).forEach(function (u) {
netTopArr.push({ url: u, count: netEndpoints[u] });
});
netTopArr.sort(function (a, b) { return b.count - a.count; });
netTopArr = netTopArr.slice(0, 5);
// Top long-task sources.
var ltSourcesArr = [];
Object.keys(longTaskSources).forEach(function (k) {
ltSourcesArr.push({
name: k,
total_ms: Math.round(longTaskSources[k].total_ms),
count: longTaskSources[k].count
});
});
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
ltSourcesArr = ltSourcesArr.slice(0, 8);
// Heap.
var heapMb = -1;
if (performance.memory && performance.memory.usedJSHeapSize) {
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
}
// DOM node count.
var domNodes = -1;
try {
domNodes = document.getElementsByTagName('*').length;
} catch (e) { /* document not ready */ }
var payload = {
tab_index: TAB_INDEX,
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
fps: fps,
timer_count: Object.keys(activeTimers).length,
timer_top: timerTopArr,
net_in_flight: netInFlight,
net_requests_last_sec: netRequestsLastSec.length,
net_top_endpoints: netTopArr,
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
event_listener_count: eventListenerCount,
worker_count: workerCount,
dom_node_count: domNodes,
long_tasks_last_sec: longTasksLastSec.map(function (r) {
return {
duration: Math.round(r.duration),
name: r.name,
attribution: r.attribution
};
}),
long_task_top_sources: ltSourcesArr,
long_task_timeline: longTasks.map(function (r) {
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
})
};
// Reset per-window accumulators.
longTasksLastSec = [];
netRequestsLastSec = [];
netEndpoints = {};
timerTop = {};
// Send. Use sync XHR to sovereign:// (same convention as the
// window.nostr shim — WebKit's custom scheme handler doesn't
// reliably support fetch() or async XHR for sovereign:// URLs).
try {
var body = encodeURIComponent(JSON.stringify(payload));
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
'&body=' + body;
var x = new XMLHttpRequest();
x.open('GET', url, false); // synchronous
x.send();
} catch (e) { /* swallow — probe must never break the page */ }
}
// FPS counter: count frames in each 1s window.
setInterval(function () {
fps = rafFrames;
rafFrames = 0;
}, 1000);
// Report every 1s. Use the original setInterval so we don't track
// ourselves in activeTimers.
origSetInterval.call(window, report, 1000);
})();