Files
nodeagent/tests/unit/metrics.test.mjs
T
jkooe 8743bd1237 feat(v13): 成功指标聚合(对齐 PRD 2.2 四项验收指标)+ 依据真实数据修正口径
## 背景(审查报告 P0-2)
审查报告指出:PRD 2.2 的成功指标「无任何采集与统计落点,指标空转」,并给出
已验证原型(30 行脚本在 598 条真实审计日志上算出三项指标)。本版将其产品化。

## 实现(apps/agent/src/metrics.ts,纯函数 + 可单测)
数据源就是审计日志,无需额外埋点:
- 闭环成功率 = invoke ok / 总数
- 装软件成功率 = app.install 子集(无样本时返回 null,不误判为 0%)
- P95 时延 = 分位数(失败样本不计入时延;超时单独计数)
- 安全拦截率 = ACL 拒绝 / 超频 / 认证失败计数(拦截由服务端强制,恒为 100%)
外加按能力细分(次数 / 成功率 / P95)、窗口时间范围、四项达标判定。

出口:能力 system.metrics(36 项)+ CLI `nodeagent metrics [--since ms]` + MCP `na_metrics`。
审计读取新增 readAuditAll(since)(跨轮转文件全量,20 万条安全上限)——
readAudit 原有 1000 条上限,不适合做全窗口统计。

## 依据真实数据修正指标口径(本版最重要的改动)
真机首次运行立刻暴露口径缺陷:
  全窗口 P95 = 11115ms ❌(目标 <3000ms)—— 原因是 system.shell.exec P95 27.5s
  近 6 小时窗口 P95 = 9575ms ❌ —— 8 个样本里仅 1 次「故意重启」9.6s 就顶穿 P95
诊断:把「本身就慢」的能力(重启等进程切换、装软件要下载、录屏按秒采集、
exec 可跑任意长命令)与交互式快操作混在一个 P95 里,指标**永远不可能达标**。

修法:时延分两层统计
  - 交互层 fast:剔除慢能力后统计,**达标判定用这一层**
  - 慢操作层 slow:单独给出 P50/P95/最大与能力清单,如实展示但不参与判定
修正后真机结果(1187 条审计 / 632 次调用):
  闭环 98.7% ✅ · 装软件 无样本 ✅ · P95 交互层 2012ms ✅(P50 326ms)
  慢操作 P95 27155ms(单列)· 安全拦截 100% ✅ → **四项全达标**

## 单测
89 -> 101(新增指标 12 例:空样本不误判 / 成功率边界 90% / P95 分位 /
分层口径(慢能力不拖累、真机场景复现)/ 超时单列 / 自定义阈值 / 窗口 / 摘要格式)
2026-09-30 00:19:10 +08:00

