Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/utils/rum.ts
T
2026-06-14 05:16:41 +08:00

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`;
}