feat: add workbench performance summary view
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user