1297 lines
54 KiB
TypeScript
1297 lines
54 KiB
TypeScript
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
|
// Keeps browser-reported performance metrics low-cardinality before Prometheus export.
|
|
|
|
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 WORKBENCH_JOURNEY_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2, 3, 5, 8, 13, 21, 30, 60, 120];
|
|
const WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
const ALLOWED_KINDS = new Set(["navigation", "web_vital", "api", "long_task", "workbench_journey", "workbench_event_phase", "workbench_backend_event_visible"]);
|
|
const DURATION_METRICS = new Set([
|
|
"navigation_ttfb",
|
|
"navigation_dom_content_loaded",
|
|
"navigation_load",
|
|
"lcp",
|
|
"inp",
|
|
"fid",
|
|
"api_request",
|
|
"long_task"
|
|
]);
|
|
const WORKBENCH_SCHEMA_VERSION = "hwlab-web-performance-v2";
|
|
const WORKBENCH_JOURNEYS = new Set([
|
|
"submit_to_first_visible",
|
|
"submit_to_failure",
|
|
"backend_event_to_visible",
|
|
"session_switch_first_visible",
|
|
"session_switch_full_load",
|
|
"workbench_open_first_visible",
|
|
"workbench_open_full_load"
|
|
]);
|
|
const WORKBENCH_EVENT_PHASES = new Set([
|
|
"created_to_append",
|
|
"append_to_sse",
|
|
"sse_to_receive",
|
|
"receive_to_project",
|
|
"project_to_paint",
|
|
"user_submit_to_api_accepted",
|
|
"api_accepted_to_backend_event"
|
|
]);
|
|
const WORKBENCH_EVENT_TYPES = new Set(["assistant", "tool_call", "backend", "terminal", "error", "status", "request", "result", "unknown"]);
|
|
const WORKBENCH_TRANSPORTS = new Set(["sse", "rest_gap", "poll", "none", "unknown"]);
|
|
const WORKBENCH_OUTCOMES = new Set(["ok", "timeout", "error", "dropped", "stale", "partial", "empty", "network", "denied", "unknown"]);
|
|
const WORKBENCH_ENTRIES = new Set(["new", "existing", "steer", "retry", "unknown"]);
|
|
const WORKBENCH_VISIBILITY = new Set(["foreground", "background", "hidden", "unknown"]);
|
|
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;
|
|
const DEFAULT_SUMMARY_WINDOW = "15m";
|
|
const PERFORMANCE_SUMMARY_WINDOWS = {
|
|
"5m": { id: "5m", label: "最近 5 分钟", milliseconds: 5 * 60 * 1000 },
|
|
"15m": { id: "15m", label: "最近 15 分钟", milliseconds: 15 * 60 * 1000 },
|
|
"1h": { id: "1h", label: "最近 1 小时", milliseconds: 60 * 60 * 1000 },
|
|
"6h": { id: "6h", label: "最近 6 小时", milliseconds: 6 * 60 * 60 * 1000 },
|
|
"24h": { id: "24h", label: "最近 24 小时", milliseconds: 24 * 60 * 60 * 1000 },
|
|
all: { id: "all", label: "全部累计", milliseconds: null }
|
|
} as const;
|
|
|
|
interface WebPerformanceStoreOptions {
|
|
env?: Record<string, unknown>;
|
|
maxSeries?: number;
|
|
maxSamples?: number;
|
|
now?: () => number;
|
|
}
|
|
|
|
interface WebPerformancePayload {
|
|
schemaVersion?: unknown;
|
|
serviceId?: unknown;
|
|
page?: unknown;
|
|
events?: unknown;
|
|
}
|
|
|
|
interface WebPerformanceEvent {
|
|
kind?: unknown;
|
|
metric?: unknown;
|
|
journey?: unknown;
|
|
phase?: unknown;
|
|
eventType?: unknown;
|
|
backend?: unknown;
|
|
transport?: unknown;
|
|
entry?: unknown;
|
|
visibility?: unknown;
|
|
cache?: unknown;
|
|
targetState?: unknown;
|
|
source?: unknown;
|
|
authState?: unknown;
|
|
route?: unknown;
|
|
valueMs?: unknown;
|
|
value?: unknown;
|
|
method?: unknown;
|
|
status?: unknown;
|
|
statusClass?: unknown;
|
|
outcome?: unknown;
|
|
}
|
|
|
|
interface NormalizedPerformanceEvent {
|
|
series: "web_duration" | "layout_shift" | "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible";
|
|
metric: string;
|
|
value: number;
|
|
labels: Record<string, string>;
|
|
counterLabels?: Record<string, string>;
|
|
}
|
|
|
|
interface RecordedPerformanceSample extends NormalizedPerformanceEvent {
|
|
atMs: number;
|
|
}
|
|
|
|
interface PerformanceSummaryWindow {
|
|
id: keyof typeof PERFORMANCE_SUMMARY_WINDOWS;
|
|
label: string;
|
|
milliseconds: number | null;
|
|
}
|
|
|
|
interface PerformanceAggregate {
|
|
durationSeries: Map<string, HistogramSeries>;
|
|
clsSeries: Map<string, HistogramSeries>;
|
|
sampleSeries: Map<string, CounterSeries>;
|
|
journeyDurationSeries: Map<string, HistogramSeries>;
|
|
journeyTotalSeries: Map<string, CounterSeries>;
|
|
eventPhaseSeries: Map<string, HistogramSeries>;
|
|
backendEventVisibleSeries: Map<string, HistogramSeries>;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
interface PerformanceDashboardPoint {
|
|
label: string;
|
|
detail: string;
|
|
value: number;
|
|
p50: number;
|
|
p75: number;
|
|
p95: number;
|
|
count: number;
|
|
tone: string;
|
|
sampleState: string;
|
|
}
|
|
|
|
interface PerformanceDashboardTopRow {
|
|
label: string;
|
|
detail: string;
|
|
value: number;
|
|
unit: string;
|
|
count: number;
|
|
tone: 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 maxSamples = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SAMPLES, options.maxSamples ?? 5000);
|
|
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
const durationSeries = new Map<string, HistogramSeries>();
|
|
const clsSeries = new Map<string, HistogramSeries>();
|
|
const sampleSeries = new Map<string, CounterSeries>();
|
|
const droppedSeries = new Map<string, CounterSeries>();
|
|
const journeyDurationSeries = new Map<string, HistogramSeries>();
|
|
const journeyTotalSeries = new Map<string, CounterSeries>();
|
|
const eventPhaseSeries = new Map<string, HistogramSeries>();
|
|
const backendEventVisibleSeries = new Map<string, HistogramSeries>();
|
|
const samples: RecordedPerformanceSample[] = [];
|
|
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) {
|
|
incrementDropped("invalid_event");
|
|
dropped += 1;
|
|
continue;
|
|
}
|
|
if (!recordNormalizedEvent(event)) {
|
|
incrementDropped("series_limit");
|
|
dropped += 1;
|
|
continue;
|
|
}
|
|
rememberSample(event);
|
|
accepted += 1;
|
|
}
|
|
return { accepted, dropped, received: events.length };
|
|
}
|
|
|
|
function normalizeEvent(rawEvent: unknown, payload: WebPerformancePayload): NormalizedPerformanceEvent | null {
|
|
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;
|
|
if (kind === "workbench_journey") return normalizeWorkbenchJourneyEvent(input, payload);
|
|
if (kind === "workbench_event_phase") return normalizeWorkbenchEventPhase(input, payload);
|
|
if (kind === "workbench_backend_event_visible") return normalizeWorkbenchBackendEventVisible(input, payload);
|
|
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 { series: isCls ? "layout_shift" : "web_duration", metric, value, labels, counterLabels: sampleLabels(labels) };
|
|
}
|
|
|
|
function normalizeWorkbenchJourneyEvent(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null {
|
|
if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null;
|
|
const journey = requiredEnum(input.journey ?? input.metric, WORKBENCH_JOURNEYS);
|
|
if (!journey) return null;
|
|
const value = durationValueSeconds(input.valueMs, 120);
|
|
if (value === null) return null;
|
|
const route = webPerformanceRouteTemplate(input.route ?? payload.page ?? "/workbench");
|
|
const pageRoute = webPerformanceRouteTemplate(payload.page ?? "/workbench");
|
|
if (isObservabilityNoise({ kind: "workbench_journey", metric: journey, route, pageRoute })) return null;
|
|
const labels = {
|
|
...baseLabels,
|
|
journey,
|
|
route,
|
|
entry: optionalEnum(input.entry, WORKBENCH_ENTRIES, "unknown"),
|
|
source: optionalEnum(input.source, WORKBENCH_SOURCES, "unknown"),
|
|
target_state: optionalEnum(input.targetState ?? field(input, "target_state"), WORKBENCH_TARGET_STATES, "unknown"),
|
|
cache: optionalEnum(input.cache, WORKBENCH_CACHE_STATES, "unknown"),
|
|
auth_state: optionalEnum(input.authState ?? field(input, "auth_state"), WORKBENCH_AUTH_STATES, "unknown"),
|
|
backend: normalizeBackendLabel(input.backend),
|
|
transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"),
|
|
visibility: optionalEnum(input.visibility, WORKBENCH_VISIBILITY, "unknown"),
|
|
outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok")
|
|
};
|
|
return { series: "workbench_journey", metric: journey, value, labels, counterLabels: labels };
|
|
}
|
|
|
|
function normalizeWorkbenchEventPhase(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null {
|
|
if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null;
|
|
const phase = requiredEnum(input.phase ?? input.metric, WORKBENCH_EVENT_PHASES);
|
|
if (!phase) return null;
|
|
const value = durationValueSeconds(input.valueMs, 30);
|
|
if (value === null) return null;
|
|
const labels = {
|
|
...baseLabels,
|
|
phase,
|
|
event_type: normalizeEventType(input.eventType ?? field(input, "event_type")),
|
|
backend: normalizeBackendLabel(input.backend),
|
|
transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"),
|
|
outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok")
|
|
};
|
|
return { series: "workbench_event_phase", metric: phase, value, labels };
|
|
}
|
|
|
|
function normalizeWorkbenchBackendEventVisible(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null {
|
|
if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null;
|
|
const value = durationValueSeconds(input.valueMs, 120);
|
|
if (value === null) return null;
|
|
const labels = {
|
|
...baseLabels,
|
|
event_type: normalizeEventType(input.eventType ?? field(input, "event_type")),
|
|
backend: normalizeBackendLabel(input.backend),
|
|
transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"),
|
|
outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok")
|
|
};
|
|
return { series: "workbench_backend_event_visible", metric: "backend_event_to_visible", value, labels };
|
|
}
|
|
|
|
function recordNormalizedEvent(event: NormalizedPerformanceEvent) {
|
|
if (event.series === "layout_shift") return recordHistogramWithCounter(clsSeries, sampleSeries, event.labels, event.counterLabels ?? sampleLabels(event.labels), event.value, CLS_BUCKETS);
|
|
if (event.series === "web_duration") return recordHistogramWithCounter(durationSeries, sampleSeries, event.labels, event.counterLabels ?? sampleLabels(event.labels), event.value, DURATION_BUCKETS_SECONDS);
|
|
if (event.series === "workbench_journey") return recordHistogramWithCounter(journeyDurationSeries, journeyTotalSeries, event.labels, event.counterLabels ?? event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS);
|
|
if (event.series === "workbench_event_phase") return recordHistogram(eventPhaseSeries, event.labels, event.value, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS, maxSeries);
|
|
return recordHistogram(backendEventVisibleSeries, event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, maxSeries);
|
|
}
|
|
|
|
function rememberSample(event: NormalizedPerformanceEvent) {
|
|
samples.push({
|
|
...event,
|
|
atMs: now(),
|
|
labels: { ...event.labels },
|
|
counterLabels: event.counterLabels ? { ...event.counterLabels } : undefined
|
|
});
|
|
if (samples.length > maxSamples) samples.splice(0, samples.length - maxSamples);
|
|
}
|
|
|
|
function recordHistogramWithCounter(histograms: Map<string, HistogramSeries>, counters: Map<string, CounterSeries>, histogramLabels: Record<string, string>, counterLabels: Record<string, string>, value: number, buckets: number[]) {
|
|
if (!canRecordSeries(histograms, histogramLabels, maxSeries) || !canRecordSeries(counters, counterLabels, maxSeries)) return false;
|
|
recordHistogram(histograms, histogramLabels, value, buckets, maxSeries);
|
|
incrementCounter(counters, counterLabels, maxSeries);
|
|
return true;
|
|
}
|
|
|
|
function incrementDropped(reason: string) {
|
|
incrementCounter(droppedSeries, { ...baseLabels, reason }, Math.max(maxSeries, 16));
|
|
}
|
|
|
|
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),
|
|
"# HELP hwlab_webui_performance_dropped_total Browser performance samples dropped before Prometheus export.",
|
|
"# TYPE hwlab_webui_performance_dropped_total counter",
|
|
...renderCounters("hwlab_webui_performance_dropped_total", droppedSeries),
|
|
"# HELP hwlab_workbench_journey_total Browser-observed HWLAB Workbench journey samples accepted by cloud-api.",
|
|
"# TYPE hwlab_workbench_journey_total counter",
|
|
...renderCounters("hwlab_workbench_journey_total", journeyTotalSeries),
|
|
"# HELP hwlab_workbench_journey_duration_seconds Browser-observed HWLAB Workbench user-visible journey durations in seconds.",
|
|
"# TYPE hwlab_workbench_journey_duration_seconds histogram",
|
|
...renderHistogram("hwlab_workbench_journey_duration_seconds", journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS),
|
|
"# HELP hwlab_workbench_event_phase_duration_seconds Browser/backend phase durations for HWLAB Workbench events in seconds.",
|
|
"# TYPE hwlab_workbench_event_phase_duration_seconds histogram",
|
|
...renderHistogram("hwlab_workbench_event_phase_duration_seconds", eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS),
|
|
"# HELP hwlab_workbench_backend_event_visible_latency_seconds Agent backend event generated to browser-visible latency in seconds.",
|
|
"# TYPE hwlab_workbench_backend_event_visible_latency_seconds histogram",
|
|
...renderHistogram("hwlab_workbench_backend_event_visible_latency_seconds", backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS),
|
|
""
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function summary(input: { window?: unknown } = {}) {
|
|
const sampleWindow = resolvePerformanceSummaryWindow(input.window);
|
|
const aggregate = aggregateForWindow(sampleWindow);
|
|
const durationRows = histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
|
|
const clsRows = histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score");
|
|
const rows = [...durationRows, ...clsRows];
|
|
const workbenchJourneys = workbenchJourneySummaryRows(aggregate.journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS)
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 24);
|
|
const workbenchEventPhases = workbenchEventPhaseSummaryRows(aggregate.eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS)
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 24);
|
|
const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(aggregate.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)
|
|
.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, ...workbenchRows]
|
|
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok" || row.lowSample === true)
|
|
.sort(sortByProblemThenP95)
|
|
.slice(0, 16);
|
|
const sampleCount = [...aggregate.sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
|
|
const workbenchSampleCount = [...aggregate.journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
|
|
+ histogramSampleCount(aggregate.eventPhaseSeries)
|
|
+ histogramSampleCount(aggregate.backendEventVisibleSeries);
|
|
const totalSampleCount = sampleCount + workbenchSampleCount;
|
|
const summaryInfo = {
|
|
sampleCount: totalSampleCount,
|
|
sampleSeries: aggregate.sampleSeries.size,
|
|
durationSeries: aggregate.durationSeries.size,
|
|
clsSeries: aggregate.clsSeries.size,
|
|
droppedSeries: droppedSeries.size,
|
|
workbenchJourneySeries: aggregate.journeyDurationSeries.size,
|
|
workbenchEventPhaseSeries: aggregate.eventPhaseSeries.size,
|
|
workbenchBackendEventVisibleSeries: aggregate.backendEventVisibleSeries.size,
|
|
routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size,
|
|
problemCount: problems.length,
|
|
status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok",
|
|
lowSampleThreshold: LOW_SAMPLE_THRESHOLD,
|
|
window: sampleWindow.id,
|
|
windowLabel: sampleWindow.label,
|
|
windowSeconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000)
|
|
};
|
|
const observedAt = new Date().toISOString();
|
|
const dashboard = buildPerformanceDashboard({
|
|
observedAt,
|
|
source: "cloud-api-rum-store",
|
|
namespace: baseLabels.namespace,
|
|
gitopsTarget: baseLabels.gitops_target,
|
|
sampleWindow,
|
|
summary: summaryInfo,
|
|
apiRoutes,
|
|
webVitals,
|
|
longTasks,
|
|
problems,
|
|
workbenchJourneys,
|
|
workbenchEventPhases,
|
|
workbenchBackendEvents,
|
|
rows: rows.sort(sortByProblemThenP95).slice(0, 40),
|
|
workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40)
|
|
});
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: "/v1/web-performance/summary",
|
|
source: "cloud-api-rum-store",
|
|
observedAt,
|
|
namespace: baseLabels.namespace,
|
|
gitopsTarget: baseLabels.gitops_target,
|
|
sampleWindow: {
|
|
id: sampleWindow.id,
|
|
label: sampleWindow.label,
|
|
seconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000)
|
|
},
|
|
summary: summaryInfo,
|
|
dashboard,
|
|
apiRoutes,
|
|
webVitals,
|
|
longTasks,
|
|
problems,
|
|
workbenchJourneys,
|
|
workbenchEventPhases,
|
|
workbenchBackendEvents,
|
|
workbenchRows: dashboard.source.workbenchRows,
|
|
rows: dashboard.source.rows
|
|
};
|
|
}
|
|
|
|
function snapshot() {
|
|
return {
|
|
sampleSeries: sampleSeries.size,
|
|
durationSeries: durationSeries.size,
|
|
clsSeries: clsSeries.size,
|
|
droppedSeries: droppedSeries.size,
|
|
workbenchJourneySeries: journeyDurationSeries.size,
|
|
workbenchEventPhaseSeries: eventPhaseSeries.size,
|
|
workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size,
|
|
sampleCount: [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
|
|
+ [...journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
|
|
+ histogramSampleCount(eventPhaseSeries)
|
|
+ histogramSampleCount(backendEventVisibleSeries),
|
|
retainedSamples: samples.length,
|
|
maxSamples
|
|
};
|
|
}
|
|
|
|
function aggregateForWindow(sampleWindow: PerformanceSummaryWindow): PerformanceAggregate {
|
|
if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries };
|
|
const aggregate = createPerformanceAggregate();
|
|
const cutoff = now() - sampleWindow.milliseconds;
|
|
for (const sample of samples) {
|
|
if (sample.atMs >= cutoff) recordSampleIntoAggregate(aggregate, sample);
|
|
}
|
|
return aggregate;
|
|
}
|
|
|
|
return { record, metricsText, snapshot, summary };
|
|
}
|
|
|
|
function createPerformanceAggregate(): PerformanceAggregate {
|
|
return {
|
|
durationSeries: new Map(),
|
|
clsSeries: new Map(),
|
|
sampleSeries: new Map(),
|
|
journeyDurationSeries: new Map(),
|
|
journeyTotalSeries: new Map(),
|
|
eventPhaseSeries: new Map(),
|
|
backendEventVisibleSeries: new Map()
|
|
};
|
|
}
|
|
|
|
function recordSampleIntoAggregate(aggregate: PerformanceAggregate, sample: RecordedPerformanceSample) {
|
|
if (sample.series === "layout_shift") {
|
|
recordHistogram(aggregate.clsSeries, sample.labels, sample.value, CLS_BUCKETS, Number.MAX_SAFE_INTEGER);
|
|
incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER);
|
|
return;
|
|
}
|
|
if (sample.series === "web_duration") {
|
|
recordHistogram(aggregate.durationSeries, sample.labels, sample.value, DURATION_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
|
|
incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER);
|
|
return;
|
|
}
|
|
if (sample.series === "workbench_journey") {
|
|
recordHistogram(aggregate.journeyDurationSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
|
|
incrementCounter(aggregate.journeyTotalSeries, sample.counterLabels ?? sample.labels, Number.MAX_SAFE_INTEGER);
|
|
return;
|
|
}
|
|
if (sample.series === "workbench_event_phase") {
|
|
recordHistogram(aggregate.eventPhaseSeries, sample.labels, sample.value, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
|
|
return;
|
|
}
|
|
recordHistogram(aggregate.backendEventVisibleSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
|
|
}
|
|
|
|
function resolvePerformanceSummaryWindow(input: unknown): PerformanceSummaryWindow {
|
|
const key = String(input ?? DEFAULT_SUMMARY_WINDOW).trim().toLowerCase();
|
|
if (Object.prototype.hasOwnProperty.call(PERFORMANCE_SUMMARY_WINDOWS, key)) return PERFORMANCE_SUMMARY_WINDOWS[key as keyof typeof PERFORMANCE_SUMMARY_WINDOWS];
|
|
return PERFORMANCE_SUMMARY_WINDOWS[DEFAULT_SUMMARY_WINDOW];
|
|
}
|
|
|
|
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 primary = String(options.store.metricsText() ?? "").trimEnd();
|
|
const extra = typeof options.extraMetricsText === "function"
|
|
? String(options.extraMetricsText() ?? "").trimEnd()
|
|
: String(options.extraMetricsText ?? "").trimEnd();
|
|
const payload = [primary, extra].filter(Boolean).join("\n") + "\n";
|
|
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 = {}) {
|
|
const url = requestUrl(request);
|
|
sendJson(response, 200, options.store.summary({ window: url.searchParams.get("window") }));
|
|
}
|
|
|
|
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 requestUrl(request) {
|
|
try {
|
|
return new URL(String(request.url ?? "/"), `http://${String(request.headers?.host ?? "hwlab.local")}`);
|
|
} catch {
|
|
return new URL("/", "http://hwlab.local");
|
|
}
|
|
}
|
|
|
|
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 canRecordSeries(series: Map<string, HistogramSeries | CounterSeries>, labels: Record<string, string>, maxSeries: number) {
|
|
const key = stableLabelKey(labels);
|
|
return series.has(key) || series.size < maxSeries;
|
|
}
|
|
|
|
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 false;
|
|
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;
|
|
return true;
|
|
}
|
|
|
|
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 false;
|
|
entry = { labels, value: 0 };
|
|
series.set(key, entry);
|
|
}
|
|
entry.value += 1;
|
|
return true;
|
|
}
|
|
|
|
function histogramSampleCount(series: Map<string, HistogramSeries>) {
|
|
return [...series.values()].reduce((sum, entry) => sum + entry.count, 0);
|
|
}
|
|
|
|
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 p75 = histogramQuantile(entry, buckets, 0.75);
|
|
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),
|
|
p75: roundMetric(p75),
|
|
p95: roundMetric(p95),
|
|
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));
|
|
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 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";
|
|
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 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;
|
|
}
|
|
|
|
function toneWeight(tone: string) {
|
|
if (tone === "blocked") return 4;
|
|
if (tone === "warn") return 3;
|
|
if (tone === "ok") return 2;
|
|
return 1;
|
|
}
|
|
|
|
function buildPerformanceDashboard(input: {
|
|
observedAt: string;
|
|
source: string;
|
|
namespace: string;
|
|
gitopsTarget: string;
|
|
sampleWindow: PerformanceSummaryWindow;
|
|
summary: Record<string, number | string | null>;
|
|
apiRoutes: PerformanceSummaryRow[];
|
|
webVitals: PerformanceSummaryRow[];
|
|
longTasks: PerformanceSummaryRow[];
|
|
problems: PerformanceSummaryRow[];
|
|
workbenchJourneys: PerformanceSummaryRow[];
|
|
workbenchEventPhases: PerformanceSummaryRow[];
|
|
workbenchBackendEvents: PerformanceSummaryRow[];
|
|
rows: PerformanceSummaryRow[];
|
|
workbenchRows: PerformanceSummaryRow[];
|
|
}) {
|
|
const allRows = [...input.rows, ...input.workbenchRows];
|
|
const sampleCount = numberField(input.summary.sampleCount);
|
|
const problemCount = numberField(input.summary.problemCount);
|
|
const status = String(input.summary.status ?? "waiting");
|
|
const trends = [
|
|
dashboardTrend("workbench-experience", "工作台体感趋势", "首屏、会话切换和提交首个可见结果的 P95", "seconds", input.workbenchJourneys),
|
|
dashboardTrend("backend-visible", "后端事件可见延迟", "AgentRun/backend 事件到浏览器可见的 P95", "seconds", input.workbenchBackendEvents),
|
|
dashboardTrend("api-timing", "同源 API 耗时", "Cloud Web 同源 API 请求 P95", "seconds", input.apiRoutes),
|
|
dashboardTrend("web-vitals", "浏览器核心指标", "LCP/INP/CLS/FCP/TTFB 当前窗口", "mixed", input.webVitals)
|
|
].filter((trend) => trend.points.length > 0);
|
|
return {
|
|
schemaVersion: "hwlab-web-performance-dashboard-v1",
|
|
generatedAt: input.observedAt,
|
|
sampleWindow: {
|
|
id: input.sampleWindow.id,
|
|
label: input.sampleWindow.label,
|
|
seconds: input.sampleWindow.milliseconds === null ? null : Math.round(input.sampleWindow.milliseconds / 1000),
|
|
bucketKind: "snapshot",
|
|
buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }]
|
|
},
|
|
freshness: {
|
|
observedAt: input.observedAt,
|
|
source: input.source,
|
|
namespace: input.namespace,
|
|
gitopsTarget: input.gitopsTarget,
|
|
status,
|
|
statusLabel: dashboardStatusLabel(status),
|
|
lowSampleThreshold: numberField(input.summary.lowSampleThreshold)
|
|
},
|
|
cards: [
|
|
{ id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source },
|
|
{ id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${input.sampleWindow.label} · ${numberField(input.summary.sampleSeries)} 个序列` },
|
|
{ id: "workbench", label: "工作台序列", value: numberField(input.summary.workbenchJourneySeries) + numberField(input.summary.workbenchEventPhaseSeries) + numberField(input.summary.workbenchBackendEventVisibleSeries), unit: "序列", tone: "ok", detail: `${input.namespace} / ${input.gitopsTarget}` },
|
|
{ id: "problems", label: "问题行", value: problemCount, unit: "条", tone: problemCount > 0 ? "warn" : "ok", detail: `低样本阈值 ${numberField(input.summary.lowSampleThreshold)} 个样本` }
|
|
],
|
|
trends,
|
|
distributions: [
|
|
dashboardDistribution("status", "状态分布", "按健康状态聚合当前窗口行数", countRowsBy(allRows, (row) => toneLabel(row.tone))),
|
|
dashboardDistribution("problems", "问题分布", "按问题类型聚合预警和阻塞行", countRowsBy(input.problems, (row) => problemLabel(row.problem || row.outcome))),
|
|
dashboardDistribution("samples", "样本状态", "正常、低样本和暂无数据分布", countRowsBy(allRows, (row) => sampleStateLabel(row.sampleState)))
|
|
].filter((item) => item.buckets.length > 0),
|
|
topN: [
|
|
dashboardTopN("top-api", "慢 API TopN", "同源 API P95 最慢路由", "seconds", input.apiRoutes),
|
|
dashboardTopN("top-workbench", "工作台慢路径 TopN", "工作台 journey 和事件可见延迟", "seconds", [...input.workbenchJourneys, ...input.workbenchBackendEvents]),
|
|
dashboardTopN("top-long-task", "长任务 TopN", "主线程长任务当前窗口", "seconds", input.longTasks)
|
|
].filter((item) => item.rows.length > 0),
|
|
source: {
|
|
rows: input.rows,
|
|
workbenchRows: input.workbenchRows
|
|
}
|
|
};
|
|
}
|
|
|
|
function dashboardTrend(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) {
|
|
return {
|
|
id,
|
|
title,
|
|
subtitle,
|
|
unit,
|
|
points: rows.slice(0, 8).map(dashboardPoint)
|
|
};
|
|
}
|
|
|
|
function dashboardPoint(row: PerformanceSummaryRow): PerformanceDashboardPoint {
|
|
return {
|
|
label: metricLabel(row),
|
|
detail: rowDetail(row),
|
|
value: row.p95,
|
|
p50: row.p50,
|
|
p75: row.p75,
|
|
p95: row.p95,
|
|
count: row.count,
|
|
tone: row.tone,
|
|
sampleState: row.sampleState ?? "ok"
|
|
};
|
|
}
|
|
|
|
function dashboardDistribution(id: string, title: string, subtitle: string, counts: Map<string, number>) {
|
|
const buckets = [...counts.entries()]
|
|
.map(([label, count]) => ({ label, count, tone: distributionTone(label) }))
|
|
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-Hans-CN"));
|
|
return { id, title, subtitle, unit: "count", buckets };
|
|
}
|
|
|
|
function dashboardTopN(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) {
|
|
const topRows: PerformanceDashboardTopRow[] = rows.slice(0, 8).map((row) => ({
|
|
label: metricLabel(row),
|
|
detail: rowDetail(row),
|
|
value: row.p95,
|
|
unit: row.unit === "score" ? "score" : unit,
|
|
count: row.count,
|
|
tone: row.tone
|
|
}));
|
|
return { id, title, subtitle, unit, rows: topRows };
|
|
}
|
|
|
|
function countRowsBy(rows: PerformanceSummaryRow[], getLabel: (row: PerformanceSummaryRow) => string) {
|
|
const counts = new Map<string, number>();
|
|
for (const row of rows) counts.set(getLabel(row), (counts.get(getLabel(row)) ?? 0) + Math.max(1, row.count));
|
|
return counts;
|
|
}
|
|
|
|
function metricLabel(row: PerformanceSummaryRow) {
|
|
const metric = row.metric || row.phase || row.kind || "unknown";
|
|
const labels: Record<string, string> = {
|
|
submit_to_first_visible: "提交到首个可见结果",
|
|
submit_to_failure: "提交失败可见",
|
|
backend_event_to_visible: "后端事件到可见",
|
|
session_switch_first_visible: "切换会话首个可见",
|
|
session_switch_full_load: "切换会话完整加载",
|
|
workbench_open_first_visible: "打开工作台首个可见",
|
|
workbench_open_full_load: "打开工作台完整加载",
|
|
created_to_append: "创建到写入 Trace",
|
|
append_to_sse: "Trace 写入到 SSE",
|
|
sse_to_receive: "SSE 到浏览器接收",
|
|
receive_to_project: "接收到状态投影",
|
|
project_to_paint: "投影到画面可见",
|
|
user_submit_to_api_accepted: "提交到 API 接收",
|
|
api_accepted_to_backend_event: "API 接收到后端事件",
|
|
api_request: "同源 API 请求",
|
|
long_task: "主线程长任务",
|
|
lcp: "最大内容绘制",
|
|
inp: "交互响应",
|
|
fid: "首次输入延迟",
|
|
cls: "布局偏移",
|
|
navigation_ttfb: "首字节时间",
|
|
navigation_dom_content_loaded: "DOM 内容加载",
|
|
navigation_load: "页面加载完成"
|
|
};
|
|
return labels[metric] ?? metric.replace(/_/gu, " ");
|
|
}
|
|
|
|
function rowDetail(row: PerformanceSummaryRow) {
|
|
const route = dashboardRouteLabel(row.route);
|
|
const parts = [
|
|
route,
|
|
row.eventType ? `事件:${eventTypeLabel(row.eventType)}` : "",
|
|
row.phase ? `阶段:${metricLabel({ ...row, metric: row.phase })}` : "",
|
|
row.backend ? `后端:${row.backend}` : "",
|
|
row.transport ? `传输:${transportLabel(row.transport)}` : "",
|
|
row.outcome ? `结果:${outcomeLabel(row.outcome)}` : ""
|
|
].filter(Boolean);
|
|
return parts.join(" / ") || "当前窗口";
|
|
}
|
|
|
|
function dashboardRouteLabel(route: string | undefined) {
|
|
const value = String(route ?? "").trim();
|
|
if (!value || value === "-") return "";
|
|
return value
|
|
.replace(/:sessionId/gu, ":会话")
|
|
.replace(/:traceId/gu, ":轨迹")
|
|
.replace(/:runId/gu, ":运行")
|
|
.replace(/:threadId/gu, ":线程")
|
|
.replace(/:turnId/gu, ":轮次");
|
|
}
|
|
|
|
function toneLabel(tone: string | undefined) {
|
|
if (tone === "ok") return "正常";
|
|
if (tone === "warn") return "预警";
|
|
if (tone === "blocked") return "阻塞";
|
|
return "等待数据";
|
|
}
|
|
|
|
function sampleStateLabel(state: string | undefined) {
|
|
if (state === "ok") return "正常样本";
|
|
if (state === "low-sample") return "低样本";
|
|
return "暂无数据";
|
|
}
|
|
|
|
function dashboardStatusLabel(status: string) {
|
|
if (status === "ok") return "正常";
|
|
if (status === "watch") return "预警";
|
|
if (status === "warming") return "低样本";
|
|
return "等待数据";
|
|
}
|
|
|
|
function dashboardTone(status: string) {
|
|
if (status === "ok") return "ok";
|
|
if (status === "watch") return "warn";
|
|
return "source";
|
|
}
|
|
|
|
function distributionTone(label: string) {
|
|
if (label.includes("阻塞")) return "blocked";
|
|
if (label.includes("预警") || label.includes("低样本")) return "warn";
|
|
return "ok";
|
|
}
|
|
|
|
function problemLabel(problem: string | undefined) {
|
|
const text = String(problem ?? "ok");
|
|
if (text === "ok") return "正常";
|
|
if (text === "low-sample") return "低样本";
|
|
if (text === "timeout") return "超时";
|
|
if (text === "network") return "网络失败";
|
|
if (text === "server-error") return "服务端错误";
|
|
if (text === "client-error") return "客户端错误";
|
|
if (text === "layout-shift") return "布局偏移";
|
|
if (text.startsWith("slow-")) return `慢路径:${metricLabel({ metric: text.slice(5), kind: "", route: "", method: "", statusClass: "", outcome: "", count: 0, average: 0, p50: 0, p75: 0, p95: 0, unit: "seconds", tone: "ok", problem: "" })}`;
|
|
return text.replace(/[_-]/gu, " ");
|
|
}
|
|
|
|
function eventTypeLabel(value: string) {
|
|
const labels: Record<string, string> = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" };
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
function transportLabel(value: string) {
|
|
const labels: Record<string, string> = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" };
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
function outcomeLabel(value: string) {
|
|
const labels: Record<string, string> = { ok: "正常", timeout: "超时", error: "错误", dropped: "丢弃", stale: "滞后", partial: "部分", empty: "空结果", network: "网络", denied: "拒绝", unknown: "未知" };
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
function numberField(value: unknown) {
|
|
return Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
}
|
|
|
|
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 requiredEnum(value: unknown, allowed: Set<string>) {
|
|
const text = sanitizeMetricName(value, "");
|
|
return allowed.has(text) ? text : null;
|
|
}
|
|
|
|
function optionalEnum(value: unknown, allowed: Set<string>, fallback: string) {
|
|
const text = sanitizeMetricName(value, fallback);
|
|
return allowed.has(text) ? text : fallback;
|
|
}
|
|
|
|
function normalizeEventType(value: unknown) {
|
|
const text = sanitizeMetricName(value, "unknown");
|
|
const aliased = text === "assistant_message" ? "assistant" : text === "tool" ? "tool_call" : text;
|
|
return WORKBENCH_EVENT_TYPES.has(aliased) ? aliased : "unknown";
|
|
}
|
|
|
|
function normalizeBackendLabel(value: unknown) {
|
|
const sanitized = sanitizeLabelValue(String(value ?? "unknown").trim().toLowerCase(), "unknown").slice(0, 64);
|
|
if (!sanitized || looksHighCardinalityLabel(sanitized)) return "unknown";
|
|
return sanitized;
|
|
}
|
|
|
|
function looksHighCardinalityLabel(value: string) {
|
|
return /(?:^|[^a-z0-9])(?:trc|trace|thread|run|cmd|command|job|ses|session|cnv|conv|conversation|gws|box|res|pod|dp|usr|user)[_-][a-z0-9_-]{6,}/iu.test(value)
|
|
|| /^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(value)
|
|
|| /^[A-Za-z0-9_-]{24,}$/u.test(value);
|
|
}
|
|
|
|
function durationValueSeconds(valueMs: unknown, maxSeconds: number) {
|
|
const seconds = finiteNumber(valueMs, NaN) / 1000;
|
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
return Math.min(seconds, maxSeconds);
|
|
}
|
|
|
|
function field(input: unknown, key: string) {
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
|
|
return (input as Record<string, unknown>)[key];
|
|
}
|
|
|
|
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`);
|
|
}
|