feat: add v02 web performance dashboard
This commit is contained in:
@@ -49,6 +49,22 @@ interface CounterSeries {
|
||||
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);
|
||||
@@ -101,7 +117,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
metric,
|
||||
route,
|
||||
method: normalizeMethod(input.method),
|
||||
status_class: normalizeStatusClass(input.statusClass ?? statusClass(input.status)),
|
||||
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 };
|
||||
@@ -123,6 +139,51 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
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,
|
||||
@@ -132,7 +193,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
};
|
||||
}
|
||||
|
||||
return { record, metricsText, snapshot };
|
||||
return { record, metricsText, snapshot, summary };
|
||||
}
|
||||
|
||||
export async function handleWebPerformanceIngestHttp(request, response, options = {}) {
|
||||
@@ -165,6 +226,10 @@ export function handleWebPerformanceMetricsHttp(request, response, options = {})
|
||||
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;
|
||||
@@ -237,6 +302,93 @@ function renderHistogram(metricName: string, series: Map<string, HistogramSeries
|
||||
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)}"`)
|
||||
|
||||
Reference in New Issue
Block a user