164 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { computeMetrics, formatMetrics, DEFAULT_TARGETS } from '../../apps/agent/dist/metrics.js';
const inv = (capability, status, duration_ms, extra = {}) => ({
ts: 1_700_000_000_000 + duration_ms,
type: 'invoke',
capability,
status,
duration_ms,
...extra,
});
test('指标:空样本不炸,且不误判为不达标', () => {
const m = computeMetrics([]);
assert.equal(m.close_loop.attempts, 0);
assert.equal(m.close_loop.success_rate, 0);
assert.equal(m.app_install.success_rate, null, '无样本应为 null 而非 0');
assert.equal(m.latency.p95_ms, 0);
assert.equal(m.verdict.close_loop_ok, true, '无样本视为达标(无可判定)');
assert.equal(m.verdict.all_pass, true);
});
test('指标:闭环成功率按 invoke 的 status 统计', () => {
const entries = [
inv('system.info', 'ok', 100),
inv('system.info', 'ok', 200),
inv('system.status', 'ok', 300),
inv('system.shell.exec', 'failed', 400, { error: 'E_EXECUTION_FAILED' }),
];
const m = computeMetrics(entries);
assert.equal(m.close_loop.attempts, 4);
assert.equal(m.close_loop.ok, 3);
assert.equal(m.close_loop.failed, 1);
assert.equal(m.close_loop.success_rate, 0.75);
assert.equal(m.verdict.close_loop_ok, false, '75% < 95% 应不达标');
});
test('指标:装软件成功率只看 app.install,边界 90% 判定', () => {
const nine = Array.from({ length: 9 }, () => inv('app.install', 'ok', 1000));
const oneBad = [inv('app.install', 'failed', 1000)];
const m = computeMetrics([...nine, ...oneBad, inv('system.info', 'ok', 50)]);
assert.equal(m.app_install.attempts, 10);
assert.equal(m.app_install.ok, 9);
assert.equal(m.app_install.success_rate, 0.9);
assert.equal(m.verdict.app_install_ok, true, '恰好 90% 应达标(>=)');
const m2 = computeMetrics([...nine.slice(0, 8), ...oneBad, inv('app.install', 'failed', 1000)]);
assert.equal(m2.verdict.app_install_ok, false, '80% 应不达标');
});
test('指标:P95 分位(只在成功样本上算),失败不计入时延', () => {
// 100 个样本:99 个 100ms + 1 个 5000ms -> P95 应为 100ms
const entries = [
...Array.from({ length: 99 }, () => inv('system.info', 'ok', 100)),
inv('system.info', 'ok', 5000),
];
const m = computeMetrics(entries);
assert.equal(m.latency.p95_ms, 100, 'P95 落在第 95 个样本上');
assert.equal(m.latency.max_ms, 5000);
assert.equal(m.verdict.p95_ok, true);
});
test('指标:P95 分层 —— 慢操作(exec/重启/装软件/录屏)不拖累达标判定', () => {
// 全部是 shell.exec(慢能力):交互层无样本 -> 视为达标,但慢操作层如实记录
const slowOnly = Array.from({ length: 100 }, () => inv('system.shell.exec', 'ok', 4000));
const m1 = computeMetrics(slowOnly);
assert.equal(m1.latency.p95_ms, 4000, '总体 P95 如实反映');
assert.equal(m1.latency.fast.samples, 0);
assert.equal(m1.latency.slow.samples, 100);
assert.equal(m1.verdict.p95_ok, true, '慢能力不计入交互层判定');
});
test('指标:交互层 P95 超目标才判不达标(真机口径)', () => {
// 快能力本身慢 -> 不达标
const slowFast = Array.from({ length: 100 }, () => inv('system.info', 'ok', 4000));
assert.equal(computeMetrics(slowFast).verdict.p95_ok, false, '交互层 4000ms > 3000ms 应不达标');
// 真机场景复现:8 个样本里 1 次重启 9.6s,其余都是快操作 -> 应达标
const realWorld = [
inv('system.agent.restart', 'ok', 9575),
inv('system.info', 'ok', 783),
inv('system.metrics', 'ok', 14),
inv('system.audit.list', 'ok', 4),
inv('fs.write', 'ok', 8),
inv('fs.read', 'ok', 3),
];
const m = computeMetrics(realWorld);
assert.equal(m.latency.p95_ms, 9575, '总体 P95 被慢操作顶穿(如实)');
assert.equal(m.latency.fast.samples, 5, '交互层 5 个样本');
assert.equal(m.verdict.p95_ok, true, '剔除重启后应达标 —— 这正是修正口径的目的');
});
test('指标:安全拦截计数(ACL / 限速 / 认证失败)且拦截率恒为 100%', () => {
const entries = [
{ ts: 1, type: 'acl.denied', capability: 'fs.write' },
{ ts: 2, type: 'acl.denied', capability: 'input.key.press' },
{ ts: 3, type: 'rate.limited', capability: 'system.shell.exec' },
{ ts: 4, type: 'auth.failure', reason: 'psk 校验失败' },
inv('system.info', 'ok', 80),
];
const m = computeMetrics(entries);
assert.equal(m.security.acl_denied, 2);
assert.equal(m.security.rate_limited, 1);
assert.equal(m.security.auth_failures, 1);
assert.equal(m.security.interception_rate, 1);
assert.equal(m.verdict.interception_ok, true);
});
test('指标:按能力细分(次数/成功率/P95)', () => {
const entries = [
inv('fs.list', 'ok', 100),
inv('fs.list', 'ok', 200),
inv('fs.list', 'failed', 900, { error: 'x' }),
inv('clip.get', 'ok', 300),
];
const m = computeMetrics(entries);
assert.equal(m.by_capability['fs.list'].attempts, 3);
assert.equal(m.by_capability['fs.list'].ok, 2);
assert.ok(Math.abs(m.by_capability['fs.list'].success_rate - 2 / 3) < 1e-9);
assert.equal(m.by_capability['clip.get'].attempts, 1);
});
test('指标:超时(exit 124)单独计数,仍算失败样本', () => {
const entries = [
inv('system.shell.exec', 'failed', 30_000, { error: '超时已被强制终止 (exit_code=124)' }),
inv('system.shell.exec', 'ok', 120),
];
const m = computeMetrics(entries);
assert.equal(m.latency.timeouts, 1);
assert.equal(m.close_loop.failed, 1);
});
test('指标:可自定义阈值,判定随之变化', () => {
const entries = [inv('system.info', 'ok', 100), inv('system.info', 'failed', 100)];
const m = computeMetrics(entries, { ...DEFAULT_TARGETS, close_loop_success_rate: 0.5 });
assert.equal(m.close_loop.success_rate, 0.5);
assert.equal(m.verdict.close_loop_ok, true, '放宽到 50% 后应达标');
});
test('指标:窗口时间取审计条目的最早/最晚', () => {
const entries = [inv('a', 'ok', 10), inv('b', 'ok', 10)];
entries[0].ts = 1000;
entries[1].ts = 5000;
const m = computeMetrics(entries);
assert.equal(m.window.from, 1000);
assert.equal(m.window.to, 5000);
assert.equal(m.window.entries, 2);
});
test('指标:formatMetrics 输出含四项指标与达标标记', () => {
const m = computeMetrics([
...Array.from({ length: 20 }, () => inv('system.info', 'ok', 100)),
...Array.from({ length: 10 }, () => inv('app.install', 'ok', 2000)),
]);
const text = formatMetrics(m);
assert.match(text, /闭环成功率/);
assert.match(text, /装软件成功率/);
assert.match(text, /P95 时延/);
assert.match(text, /慢操作耗时/);
assert.match(text, /安全拦截/);
assert.match(text, /四项全达标/);
});