Skip to content

Commit 0bd9dda

Browse files
committed
Export the accounting to a monitor, in two shapes on purpose
TODO §11. Traces and accounting were rendered for a person; nothing was machine-readable. `src/metrics.ts` folds the session log itself into a snapshot — no second copy of the counters, because two copies drift and then nobody can say which one to trust. The `run:start` of each run carries the task and its `run:end` carries the stats, so the export needs no new record format and no running server. It exports two shapes deliberately: · `GET /metrics` — Prometheus text, aggregates only; · `GET /api/metrics` — JSON snapshot, with per-task detail. The split is the decision worth recording. The obvious way to write "decisions per task" is `jevloop_decisions{task="..."}`, and that is not done: task names are free text with almost no repetition, so labelling a metric with them makes the time series cardinality unbounded. That is a classic way to take a monitor down, and it happens inside a change that looks like "just added a label" — so the per-task numbers live in the JSON shape, where an unbounded label costs nothing, and Prometheus gets only what is genuinely additive. `decision_batches_without_usage_total` is exported as well. §9 made the token totals a lower bound when a backend does not report usage, and a monitor should be able to see that instead of reading a silent backend as free. Missing accounting is counted rather than filled in: a run whose `run:end` has no stats still counts as a run, its numbers are zero, and its halt reason is kept. A run with no `run:start` reports its task as "(not recorded)" rather than inventing one. An export that looks complete when it is not is worse than one that shows a gap. `npm run metrics -- <session.jsonl> [--format prom|json]` does the same offline. Verified against a real session log in this checkout, not only fixtures: 1 run, 15 decisions, 1 model call. §11's other item — a deployment artefact, or a documented reason there is none — is closed by pointing at the reason that already exists in SECURITY.md, with the part that is still deliberately absent stated rather than left to be discovered: sessions are local files and two processes cannot share them.
1 parent 6c1165a commit 0bd9dda

9 files changed

Lines changed: 382 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ npm run adapter-report # the capability report must match the host's code
137137
npm run replay -- <session> # a real session's decision records must still add up
138138
npm run trace -- <session> # …and render each one as its own readable decisions/*.md
139139
npm run trace -- --verify <dir> # re-derive the digests from those .md files alone
140+
npm run metrics -- <session> # decisions / calls / tokens as Prometheus text, offline
140141
```
141142

142143
> **On the npm status.** `jevloop` is on npm, but the newest published version (`0.2.0`) declares `github.com/Xubqpanda/JevLoop` as its repository, while this tree is `zjunlp/JevLoop` and its `package.json` still reads `0.1.0`. So the published tarball is **not** this revision. To get this one, install from git — `npm install github:zjunlp/JevLoop` — or clone it. `npx jevloop …` is shorthand for the CLI commands above.

‎README.zh-CN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ npm run adapter-report # 能力报告必须和宿主的代码对得上
138138
npm run replay -- <会话> # 真实会话的判定记录必须仍然自洽
139139
npm run trace -- <会话> # ……并把每一条渲染成一个可读的 decisions/*.md
140140
npm run trace -- --verify <目录> # 只从那批 .md 本身重算指纹(改了正文也会报)
141+
npm run metrics -- <会话> # 判定 / 调用 / token,导出成 Prometheus 文本(离线)
141142
```
142143

143144
> **关于 npm 状态。** `jevloop` 已经在 npm 上,但最新已发布版本(`0.2.0`)声明的仓库是 `github.com/Xubqpanda/JevLoop`,而本仓库是 `zjunlp/JevLoop`,`package.json` 还停在 `0.1.0`。所以那个已发布的 tarball **不是**这个 revision。要拿这一份,从 git 装 —— `npm install github:zjunlp/JevLoop` —— 或者直接 clone。`npx jevloop …` 只是上面那些 CLI 命令的简写。

‎TODO.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ Classification and backoff are better here than in most harnesses. What is missi
157157

158158
No deployment shape. Sessions are local files, so two processes cannot share them. Traces and accounting exist, but they are rendered for a person, not exported for a monitor.
159159

