125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
interface RumEvent {
|
|
kind: "navigation" | "web_vital" | "api" | "long_task";
|
|
metric: string;
|
|
route?: string;
|
|
valueMs?: number;
|
|
value?: number;
|
|
method?: string;
|
|
status?: number;
|
|
statusClass?: string;
|
|
outcome?: string;
|
|
}
|
|
|
|
interface ApiTimingInput {
|
|
route: string;
|
|
method?: string;
|
|
status?: number;
|
|
startedAt: number;
|
|
outcome: string;
|
|
}
|
|
|
|
const SCHEMA_VERSION = "hwlab-web-performance-v1";
|
|
const FLUSH_INTERVAL_MS = 4000;
|
|
const MAX_QUEUE = 80;
|
|
const queue: RumEvent[] = [];
|
|
let installed = false;
|
|
let flushTimer: number | null = null;
|
|
|
|
export function installWebRum(): void {
|
|
if (installed || typeof window === "undefined") return;
|
|
installed = true;
|
|
window.addEventListener("load", () => window.setTimeout(recordNavigationTiming, 0), { once: true });
|
|
window.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden") flushRum(true);
|
|
});
|
|
installPerformanceObservers();
|
|
}
|
|
|
|
export function recordApiTiming(input: ApiTimingInput): void {
|
|
if (typeof window === "undefined") return;
|
|
const valueMs = Math.max(0, Date.now() - input.startedAt);
|
|
enqueue({
|
|
kind: "api",
|
|
metric: "api_request",
|
|
route: input.route,
|
|
method: (input.method || "GET").toUpperCase(),
|
|
status: input.status ?? 0,
|
|
statusClass: statusClass(input.status),
|
|
outcome: input.outcome,
|
|
valueMs
|
|
});
|
|
}
|
|
|
|
function installPerformanceObservers(): void {
|
|
const Observer = window.PerformanceObserver;
|
|
if (!Observer) return;
|
|
observe("largest-contentful-paint", (entry) => enqueue({ kind: "web_vital", metric: "lcp", route: pageRoute(), valueMs: entry.startTime }));
|
|
observe("first-input", (entry) => {
|
|
const firstInput = entry as PerformanceEventTiming;
|
|
enqueue({ kind: "web_vital", metric: "fid", route: pageRoute(), valueMs: Math.max(0, firstInput.processingStart - firstInput.startTime) });
|
|
});
|
|
observe("layout-shift", (entry) => {
|
|
const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean };
|
|
if (shift.hadRecentInput) return;
|
|
enqueue({ kind: "web_vital", metric: "cls", route: pageRoute(), value: Number(shift.value) || 0 });
|
|
});
|
|
observe("longtask", (entry) => enqueue({ kind: "long_task", metric: "long_task", route: pageRoute(), valueMs: entry.duration }));
|
|
}
|
|
|
|
function observe(type: string, onEntry: (entry: PerformanceEntry) => void): void {
|
|
try {
|
|
const observer = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) onEntry(entry);
|
|
});
|
|
observer.observe({ type, buffered: true });
|
|
} catch {
|
|
// Older Chromium builds may not support every Web Vital entry type.
|
|
}
|
|
}
|
|
|
|
function recordNavigationTiming(): void {
|
|
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
|
|
if (!navigation) return;
|
|
const route = pageRoute();
|
|
const start = navigation.startTime || 0;
|
|
enqueue({ kind: "navigation", metric: "navigation_ttfb", route, valueMs: Math.max(0, navigation.responseStart - start) });
|
|
enqueue({ kind: "navigation", metric: "navigation_dom_content_loaded", route, valueMs: Math.max(0, navigation.domContentLoadedEventEnd - start) });
|
|
enqueue({ kind: "navigation", metric: "navigation_load", route, valueMs: Math.max(0, navigation.loadEventEnd - start) });
|
|
}
|
|
|
|
function enqueue(event: RumEvent): void {
|
|
if (!Number.isFinite(event.valueMs ?? event.value ?? NaN)) return;
|
|
queue.push(event);
|
|
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
|
scheduleFlush();
|
|
}
|
|
|
|
function scheduleFlush(): void {
|
|
if (flushTimer !== null) return;
|
|
flushTimer = window.setTimeout(() => flushRum(false), FLUSH_INTERVAL_MS);
|
|
}
|
|
|
|
function flushRum(useBeacon: boolean): void {
|
|
if (flushTimer !== null) {
|
|
window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (!queue.length) return;
|
|
const events = queue.splice(0, queue.length);
|
|
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
|
|
if (useBeacon && navigator.sendBeacon) {
|
|
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
|
|
return;
|
|
}
|
|
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
|
|
}
|
|
|
|
function pageRoute(): string {
|
|
return `${window.location.pathname}${window.location.hash || ""}` || "/";
|
|
}
|
|
|
|
function statusClass(status?: number): string {
|
|
if (!status || !Number.isFinite(status)) return "network";
|
|
return `${Math.floor(status / 100)}xx`;
|
|
}
|