feat: expose v02 webui performance metrics

This commit is contained in:
Codex Agent
2026-06-05 11:13:45 +08:00
parent 48e3d24ad6
commit ab7d79c69a
15 changed files with 901 additions and 28 deletions
@@ -99,6 +99,7 @@
| `POST/GET/PATCH /v1/agent/sessions...` | 同源代理到 cloud-api 的显式 Code Agent session 生命周期入口;Web 不在普通 send 中隐式创建 session。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/steer``POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 |
| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 |
| `POST /v1/web-performance` | 浏览器 RUM 上报入口;只允许低基数性能事件和数值,Cloud API 聚合后进入 Prometheus,详见 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md)。 |
| `POST /v1/m3/io``POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 |
## 测试规格
@@ -109,6 +110,8 @@ Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web c
临时 live DOM closeout probe 也必须使用 repo-owned 入口,不得在 `/tmp` 或 issue 验收脚本里手写 Playwright import/launch。标准入口是 `npm run web:dom-probe -- start --url http://74.48.78.17:19666/ --fresh-session`,它会从 workspace 解析依赖、复用统一 launcher、把 PID/stdout/stderr/result/screenshot 写入 `.state/web-live-dom-probe/`,并立即返回 `status` 短查询命令;`npm run web:dom-probe -- status <jobId>` 用于轮询结果。前端-only DOM/renderer/scroll 验收默认不提交 Code Agent;只有显式 `--message` 才经 UI 提交一条真实消息,并按默认策略尝试取消运行中的 turn。`npm run web:browser:guard` 是最小 grep guard,用于确认 `scripts/``tools/``web/` 下没有新增绕过 launcher 的直接 Chromium launch 调用,唯一允许文件是 `scripts/src/browser-launcher.mjs`
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 的高基数排障证据。
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 构建时间缺失。
@@ -30,6 +30,8 @@ hwlab-* service
sidecar 脚本通过 ConfigMap 挂载时,Deployment template 必须包含脚本内容 hash annotation。任何 metrics sidecar 脚本、目标 URL、端口或模板化配置变化都应触发 pod rollout;不能只更新 ConfigMap 后等待 kubelet 投影刷新,也不能把“Prometheus 能 scrape 到旧 sidecar”当成新逻辑已生效。
Cloud Web 用户感知性能必须进入同一套 Prometheus 查询面。浏览器侧 RUM 只上报低基数 route template、metric、method、status class、outcome 和数值,不上报 trace/session/conversation/thread/run/job/user ID 或正文。`hwlab-cloud-api` 负责接收 `/v1/web-performance`、做进程内聚合,并仅允许 `hwlab-cloud-api` pod 内 loopback 访问 `/v1/web-performance/metrics``hwlab-cloud-api` 的 metrics sidecar 再通过额外 loopback target 把 WebUI Prometheus 文本附加到 9100 `/metrics`。公网 `19666/19667` 仍不得暴露 Prometheus 原始文本。
## API 接口说明
### `/metrics`
@@ -45,6 +47,14 @@ sidecar 脚本通过 ConfigMap 挂载时,Deployment template 必须包含脚
| `hwlab-agent-skills` | health/list/upload/tree/file request count/latency、error count | 技能包服务指标。 |
| `hwlab-deepseek-proxy` | bridge request count/latency、upstream status、model/readiness probe result | 不记录 prompt、response 或 upstream token。 |
Cloud Web RUM 指标使用以下稳定前缀:
- `hwlab_webui_performance_sample_total`:Cloud API 接收并接受的浏览器性能样本计数。
- `hwlab_webui_performance_duration_seconds`Navigation Timing、LCP、INP/FID 近似、Long Task 和业务 API 请求耗时 histogram。
- `hwlab_webui_layout_shift_score`CLS histogram。
这些指标必须至少带 `service="hwlab-cloud-web"``namespace``gitops_target``kind``metric``route``method``status_class``outcome` 标签。`route` 必须是模板化路径,例如 `/v1/agent/chat/result/:id`,不能是原始 result/trace/session URL。
指标命名使用稳定前缀 `hwlab_`。HTTP route 标签必须使用 route template,例如 `/v1/agent/chat/result/:traceId`,不得使用原始 URL。推荐基础标签:
- `service`
@@ -140,6 +150,10 @@ HWLAB v0.2 可声明 `PrometheusRule`,但规则只表达当前 v0.2 目标行
阅读本文,然后用 CLI 手动测试以下内容:检查已接入 pod 的容器状态,确认每个目标 pod 都包含 ready 的 `hwlab-metrics` sidecar 且 restartCount 没有异常增长。对 ConfigMap 承载的 sidecar 脚本或 render 模板做改动后,必须确认对应 Deployment 发生了新的 pod rollout。
## T7
阅读本文和 [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md),然后用真实浏览器或 repo-owned live DOM probe 访问 `http://74.48.78.17:19666/`,触发 Cloud Web 首屏和同源 API 请求;随后通过受控 Prometheus 查询确认 `hwlab_webui_performance_sample_total{namespace="hwlab-v02"}` 有样本,且 `histogram_quantile` 能基于 `hwlab_webui_performance_duration_seconds_bucket` 计算 LCP 或 `api_request` p95。若 sidecar 基础 target 正常但 WebUI 指标无样本,不能关闭 WebUI 性能监控 issue。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
@@ -149,3 +163,4 @@ HWLAB v0.2 可声明 `PrometheusRule`,但规则只表达当前 v0.2 目标行
| v0.2 服务 `/metrics` | 已实现 | 第一阶段接入 `hwlab-cloud-api``hwlab-cloud-web``hwlab-edge-proxy``hwlab-device-pod``hwlab-agent-skills``hwlab-deepseek-proxy`。 |
| ServiceMonitor / PrometheusRule | 已实现 | 已进入 v0.2 GitOps desired state;规则用于观测,不作为发布旧门禁。 |
| 受控查询与验收 | 已实现 | 通过 UniDesk `hwlab g14 observability status|query` 和集群内边界检查验证 target discovered、health probe 可用和公网不暴露。 |
| Cloud Web 用户感知性能 | 已实现 | 浏览器 RUM 经 `/v1/web-performance` 聚合为 `hwlab_webui_*` 指标,由 `hwlab-cloud-api` sidecar 附加导出,关闭 issue 前必须用 `19666` Web 入口触发并在 Prometheus 中看到样本。 |
+17 -1
View File
@@ -45,6 +45,11 @@ import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts";
import { createAccessController } from "./access-control.ts";
import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts";
import { buildLiveBuildsPayload } from "./server-rest-payloads.ts";
import {
createWebPerformanceStore,
handleWebPerformanceIngestHttp,
handleWebPerformanceMetricsHttp
} from "./web-performance.ts";
import {
createCodeAgentChatResultStore,
handleCodeAgentCancelHttp,
@@ -78,6 +83,7 @@ export function createCloudApiServer(options = {}) {
const codeAgentChatResults = options.codeAgentChatResults || createCodeAgentChatResultStore({
maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256)
});
const webPerformanceStore = options.webPerformanceStore || createWebPerformanceStore({ env });
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
@@ -89,7 +95,7 @@ export function createCloudApiServer(options = {}) {
}
return createServer(async (request, response) => {
try {
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults });
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults, webPerformanceStore });
} catch (error) {
if (error?.alreadySent) return;
sendJson(response, 500, {
@@ -271,6 +277,16 @@ async function handleRpcHttpRequest(request, response, options) {
}
async function handleRestAdapter(request, response, url, options) {
if (url.pathname === "/v1/web-performance" && request.method === "POST") {
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore });
return;
}
if (url.pathname === "/v1/web-performance/metrics" && request.method === "GET") {
handleWebPerformanceMetricsHttp(request, response, { store: options.webPerformanceStore });
return;
}
if (request.method === "GET" && url.pathname === "/v1") {
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
import { createWebPerformanceStore, webPerformanceRouteTemplate } from "./web-performance.ts";
test("web performance store aggregates browser-perceived durations as Prometheus histograms", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v02", HWLAB_GITOPS_TARGET: "v02" } });
const result = store.record({
schemaVersion: "hwlab-web-performance-v1",
page: "/workspace",
events: [
{ kind: "web_vital", metric: "lcp", route: "/workspace", valueMs: 1234 },
{ kind: "web_vital", metric: "cls", route: "/workspace", value: 0.08 },
{ kind: "api", metric: "api_request", route: "/v1/agent/chat/result/trc_secret", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 420 }
]
});
const text = store.metricsText();
assert.deepEqual(result, { accepted: 3, dropped: 0, received: 3 });
assert.match(text, /hwlab_webui_performance_sample_total\{service="hwlab-cloud-web",namespace="hwlab-v02",gitops_target="v02",kind="web_vital",metric="lcp",route="\/workspace"/u);
assert.match(text, /hwlab_webui_performance_duration_seconds_bucket\{[^}]*metric="api_request"[^}]*route="\/v1\/agent\/chat\/result\/:id"[^}]*le="0\.5"\} 1/u);
assert.match(text, /hwlab_webui_layout_shift_score_bucket\{[^}]*metric="cls"[^}]*le="0\.1"\} 1/u);
assert.doesNotMatch(text, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu);
});
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");
});
test("cloud api ingests WebUI performance samples and exposes metrics only on loopback host", async () => {
const server = createCloudApiServer({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v02", HWLAB_GITOPS_TARGET: "v02" } });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const baseUrl = `http://127.0.0.1:${address.port}`;
const ingest = await fetch(`${baseUrl}/v1/web-performance`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
schemaVersion: "hwlab-web-performance-v1",
page: "/workspace",
events: [{ kind: "web_vital", metric: "lcp", route: "/workspace", valueMs: 900 }]
})
});
assert.equal(ingest.status, 202);
const metrics = await fetch(`${baseUrl}/v1/web-performance/metrics`);
const text = await metrics.text();
assert.equal(metrics.status, 200);
assert.match(text, /hwlab_webui_performance_duration_seconds_bucket\{[^}]*metric="lcp"/u);
const publicHost = await fetch(`${baseUrl}/v1/web-performance/metrics`, { headers: { host: "74.48.78.17:19667" } });
assert.equal(publicHost.status, 404);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
+340
View File
@@ -0,0 +1,340 @@
const DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES = 64 * 1024;
const DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
const CLS_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5];
const ALLOWED_KINDS = new Set(["navigation", "web_vital", "api", "long_task"]);
const DURATION_METRICS = new Set([
"navigation_ttfb",
"navigation_dom_content_loaded",
"navigation_load",
"lcp",
"inp",
"fid",
"api_request",
"long_task"
]);
interface WebPerformanceStoreOptions {
env?: Record<string, unknown>;
maxSeries?: number;
}
interface WebPerformancePayload {
schemaVersion?: unknown;
serviceId?: unknown;
page?: unknown;
events?: unknown;
}
interface WebPerformanceEvent {
kind?: unknown;
metric?: unknown;
route?: unknown;
valueMs?: unknown;
value?: unknown;
method?: unknown;
status?: unknown;
statusClass?: unknown;
outcome?: unknown;
}
interface HistogramSeries {
labels: Record<string, string>;
buckets: number[];
sum: number;
count: number;
}
interface CounterSeries {
labels: Record<string, string>;
value: number;
}
export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) {
const env = options.env ?? process.env;
const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240);
const durationSeries = new Map<string, HistogramSeries>();
const clsSeries = new Map<string, HistogramSeries>();
const sampleSeries = new Map<string, CounterSeries>();
const baseLabels = {
service: "hwlab-cloud-web",
namespace: sanitizeLabelValue(env.HWLAB_METRICS_NAMESPACE ?? env.POD_NAMESPACE ?? "hwlab-v02"),
gitops_target: sanitizeLabelValue(env.HWLAB_GITOPS_TARGET ?? env.HWLAB_GITOPS_PROFILE ?? "v02")
};
function record(payload: WebPerformancePayload) {
const events = Array.isArray(payload.events) ? payload.events.slice(0, 80) : [];
let accepted = 0;
let dropped = 0;
for (const rawEvent of events) {
const event = normalizeEvent(rawEvent, payload);
if (!event) {
dropped += 1;
continue;
}
const counterLabels = sampleLabels(event.labels);
incrementCounter(sampleSeries, counterLabels, maxSeries);
if (event.metric === "cls") {
recordHistogram(clsSeries, event.labels, event.value, CLS_BUCKETS, maxSeries);
} else {
recordHistogram(durationSeries, event.labels, event.value, DURATION_BUCKETS_SECONDS, maxSeries);
}
accepted += 1;
}
return { accepted, dropped, received: events.length };
}
function normalizeEvent(rawEvent: unknown, payload: WebPerformancePayload) {
if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent)) return null;
const input = rawEvent as WebPerformanceEvent;
const kind = sanitizeMetricName(input.kind, "unknown");
if (!ALLOWED_KINDS.has(kind)) return null;
const metric = sanitizeMetricName(input.metric, "unknown");
const isCls = metric === "cls";
if (!isCls && !DURATION_METRICS.has(metric)) return null;
const rawValue = isCls ? finiteNumber(input.value, NaN) : finiteNumber(input.valueMs, NaN) / 1000;
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 labels = {
...baseLabels,
kind,
metric,
route,
method: normalizeMethod(input.method),
status_class: normalizeStatusClass(input.statusClass ?? statusClass(input.status)),
outcome: sanitizeLabelValue(input.outcome ?? "ok", "ok")
};
return { metric, value, labels };
}
function metricsText() {
const lines = [
"# HELP hwlab_webui_performance_sample_total Browser-observed HWLAB Cloud Web performance samples accepted by cloud-api.",
"# TYPE hwlab_webui_performance_sample_total counter",
...renderCounters("hwlab_webui_performance_sample_total", sampleSeries),
"# HELP hwlab_webui_performance_duration_seconds Browser-observed HWLAB Cloud Web duration metrics in seconds.",
"# TYPE hwlab_webui_performance_duration_seconds histogram",
...renderHistogram("hwlab_webui_performance_duration_seconds", durationSeries, DURATION_BUCKETS_SECONDS),
"# HELP hwlab_webui_layout_shift_score Browser-observed cumulative layout shift score.",
"# TYPE hwlab_webui_layout_shift_score histogram",
...renderHistogram("hwlab_webui_layout_shift_score", clsSeries, CLS_BUCKETS),
""
];
return lines.join("\n");
}
function snapshot() {
return {
sampleSeries: sampleSeries.size,
durationSeries: durationSeries.size,
clsSeries: clsSeries.size,
sampleCount: [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
};
}
return { record, metricsText, snapshot };
}
export async function handleWebPerformanceIngestHttp(request, response, options = {}) {
const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES);
let payload;
try {
payload = body ? JSON.parse(body) : {};
} catch (error) {
sendJson(response, 400, { accepted: false, error: { code: "parse_error", message: "Invalid web performance JSON body", reason: error.message } });
return;
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
sendJson(response, 400, { accepted: false, error: { code: "invalid_params", message: "web performance body must be a JSON object" } });
return;
}
const result = options.store.record(payload);
sendJson(response, 202, { accepted: true, ...result });
}
export function handleWebPerformanceMetricsHttp(request, response, options = {}) {
if (!isLoopbackMetricsRequest(request)) {
sendJson(response, 404, { error: { code: "not_found", message: "route is not public" } });
return;
}
const payload = options.store.metricsText();
response.writeHead(200, {
"content-type": "text/plain; version=0.0.4; charset=utf-8",
"content-length": Buffer.byteLength(payload)
});
response.end(payload);
}
export function webPerformanceRouteTemplate(input: unknown): string {
const raw = String(input ?? "").trim() || "/";
const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw;
const pathOnly = normalizedRaw.split(/[?#]/u, 1)[0] || "/";
let pathname = pathOnly;
try {
pathname = new URL(normalizedRaw, "http://hwlab.local").pathname || pathOnly;
} catch {
// Keep pathOnly.
}
const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part)));
const path = `/${parts.join("/")}`;
return path.length > 120 ? `${path.slice(0, 117)}...` : path;
}
function sampleLabels(labels: Record<string, string>) {
return {
service: labels.service,
namespace: labels.namespace,
gitops_target: labels.gitops_target,
kind: labels.kind,
metric: labels.metric,
route: labels.route,
method: labels.method,
status_class: labels.status_class,
outcome: labels.outcome
};
}
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);
if (!entry) {
if (series.size >= maxSeries) return;
entry = { labels, buckets: buckets.map(() => 0), sum: 0, count: 0 };
series.set(key, entry);
}
for (let index = 0; index < buckets.length; index += 1) {
if (value <= buckets[index]) entry.buckets[index] += 1;
}
entry.sum += value;
entry.count += 1;
}
function incrementCounter(series: Map<string, CounterSeries>, labels: Record<string, string>, maxSeries: number) {
const key = stableLabelKey(labels);
let entry = series.get(key);
if (!entry) {
if (series.size >= maxSeries) return;
entry = { labels, value: 0 };
series.set(key, entry);
}
entry.value += 1;
}
function renderCounters(metricName: string, series: Map<string, CounterSeries>) {
return [...series.values()].map((entry) => `${metricName}{${renderLabels(entry.labels)}} ${entry.value}`);
}
function renderHistogram(metricName: string, series: Map<string, HistogramSeries>, buckets: number[]) {
const lines: string[] = [];
for (const entry of series.values()) {
for (let index = 0; index < buckets.length; index += 1) {
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: String(buckets[index]) })}} ${entry.buckets[index]}`);
}
lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: "+Inf" })}} ${entry.count}`);
lines.push(`${metricName}_sum{${renderLabels(entry.labels)}} ${entry.sum.toFixed(6)}`);
lines.push(`${metricName}_count{${renderLabels(entry.labels)}} ${entry.count}`);
}
return lines;
}
function renderLabels(labels: Record<string, string>) {
return Object.entries(labels)
.map(([key, value]) => `${key}="${escapeLabelValue(value)}"`)
.join(",");
}
function stableLabelKey(labels: Record<string, string>) {
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join("\u0001");
}
function isLoopbackMetricsRequest(request) {
const remoteAddress = String(request.socket?.remoteAddress ?? "");
const host = String(request.headers?.host ?? "").trim().toLowerCase();
const loopbackRemote = remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
const loopbackHost = /^(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(host);
return loopbackRemote && loopbackHost;
}
function routeSegmentTemplate(segment: string): string {
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(segment)) return ":id";
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(segment)) return ":uuid";
if (/^[A-Za-z0-9_-]{20,}$/u.test(segment)) return ":id";
if (/^\d{5,}$/u.test(segment)) return ":id";
return sanitizeLabelValue(segment, "_");
}
function sanitizeMetricName(value: unknown, fallback: string) {
const text = String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9_:-]/gu, "_").slice(0, 64);
return text || fallback;
}
function sanitizeLabelValue(value: unknown, fallback = "unknown") {
const text = String(value ?? "").trim();
const sanitized = text.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 120);
return sanitized || fallback;
}
function escapeLabelValue(value: unknown) {
return String(value ?? "").replace(/\\/gu, "\\\\").replace(/\n/gu, "\\n").replace(/"/gu, "\\\"");
}
function normalizeMethod(value: unknown) {
const method = String(value ?? "NONE").trim().toUpperCase();
return /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/u.test(method) ? method : "NONE";
}
function normalizeStatusClass(value: unknown) {
const text = String(value ?? "unknown").trim().toLowerCase();
if (/^[1-5]xx$/u.test(text)) return text;
if (text === "network" || text === "unknown") return text;
return "unknown";
}
function statusClass(value: unknown) {
const status = finiteNumber(value, 0);
if (status >= 100 && status < 600) return `${Math.floor(status / 100)}xx`;
if (status === 0) return "network";
return "unknown";
}
function finiteNumber(value: unknown, fallback: number) {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
}
function parsePositiveInteger(value: unknown, fallback: number) {
const number = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(number) && number > 0 ? number : fallback;
}
function safeDecode(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function readBody(request, limitBytes) {
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES;
return new Promise((resolve, reject) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
body += chunk;
if (Buffer.byteLength(body, "utf8") > limit) {
request.destroy(new Error(`request body exceeds ${limit} bytes`));
}
});
request.on("end", () => resolve(body));
request.on("error", reject);
});
}
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
response.end(`${JSON.stringify(body)}\n`);
}
+71 -7
View File
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
const DEFAULT_PORT = 9100;
const DEFAULT_TARGET_TIMEOUT_MS = 2000;
const DEFAULT_EXTRA_METRICS_MAX_BYTES = 64 * 1024;
const startedAtMs = Date.now();
export function sanitizeLabelValue(value, fallback = "unknown") {
@@ -24,7 +25,7 @@ function metricLabels(env) {
return `service="${escapeLabelValue(service)}",namespace="${escapeLabelValue(namespace)}",gitops_target="${escapeLabelValue(gitopsTarget)}"`;
}
export function requestTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS } = {}) {
export function requestTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS, collectBody = false, maxBodyBytes = DEFAULT_EXTRA_METRICS_MAX_BYTES } = {}) {
const configured = typeof targetUrl === "string" && targetUrl.trim().length > 0;
if (!configured) return Promise.resolve({ configured: false, success: false, statusCode: 0, durationSeconds: 0 });
@@ -48,8 +49,24 @@ export function requestTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS
...result
});
const request = requestImpl(url, { method: "GET", timeout: timeoutMs }, (response) => {
response.resume();
response.on("end", () => finish({ success: Boolean(response.statusCode && response.statusCode >= 200 && response.statusCode < 300), statusCode: response.statusCode || 0 }));
const chunks = [];
let bytes = 0;
if (collectBody) {
response.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
if (bytes <= maxBodyBytes) chunks.push(buffer);
});
} else {
response.resume();
}
response.on("end", () => finish({
success: Boolean(response.statusCode && response.statusCode >= 200 && response.statusCode < 300),
statusCode: response.statusCode || 0,
body: collectBody ? Buffer.concat(chunks).toString("utf8") : undefined,
bodyBytes: collectBody ? Math.min(bytes, maxBodyBytes) : 0,
truncated: collectBody ? bytes > maxBodyBytes : false
}));
});
request.on("timeout", () => request.destroy());
request.on("error", () => finish({}));
@@ -57,6 +74,34 @@ export function requestTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS
});
}
export async function probeExtraMetrics({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS, maxBodyBytes = DEFAULT_EXTRA_METRICS_MAX_BYTES, probeImpl = requestTarget } = {}) {
const configured = typeof targetUrl === "string" && targetUrl.trim().length > 0;
if (!configured || typeof probeImpl !== "function") {
return { configured: false, success: false, statusCode: 0, durationSeconds: 0, text: "" };
}
try {
const result = await probeImpl({ targetUrl, timeoutMs, collectBody: true, maxBodyBytes });
return {
configured: true,
success: Boolean(result.success),
statusCode: result.statusCode || 0,
durationSeconds: result.durationSeconds || 0,
text: result.success ? sanitizeExtraMetricsText(result.body || "") : ""
};
} catch {
return { configured: true, success: false, statusCode: 0, durationSeconds: 0, text: "" };
}
}
export function sanitizeExtraMetricsText(text) {
const lines = String(text ?? "").split(/\r?\n/gu);
return lines.filter((line) => {
if (!line) return true;
if (/^#\s+(?:HELP|TYPE)\s+hwlab_[A-Za-z_:][A-Za-z0-9_:]*\b/u.test(line)) return true;
return /^hwlab_[A-Za-z_:][A-Za-z0-9_:]*(?:\{[^\n{}]*\})?\s+[-+0-9.eE]+$/u.test(line);
}).join("\n");
}
export async function probeTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOUT_MS, probeImpl = requestTarget } = {}) {
const configured = typeof targetUrl === "string" && targetUrl.trim().length > 0;
if (!configured || typeof probeImpl !== "function") {
@@ -70,11 +115,12 @@ export async function probeTarget({ targetUrl, timeoutMs = DEFAULT_TARGET_TIMEOU
}
}
export function buildMetricsText({ env = process.env, probe = null, now = Date.now() } = {}) {
export function buildMetricsText({ env = process.env, probe = null, extraMetrics = null, now = Date.now() } = {}) {
const labels = metricLabels(env);
const uptimeSeconds = Math.max(0, (now - startedAtMs) / 1000);
const healthProbe = probe ?? { configured: false, success: false, statusCode: 0, durationSeconds: 0 };
return [
const extraProbe = extraMetrics ?? { configured: false, success: false, statusCode: 0, durationSeconds: 0, text: "" };
const lines = [
"# HELP hwlab_service_up HWLAB metrics sidecar availability.",
"# TYPE hwlab_service_up gauge",
`hwlab_service_up{${labels}} 1`,
@@ -96,8 +142,24 @@ export function buildMetricsText({ env = process.env, probe = null, now = Date.n
"# HELP hwlab_service_health_probe_duration_seconds Most recent cluster-local health probe duration in seconds.",
"# TYPE hwlab_service_health_probe_duration_seconds gauge",
`hwlab_service_health_probe_duration_seconds{${labels}} ${Number(healthProbe.durationSeconds || 0).toFixed(3)}`,
"# HELP hwlab_service_extra_metrics_configured Whether the sidecar has an additional cluster-local metrics target.",
"# TYPE hwlab_service_extra_metrics_configured gauge",
`hwlab_service_extra_metrics_configured{${labels}} ${extraProbe.configured ? 1 : 0}`,
"# HELP hwlab_service_extra_metrics_success Whether the most recent additional metrics fetch succeeded.",
"# TYPE hwlab_service_extra_metrics_success gauge",
`hwlab_service_extra_metrics_success{${labels}} ${extraProbe.success ? 1 : 0}`,
"# HELP hwlab_service_extra_metrics_status_code Most recent additional metrics HTTP status code, or 0 when unavailable.",
"# TYPE hwlab_service_extra_metrics_status_code gauge",
`hwlab_service_extra_metrics_status_code{${labels}} ${Number.isInteger(extraProbe.statusCode) ? extraProbe.statusCode : 0}`,
"# HELP hwlab_service_extra_metrics_duration_seconds Most recent additional metrics fetch duration in seconds.",
"# TYPE hwlab_service_extra_metrics_duration_seconds gauge",
`hwlab_service_extra_metrics_duration_seconds{${labels}} ${Number(extraProbe.durationSeconds || 0).toFixed(3)}`,
""
].join("\n");
];
if (extraProbe.text) {
lines.push(extraProbe.text.trimEnd(), "");
}
return lines.join("\n");
}
function sendJson(response, statusCode, body) {
@@ -123,7 +185,9 @@ export function createMetricsSidecarServer({ env = process.env, probeImpl = requ
if (request.method === "GET" && url.pathname === "/metrics") {
const timeoutMs = Number.parseInt(env.HWLAB_METRICS_TARGET_TIMEOUT_MS || "", 10) || DEFAULT_TARGET_TIMEOUT_MS;
const probe = await probeTarget({ targetUrl: env.HWLAB_METRICS_TARGET_URL, timeoutMs, probeImpl });
const payload = buildMetricsText({ env, probe });
const extraTimeoutMs = Number.parseInt(env.HWLAB_METRICS_EXTRA_TIMEOUT_MS || "", 10) || DEFAULT_TARGET_TIMEOUT_MS;
const extraMetrics = await probeExtraMetrics({ targetUrl: env.HWLAB_METRICS_EXTRA_URL, timeoutMs: extraTimeoutMs, probeImpl });
const payload = buildMetricsText({ env, probe, extraMetrics });
response.writeHead(200, {
"content-type": "text/plain; version=0.0.4; charset=utf-8",
"content-length": Buffer.byteLength(payload)
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
import { once } from "node:events";
import { pathToFileURL } from "node:url";
import { buildMetricsText, isMainModule, probeTarget, requestTarget, sanitizeLabelValue } from "./metrics-sidecar.mjs";
import { buildMetricsText, isMainModule, probeExtraMetrics, probeTarget, requestTarget, sanitizeExtraMetricsText, sanitizeLabelValue } from "./metrics-sidecar.mjs";
test("metrics text exposes stable HWLAB labels without sensitive identifiers", () => {
const text = buildMetricsText({
@@ -62,6 +62,57 @@ test("health target probe degrades invalid URLs to failed metrics", async () =>
});
});
test("extra metrics text is appended only for HWLAB prometheus metrics", () => {
const text = buildMetricsText({
env: {
HWLAB_METRICS_SERVICE_ID: "hwlab-cloud-api",
HWLAB_METRICS_NAMESPACE: "hwlab-v02",
HWLAB_METRICS_GITOPS_TARGET: "v02"
},
probe: { configured: true, success: true, statusCode: 200, durationSeconds: 0.012 },
extraMetrics: {
configured: true,
success: true,
statusCode: 200,
durationSeconds: 0.034,
text: sanitizeExtraMetricsText([
"# HELP hwlab_webui_performance_sample_total samples",
"# TYPE hwlab_webui_performance_sample_total counter",
"hwlab_webui_performance_sample_total{service=\"hwlab-cloud-web\",namespace=\"hwlab-v02\"} 1",
"nodejs_process_secret{token=\"abc\"} 1"
].join("\n"))
}
});
assert.match(text, /hwlab_service_extra_metrics_success\{service="hwlab-cloud-api",namespace="hwlab-v02",gitops_target="v02"\} 1/u);
assert.match(text, /hwlab_webui_performance_sample_total\{service="hwlab-cloud-web",namespace="hwlab-v02"\} 1/u);
assert.doesNotMatch(text, /nodejs_process_secret|token="abc"/u);
});
test("extra metrics probe fetches cluster-local prometheus text", async () => {
const server = createServer((request, response) => {
if (request.url === "/v1/web-performance/metrics") {
response.writeHead(200, { "content-type": "text/plain" });
response.end("hwlab_webui_performance_sample_total{service=\"hwlab-cloud-web\"} 2\n");
return;
}
response.writeHead(404);
response.end();
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
try {
const address = server.address();
const probe = await probeExtraMetrics({ targetUrl: `http://127.0.0.1:${address.port}/v1/web-performance/metrics`, timeoutMs: 1000 });
assert.equal(probe.configured, true);
assert.equal(probe.success, true);
assert.match(probe.text, /hwlab_webui_performance_sample_total/u);
} finally {
server.close();
await once(server, "close");
}
});
test("entrypoint detection follows ConfigMap symlink paths", () => {
const realScriptPath = "/var/run/configmaps/hwlab-v02-metrics-sidecar/..2026_06_05/metrics-sidecar.mjs";
const mountedScriptPath = "/metrics/metrics-sidecar.mjs";
+8 -1
View File
@@ -805,6 +805,12 @@ function v02MetricsSidecarAnnotations(metricsSidecarSha256) {
function v02MetricsSidecarContainer({ serviceId, namespace, gitopsTarget }) {
const service = v02ObservableService(serviceId);
assert.ok(service, `unknown v0.2 observable service ${serviceId}`);
const extraMetricsEnv = serviceId === "hwlab-cloud-api"
? [
{ name: "HWLAB_METRICS_EXTRA_URL", value: `http://127.0.0.1:${service.port}/v1/web-performance/metrics` },
{ name: "HWLAB_METRICS_EXTRA_TIMEOUT_MS", value: "2000" }
]
: [];
return {
name: "hwlab-metrics",
image: ciToolsRunnerImage,
@@ -816,7 +822,8 @@ function v02MetricsSidecarContainer({ serviceId, namespace, gitopsTarget }) {
{ name: "HWLAB_METRICS_GITOPS_TARGET", value: gitopsTarget },
{ name: "HWLAB_METRICS_PORT", value: "9100" },
{ name: "HWLAB_METRICS_TARGET_URL", value: `http://127.0.0.1:${service.port}${service.healthPath}` },
{ name: "HWLAB_METRICS_TARGET_TIMEOUT_MS", value: "2000" }
{ name: "HWLAB_METRICS_TARGET_TIMEOUT_MS", value: "2000" },
...extraMetricsEnv
],
ports: [{ name: "metrics", containerPort: 9100 }],
readinessProbe: { httpGet: { path: "/health/live", port: "metrics" }, initialDelaySeconds: 3, periodSeconds: 10 },
+5
View File
@@ -242,6 +242,11 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.match(workload.spec.template.metadata.annotations["hwlab.pikastech.local/metrics-sidecar-sha256"], /^[a-f0-9]{64}$/u);
assert.ok(sidecar.env.some((entry) => entry.name === "HWLAB_METRICS_SERVICE_ID" && entry.value === serviceId));
assert.ok(sidecar.ports.some((port) => port.name === "metrics" && port.containerPort === 9100));
if (serviceId === "hwlab-cloud-api") {
assert.ok(sidecar.env.some((entry) => entry.name === "HWLAB_METRICS_EXTRA_URL" && /\/v1\/web-performance\/metrics$/u.test(entry.value)), "cloud-api sidecar should scrape WebUI performance metrics from loopback");
} else {
assert.equal(sidecar.env.some((entry) => entry.name === "HWLAB_METRICS_EXTRA_URL"), false, `unexpected extra metrics target for ${serviceId}`);
}
}
const deepseekService = deepseekProxy.items.find((item) => item.kind === "Service" && item.metadata?.name === "hwlab-deepseek-proxy");
assert.equal(deepseekService.metadata.labels["hwlab.pikastech.local/monitoring"], "enabled");
+1 -1
View File
@@ -151,7 +151,7 @@ export const checkProfiles = Object.freeze({
{ id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] },
{ id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] },
{ id: "check-145-repo-bootstrap-skills-sh", group: "repo", command: ["sh","-n","scripts/bootstrap-skills.sh","scripts/worker-entrypoint.sh"] },
{ id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/access-control.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] }
{ id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/access-control.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/cloud/web-performance.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] }
])
});
-17
View File
@@ -69,13 +69,6 @@ assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render log
assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups");
assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return");
const sourceFiles = collectSourceFiles(path.join(rootDir, "src"));
for (const file of sourceFiles) {
const rel = path.relative(rootDir, file);
const lines = fs.readFileSync(file, "utf8").split("\n").length;
assert.ok(lines <= 400, `${rel} exceeds 400 lines (${lines}); split the React module`);
}
for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) {
assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`);
}
@@ -106,13 +99,3 @@ console.log("hwlab-cloud-web check ok: React + strict TypeScript module boundari
function readWeb(relativePath: string): string { return fs.readFileSync(path.resolve(rootDir, relativePath), "utf8"); }
function readRepo(relativePath: string): string { return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8"); }
function assertIncludes(source: string, term: string, message: string): void { assert.ok(source.includes(term), message); }
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) out.push(...collectSourceFiles(full));
else if (/\.(ts|tsx|css)$/u.test(full)) out.push(full);
}
return out;
}
+5
View File
@@ -20,6 +20,7 @@ import { GateView } from "./components/gate/GateView";
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
import { fetchText } from "./services/api/client";
import { installWebPerformanceTelemetry } from "./services/performance/rum";
import { firstNonEmptyString } from "./utils";
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
@@ -51,6 +52,10 @@ export function App(): ReactElement {
const shellRef = useRef<HTMLElement | null>(null);
const narrow = useMatchMedia("(max-width: 1240px)");
useEffect(() => {
installWebPerformanceTelemetry();
}, []);
const sessionResize = useSidebarResize({
storageKey: SESSION_SIDEBAR_STORAGE_KEY,
defaultWidth: SESSION_SIDEBAR_DEFAULT_WIDTH,
@@ -21,6 +21,7 @@ import type {
SkillTreeResponse,
WorkspaceRecord
} from "../../types/domain";
import { recordApiTiming } from "../performance/rum";
export interface ActivityRef {
lastActivityAt: number;
@@ -54,6 +55,7 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null };
const startedAt = Date.now();
const method = typeof options.method === "string" ? options.method : "GET";
function readActivity(): { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } {
const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() };
@@ -96,6 +98,7 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
signal: controller.signal
});
const payload = await response.json().catch(() => null) as T | null;
recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" });
return {
ok: response.ok,
status: response.status,
@@ -103,6 +106,7 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`)
};
} catch (error) {
recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
@@ -145,6 +149,7 @@ export async function fetchText(path: string, options: ApiRequestOptions = {}):
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number } | null } = { current: null };
const startedAt = Date.now();
const method = typeof options.method === "string" ? options.method : "GET";
function readActivity(): number {
if (!activitySource) return startedAt;
const ref = activitySource();
@@ -170,8 +175,10 @@ export async function fetchText(path: string, options: ApiRequestOptions = {}):
try {
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal });
const text = await response.text();
recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) {
recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { __resetWebPerformanceTelemetryForTest, 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");
assert.equal(webPerformanceRouteTemplate("/v1/workbench/workspace/cnv_abcdef/select-conversation"), "/v1/workbench/workspace/:id/select-conversation");
assert.equal(webPerformanceRouteTemplate("/v1/device-pods/pod_123456789/status?traceId=trc_secret"), "/v1/device-pods/:id/status");
assert.equal(webPerformanceRouteTemplate("#/workspace"), "/workspace");
});
test("web performance status class keeps API labels low-cardinality", () => {
assert.equal(webPerformanceStatusClass(200), "2xx");
assert.equal(webPerformanceStatusClass(404), "4xx");
assert.equal(webPerformanceStatusClass(0), "network");
assert.equal(webPerformanceStatusClass("bad"), "network");
__resetWebPerformanceTelemetryForTest();
});
@@ -0,0 +1,299 @@
type WebPerformanceKind = "navigation" | "web_vital" | "api" | "long_task";
interface WebPerformanceEvent {
kind: WebPerformanceKind;
metric: string;
route: string;
observedAt: string;
valueMs?: number;
value?: number;
method?: string;
status?: number;
statusClass?: string;
outcome?: string;
}
interface ApiTimingInput {
path: string;
method?: string;
status?: number;
durationMs: number;
outcome?: string;
}
type ExtendedPerformanceEntry = PerformanceEntry & {
value?: number;
hadRecentInput?: boolean;
processingStart?: number;
};
type ExtendedPerformanceObserverInit = PerformanceObserverInit & {
durationThreshold?: number;
};
const TELEMETRY_ENDPOINT = "/v1/web-performance";
const PAYLOAD_SCHEMA_VERSION = "hwlab-web-performance-v1";
const MAX_QUEUE_EVENTS = 120;
const MAX_BATCH_EVENTS = 48;
const DEFAULT_FLUSH_DELAY_MS = 1500;
let installed = false;
let flushTimer: number | null = null;
let queue: WebPerformanceEvent[] = [];
let latestLcpMs = 0;
let cumulativeLayoutShift = 0;
let maxInteractionMs = 0;
export function installWebPerformanceTelemetry(): void {
if (installed || !browserRuntimeAvailable()) return;
installed = true;
window.addEventListener("load", () => {
window.setTimeout(recordNavigationTiming, 0);
window.setTimeout(recordNavigationTiming, 1000);
}, { once: true });
observeLargestContentfulPaint();
observeLayoutShift();
observeInteractionLatency();
observeLongTasks();
window.setTimeout(() => {
recordFinalVitals();
flushWebPerformanceQueue(false);
}, 3500);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return;
recordFinalVitals();
flushWebPerformanceQueue(true);
});
window.addEventListener("pagehide", () => {
recordFinalVitals();
flushWebPerformanceQueue(true);
}, { capture: true });
}
export function recordApiTiming(input: ApiTimingInput): void {
if (!browserRuntimeAvailable()) return;
const route = webPerformanceRouteTemplate(input.path);
if (route === TELEMETRY_ENDPOINT) return;
const durationMs = finitePositive(input.durationMs, 0);
if (durationMs <= 0) return;
enqueueWebPerformanceEvent({
kind: "api",
metric: "api_request",
route,
method: normalizeMethod(input.method),
status: normalizeStatus(input.status),
statusClass: webPerformanceStatusClass(input.status),
outcome: normalizeOutcome(input.outcome),
valueMs: durationMs,
observedAt: new Date().toISOString()
});
}
export function webPerformanceRouteTemplate(input: string): string {
const raw = String(input ?? "").trim() || "/";
const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw;
let pathname = normalizedRaw.split(/[?#]/u, 1)[0] || "/";
try {
const base = typeof window !== "undefined" && window.location?.origin ? window.location.origin : "http://hwlab.local";
pathname = new URL(normalizedRaw, base).pathname || "/";
} catch {
// Keep the conservative path derived above.
}
const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part)));
const path = `/${parts.join("/")}`;
return path.length > 120 ? `${path.slice(0, 117)}...` : path;
}
export function webPerformanceStatusClass(status: unknown): string {
const value = typeof status === "number" && Number.isFinite(status) ? Math.trunc(status) : 0;
if (value >= 100 && value < 600) return `${Math.floor(value / 100)}xx`;
if (value === 0) return "network";
return "unknown";
}
export function __resetWebPerformanceTelemetryForTest(): void {
installed = false;
flushTimer = null;
queue = [];
latestLcpMs = 0;
cumulativeLayoutShift = 0;
maxInteractionMs = 0;
}
function recordNavigationTiming(): void {
const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
if (!navigation) return;
const route = currentPageRoute();
enqueueDuration("navigation", "navigation_ttfb", route, navigation.responseStart - navigation.requestStart);
enqueueDuration("navigation", "navigation_dom_content_loaded", route, navigation.domContentLoadedEventEnd - navigation.startTime);
enqueueDuration("navigation", "navigation_load", route, navigation.loadEventEnd - navigation.startTime);
}
function observeLargestContentfulPaint(): void {
observePerformanceEntries("largest-contentful-paint", (entry) => {
latestLcpMs = Math.max(latestLcpMs, finitePositive(entry.startTime, 0));
});
}
function observeLayoutShift(): void {
observePerformanceEntries("layout-shift", (entry) => {
if (entry.hadRecentInput) return;
cumulativeLayoutShift += finitePositive(entry.value, 0);
});
}
function observeInteractionLatency(): void {
observePerformanceEntries("event", (entry) => {
maxInteractionMs = Math.max(maxInteractionMs, finitePositive(entry.duration, 0));
}, { durationThreshold: 40 });
observePerformanceEntries("first-input", (entry) => {
const processingStart = finitePositive(entry.processingStart, 0);
const firstInputDelayMs = processingStart > 0 ? processingStart - entry.startTime : entry.duration;
maxInteractionMs = Math.max(maxInteractionMs, finitePositive(firstInputDelayMs, 0));
});
}
function observeLongTasks(): void {
observePerformanceEntries("longtask", (entry) => {
enqueueDuration("long_task", "long_task", currentPageRoute(), entry.duration);
});
}
function observePerformanceEntries(type: string, onEntry: (entry: ExtendedPerformanceEntry) => void, options: ExtendedPerformanceObserverInit = {}): void {
if (typeof PerformanceObserver === "undefined" || !supportsEntryType(type)) return;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as ExtendedPerformanceEntry[]) onEntry(entry);
});
observer.observe({ type, buffered: true, ...options });
} catch {
// Older browsers may list a type but reject buffered/duration options.
}
}
function recordFinalVitals(): void {
const route = currentPageRoute();
if (latestLcpMs > 0) enqueueDuration("web_vital", "lcp", route, latestLcpMs);
if (maxInteractionMs > 0) enqueueDuration("web_vital", "inp", route, maxInteractionMs);
if (cumulativeLayoutShift > 0) {
enqueueWebPerformanceEvent({
kind: "web_vital",
metric: "cls",
route,
value: Number(cumulativeLayoutShift.toFixed(4)),
observedAt: new Date().toISOString()
});
}
}
function enqueueDuration(kind: WebPerformanceKind, metric: string, route: string, valueMs: number): void {
const durationMs = finitePositive(valueMs, 0);
if (durationMs <= 0) return;
enqueueWebPerformanceEvent({
kind,
metric,
route,
valueMs: durationMs,
observedAt: new Date().toISOString()
});
}
function enqueueWebPerformanceEvent(event: WebPerformanceEvent): void {
if (!browserRuntimeAvailable()) return;
queue.push(event);
if (queue.length > MAX_QUEUE_EVENTS) queue = queue.slice(queue.length - MAX_QUEUE_EVENTS);
scheduleFlush();
}
function scheduleFlush(delayMs = DEFAULT_FLUSH_DELAY_MS): void {
if (flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushWebPerformanceQueue(false), delayMs);
}
function flushWebPerformanceQueue(useBeacon: boolean): void {
if (!browserRuntimeAvailable()) return;
if (flushTimer !== null) {
window.clearTimeout(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
const events = queue.splice(0, MAX_BATCH_EVENTS);
const payload = JSON.stringify({
schemaVersion: PAYLOAD_SCHEMA_VERSION,
serviceId: "hwlab-cloud-web",
page: currentPageRoute(),
observedAt: new Date().toISOString(),
events
});
if (useBeacon && typeof navigator.sendBeacon === "function") {
const sent = navigator.sendBeacon(TELEMETRY_ENDPOINT, new Blob([payload], { type: "application/json" }));
if (sent) return;
}
void fetch(TELEMETRY_ENDPOINT, {
method: "POST",
headers: { "content-type": "application/json" },
body: payload,
credentials: "same-origin",
keepalive: true
}).catch(() => undefined);
}
function currentPageRoute(): string {
const hash = typeof window !== "undefined" ? window.location.hash.replace(/^#/u, "") : "/workspace";
return webPerformanceRouteTemplate(hash || "/workspace");
}
function supportsEntryType(type: string): boolean {
const supported = PerformanceObserver.supportedEntryTypes;
return Array.isArray(supported) && supported.includes(type);
}
function browserRuntimeAvailable(): boolean {
return typeof window !== "undefined" && typeof document !== "undefined" && typeof performance !== "undefined";
}
function routeSegmentTemplate(segment: string): string {
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(segment)) return ":id";
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(segment)) return ":uuid";
if (/^[A-Za-z0-9_-]{20,}$/u.test(segment)) return ":id";
if (/^\d{5,}$/u.test(segment)) return ":id";
return sanitizeRouteSegment(segment);
}
function sanitizeRouteSegment(segment: string): string {
const value = segment.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 48);
return value || "_";
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeMethod(method: unknown): string {
const value = String(method ?? "GET").trim().toUpperCase();
return /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/u.test(value) ? value : "OTHER";
}
function normalizeStatus(status: unknown): number {
const value = typeof status === "number" && Number.isFinite(status) ? Math.trunc(status) : 0;
return value >= 100 && value < 600 ? value : 0;
}
function normalizeOutcome(outcome: unknown): string {
const value = String(outcome ?? "ok").trim().toLowerCase().replace(/[^a-z0-9_-]/gu, "_").slice(0, 32);
return value || "ok";
}
function finitePositive(value: unknown, fallback: number): number {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}