501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
const DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES = 64 * 1024;
|
|
const DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
|
|
const CLS_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5];
|
|
const ALLOWED_KINDS = new Set(["navigation", "web_vital", "api", "long_task"]);
|
|
const DURATION_METRICS = new Set([
|
|
"navigation_ttfb",
|
|
"navigation_dom_content_loaded",
|
|
"navigation_load",
|
|
"lcp",
|
|
"inp",
|
|
"fid",
|
|
"api_request",
|
|
"long_task"
|
|
]);
|
|
|
|
interface WebPerformanceStoreOptions {
|
|
env?: Record<string, unknown>;
|
|
maxSeries?: number;
|
|
}
|
|
|
|
interface WebPerformancePayload {
|
|
schemaVersion?: unknown;
|
|
serviceId?: unknown;
|
|
page?: unknown;
|
|
events?: unknown;
|
|
}
|
|
|
|
interface WebPerformanceEvent {
|
|
kind?: unknown;
|
|
metric?: unknown;
|
|
route?: unknown;
|
|
valueMs?: unknown;
|
|
value?: unknown;
|
|
method?: unknown;
|
|
status?: unknown;
|
|
statusClass?: unknown;
|
|
outcome?: unknown;
|
|
}
|
|
|
|
interface HistogramSeries {
|
|
labels: Record<string, string>;
|
|
buckets: number[];
|
|
sum: number;
|
|
count: number;
|
|
}
|
|
|
|
interface CounterSeries {
|
|
labels: Record<string, string>;
|
|
value: number;
|
|
}
|
|
|
|
interface PerformanceSummaryRow {
|
|
kind: string;
|
|
metric: string;
|
|
route: string;
|
|
method: string;
|
|
statusClass: string;
|
|
outcome: string;
|
|
count: number;
|
|
average: number;
|
|
p50: number;
|
|
p95: number;
|
|
unit: "seconds" | "score";
|
|
tone: "ok" | "warn" | "blocked" | "source";
|
|
problem: string;
|
|
}
|
|
|
|
export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) {
|
|
const env = options.env ?? process.env;
|
|
const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240);
|
|
const durationSeries = new Map<string, HistogramSeries>();
|
|
const clsSeries = new Map<string, HistogramSeries>();
|
|
const sampleSeries = new Map<string, CounterSeries>();
|
|
const baseLabels = {
|
|
service: "hwlab-cloud-web",
|
|
namespace: sanitizeLabelValue(env.HWLAB_METRICS_NAMESPACE ?? env.POD_NAMESPACE ?? "hwlab-v02"),
|
|
gitops_target: sanitizeLabelValue(env.HWLAB_GITOPS_TARGET ?? env.HWLAB_GITOPS_PROFILE ?? "v02")
|
|
};
|
|
|
|
function record(payload: WebPerformancePayload) {
|
|
const events = Array.isArray(payload.events) ? payload.events.slice(0, 80) : [];
|
|
let accepted = 0;
|
|
let dropped = 0;
|
|
for (const rawEvent of events) {
|
|
const event = normalizeEvent(rawEvent, payload);
|
|
if (!event) {
|
|
dropped += 1;
|
|
continue;
|
|
}
|
|
const counterLabels = sampleLabels(event.labels);
|
|
incrementCounter(sampleSeries, counterLabels, maxSeries);
|
|
if (event.metric === "cls") {
|
|
recordHistogram(clsSeries, event.labels, event.value, CLS_BUCKETS, maxSeries);
|
|
} else {
|
|
recordHistogram(durationSeries, event.labels, event.value, DURATION_BUCKETS_SECONDS, maxSeries);
|
|
}
|
|
accepted += 1;
|
|
}
|
|
return { accepted, dropped, received: events.length };
|
|
}
|
|
|
|
function normalizeEvent(rawEvent: unknown, payload: WebPerformancePayload) {
|
|
if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent)) return null;
|
|
const input = rawEvent as WebPerformanceEvent;
|
|
const kind = sanitizeMetricName(input.kind, "unknown");
|
|
if (!ALLOWED_KINDS.has(kind)) return null;
|
|
const metric = sanitizeMetricName(input.metric, "unknown");
|
|
const isCls = metric === "cls";
|
|
if (!isCls && !DURATION_METRICS.has(metric)) return null;
|
|
const rawValue = isCls ? finiteNumber(input.value, NaN) : finiteNumber(input.valueMs, NaN) / 1000;
|
|
if (!Number.isFinite(rawValue) || rawValue < 0) return null;
|
|
const value = isCls ? Math.min(rawValue, 10) : Math.min(rawValue, 120);
|
|
const route = webPerformanceRouteTemplate(input.route ?? payload.page ?? "/workspace");
|
|
const pageRoute = webPerformanceRouteTemplate(payload.page ?? "/workspace");
|
|
if (isObservabilityNoise({ kind, metric, route, pageRoute })) return null;
|
|
const labels = {
|
|
...baseLabels,
|
|
kind,
|
|
metric,
|
|
route,
|
|
method: normalizeMethod(input.method),
|
|
status_class: normalizeStatusClass(input.statusClass ?? (input.status === undefined || input.status === null ? "unknown" : statusClass(input.status))),
|
|
outcome: sanitizeLabelValue(input.outcome ?? "ok", "ok")
|
|
};
|
|
return { metric, value, labels };
|
|
}
|
|
|
|
function metricsText() {
|
|
const lines = [
|
|
"# HELP hwlab_webui_performance_sample_total Browser-observed HWLAB Cloud Web performance samples accepted by cloud-api.",
|
|
"# TYPE hwlab_webui_performance_sample_total counter",
|
|
...renderCounters("hwlab_webui_performance_sample_total", sampleSeries),
|
|
"# HELP hwlab_webui_performance_duration_seconds Browser-observed HWLAB Cloud Web duration metrics in seconds.",
|
|
"# TYPE hwlab_webui_performance_duration_seconds histogram",
|
|
...renderHistogram("hwlab_webui_performance_duration_seconds", durationSeries, DURATION_BUCKETS_SECONDS),
|
|
"# HELP hwlab_webui_layout_shift_score Browser-observed cumulative layout shift score.",
|
|
"# TYPE hwlab_webui_layout_shift_score histogram",
|
|
...renderHistogram("hwlab_webui_layout_shift_score", clsSeries, CLS_BUCKETS),
|
|
""
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function summary() {
|
|
const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
|
|
const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score");
|
|
const rows = [...durationRows, ...clsRows];
|
|
const apiRoutes = durationRows
|
|
.filter((row) => row.metric === "api_request")
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 12);
|
|
const webVitals = rows
|
|
.filter((row) => row.kind === "web_vital" || row.metric.startsWith("navigation_"))
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 12);
|
|
const longTasks = durationRows
|
|
.filter((row) => row.metric === "long_task")
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 12);
|
|
const problems = rows
|
|
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok")
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 16);
|
|
const sampleCount = [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: "/v1/web-performance/summary",
|
|
source: "cloud-api-rum-store",
|
|
observedAt: new Date().toISOString(),
|
|
namespace: baseLabels.namespace,
|
|
gitopsTarget: baseLabels.gitops_target,
|
|
summary: {
|
|
sampleCount,
|
|
sampleSeries: sampleSeries.size,
|
|
durationSeries: durationSeries.size,
|
|
clsSeries: clsSeries.size,
|
|
routeCount: new Set(rows.map((row) => row.route)).size,
|
|
problemCount: problems.length,
|
|
status: sampleCount === 0 ? "waiting" : sampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok"
|
|
},
|
|
apiRoutes,
|
|
webVitals,
|
|
longTasks,
|
|
problems,
|
|
rows: rows.sort(sortByProblemThenP95).slice(0, 40)
|
|
};
|
|
}
|
|
|
|
function snapshot() {
|
|
return {
|
|
sampleSeries: sampleSeries.size,
|
|
durationSeries: durationSeries.size,
|
|
clsSeries: clsSeries.size,
|
|
sampleCount: [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
|
|
};
|
|
}
|
|
|
|
return { record, metricsText, snapshot, summary };
|
|
}
|
|
|
|
export async function handleWebPerformanceIngestHttp(request, response, options = {}) {
|
|
const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES);
|
|
let payload;
|
|
try {
|
|
payload = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, { accepted: false, error: { code: "parse_error", message: "Invalid web performance JSON body", reason: error.message } });
|
|
return;
|
|
}
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
sendJson(response, 400, { accepted: false, error: { code: "invalid_params", message: "web performance body must be a JSON object" } });
|
|
return;
|
|
}
|
|
const result = options.store.record(payload);
|
|
sendJson(response, 202, { accepted: true, ...result });
|
|
}
|
|
|
|
export function handleWebPerformanceMetricsHttp(request, response, options = {}) {
|
|
if (!isLoopbackMetricsRequest(request)) {
|
|
sendJson(response, 404, { error: { code: "not_found", message: "route is not public" } });
|
|
return;
|
|
}
|
|
const payload = options.store.metricsText();
|
|
response.writeHead(200, {
|
|
"content-type": "text/plain; version=0.0.4; charset=utf-8",
|
|
"content-length": Buffer.byteLength(payload)
|
|
});
|
|
response.end(payload);
|
|
}
|
|
|
|
export function handleWebPerformanceSummaryHttp(_request, response, options = {}) {
|
|
sendJson(response, 200, options.store.summary());
|
|
}
|
|
|
|
export function webPerformanceRouteTemplate(input: unknown): string {
|
|
const raw = String(input ?? "").trim() || "/";
|
|
const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw;
|
|
const pathOnly = normalizedRaw.split(/[?#]/u, 1)[0] || "/";
|
|
let pathname = pathOnly;
|
|
try {
|
|
pathname = new URL(normalizedRaw, "http://hwlab.local").pathname || pathOnly;
|
|
} catch {
|
|
// Keep pathOnly.
|
|
}
|
|
const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part)));
|
|
const path = `/${parts.join("/")}`;
|
|
return path.length > 120 ? `${path.slice(0, 117)}...` : path;
|
|
}
|
|
|
|
function sampleLabels(labels: Record<string, string>) {
|
|
return {
|
|
service: labels.service,
|
|
namespace: labels.namespace,
|
|
gitops_target: labels.gitops_target,
|
|
kind: labels.kind,
|
|
metric: labels.metric,
|
|
route: labels.route,
|
|
method: labels.method,
|
|
status_class: labels.status_class,
|
|
outcome: labels.outcome
|
|
};
|
|
}
|
|
|
|
function isObservabilityNoise(event: { kind: string; metric: string; route: string; pageRoute: string }) {
|
|
if (event.pageRoute === "/performance") return true;
|
|
if (event.route === "/performance" && (event.kind === "navigation" || event.kind === "web_vital" || event.kind === "long_task")) return true;
|
|
return event.route === "/v1/web-performance" || event.route.startsWith("/v1/web-performance/");
|
|
}
|
|
|
|
function recordHistogram(series: Map<string, HistogramSeries>, labels: Record<string, string>, value: number, buckets: number[], maxSeries: number) {
|
|
const key = stableLabelKey(labels);
|
|
let entry = series.get(key);
|
|
if (!entry) {
|
|
if (series.size >= maxSeries) return;
|
|
entry = { labels, buckets: buckets.map(() => 0), sum: 0, count: 0 };
|
|
series.set(key, entry);
|
|
}
|
|
for (let index = 0; index < buckets.length; index += 1) {
|
|
if (value <= buckets[index]) entry.buckets[index] += 1;
|
|
}
|
|
entry.sum += value;
|
|
entry.count += 1;
|
|
}
|
|
|
|
function incrementCounter(series: Map<string, CounterSeries>, labels: Record<string, string>, maxSeries: number) {
|
|
const key = stableLabelKey(labels);
|
|
let entry = series.get(key);
|
|
if (!entry) {
|
|
if (series.size >= maxSeries) return;
|
|
entry = { labels, value: 0 };
|
|
series.set(key, entry);
|
|
}
|
|
entry.value += 1;
|
|
}
|
|
|
|
function renderCounters(metricName: string, series: Map<string, CounterSeries>) {
|
|
return [...series.values()].map((entry) => `${metricName}{${renderLabels(entry.labels)}} ${entry.value}`);
|
|
}
|
|
|
|
function renderHistogram(metricName: string, series: Map<string, HistogramSeries>, buckets: number[]) {
|
|
const lines: string[] = [];
|
|
for (const entry of series.values()) {
|
|
for (let index = 0; index < buckets.length; index += 1) {
|
|
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: String(buckets[index]) })}} ${entry.buckets[index]}`);
|
|
}
|
|
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: "+Inf" })}} ${entry.count}`);
|
|
lines.push(`${metricName}_sum{${renderLabels(entry.labels)}} ${entry.sum.toFixed(6)}`);
|
|
lines.push(`${metricName}_count{${renderLabels(entry.labels)}} ${entry.count}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: number[], unit: "seconds" | "score"): PerformanceSummaryRow[] {
|
|
return [...series.values()].map((entry) => {
|
|
const labels = entry.labels;
|
|
const count = entry.count;
|
|
const average = count > 0 ? entry.sum / count : 0;
|
|
const p50 = histogramQuantile(entry, buckets, 0.5);
|
|
const p95 = histogramQuantile(entry, buckets, 0.95);
|
|
const tone = performanceTone(labels, p95, count, unit);
|
|
return {
|
|
kind: labels.kind,
|
|
metric: labels.metric,
|
|
route: labels.route,
|
|
method: labels.method,
|
|
statusClass: labels.status_class,
|
|
outcome: labels.outcome,
|
|
count,
|
|
average: roundMetric(average),
|
|
p50: roundMetric(p50),
|
|
p95: roundMetric(p95),
|
|
unit,
|
|
tone,
|
|
problem: performanceProblem(labels, p95, count, unit, tone)
|
|
};
|
|
});
|
|
}
|
|
|
|
function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) {
|
|
if (entry.count <= 0) return 0;
|
|
const rank = Math.max(1, Math.ceil(entry.count * quantile));
|
|
for (let index = 0; index < entry.buckets.length; index += 1) {
|
|
if (entry.buckets[index] >= rank) return buckets[index];
|
|
}
|
|
return buckets[buckets.length - 1] ?? 0;
|
|
}
|
|
|
|
function performanceTone(labels: Record<string, string>, p95: number, count: number, unit: "seconds" | "score") {
|
|
if (labels.outcome !== "ok" || labels.status_class === "network" || labels.status_class === "5xx") return "blocked";
|
|
if (labels.status_class === "4xx") return "warn";
|
|
if (count <= 0) return "source";
|
|
const metric = labels.metric;
|
|
if (unit === "score") {
|
|
if (p95 >= 0.25) return "blocked";
|
|
if (p95 >= 0.1) return "warn";
|
|
return "ok";
|
|
}
|
|
if (metric === "lcp") return thresholdTone(p95, 2.5, 4);
|
|
if (metric === "inp" || metric === "fid") return thresholdTone(p95, 0.2, 0.5);
|
|
if (metric === "api_request") return thresholdTone(p95, 1, 2.5);
|
|
if (metric === "long_task") return thresholdTone(p95, 0.2, 0.5);
|
|
if (metric === "navigation_dom_content_loaded") return thresholdTone(p95, 2, 4);
|
|
if (metric === "navigation_load") return thresholdTone(p95, 3, 6);
|
|
if (metric === "navigation_ttfb") return thresholdTone(p95, 0.8, 1.8);
|
|
return p95 > 1 ? "warn" : "ok";
|
|
}
|
|
|
|
function thresholdTone(value: number, warn: number, blocked: number) {
|
|
if (value >= blocked) return "blocked";
|
|
if (value >= warn) return "warn";
|
|
return "ok";
|
|
}
|
|
|
|
function performanceProblem(labels: Record<string, string>, p95: number, count: number, unit: "seconds" | "score", tone: string) {
|
|
if (count <= 0) return "no-sample";
|
|
if (labels.outcome !== "ok") return labels.outcome;
|
|
if (labels.status_class === "network") return "network";
|
|
if (labels.status_class === "5xx") return "server-error";
|
|
if (labels.status_class === "4xx") return "client-error";
|
|
if (tone === "blocked" || tone === "warn") return unit === "score" ? "layout-shift" : `slow-${labels.metric}`;
|
|
return "ok";
|
|
}
|
|
|
|
function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) {
|
|
return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count;
|
|
}
|
|
|
|
function toneWeight(tone: string) {
|
|
if (tone === "blocked") return 4;
|
|
if (tone === "warn") return 3;
|
|
if (tone === "ok") return 2;
|
|
return 1;
|
|
}
|
|
|
|
function roundMetric(value: number) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.round(value * 10000) / 10000;
|
|
}
|
|
|
|
function renderLabels(labels: Record<string, string>) {
|
|
return Object.entries(labels)
|
|
.map(([key, value]) => `${key}="${escapeLabelValue(value)}"`)
|
|
.join(",");
|
|
}
|
|
|
|
function stableLabelKey(labels: Record<string, string>) {
|
|
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join("\u0001");
|
|
}
|
|
|
|
function isLoopbackMetricsRequest(request) {
|
|
const remoteAddress = String(request.socket?.remoteAddress ?? "");
|
|
const host = String(request.headers?.host ?? "").trim().toLowerCase();
|
|
const loopbackRemote = remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
|
|
const loopbackHost = /^(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(host);
|
|
return loopbackRemote && loopbackHost;
|
|
}
|
|
|
|
function routeSegmentTemplate(segment: string): string {
|
|
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(segment)) return ":id";
|
|
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(segment)) return ":uuid";
|
|
if (/^[A-Za-z0-9_-]{20,}$/u.test(segment)) return ":id";
|
|
if (/^\d{5,}$/u.test(segment)) return ":id";
|
|
return sanitizeLabelValue(segment, "_");
|
|
}
|
|
|
|
function sanitizeMetricName(value: unknown, fallback: string) {
|
|
const text = String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9_:-]/gu, "_").slice(0, 64);
|
|
return text || fallback;
|
|
}
|
|
|
|
function sanitizeLabelValue(value: unknown, fallback = "unknown") {
|
|
const text = String(value ?? "").trim();
|
|
const sanitized = text.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 120);
|
|
return sanitized || fallback;
|
|
}
|
|
|
|
function escapeLabelValue(value: unknown) {
|
|
return String(value ?? "").replace(/\\/gu, "\\\\").replace(/\n/gu, "\\n").replace(/"/gu, "\\\"");
|
|
}
|
|
|
|
function normalizeMethod(value: unknown) {
|
|
const method = String(value ?? "NONE").trim().toUpperCase();
|
|
return /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/u.test(method) ? method : "NONE";
|
|
}
|
|
|
|
function normalizeStatusClass(value: unknown) {
|
|
const text = String(value ?? "unknown").trim().toLowerCase();
|
|
if (/^[1-5]xx$/u.test(text)) return text;
|
|
if (text === "network" || text === "unknown") return text;
|
|
return "unknown";
|
|
}
|
|
|
|
function statusClass(value: unknown) {
|
|
const status = finiteNumber(value, 0);
|
|
if (status >= 100 && status < 600) return `${Math.floor(status / 100)}xx`;
|
|
if (status === 0) return "network";
|
|
return "unknown";
|
|
}
|
|
|
|
function finiteNumber(value: unknown, fallback: number) {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(number) ? number : fallback;
|
|
}
|
|
|
|
function parsePositiveInteger(value: unknown, fallback: number) {
|
|
const number = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
}
|
|
|
|
function safeDecode(value: string) {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function readBody(request, limitBytes) {
|
|
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES;
|
|
return new Promise((resolve, reject) => {
|
|
let body = "";
|
|
request.setEncoding("utf8");
|
|
request.on("data", (chunk) => {
|
|
body += chunk;
|
|
if (Buffer.byteLength(body, "utf8") > limit) {
|
|
request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
|
}
|
|
});
|
|
request.on("end", () => resolve(body));
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(response, statusCode, body) {
|
|
response.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store"
|
|
});
|
|
response.end(`${JSON.stringify(body)}\n`);
|
|
}
|