From ed7d1879214da29d6d5951243715f381a11f6302 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:49:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20Performance=20summa?= =?UTF-8?q?ry=20=E8=A7=82=E6=B5=8B=E5=A5=91=E7=BA=A6=20(#1647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/web-performance.test.ts | 14 +- internal/cloud/web-performance.ts | 250 +++++++++++++++++++++++-- web/hwlab-cloud-web/src/types/index.ts | 18 +- 3 files changed, 258 insertions(+), 24 deletions(-) diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 4aad735d..f6c9f86d 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 +// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-19-p0 // Verifies the Workbench RUM metrics contract and Prometheus label privacy boundary. import assert from "node:assert/strict"; @@ -49,8 +49,9 @@ test("web performance summary exposes user-perceived route latency without high- assert.ok(summary.dashboard.trends.some((trend) => trend.id === "api-timing" && trend.points.length > 0)); assert.ok(summary.dashboard.topN.some((group) => group.id === "top-api" && group.rows.length > 0)); assert.ok(summary.apiRoutes.some((row) => row.route === "/v1/agent/chat/result/:id" && row.metric === "api_request" && row.p95 >= 2.5)); - assert.ok(summary.problems.some((row) => row.problem === "timeout" && row.tone === "blocked")); - assert.ok(summary.longTasks.some((row) => row.metric === "long_task" && row.problem === "slow-long_task" && row.tone !== "ok")); + assert.ok(summary.problems.some((row) => row.outcome === "timeout" && row.problem === "low-sample" && row.tone === "source" && row.sourceFamily === "rum_api_timing" && row.aggregationKind === "exact" && row.approximation === "exact")); + assert.ok(summary.longTasks.some((row) => row.metric === "long_task" && row.problem === "low-sample" && row.tone === "source")); + assert.ok(summary.rows.every((row) => row.windowFrom && row.windowTo && row.generatedAt && row.aggregationKind === "exact" && row.approximation === "exact")); assert.doesNotMatch(json, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu); }); @@ -82,10 +83,13 @@ test("web performance summary filters rows by requested recent window", () => { assert.equal(recent.sampleWindow.id, "5m"); assert.equal(recent.summary.sampleCount, 1); - assert.equal(recent.apiRoutes[0].p95, 0.5); + assert.equal(recent.apiRoutes[0].p95, 0.32); + assert.equal(recent.apiRoutes[0].aggregationKind, "exact"); + assert.equal(recent.apiRoutes[0].approximation, "exact"); assert.equal(all.sampleWindow.id, "all"); assert.equal(all.summary.sampleCount, 2); - assert.equal(all.apiRoutes[0].p95, 5); + assert.equal(all.apiRoutes[0].p95, 2.6); + assert.equal(all.sampleWindow.windowFrom, null); assert.doesNotMatch(recentJson, /trc_old_secret|trc_new_secret|traceId|prompt|api key/iu); assert.doesNotMatch(allJson, /trc_old_secret|trc_new_secret|traceId|prompt|api key/iu); }); diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index d94cce99..a0c5f3fa 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 +// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-19-p0 // Keeps browser-reported performance metrics low-cardinality before Prometheus export. import { emitWorkbenchUiOtelSpan } from "./otel-trace.ts"; @@ -173,6 +173,15 @@ interface PerformanceSummaryRow { p75: number; p95: number; unit: "seconds" | "score"; + sourceFamily?: string; + windowFrom?: string | null; + windowTo?: string; + generatedAt?: string; + sampleCount?: number; + freshness?: "fresh" | "low-sample" | "empty"; + aggregationKind?: "exact" | "histogram"; + approximation?: "exact" | "bucketed"; + bucketBoundaries?: number[]; tone: "ok" | "warn" | "blocked" | "source"; problem: string; lowSample?: boolean; @@ -431,17 +440,21 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = 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 generatedAtMs = now(); + const observedAt = new Date(generatedAtMs).toISOString(); + const windowBounds = performanceSummaryWindowBounds(sampleWindow, generatedAtMs, observedAt); + const aggregate = aggregateForWindow(sampleWindow, generatedAtMs); + const exactStats = exactStatsForSamples(samplesForWindow(sampleWindow, generatedAtMs, samples)); + const durationRows = applySummaryContract(histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds"), exactStats, windowBounds); + const clsRows = applySummaryContract(histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score"), exactStats, windowBounds); const rows = [...durationRows, ...clsRows]; - const workbenchJourneys = workbenchJourneySummaryRows(aggregate.journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) + const workbenchJourneys = applySummaryContract(workbenchJourneySummaryRows(aggregate.journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); - const workbenchEventPhases = workbenchEventPhaseSummaryRows(aggregate.eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS) + const workbenchEventPhases = applySummaryContract(workbenchEventPhaseSummaryRows(aggregate.eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); - const workbenchBackendEvents = workbenchBackendEventVisibleSummaryRows(aggregate.backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS) + const workbenchBackendEvents = applySummaryContract(workbenchBackendEventVisibleSummaryRows(aggregate.backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents]; @@ -483,9 +496,13 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = lowSampleThreshold: LOW_SAMPLE_THRESHOLD, window: sampleWindow.id, windowLabel: sampleWindow.label, - windowSeconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000) + windowSeconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000), + windowFrom: windowBounds.windowFrom, + windowTo: windowBounds.windowTo, + generatedAt: observedAt, + aggregationKind: "exact", + approximation: "exact" }; - const observedAt = new Date().toISOString(); const dashboard = buildPerformanceDashboard({ observedAt, source: "cloud-api-rum-store", @@ -513,7 +530,12 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = sampleWindow: { id: sampleWindow.id, label: sampleWindow.label, - seconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000) + seconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000), + windowFrom: windowBounds.windowFrom, + windowTo: windowBounds.windowTo, + generatedAt: observedAt, + aggregationKind: "exact", + approximation: "exact" }, summary: summaryInfo, dashboard, @@ -549,13 +571,9 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = }; } - function aggregateForWindow(sampleWindow: PerformanceSummaryWindow): PerformanceAggregate { - if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries, workbenchUiEventSeries, workbenchUiEventTotalSeries }; + function aggregateForWindow(sampleWindow: PerformanceSummaryWindow, generatedAtMs = now()): PerformanceAggregate { const aggregate = createPerformanceAggregate(); - const cutoff = now() - sampleWindow.milliseconds; - for (const sample of samples) { - if (sample.atMs >= cutoff) recordSampleIntoAggregate(aggregate, sample); - } + for (const sample of samplesForWindow(sampleWindow, generatedAtMs, samples)) recordSampleIntoAggregate(aggregate, sample); return aggregate; } @@ -604,12 +622,25 @@ function recordSampleIntoAggregate(aggregate: PerformanceAggregate, sample: Reco recordHistogram(aggregate.backendEventVisibleSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); } +function samplesForWindow(samplesWindow: PerformanceSummaryWindow, generatedAtMs: number, retainedSamples: RecordedPerformanceSample[] = []) { + const source = retainedSamples.length ? retainedSamples : []; + if (samplesWindow.milliseconds === null) return source.slice(); + const cutoff = generatedAtMs - samplesWindow.milliseconds; + return source.filter((sample) => sample.atMs >= cutoff && sample.atMs <= generatedAtMs); +} + 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]; } +function performanceSummaryWindowBounds(sampleWindow: PerformanceSummaryWindow, generatedAtMs: number, generatedAt: string) { + const windowTo = generatedAt; + const windowFrom = sampleWindow.milliseconds === null ? null : new Date(Math.max(0, generatedAtMs - sampleWindow.milliseconds)).toISOString(); + return { windowFrom, windowTo, generatedAt }; +} + export async function handleWebPerformanceIngestHttp(request, response, options = {}) { const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES); let payload; @@ -856,6 +887,181 @@ function histogramSummaryRows(series: Map, buckets: num }); } +function exactStatsForSamples(windowSamples: RecordedPerformanceSample[]) { + const grouped = new Map(); + for (const sample of windowSamples) { + const identity = summaryRowIdentityForSample(sample); + if (!identity) continue; + const key = summaryRowKey(identity); + let entry = grouped.get(key); + if (!entry) { + entry = { values: [] }; + grouped.set(key, entry); + } + entry.values.push(sample.value); + } + const stats = new Map(); + for (const [key, entry] of grouped.entries()) stats.set(key, exactStats(entry.values)); + return stats; +} + +function exactStats(values: number[]) { + const sorted = values.filter(Number.isFinite).sort((left, right) => left - right); + const count = sorted.length; + const average = count > 0 ? sorted.reduce((sum, value) => sum + value, 0) / count : 0; + return { + count, + average: roundMetric(average), + p50: roundMetric(exactQuantile(sorted, 0.5)), + p75: roundMetric(exactQuantile(sorted, 0.75)), + p95: roundMetric(exactQuantile(sorted, 0.95)) + }; +} + +function exactQuantile(sortedValues: number[], quantile: number) { + if (sortedValues.length <= 0) return 0; + const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * quantile) - 1)); + return sortedValues[index] ?? 0; +} + +function applySummaryContract(rows: PerformanceSummaryRow[], exactStatsByRow: Map, windowBounds: { windowFrom: string | null; windowTo: string; generatedAt: string }) { + return rows.map((row) => { + const exact = exactStatsByRow.get(summaryRowKey(row)); + const count = exact?.count ?? row.count; + const p95 = exact?.p95 ?? row.p95; + const nextRow = { + ...row, + ...(exact ? { count, average: exact.average, p50: exact.p50, p75: exact.p75, p95 } : {}), + sampleCount: count, + sourceFamily: sourceFamilyForRow(row), + windowFrom: windowBounds.windowFrom, + windowTo: windowBounds.windowTo, + generatedAt: windowBounds.generatedAt, + freshness: sampleFreshness(count), + aggregationKind: exact ? "exact" : "histogram", + approximation: exact ? "exact" : "bucketed", + bucketBoundaries: exact ? undefined : bucketBoundariesForRow(row), + lowSample: count > 0 && count < LOW_SAMPLE_THRESHOLD, + sampleState: sampleState(count) + }; + const tone = summaryToneForRow(nextRow, p95, count); + return { ...nextRow, tone, problem: summaryProblemForRow(nextRow, p95, count, tone) }; + }); +} + +function summaryRowIdentityForSample(sample: RecordedPerformanceSample): Record | null { + const labels = sample.labels; + if (sample.series === "web_duration" || sample.series === "layout_shift") { + return { + kind: labels.kind, + metric: labels.metric, + route: labels.route, + method: labels.method, + statusClass: labels.status_class, + outcome: labels.outcome + }; + } + if (sample.series === "workbench_journey") { + return { + kind: "workbench_journey", + metric: labels.journey, + route: labels.route, + method: "-", + statusClass: "-", + 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 + }; + } + if (sample.series === "workbench_event_phase") { + return { + kind: "workbench_event_phase", + metric: labels.phase, + route: "workbench-events", + method: "-", + statusClass: "-", + outcome: labels.outcome, + backend: labels.backend, + transport: labels.transport, + eventType: labels.event_type, + phase: labels.phase + }; + } + if (sample.series === "workbench_backend_event_visible") { + return { + kind: "workbench_backend_event_visible", + metric: "backend_event_to_visible", + route: "workbench-events", + method: "-", + statusClass: "-", + outcome: labels.outcome, + backend: labels.backend, + transport: labels.transport, + eventType: labels.event_type + }; + } + return null; +} + +function summaryRowKey(row: Record) { + const fields = ["kind", "metric", "route", "method", "statusClass", "outcome", "backend", "transport", "targetState", "cache", "source", "entry", "authState", "eventType", "phase"]; + const labels: Record = {}; + for (const field of fields) labels[field] = String(row[field] ?? ""); + return stableLabelKey(labels); +} + +function summaryToneForRow(row: PerformanceSummaryRow, p95: number, count: number): "ok" | "warn" | "blocked" | "source" { + if (count <= 0 || count < LOW_SAMPLE_THRESHOLD) return "source"; + if (row.kind === "workbench_journey") { + const threshold = workbenchJourneyThreshold(row.metric); + return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); + } + if (row.kind === "workbench_event_phase") { + const threshold = workbenchEventPhaseThreshold(row.metric); + return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); + } + if (row.kind === "workbench_backend_event_visible") { + const threshold = workbenchJourneyThreshold("backend_event_to_visible"); + return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); + } + return performanceTone({ metric: row.metric, outcome: row.outcome, status_class: row.statusClass }, p95, count, row.unit === "score" ? "score" : "seconds"); +} + +function summaryProblemForRow(row: PerformanceSummaryRow, p95: number, count: number, tone: string) { + if (count <= 0) return "no-sample"; + if (count < LOW_SAMPLE_THRESHOLD) return "low-sample"; + if (row.kind === "workbench_journey" || row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible") return workbenchProblem(row.outcome, row.metric, count, tone); + return performanceProblem({ metric: row.metric, outcome: row.outcome, status_class: row.statusClass }, p95, count, row.unit === "score" ? "score" : "seconds", tone); +} + +function sourceFamilyForRow(row: PerformanceSummaryRow) { + if (row.kind === "api") return "rum_api_timing"; + if (row.kind === "web_vital") return "rum_web_vitals"; + if (row.kind === "navigation") return "rum_navigation"; + if (row.kind === "long_task") return "rum_long_task"; + if (row.kind === "workbench_journey") return "workbench_journey"; + if (row.kind === "workbench_event_phase") return "workbench_event_phase"; + if (row.kind === "workbench_backend_event_visible") return "workbench_backend_event_visible"; + return "unknown"; +} + +function sampleFreshness(count: number): "fresh" | "low-sample" | "empty" { + if (count <= 0) return "empty"; + return count < LOW_SAMPLE_THRESHOLD ? "low-sample" : "fresh"; +} + +function bucketBoundariesForRow(row: PerformanceSummaryRow) { + if (row.unit === "score") return CLS_BUCKETS; + if (row.kind === "workbench_event_phase") return WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS; + if (row.kind === "workbench_journey" || row.kind === "workbench_backend_event_visible") return WORKBENCH_JOURNEY_BUCKETS_SECONDS; + return DURATION_BUCKETS_SECONDS; +} + function workbenchJourneySummaryRows(series: Map, buckets: number[]): PerformanceSummaryRow[] { return [...series.values()].map((entry) => { const labels = entry.labels; @@ -1096,6 +1302,11 @@ function buildPerformanceDashboard(input: { id: input.sampleWindow.id, label: input.sampleWindow.label, seconds: input.sampleWindow.milliseconds === null ? null : Math.round(input.sampleWindow.milliseconds / 1000), + windowFrom: input.summary.windowFrom ?? null, + windowTo: input.summary.windowTo ?? input.observedAt, + generatedAt: input.summary.generatedAt ?? input.observedAt, + aggregationKind: input.summary.aggregationKind ?? "exact", + approximation: input.summary.approximation ?? "exact", bucketKind: "snapshot", buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }] }, @@ -1106,7 +1317,12 @@ function buildPerformanceDashboard(input: { gitopsTarget: input.gitopsTarget, status, statusLabel: dashboardStatusLabel(status), - lowSampleThreshold: numberField(input.summary.lowSampleThreshold) + lowSampleThreshold: numberField(input.summary.lowSampleThreshold), + generatedAt: input.summary.generatedAt ?? input.observedAt, + windowFrom: input.summary.windowFrom ?? null, + windowTo: input.summary.windowTo ?? input.observedAt, + sampleCount, + sourceFamily: "mixed" }, cards: [ { id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source }, diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 24d04cb8..abb6f7d8 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -332,6 +332,15 @@ export interface WebPerformanceRow extends Record { p75?: number; p95?: number; unit?: "seconds" | "score" | string; + sourceFamily?: string; + windowFrom?: string | null; + windowTo?: string; + generatedAt?: string; + sampleCount?: number; + freshness?: "fresh" | "low-sample" | "empty" | string; + aggregationKind?: "exact" | "histogram" | string; + approximation?: "exact" | "bucketed" | string; + bucketBoundaries?: number[]; tone?: "ok" | "warn" | "blocked" | "pending" | string; problem?: string; lowSample?: boolean; @@ -353,6 +362,11 @@ export interface WebPerformanceSampleWindow extends Record { id?: WebPerformanceWindow | string; label?: string; seconds?: number | null; + windowFrom?: string | null; + windowTo?: string; + generatedAt?: string; + aggregationKind?: "exact" | "histogram" | string; + approximation?: "exact" | "bucketed" | string; } export interface WebPerformanceDashboardPoint extends Record { @@ -419,7 +433,7 @@ export interface WebPerformanceDashboard extends Record { schemaVersion?: string; generatedAt?: string; sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array>; [key: string]: unknown }; - freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; [key: string]: unknown }; + freshness?: { observedAt?: string; source?: string; namespace?: string; gitopsTarget?: string; status?: string; statusLabel?: string; lowSampleThreshold?: number; generatedAt?: string; windowFrom?: string | null; windowTo?: string; sampleCount?: number; sourceFamily?: string; [key: string]: unknown }; cards?: WebPerformanceDashboardCard[]; trends?: WebPerformanceDashboardTrend[]; distributions?: WebPerformanceDashboardDistribution[]; @@ -433,7 +447,7 @@ export interface WebPerformanceSummaryResponse { namespace?: string; gitopsTarget?: string; sampleWindow?: WebPerformanceSampleWindow; - summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; window?: WebPerformanceWindow | string; windowLabel?: string; windowSeconds?: number | null; [key: string]: unknown }; + summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; lowSampleThreshold?: number; window?: WebPerformanceWindow | string; windowLabel?: string; windowSeconds?: number | null; windowFrom?: string | null; windowTo?: string; generatedAt?: string; aggregationKind?: "exact" | "histogram" | string; approximation?: "exact" | "bucketed" | string; [key: string]: unknown }; dashboard?: WebPerformanceDashboard; apiRoutes?: WebPerformanceRow[]; webVitals?: WebPerformanceRow[];