|
| 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 | +} |
0 commit comments