fix: depollute v02 web performance telemetry
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user