feat: expose v02 webui performance metrics
This commit is contained in:
@@ -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 中看到样本。 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] }
|
||||
])
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user