160-
- [ ] A deployment artefact — or a documented reason there is none.
161-
- [ ] Export metrics: decisions per task, calls per task, spend per task.
160+
- [x] A deployment artefact — or a documented reason there is none. *(2026-09-29. The reason exists and is written down: [`SECURITY.md`](SECURITY.md) carries the container invocation, what each flag is for, and the decision not to ship an image — an image would be a build artifact to maintain for a project whose shape is "no build step, no dependencies", while the bounds you actually want come from runtime flags. `JEVLOOP_ISOLATED=1` is now part of that recipe, because §8 made isolation a machine question rather than a promise. **What is still deliberately absent is a shared deployment**: sessions are local files and two processes cannot share them, which is stated here and in `SECURITY.md` rather than left for someone to discover.)*
161+
- [x] Export metrics: decisions per task, calls per task, spend per task. *(2026-09-29. `src/metrics.ts` folds the **session log itself** into a snapshot — no second copy of the counters, because two copies drift and then nobody knows which to trust. It exports **two shapes on purpose**: `GET /metrics` serves Prometheus text with aggregates only, and `GET /api/metrics` serves a JSON snapshot with per-task detail. The split is the design decision worth recording — the obvious way to write "decisions per task" is `jevloop_decisions{task="…"}`, and **that is not done**: task names are free text with almost no repetition, and labelling a metric with them makes the time series cardinality unbounded, which is a classic way to take a monitor down inside a change that looks like "just added a label". `decision_batches_without_usage_total` is exported too, because §9 made token totals a **lower bound** when a backend does not report usage, and a monitor should see that rather than read a silent backend as free. `npm run metrics -- <session.jsonl> [--format prom|json]` does the same offline, with no server running.)*
162162

