feat: add workbench performance summary view

This commit is contained in:
lyon
2026-06-17 19:39:20 +08:00
parent 4fb199df92
commit 206d0306a6
5 changed files with 329 additions and 13 deletions
+189 -6
View File
@@ -44,6 +44,7 @@ const WORKBENCH_CACHE_STATES = new Set(["warm", "cold", "unknown"]);
const WORKBENCH_TARGET_STATES = new Set(["running", "terminal", "empty", "unknown"]);
const WORKBENCH_SOURCES = new Set(["rail", "deeplink", "history", "direct", "hydrate", "unknown"]);
const WORKBENCH_AUTH_STATES = new Set(["warm", "login_redirect", "unknown"]);
const LOW_SAMPLE_THRESHOLD = 5;
interface WebPerformanceStoreOptions {
env?: Record<string, unknown>;
@@ -110,10 +111,22 @@ interface PerformanceSummaryRow {
count: number;
average: number;
p50: number;
p75: number;
p95: number;
unit: "seconds" | "score";
tone: "ok" | "warn" | "blocked" | "source";
problem: string;
lowSample?: boolean;
sampleState?: "ok" | "low-sample" | "empty";
backend?: string;
transport?: string;
targetState?: string;
cache?: string;
source?: string;
entry?: string;
authState?: string;
eventType?: string;
phase?: string;
}
export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) {
@@ -294,6 +307,16 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score");
const rows = [...durationRows, ...clsRows];
const workbenchJourneys = workbenchJourneySummaryRows(journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchEventPhases = workbenchEventPhaseSummaryRows(eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS)
.sort(sortByProblemThenP95)
.slice(0, 24);
const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents];
const apiRoutes = durationRows
.filter((row) => row.metric === "api_request")
.sort(sortByProblemThenP95)
@@ -306,14 +329,15 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
.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")
const problems = [...rows, ...workbenchRows]
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok" || row.lowSample === true)
.sort(sortByProblemThenP95)
.slice(0, 16);
const sampleCount = [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
const workbenchSampleCount = [...journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
+ histogramSampleCount(eventPhaseSeries)
+ histogramSampleCount(backendEventVisibleSeries);
const totalSampleCount = sampleCount + workbenchSampleCount;
return {
serviceId: "hwlab-cloud-api",
route: "/v1/web-performance/summary",
@@ -322,7 +346,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
namespace: baseLabels.namespace,
gitopsTarget: baseLabels.gitops_target,
summary: {
sampleCount: sampleCount + workbenchSampleCount,
sampleCount: totalSampleCount,
sampleSeries: sampleSeries.size,
durationSeries: durationSeries.size,
clsSeries: clsSeries.size,
@@ -330,14 +354,19 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
workbenchJourneySeries: journeyDurationSeries.size,
workbenchEventPhaseSeries: eventPhaseSeries.size,
workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size,
routeCount: new Set(rows.map((row) => row.route)).size,
routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size,
problemCount: problems.length,
status: sampleCount === 0 ? "waiting" : sampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok"
status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok",
lowSampleThreshold: LOW_SAMPLE_THRESHOLD
},
apiRoutes,
webVitals,
longTasks,
problems,
workbenchJourneys,
workbenchEventPhases,
workbenchBackendEvents,
workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40),
rows: rows.sort(sortByProblemThenP95).slice(0, 40)
};
}
@@ -494,6 +523,7 @@ function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: num
const count = entry.count;
const average = count > 0 ? entry.sum / count : 0;
const p50 = histogramQuantile(entry, buckets, 0.5);
const p75 = histogramQuantile(entry, buckets, 0.75);
const p95 = histogramQuantile(entry, buckets, 0.95);
const tone = performanceTone(labels, p95, count, unit);
return {
@@ -506,14 +536,131 @@ function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: num
count,
average: roundMetric(average),
p50: roundMetric(p50),
p75: roundMetric(p75),
p95: roundMetric(p95),
unit,
tone,
problem: performanceProblem(labels, p95, count, unit, tone)
problem: performanceProblem(labels, p95, count, unit, tone),
lowSample: count > 0 && count < LOW_SAMPLE_THRESHOLD,
sampleState: sampleState(count)
};
});
}
function workbenchJourneySummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const metric = labels.journey;
const row = workbenchSummaryRow(entry, buckets, {
kind: "workbench_journey",
metric,
route: labels.route,
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
targetState: labels.target_state,
cache: labels.cache,
source: labels.source,
entry: labels.entry,
authState: labels.auth_state,
warn: workbenchJourneyThreshold(metric).warn,
blocked: workbenchJourneyThreshold(metric).blocked
});
return row;
});
}
function workbenchEventPhaseSummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const metric = labels.phase;
const threshold = workbenchEventPhaseThreshold(metric);
return workbenchSummaryRow(entry, buckets, {
kind: "workbench_event_phase",
metric,
route: "workbench-events",
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
eventType: labels.event_type,
phase: labels.phase,
warn: threshold.warn,
blocked: threshold.blocked
});
});
}
function workbenchBackendEventVisibleSummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
return [...series.values()].map((entry) => {
const labels = entry.labels;
const threshold = workbenchJourneyThreshold("backend_event_to_visible");
return workbenchSummaryRow(entry, buckets, {
kind: "workbench_backend_event_visible",
metric: "backend_event_to_visible",
route: "workbench-events",
outcome: labels.outcome,
backend: labels.backend,
transport: labels.transport,
eventType: labels.event_type,
warn: threshold.warn,
blocked: threshold.blocked
});
});
}
function workbenchSummaryRow(entry: HistogramSeries, buckets: number[], input: {
kind: string;
metric: string;
route: string;
outcome: string;
warn: number;
blocked: number;
backend?: string;
transport?: string;
targetState?: string;
cache?: string;
source?: string;
entry?: string;
authState?: string;
eventType?: string;
phase?: string;
}): PerformanceSummaryRow {
const count = entry.count;
const average = count > 0 ? entry.sum / count : 0;
const p50 = histogramQuantile(entry, buckets, 0.5);
const p75 = histogramQuantile(entry, buckets, 0.75);
const p95 = histogramQuantile(entry, buckets, 0.95);
const lowSample = count > 0 && count < LOW_SAMPLE_THRESHOLD;
const tone = workbenchTone(input.outcome, p95, count, input.warn, input.blocked);
return {
kind: input.kind,
metric: input.metric,
route: input.route,
method: "-",
statusClass: "-",
outcome: input.outcome,
count,
average: roundMetric(average),
p50: roundMetric(p50),
p75: roundMetric(p75),
p95: roundMetric(p95),
unit: "seconds",
tone,
problem: workbenchProblem(input.outcome, input.metric, count, tone),
lowSample,
sampleState: sampleState(count),
backend: input.backend,
transport: input.transport,
targetState: input.targetState,
cache: input.cache,
source: input.source,
entry: input.entry,
authState: input.authState,
eventType: input.eventType,
phase: input.phase
};
}
function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) {
if (entry.count <= 0) return 0;
const rank = Math.max(1, Math.ceil(entry.count * quantile));
@@ -543,6 +690,29 @@ function performanceTone(labels: Record<string, string>, p95: number, count: num
return p95 > 1 ? "warn" : "ok";
}
function workbenchTone(outcome: string, p95: number, count: number, warn: number, blocked: number) {
if (outcome !== "ok") return "blocked";
if (count <= 0 || count < LOW_SAMPLE_THRESHOLD) return "source";
return thresholdTone(p95, warn, blocked);
}
function workbenchJourneyThreshold(metric: string) {
if (metric === "session_switch_first_visible") return { warn: 1, blocked: 3 };
if (metric === "session_switch_full_load") return { warn: 2.5, blocked: 8 };
if (metric === "workbench_open_first_visible") return { warn: 2.5, blocked: 5 };
if (metric === "workbench_open_full_load") return { warn: 5, blocked: 13 };
if (metric === "backend_event_to_visible") return { warn: 3, blocked: 10 };
return { warn: 5, blocked: 15 };
}
function workbenchEventPhaseThreshold(metric: string) {
if (metric === "append_to_sse") return { warn: 0.25, blocked: 1 };
if (metric === "created_to_append" || metric === "receive_to_project" || metric === "project_to_paint") return { warn: 0.5, blocked: 2 };
if (metric === "sse_to_receive") return { warn: 1, blocked: 3 };
if (metric === "user_submit_to_api_accepted") return { warn: 1, blocked: 3 };
return { warn: 5, blocked: 15 };
}
function thresholdTone(value: number, warn: number, blocked: number) {
if (value >= blocked) return "blocked";
if (value >= warn) return "warn";
@@ -559,6 +729,19 @@ function performanceProblem(labels: Record<string, string>, p95: number, count:
return "ok";
}
function workbenchProblem(outcome: string, metric: string, count: number, tone: string) {
if (count <= 0) return "no-sample";
if (count < LOW_SAMPLE_THRESHOLD) return "low-sample";
if (outcome !== "ok") return outcome;
if (tone === "blocked" || tone === "warn") return `slow-${metric}`;
return "ok";
}
function sampleState(count: number): "ok" | "low-sample" | "empty" {
if (count <= 0) return "empty";
return count < LOW_SAMPLE_THRESHOLD ? "low-sample" : "ok";
}
function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) {
return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count;
}