fix: 收敛 Performance summary 观测契约 (#1647)

This commit is contained in:
Lyon
2026-06-20 01:49:22 +08:00
committed by GitHub
parent 5d8b9ffff9
commit ed7d187921
3 changed files with 258 additions and 24 deletions
+9 -5
View File
@@ -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);
});
+233 -17
View File
@@ -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<string, HistogramSeries>, buckets: num
});
}
function exactStatsForSamples(windowSamples: RecordedPerformanceSample[]) {
const grouped = new Map<string, { values: number[] }>();
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<string, { count: number; average: number; p50: number; p75: number; p95: number }>();
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<string, { count: number; average: number; p50: number; p75: number; p95: number }>, 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<string, unknown> | 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<string, unknown>) {
const fields = ["kind", "metric", "route", "method", "statusClass", "outcome", "backend", "transport", "targetState", "cache", "source", "entry", "authState", "eventType", "phase"];
const labels: Record<string, string> = {};
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<string, HistogramSeries>, 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 },
+16 -2
View File
@@ -332,6 +332,15 @@ export interface WebPerformanceRow extends Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
@@ -419,7 +433,7 @@ export interface WebPerformanceDashboard extends Record<string, unknown> {
schemaVersion?: string;
generatedAt?: string;
sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array<Record<string, unknown>>; [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[];