163163
---
164164

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"gate-compare": "node --experimental-strip-types bench/gate-compare.ts",
2626
"gate-derivable": "node --experimental-strip-types bench/gate-derivable-run.ts",
2727
"replay": "node --experimental-strip-types scripts/replay.ts",
28-
"trace": "node --experimental-strip-types scripts/trace.ts"
28+
"trace": "node --experimental-strip-types scripts/trace.ts",
29+
"metrics": "node --experimental-strip-types scripts/metrics.ts"
2930
},
3031
"keywords": [
3132
"agent",

‎scripts/check.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ const LAYER: Record<string, number> = {
306306
// 恢复点:折会话日志成一个可接着跑的状态。它要知道 `StepRecord`(L3 的 frame.ts)
307307
// 与事件形状(L1 的 events.ts),所以放 L4 —— 依赖只指向更低的层。
308308
resume: 4,
309+
// 指标导出:只 import vocab-records(L0)的类型,纯函数、零 IO。
310+
// 它把**已经落盘的账目**换成监视器能读的两种格式,不认识循环也不认识会话文件。
311+
metrics: 0,
309312
// `frame-merge` 同它:`mergeConflicts` 跨 L2/L3 共用(决定合并是否合法,
310313
// L2 的 `decide.ts` 必须查得了),而它对领域一无所知 ——
311314
// 只收「看了什么 / 故意不看什么」,出「能不能合」。零 import、纯函数。

‎scripts/metrics.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* JevLoop · 把一份会话日志导出成指标(TODO §11)
3+
*
4+
* npm run metrics -- <会话日志.jsonl> [--format prom|json]
5+
*
6+
* 离线、不需要服务在跑 —— 数据就在日志里(每一轮 `run:end` 带着那一轮的账目)。
7+
* 格式与取舍见 `src/metrics.ts`。
8+
*
9+
* @module JevLoop/metrics-cli
10+
*/
11+
12+
import { readFileSync } from 'node:fs'
13+
import { resolve } from 'node:path'
14+
import { fileURLToPath } from 'node:url'
15+
16+
import { parseSessionLine } from '../src/session-log.ts'
17+
import { metricsFrom, renderMetricsJson, renderPrometheus } from '../src/metrics.ts'
18+
19+
function usage(): void {
20+
console.error('用法:npm run metrics -- <会话日志.jsonl> [--format prom|json]')
21+
}
22+
23+
function main(argv: string[]): number {
24+
const fmtAt = argv.indexOf('--format')
25+
const format = fmtAt === -1 ? 'prom' : (argv[fmtAt + 1] ?? 'prom')
26+
const file = argv.find((a, i) => !a.startsWith('--') && (fmtAt === -1 || i !== fmtAt + 1))
27+
if (!file || (format !== 'prom' && format !== 'json')) {
28+
usage()
29+
return 1
30+
}
31+
32+
// ★ 事件藏在包装行里(`{kind:'event', run, at, e}`);认不出的行**计数**,不静默丢
33+
const events: unknown[] = []
34+
let skipped = 0
35+
for (const raw of readFileSync(file, 'utf8').split('\n')) {
36+
if (!raw.trim()) continue
37+
const line = parseSessionLine(raw)
38+
if (!line) {
39+
skipped++
40+
continue
41+
}
42+
if (line.kind === 'event') events.push(line.e)
43+
}
44+
45+
const snap = metricsFrom(events)
46+
process.stdout.write(format === 'json' ? `${renderMetricsJson(snap)}\n` : renderPrometheus(snap))
47+
if (skipped > 0) console.error(`跳过 ${skipped} 行(坏行或认不出的形状)`)
48+
return 0
49+
}
50+
51+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
52+
process.exit(main(process.argv.slice(2)))
53+
}

‎src/metrics.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* JevLoop · 把账目**导出给监视器**(TODO §11)
3+
*
4+
* ══════════════════════════════════════════════════════════════
5+
* 轨迹和账目一直是**给人看**的,这一份是给机器读的
6+
* ══════════════════════════════════════════════════════════════
7+
*
8+
* 数据来源是**会话日志本身**:每一轮的 `run:end` 里带着那一轮的 `stats`
9+
* (判定数、模型调用数、token),`run:start` 里带着任务。所以导出不需要新的
10+
* 记录格式,也不需要服务在跑 —— 离线折一份日志就能出数。
11+
*
12+
* ── ★ 一个刻意的取舍:任务名不进 Prometheus 标签 ────────────────
13+
*
14+
* 「每任务多少」这句话最直觉的写法是 `jevloop_decisions{task="…"}`。**不做**:
15+
* 任务名是自由文本,几乎每条都不一样,给指标加这种标签会让时间序列的基数
16+
* 无界增长 —— 那是把监视器打垮的经典方式,而且是在「加了个标签」这种看不出
17+
* 问题的小改动里发生的。
18+
*
19+
* 所以分成两个出口,各自用对形状:
20+
*
21+
* Prometheus 文本 只有**聚合计数**(可加、基数固定),给监视器
22+
* JSON 快照 有**逐任务明细**,给人 / 给事后分析
23+
*
24+
* @module JevLoop/metrics
25+
*/
26+
27+
import type { MeterStats } from './vocab-records.ts'
28+
29+
/** 一轮任务跑完之后的账目 */
30+
export interface TaskMetrics {
31+
task: string
32+
halt: string
33+
decisions: number
34+
modelCalls: number
35+
/** 生成 + 判定的总 token(§9) */
36+
inputTokens: number
37+
outputTokens: number
38+
/** 其中判定那部分 */
39+
decisionInputTokens: number
40+
decisionOutputTokens: number
41+
/** 后端一次 usage 都没报的判定批次 —— 于是 token 数是**下界** */
42+
decisionBatchesWithoutUsage: number
43+
}
44+
45+
/** 一份可导出的快照 */
46+
export interface MetricsSnapshot {
47+
/** 一共几轮 */
48+
runs: number
49+
/** 逐任务明细(Prometheus 那边**不放**这些) */
50+
tasks: TaskMetrics[]
51+
/** 聚合计数,可加 */
52+
totals: Omit<TaskMetrics, 'task' | 'halt'>
53+
}
54+
55+
function isRecord(v: unknown): v is Record<string, unknown> {
56+
return !!v && typeof v === 'object' && !Array.isArray(v)
57+
}
58+
59+
/** 从 `run:end.stats` 里取账目。取不到就按 0 计 —— 并**计数**,见 `unreadable` */
60+
function statsOf(v: unknown): MeterStats | undefined {
61+
if (!isRecord(v)) return undefined
62+
const s = v as unknown as MeterStats
63+
return typeof s.decisions === 'number' ? s : undefined
64+
}
65+
66+
/**
67+
* 一串事件(一个会话文件里的全部)→ 快照。
68+
*
69+
* 每一轮取**最后一次** `run:start` 的任务名与 `run:end` 的账目。缺账目的轮次
70+
* **照样计入 `runs`**,但它的各项按 0 计 —— 所以读了它的人要能看出「有几轮没数」。
71+
* 这里不编一个数字出来:一份看起来完整的导出比一份缺了东西的更危险。
72+
*/
73+
export function metricsFrom(events: readonly unknown[]): MetricsSnapshot {
74+
const tasks: TaskMetrics[] = []
75+
let current: string | undefined
76+
77+
for (const e of events) {
78+
if (!isRecord(e)) continue
79+
if (e.type === 'run:start' && typeof e.task === 'string') {
80+
current = e.task
81+
continue
82+
}
83+
if (e.type !== 'run:end') continue
84+
const s = statsOf(e.stats)
85+
tasks.push({
86+
task: current ?? '(未记任务)',
87+
halt: typeof e.halt === 'string' ? e.halt : '(未记)',
88+
decisions: s?.decisions ?? 0,
89+
modelCalls: s?.modelCalls ?? 0,
90+
inputTokens: s?.inputTokens ?? 0,
91+
outputTokens: s?.outputTokens ?? 0,
92+
decisionInputTokens: s?.decisionInputTokens ?? 0,
93+
decisionOutputTokens: s?.decisionOutputTokens ?? 0,
94+
decisionBatchesWithoutUsage: s?.decisionBatchesWithoutUsage ?? 0,
95+
})
96+
}
97+
98+
const sum = (f: (t: TaskMetrics) => number) => tasks.reduce((a, t) => a + f(t), 0)
99+
return {
100+
runs: tasks.length,
101+
tasks,
102+
totals: {
103+
decisions: sum((t) => t.decisions),
104+
modelCalls: sum((t) => t.modelCalls),
105+
inputTokens: sum((t) => t.inputTokens),
106+
outputTokens: sum((t) => t.outputTokens),
107+
decisionInputTokens: sum((t) => t.decisionInputTokens),
108+
decisionOutputTokens: sum((t) => t.decisionOutputTokens),
109+
decisionBatchesWithoutUsage: sum((t) => t.decisionBatchesWithoutUsage),
110+
},
111+
}
112+
}
113+
114+
/**
115+
* Prometheus 文本格式。
116+
*
117+
* ★ 只有**聚合**,没有任务标签 —— 理由见模块头。任务名出现在 `# HELP` 里会成
118+
* 为误导(那是标签该干的事),所以这里连一个都不放。
119+
*
120+
* ★ `decision_batches_without_usage` 也导出去:token 总数是**下界**这件事
121+
* 必须能被监视器看见,否则一个漏报 token 的后端在面板上看起来是免费的。
122+
*/
123+
export function renderPrometheus(snap: MetricsSnapshot): string {
124+
const t = snap.totals
125+
const line = (name: string, help: string, value: number): string =>
126+
`# HELP ${name} ${help}\n# TYPE ${name} counter\n${name} ${value}\n`
127+
return (
128+
line('jevloop_runs_total', 'Runs recorded in the exported session log.', snap.runs) +
129+
line('jevloop_decisions_total', 'Decisions made across all runs.', t.decisions) +
130+
line('jevloop_model_calls_total', 'Generation calls across all runs.', t.modelCalls) +
131+
line('jevloop_input_tokens_total', 'Input tokens, generation and decision.', t.inputTokens) +
132+
line('jevloop_output_tokens_total', 'Output tokens, generation and decision.', t.outputTokens) +
133+
line('jevloop_decision_input_tokens_total', 'Input tokens spent on decisions.', t.decisionInputTokens) +
134+
line('jevloop_decision_output_tokens_total', 'Output tokens spent on decisions.', t.decisionOutputTokens) +
135+
line(
136+
'jevloop_decision_batches_without_usage_total',
137+
'Decision batches whose backend reported no token usage; token totals are a lower bound by this much.',
138+
t.decisionBatchesWithoutUsage,
139+
)
140+
)
141+
}
142+
143+
/** 给人和事后分析用的 JSON。逐任务明细在这里。 */
144+
export function renderMetricsJson(snap: MetricsSnapshot): string {
145+
return JSON.stringify(snap, null, 2)
146+
}

‎src/server.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { runAgent } from './agent.ts'
5353
import { parseDecisionDoc, summarize, headline, isGate } from './decisiondoc.ts'
5454
import { compilePolicy, compilePredicate } from './decision-compile.ts'
5555
import type { AgentEvent, GenerateDelta } from './events.ts'
56+
import { metricsFrom, renderPrometheus } from './metrics.ts'
5657
import { SessionStore, assertSessionId } from './session-store.ts'
5758
import { migrateLegacyLayout } from './session-migrate.ts'
5859
import { createDir, listDirs } from './dir-browse.ts'
@@ -666,6 +667,25 @@ async function handleSession(req: IncomingMessage, res: ServerResponse, url: URL
666667
* `DELETE` 删一个会话。删是**看得见**的动作:界面上有列表,所以删掉
667668
* 什么用户是知道的(对比以前那个悄悄丢最老会话的 LRU)。
668669
*/
670+
/**
671+
* 指标出口。`prom = true` 出 Prometheus 文本,否则出 JSON 快照。
672+
*
673+
* 数据来自**已经落盘的会话日志** —— 不另存一份计数,因为两份必然会分叉。
674+
*/
675+
async function handleMetrics(res: ServerResponse, prom: boolean): Promise<void> {
676+
const events: unknown[] = []
677+
for (const summary of await store.list()) {
678+
for (const run of await store.load(summary.id)) events.push(...run.events)
679+
}
680+
const snap = metricsFrom(events)
681+
if (!prom) {
682+
sendJson(res, 200, snap)
683+
return
684+
}
685+
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' })
686+
res.end(renderPrometheus(snap))
687+
}
688+
669689
async function handleSessions(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
670690
if (req.method === 'DELETE') {
671691
const id = resolveSessionId(url.searchParams.get('id'))
@@ -931,6 +951,23 @@ const server = createServer(async (req, res) => {
931951
await handleWorkspaces(req, res, url)
932952
return
933953
}
954+
/*
955+
TODO §11:把账目**导出给监视器**。
956+
957+
`/metrics` Prometheus 文本(聚合计数,可加、基数固定)
958+
`/api/metrics` JSON 快照(含逐任务明细)
959+
960+
★ 两个出口而不是一个,是因为两边要的形状不同:任务名是自由文本,把它做成
961+
Prometheus 标签会让时间序列基数无界增长 —— 那是把监视器打垮的经典方式。
962+
理由写在 `src/metrics.ts` 的文件头。
963+
964+
★ 聚合**所有**会话:这是给一个进程内、本地文件的部署用的(§11 的另一半),
965+
所以「全部」就是这个部署的全部。跨进程共享会话不在这一版里。
966+
*/
967+
if (url.pathname === '/metrics' || url.pathname === '/api/metrics') {
968+
await handleMetrics(res, url.pathname === '/metrics')
969+
return
970+
}
934971
await serveStatic(res, url.pathname)
935972
} catch (err) {
936973
// ★ **错误一律 JSON**,和成功路径同一个形状。

0 commit comments

Comments
 (0)