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; 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; buckets: number[]; sum: number; count: number; } interface CounterSeries { labels: Record; 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(); const clsSeries = new Map(); const sampleSeries = new Map(); 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) { 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, labels: Record, 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, labels: Record, 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) { return [...series.values()].map((entry) => `${metricName}{${renderLabels(entry.labels)}} ${entry.value}`); } function renderHistogram(metricName: string, series: Map, 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, 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, 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, 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) { return Object.entries(labels) .map(([key, value]) => `${key}="${escapeLabelValue(value)}"`) .join(","); } function stableLabelKey(labels: Record) { 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`); }