fix: depollute v02 web performance telemetry

This commit is contained in:
Codex Agent
2026-06-05 13:03:09 +08:00
parent aec6903407
commit ac2d3001fa
8 changed files with 83 additions and 5 deletions
@@ -113,6 +113,8 @@ Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web c
WebUI 性能监控 issue 的 live closeout 不能只检查页面可见或 sidecar 存活;必须用 `19666` Web 入口触发真实浏览器 RUM 上报,再按 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md) 查询 `hwlab_webui_*` Prometheus 指标。LCP、Navigation Timing、业务 API timing、Long Task、CLS/INP/FID 近似只表达用户感知性能趋势,不替代 trace/result/inspect 的高基数排障证据。
Cloud Web 顶级性能页是观测面,不是业务工作台。访问 `/performance` / `#/performance` 时不得初始化 Workbench store 的 workspace hydrate 或 live refresh,也不得触发 device-pod events、agent conversations、live-builds、system.health 等 workspace-only 请求。性能页只允许主动请求 `GET /v1/web-performance/summary` 和静态资源;如果需要调查 workspace 或 device-pod 性能,必须从 workspace 原入口触发样本,而不是让性能页自己制造业务流量。
Live smoke 登录前必须等待前端 auth bootstrap 结束(`body[data-auth-state]` 不再是 `checking`,且 login submit 已可见/可用)再填表;登录后必须断言 URL query 不含 `username``password`,防止原生 form submit 泄漏凭据并伪装成 layout 超时。
Workbench build summary 的顶部 chip 可以展示 `/v1/live-builds.latest`,但 `latest` 是跨所有 HWLAB runtime service 的最新构建,不是 Cloud Web 专属字段。Cloud Web 布局或 build-time issue 的验收必须打开详情或读取 `services[]``serviceId=hwlab-cloud-web` 的行,分别核对 build time、env image、actual commit、revision 和 source metadata;不得因为 `latest.serviceId` 指向 `hwlab-cloud-api` 就判定 Web 构建时间缺失。
@@ -34,6 +34,8 @@ Cloud Web 用户感知性能必须进入同一套 Prometheus 查询面。浏览
Cloud Web 顶级性能页通过同源 `GET /v1/web-performance/summary` 读取低基数 JSON 摘要,用于展示用户可感知的 WebUI 样本数、慢 API route p95、Web Vitals、long task 和错误/超时问题队列。该接口不得返回 Prometheus 原始文本、Secret、prompt/assistant 正文或 trace/session/conversation/thread/user 等高基数标识;CLI 同路径验收使用 `hwlab-cli client request GET /v1/web-performance/summary`,不直连 Prometheus 公网地址。
观测面不得污染被观测对象。Cloud Web 性能页自身(`/performance` / `#/performance`)不得启动 workspace hydrate、live refresh、device-pod events、agent conversations 等业务请求,也不得把性能页自身的 LCP、Navigation Timing、Long Task 或 `/v1/web-performance*` 请求写入 WebUI 业务性能样本。Cloud API 在 `/v1/web-performance` 入库时必须丢弃来自性能页或观测 API 的样本,作为旧 bundle、缓存客户端或探针误上报的兜底。
## API 接口说明
### `/metrics`
+19
View File
@@ -47,6 +47,25 @@ test("web performance summary exposes user-perceived route latency without high-
assert.doesNotMatch(json, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu);
});
test("web performance store filters observability self-noise", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v02", HWLAB_GITOPS_TARGET: "v02" } });
const result = store.record({
schemaVersion: "hwlab-web-performance-v1",
page: "/performance",
events: [
{ kind: "web_vital", metric: "lcp", route: "/performance", valueMs: 5900 },
{ kind: "long_task", metric: "long_task", route: "/performance", valueMs: 120 },
{ kind: "api", metric: "api_request", route: "/v1/web-performance/summary", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 620 },
{ kind: "api", metric: "api_request", route: "/v1/device-pods", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 780 }
]
});
const summary = store.summary();
const rowsJson = JSON.stringify({ apiRoutes: summary.apiRoutes, webVitals: summary.webVitals, longTasks: summary.longTasks, problems: summary.problems, rows: summary.rows });
assert.deepEqual(result, { accepted: 0, dropped: 4, received: 4 });
assert.equal(summary.summary.sampleCount, 0);
assert.doesNotMatch(rowsJson, /\/performance|\/v1\/web-performance/u);
});
test("web performance route templates keep metrics low-cardinality", () => {
assert.equal(webPerformanceRouteTemplate("/v1/workbench/workspace/cnv_abcdef/select-conversation?traceId=trc_secret"), "/v1/workbench/workspace/:id/select-conversation");
assert.equal(webPerformanceRouteTemplate("#/skills"), "/skills");
+8
View File
@@ -111,6 +111,8 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
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,
@@ -259,6 +261,12 @@ function sampleLabels(labels: Record<string, string>) {
};
}
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 recordHistogram(series: Map<string, HistogramSeries>, labels: Record<string, string>, value: number, buckets: number[], maxSeries: number) {
const key = stableLabelKey(labels);
let entry = series.get(key);
+2 -1
View File
@@ -57,7 +57,8 @@ assertIncludes(appSource, "CommandBar", "command bar component must exist");
assertIncludes(appSource, "PerformanceView", "performance view component must exist");
assertIncludes(appSource, "SkillsView", "skills component must exist");
assertIncludes(appSource, "SettingsView", "settings component must exist");
assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\")", "Workbench data requests must wait until auth is authenticated");
assertIncludes(appTsx, "const workspaceRoute = route === \"workspace\"", "React runtime must explicitly identify workspace route before starting workbench data flows");
assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\" && workspaceRoute)", "Workbench data requests must wait until auth is authenticated and workspace route is active");
assertIncludes(apiClientSource, "adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson(\"/v1/rpc/system.health\", { method: \"POST\"", "adapter health must use POST /v1/rpc/system.health");
assertIncludes(appSource, "selectVisibleDevicePodId", "Device Pod detail probes must be selected from the authenticated visible list");
assertIncludes(appSource, "api.devicePods()", "Device Pod list must be fetched before detail probes");
+2 -2
View File
@@ -45,8 +45,9 @@ const routeLabels: Record<RouteId, string> = {
export function App(): ReactElement {
const auth = useAuth();
const store = useWorkbenchStore(auth.authState === "authenticated");
const [route, setRoute] = useState<RouteId>(() => routeFromLocation());
const workspaceRoute = route === "workspace";
const store = useWorkbenchStore(auth.authState === "authenticated" && workspaceRoute);
const [pickedDraft, setPickedDraft] = useState<string | null>(null);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
@@ -113,7 +114,6 @@ export function App(): ReactElement {
}, [route]);
const modelLabel = useMemo(() => store.state.providerProfile === "codex-api" ? "Codex API" : store.state.providerProfile === "minimax-m3" ? "MiniMax-M3" : "DeepSeek", [store.state.providerProfile]);
const workspaceRoute = route === "workspace";
const sessionSidebarVisible = workspaceRoute && !leftCollapsed;
const devicePodSidebarVisible = workspaceRoute && !rightCollapsed;
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { __resetWebPerformanceTelemetryForTest, webPerformanceRouteTemplate, webPerformanceStatusClass } from "./rum";
import { __resetWebPerformanceTelemetryForTest, recordApiTiming, webPerformanceRouteTemplate, webPerformanceStatusClass } from "./rum";
test("web performance route templates remove high-cardinality identifiers", () => {
assert.equal(webPerformanceRouteTemplate("/v1/agent/chat/result/trc_abc123456789"), "/v1/agent/chat/result/:id");
@@ -17,3 +17,36 @@ test("web performance status class keeps API labels low-cardinality", () => {
assert.equal(webPerformanceStatusClass("bad"), "network");
__resetWebPerformanceTelemetryForTest();
});
test("web performance telemetry does not record observability page API calls", () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousPerformance = globalThis.performance;
const previousFetch = globalThis.fetch;
const events = [];
globalThis.window = {
location: { hash: "#/performance", origin: "http://web.test" },
setTimeout: (handler: TimerHandler) => {
if (typeof handler === "function") handler();
return 0;
},
clearTimeout: () => undefined
} as unknown as Window & typeof globalThis;
globalThis.document = {} as Document;
globalThis.performance = {} as Performance;
globalThis.fetch = (async (_path: string, init?: RequestInit) => {
events.push(JSON.parse(String(init?.body ?? "{}")));
return new Response("{}", { status: 202, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
recordApiTiming({ path: "/v1/live-builds", durationMs: 1200, status: 200, outcome: "ok" });
recordApiTiming({ path: "/v1/web-performance/summary", durationMs: 600, status: 200, outcome: "ok" });
assert.equal(events.length, 0);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
globalThis.performance = previousPerformance;
globalThis.fetch = previousFetch;
__resetWebPerformanceTelemetryForTest();
}
});
@@ -77,7 +77,7 @@ export function installWebPerformanceTelemetry(): void {
export function recordApiTiming(input: ApiTimingInput): void {
if (!browserRuntimeAvailable()) return;
const route = webPerformanceRouteTemplate(input.path);
if (route === TELEMETRY_ENDPOINT) return;
if (isObservabilityRoute(route) || currentPageRoute() === "/performance") return;
const durationMs = finitePositive(input.durationMs, 0);
if (durationMs <= 0) return;
enqueueWebPerformanceEvent({
@@ -205,6 +205,7 @@ function enqueueDuration(kind: WebPerformanceKind, metric: string, route: string
function enqueueWebPerformanceEvent(event: WebPerformanceEvent): void {
if (!browserRuntimeAvailable()) return;
if (isObservabilityPageRoute(currentPageRoute())) return;
queue.push(event);
if (queue.length > MAX_QUEUE_EVENTS) queue = queue.slice(queue.length - MAX_QUEUE_EVENTS);
scheduleFlush();
@@ -222,6 +223,10 @@ function flushWebPerformanceQueue(useBeacon: boolean): void {
flushTimer = null;
}
if (queue.length === 0) return;
if (isObservabilityPageRoute(currentPageRoute())) {
queue = [];
return;
}
const events = queue.splice(0, MAX_BATCH_EVENTS);
const payload = JSON.stringify({
schemaVersion: PAYLOAD_SCHEMA_VERSION,
@@ -248,6 +253,14 @@ function currentPageRoute(): string {
return webPerformanceRouteTemplate(hash || "/workspace");
}
function isObservabilityRoute(route: string): boolean {
return route === TELEMETRY_ENDPOINT || route.startsWith(`${TELEMETRY_ENDPOINT}/`);
}
function isObservabilityPageRoute(route: string): boolean {
return route === "/performance";
}
function supportsEntryType(type: string): boolean {
const supported = PerformanceObserver.supportedEntryTypes;
return Array.isArray(supported) && supported.includes(type);