From ac2d3001fa8cecc849779fa8a07c38c2900629f7 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 13:03:09 +0800 Subject: [PATCH] fix: depollute v02 web performance telemetry --- docs/reference/spec-v02-hwlab-cloud-web.md | 2 ++ .../spec-v02-observability-monitoring.md | 2 ++ internal/cloud/web-performance.test.ts | 19 ++++++++++ internal/cloud/web-performance.ts | 8 +++++ web/hwlab-cloud-web/scripts/check.ts | 3 +- web/hwlab-cloud-web/src/App.tsx | 4 +-- .../src/services/performance/rum.test.ts | 35 ++++++++++++++++++- .../src/services/performance/rum.ts | 15 +++++++- 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index c22a7573..2e96c7e5 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -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 构建时间缺失。 diff --git a/docs/reference/spec-v02-observability-monitoring.md b/docs/reference/spec-v02-observability-monitoring.md index 26070525..53a02911 100644 --- a/docs/reference/spec-v02-observability-monitoring.md +++ b/docs/reference/spec-v02-observability-monitoring.md @@ -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` diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index 07d261f1..f1752486 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -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"); diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 9080b35c..6fc918d3 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -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) { }; } +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, labels: Record, value: number, buckets: number[], maxSeries: number) { const key = stableLabelKey(labels); let entry = series.get(key); diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index cfe2ddb8..03fd8399 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -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> => 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"); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 341ef003..923e94e7 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -45,8 +45,9 @@ const routeLabels: Record = { export function App(): ReactElement { const auth = useAuth(); - const store = useWorkbenchStore(auth.authState === "authenticated"); const [route, setRoute] = useState(() => routeFromLocation()); + const workspaceRoute = route === "workspace"; + const store = useWorkbenchStore(auth.authState === "authenticated" && workspaceRoute); const [pickedDraft, setPickedDraft] = useState(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; diff --git a/web/hwlab-cloud-web/src/services/performance/rum.test.ts b/web/hwlab-cloud-web/src/services/performance/rum.test.ts index 739956e1..bb7e355b 100644 --- a/web/hwlab-cloud-web/src/services/performance/rum.test.ts +++ b/web/hwlab-cloud-web/src/services/performance/rum.test.ts @@ -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(); + } +}); diff --git a/web/hwlab-cloud-web/src/services/performance/rum.ts b/web/hwlab-cloud-web/src/services/performance/rum.ts index 4cf3313f..9e4001f1 100644 --- a/web/hwlab-cloud-web/src/services/performance/rum.ts +++ b/web/hwlab-cloud-web/src/services/performance/rum.ts @@ -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);