fix: 收敛 Performance summary 观测契约 (#1647)
This commit is contained in:
@@ -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.
|
// Verifies the Workbench RUM metrics contract and Prometheus label privacy boundary.
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
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.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.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.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.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 === "slow-long_task" && row.tone !== "ok"));
|
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);
|
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.sampleWindow.id, "5m");
|
||||||
assert.equal(recent.summary.sampleCount, 1);
|
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.sampleWindow.id, "all");
|
||||||
assert.equal(all.summary.sampleCount, 2);
|
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(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);
|
assert.doesNotMatch(allJson, /trc_old_secret|trc_new_secret|traceId|prompt|api key/iu);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// Keeps browser-reported performance metrics low-cardinality before Prometheus export.
|
||||||
|
|
||||||
import { emitWorkbenchUiOtelSpan } from "./otel-trace.ts";
|
import { emitWorkbenchUiOtelSpan } from "./otel-trace.ts";
|
||||||
@@ -173,6 +173,15 @@ interface PerformanceSummaryRow {
|
|||||||
p75: number;
|
p75: number;
|
||||||
p95: number;
|
p95: number;
|
||||||
unit: "seconds" | "score";
|
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";
|
tone: "ok" | "warn" | "blocked" | "source";
|
||||||
problem: string;
|
problem: string;
|
||||||
lowSample?: boolean;
|
lowSample?: boolean;
|
||||||
@@ -431,17 +440,21 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
|||||||
|
|
||||||
function summary(input: { window?: unknown } = {}) {
|
function summary(input: { window?: unknown } = {}) {
|
||||||
const sampleWindow = resolvePerformanceSummaryWindow(input.window);
|
const sampleWindow = resolvePerformanceSummaryWindow(input.window);
|
||||||
const aggregate = aggregateForWindow(sampleWindow);
|
const generatedAtMs = now();
|
||||||
const durationRows = histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
|
const observedAt = new Date(generatedAtMs).toISOString();
|
||||||
const clsRows = histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score");
|
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 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)
|
.sort(sortByProblemThenP95)
|
||||||
.slice(0, 24);
|
.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)
|
.sort(sortByProblemThenP95)
|
||||||
.slice(0, 24);
|
.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)
|
.sort(sortByProblemThenP95)
|
||||||
.slice(0, 24);
|
.slice(0, 24);
|
||||||
const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents];
|
const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents];
|
||||||
@@ -483,9 +496,13 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
|||||||
lowSampleThreshold: LOW_SAMPLE_THRESHOLD,
|
lowSampleThreshold: LOW_SAMPLE_THRESHOLD,
|
||||||
window: sampleWindow.id,
|
window: sampleWindow.id,
|
||||||
windowLabel: sampleWindow.label,
|
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({
|
const dashboard = buildPerformanceDashboard({
|
||||||
observedAt,
|
observedAt,
|
||||||
source: "cloud-api-rum-store",
|
source: "cloud-api-rum-store",
|
||||||
@@ -513,7 +530,12 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
|||||||
sampleWindow: {
|
sampleWindow: {
|
||||||
id: sampleWindow.id,
|
id: sampleWindow.id,
|
||||||
label: sampleWindow.label,
|
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,
|
summary: summaryInfo,
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -549,13 +571,9 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aggregateForWindow(sampleWindow: PerformanceSummaryWindow): PerformanceAggregate {
|
function aggregateForWindow(sampleWindow: PerformanceSummaryWindow, generatedAtMs = now()): PerformanceAggregate {
|
||||||
if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries, workbenchUiEventSeries, workbenchUiEventTotalSeries };
|
|
||||||
const aggregate = createPerformanceAggregate();
|
const aggregate = createPerformanceAggregate();
|
||||||
const cutoff = now() - sampleWindow.milliseconds;
|
for (const sample of samplesForWindow(sampleWindow, generatedAtMs, samples)) recordSampleIntoAggregate(aggregate, sample);
|
||||||
for (const sample of samples) {
|
|
||||||
if (sample.atMs >= cutoff) recordSampleIntoAggregate(aggregate, sample);
|
|
||||||
}
|
|
||||||
return aggregate;
|
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);
|
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 {
|
function resolvePerformanceSummaryWindow(input: unknown): PerformanceSummaryWindow {
|
||||||
const key = String(input ?? DEFAULT_SUMMARY_WINDOW).trim().toLowerCase();
|
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];
|
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];
|
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 = {}) {
|
export async function handleWebPerformanceIngestHttp(request, response, options = {}) {
|
||||||
const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES);
|
const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES);
|
||||||
let payload;
|
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[] {
|
function workbenchJourneySummaryRows(series: Map<string, HistogramSeries>, buckets: number[]): PerformanceSummaryRow[] {
|
||||||
return [...series.values()].map((entry) => {
|
return [...series.values()].map((entry) => {
|
||||||
const labels = entry.labels;
|
const labels = entry.labels;
|
||||||
@@ -1096,6 +1302,11 @@ function buildPerformanceDashboard(input: {
|
|||||||
id: input.sampleWindow.id,
|
id: input.sampleWindow.id,
|
||||||
label: input.sampleWindow.label,
|
label: input.sampleWindow.label,
|
||||||
seconds: input.sampleWindow.milliseconds === null ? null : Math.round(input.sampleWindow.milliseconds / 1000),
|
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",
|
bucketKind: "snapshot",
|
||||||
buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }]
|
buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }]
|
||||||
},
|
},
|
||||||
@@ -1106,7 +1317,12 @@ function buildPerformanceDashboard(input: {
|
|||||||
gitopsTarget: input.gitopsTarget,
|
gitopsTarget: input.gitopsTarget,
|
||||||
status,
|
status,
|
||||||
statusLabel: dashboardStatusLabel(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: [
|
cards: [
|
||||||
{ id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source },
|
{ id: "health", label: "采集状态", value: dashboardStatusLabel(status), unit: "状态", tone: dashboardTone(status), detail: input.source },
|
||||||
|
|||||||
@@ -332,6 +332,15 @@ export interface WebPerformanceRow extends Record<string, unknown> {
|
|||||||
p75?: number;
|
p75?: number;
|
||||||
p95?: number;
|
p95?: number;
|
||||||
unit?: "seconds" | "score" | string;
|
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;
|
tone?: "ok" | "warn" | "blocked" | "pending" | string;
|
||||||
problem?: string;
|
problem?: string;
|
||||||
lowSample?: boolean;
|
lowSample?: boolean;
|
||||||
@@ -353,6 +362,11 @@ export interface WebPerformanceSampleWindow extends Record<string, unknown> {
|
|||||||
id?: WebPerformanceWindow | string;
|
id?: WebPerformanceWindow | string;
|
||||||
label?: string;
|
label?: string;
|
||||||
seconds?: number | null;
|
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> {
|
export interface WebPerformanceDashboardPoint extends Record<string, unknown> {
|
||||||
@@ -419,7 +433,7 @@ export interface WebPerformanceDashboard extends Record<string, unknown> {
|
|||||||
schemaVersion?: string;
|
schemaVersion?: string;
|
||||||
generatedAt?: string;
|
generatedAt?: string;
|
||||||
sampleWindow?: WebPerformanceSampleWindow & { bucketKind?: string; buckets?: Array<Record<string, unknown>>; [key: string]: unknown };
|
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[];
|
cards?: WebPerformanceDashboardCard[];
|
||||||
trends?: WebPerformanceDashboardTrend[];
|
trends?: WebPerformanceDashboardTrend[];
|
||||||
distributions?: WebPerformanceDashboardDistribution[];
|
distributions?: WebPerformanceDashboardDistribution[];
|
||||||
@@ -433,7 +447,7 @@ export interface WebPerformanceSummaryResponse {
|
|||||||
namespace?: string;
|
namespace?: string;
|
||||||
gitopsTarget?: string;
|
gitopsTarget?: string;
|
||||||
sampleWindow?: WebPerformanceSampleWindow;
|
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;
|
dashboard?: WebPerformanceDashboard;
|
||||||
apiRoutes?: WebPerformanceRow[];
|
apiRoutes?: WebPerformanceRow[];
|
||||||
webVitals?: WebPerformanceRow[];
|
webVitals?: WebPerformanceRow[];
|
||||||
|
|||||||
Reference in New Issue
Block a user