Files
bitvid/js/performanceHarness.js

237 lines
6.8 KiB
JavaScript

// Local-only performance measurement harness.
//
// This intentionally observes rather than changes application behavior. It is
// available only on a loopback host *and* after an explicit localStorage opt-in,
// so a production visitor cannot accidentally enable it.
const STORAGE_KEY = "__bitvidPerformanceHarness__";
const GLOBAL_KEY = "__bitvidPerformance";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const MAX_LONG_TASKS = 200;
const MAX_MARKS = 200;
function readStorage(storage, key) {
try {
return storage?.getItem?.(key) ?? null;
} catch (_) {
return null;
}
}
function finiteNumber(value, fallback = 0) {
return Number.isFinite(value) ? Number(value) : fallback;
}
function normalizeMarkName(name) {
return typeof name === "string" ? name.trim().slice(0, 120) : "";
}
export function isLocalPerformanceHarnessEnabled({
locationRef = typeof location !== "undefined" ? location : null,
storage = typeof localStorage !== "undefined" ? localStorage : null,
} = {}) {
const hostname = typeof locationRef?.hostname === "string"
? locationRef.hostname.toLowerCase()
: "";
return LOCAL_HOSTS.has(hostname) && readStorage(storage, STORAGE_KEY) === "1";
}
export class PerformanceHarness {
constructor({ scope, performanceRef, PerformanceObserverClass } = {}) {
this.scope = scope || null;
this.performance = performanceRef || this.scope?.performance || null;
this.PerformanceObserverClass =
PerformanceObserverClass || this.scope?.PerformanceObserver || null;
this.startedAt = 0;
this.longTasks = [];
this.marks = [];
this.gauges = new Map();
this.longTaskObserver = null;
this.started = false;
}
start() {
if (this.started) {
return this;
}
this.started = true;
this.startedAt = this.now();
this.observeLongTasks();
this.mark("harness-start");
if (this.scope) {
this.scope[GLOBAL_KEY] = this.publicApi();
}
return this;
}
stop() {
try {
this.longTaskObserver?.disconnect?.();
} catch (_) {
// Diagnostics must never affect application teardown.
}
this.longTaskObserver = null;
this.started = false;
if (this.scope?.[GLOBAL_KEY]) {
delete this.scope[GLOBAL_KEY];
}
}
now() {
return finiteNumber(this.performance?.now?.(), Date.now());
}
observeLongTasks() {
if (typeof this.PerformanceObserverClass !== "function") {
return;
}
try {
const observer = new this.PerformanceObserverClass((list) => {
const entries = list?.getEntries?.() || [];
for (const entry of entries) {
this.recordLongTask(entry);
}
});
observer.observe({ type: "longtask", buffered: true });
this.longTaskObserver = observer;
} catch (_) {
// Long Task timing is optional and unsupported in several browsers.
}
}
recordLongTask(entry) {
const task = {
startTime: finiteNumber(entry?.startTime),
duration: finiteNumber(entry?.duration),
name: typeof entry?.name === "string" ? entry.name : "longtask",
};
this.longTasks.push(task);
if (this.longTasks.length > MAX_LONG_TASKS) {
this.longTasks.splice(0, this.longTasks.length - MAX_LONG_TASKS);
}
}
mark(name, detail = {}) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName) {
return false;
}
const entry = {
name: normalizedName,
at: this.now(),
detail: detail && typeof detail === "object" ? { ...detail } : {},
};
this.marks.push(entry);
if (this.marks.length > MAX_MARKS) {
this.marks.splice(0, this.marks.length - MAX_MARKS);
}
try {
this.performance?.mark?.(`bitvid:${normalizedName}`);
} catch (_) {
// User Timing is also optional; retain the local mark either way.
}
return true;
}
setGauge(name, value) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(value)) {
return false;
}
this.gauges.set(normalizedName, Number(value));
return true;
}
increment(name, amount = 1) {
const normalizedName = normalizeMarkName(name);
if (!normalizedName || !Number.isFinite(amount)) {
return false;
}
return this.setGauge(normalizedName, (this.gauges.get(normalizedName) || 0) + amount);
}
snapshot() {
const resources = this.performance?.getEntriesByType?.("resource") || [];
const navigation = this.performance?.getEntriesByType?.("navigation")?.[0] || null;
const memory = this.performance?.memory;
const longTaskDurationMs = this.longTasks.reduce(
(total, task) => total + finiteNumber(task.duration),
0,
);
return {
enabled: this.started,
startedAt: this.startedAt,
elapsedMs: Math.max(0, this.now() - this.startedAt),
longTasks: {
count: this.longTasks.length,
totalDurationMs: longTaskDurationMs,
recent: this.longTasks.slice(-20),
},
marks: this.marks.slice(-50),
gauges: Object.fromEntries(this.gauges.entries()),
resources: {
count: resources.length,
transferSize: resources.reduce(
(total, entry) => total + finiteNumber(entry?.transferSize),
0,
),
},
navigation: navigation
? {
domContentLoadedMs: finiteNumber(navigation.domContentLoadedEventEnd),
loadEventMs: finiteNumber(navigation.loadEventEnd),
}
: null,
memory: memory
? {
usedJsHeapSize: finiteNumber(memory.usedJSHeapSize),
totalJsHeapSize: finiteNumber(memory.totalJSHeapSize),
jsHeapSizeLimit: finiteNumber(memory.jsHeapSizeLimit),
}
: null,
};
}
reset() {
this.longTasks.length = 0;
this.marks.length = 0;
this.gauges.clear();
this.mark("harness-reset");
}
publicApi() {
return Object.freeze({
snapshot: () => this.snapshot(),
mark: (name, detail) => this.mark(name, detail),
setGauge: (name, value) => this.setGauge(name, value),
increment: (name, amount) => this.increment(name, amount),
reset: () => this.reset(),
stop: () => this.stop(),
});
}
}
export function initPerformanceHarness({
scope = typeof window !== "undefined" ? window : null,
locationRef = scope?.location,
storage = scope?.localStorage,
performanceRef = scope?.performance,
PerformanceObserverClass = scope?.PerformanceObserver,
} = {}) {
if (!scope || !isLocalPerformanceHarnessEnabled({ locationRef, storage })) {
return null;
}
if (scope[GLOBAL_KEY]) {
return scope[GLOBAL_KEY];
}
const harness = new PerformanceHarness({
scope,
performanceRef,
PerformanceObserverClass,
});
harness.start();
return scope[GLOBAL_KEY];
}
export const PERFORMANCE_HARNESS_STORAGE_KEY = STORAGE_KEY;