@migor/agentia 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +1 -1
  3. package/dist/AGENTS.md +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/integrations/anthropic.d.ts +5 -0
  7. package/dist/integrations/anthropic.js +29 -2
  8. package/dist/integrations/anthropic.js.map +1 -1
  9. package/dist/integrations/mcp-http.d.ts +43 -0
  10. package/dist/integrations/mcp-http.js +250 -0
  11. package/dist/integrations/mcp-http.js.map +1 -0
  12. package/dist/integrations/mcp-stdio.d.ts +46 -0
  13. package/dist/integrations/mcp-stdio.js +230 -0
  14. package/dist/integrations/mcp-stdio.js.map +1 -0
  15. package/dist/integrations/mcp.d.ts +37 -72
  16. package/dist/integrations/mcp.js +9 -468
  17. package/dist/integrations/mcp.js.map +1 -1
  18. package/dist/integrations/metrics-otlp.d.ts +42 -0
  19. package/dist/integrations/metrics-otlp.js +178 -0
  20. package/dist/integrations/metrics-otlp.js.map +1 -0
  21. package/dist/integrations/metrics-render.d.ts +2 -0
  22. package/dist/integrations/metrics-render.js +145 -0
  23. package/dist/integrations/metrics-render.js.map +1 -0
  24. package/dist/integrations/metrics-state.d.ts +191 -0
  25. package/dist/integrations/metrics-state.js +363 -0
  26. package/dist/integrations/metrics-state.js.map +1 -0
  27. package/dist/integrations/metrics.d.ts +3 -86
  28. package/dist/integrations/metrics.js +28 -648
  29. package/dist/integrations/metrics.js.map +1 -1
  30. package/dist/integrations/openai.js +4 -3
  31. package/dist/integrations/openai.js.map +1 -1
  32. package/dist/store/fsStore.d.ts +1 -1
  33. package/dist/store/fsStore.js +5 -2
  34. package/dist/store/fsStore.js.map +1 -1
  35. package/dist/transport/async.d.ts +13 -1
  36. package/dist/transport/async.js +109 -12
  37. package/dist/transport/async.js.map +1 -1
  38. package/package.json +2 -2
@@ -1,24 +1,7 @@
1
- import { capabilityKindOf } from '../core/trace.js';
2
- import { percentile } from '../core/stats.js';
3
- /**
4
- * OTLP metrics 导出失败(带数值 `status`)。
5
- *
6
- * 为什么不能抛裸 `Error`:`engine/errors.ts` 的分类是鸭子类型,只看数据属性。
7
- * 裸 Error 会被判 `unknown` + `retryable:false` —— 宿主在 `onExportError` 里拿不到
8
- * status,也就没法区分「collector 拒收(4xx,改配置)」与「collector 挂了(5xx,等它回来)」。
9
- * 形状与 `otlp.ts` 的 `OtlpExportError` 一致(同层两处导出器不该有两种错误形状)。
10
- *
11
- * 注:本处抛错由 `onExportError` 接住并按「观测失败不得击穿业务」吞掉 —— 带 status 是为了
12
- * **可判断**,不是为了让谁重试。
13
- */
14
- export class MetricsExportError extends Error {
15
- status;
16
- constructor(status, message) {
17
- super(message);
18
- this.name = 'MetricsExportError';
19
- this.status = status;
20
- }
21
- }
1
+ import { MetricsState } from './metrics-state.js';
2
+ import { renderPrometheus } from './metrics-render.js';
3
+ import { flushOtlpMetrics } from './metrics-otlp.js';
4
+ export { MetricsExportError } from './metrics-otlp.js';
22
5
  const DEFAULT_WINDOW = 1024;
23
6
  const DEFAULT_MAX_CAPABILITIES = 200;
24
7
  /** 模型 id 实际就那么几个,默认给 50 已经很宽(够覆盖多版本并存的灰度期) */
@@ -29,150 +12,6 @@ const DEFAULT_MAX_SCORES = 200;
29
12
  export const DEFAULT_BUCKETS = [
30
13
  25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000,
31
14
  ];
32
- /** 超过基数上限后新键的兜底标签(能力 / 模型 / 评分三个维度共用) */
33
- const OTHER_LABEL = '__other__';
34
- /**
35
- * 折叠计数最多记这么多个**不同**键(每个维度各一本)。计数只用于报警「基数爆了」,
36
- * 不值得为它留一本无界的账 —— 那等于把上限又拆掉一角。正常上限(50~200)下这个数
37
- * 远够用:真要记满,说明键空间已经失控,此时「≥1024」和精确值一样能说明问题。
38
- */
39
- const MAX_DROPPED_TRACKING = 1024;
40
- /**
41
- * 标签基数配额:三个维度共用的「有界键空间」。
42
- *
43
- * 键第一次出现且在额度内 → 分到自己的标签(**永远认自己**,不会时而被折叠时而不折叠);
44
- * 超限 → 归入 `OTHER_LABEL`。
45
- *
46
- * 被折叠的键记进 `over`,只为回答「多少个**不同**的键被折叠了」;这本账自身也有界
47
- * (`MAX_DROPPED_TRACKING`),满了以后 `dropped` 是下界。
48
- */
49
- class KeyBudget {
50
- max;
51
- assigned = new Set();
52
- over = new Set();
53
- constructor(max) {
54
- this.max = max;
55
- }
56
- /** 取键的标签:额度内原样返回,超限返回 `OTHER_LABEL` */
57
- take(key) {
58
- if (this.assigned.has(key))
59
- return key;
60
- if (this.assigned.size < this.max) {
61
- this.assigned.add(key);
62
- return key;
63
- }
64
- if (this.over.size < MAX_DROPPED_TRACKING)
65
- this.over.add(key);
66
- return OTHER_LABEL;
67
- }
68
- /** 被折叠的**不同**键数(记满 `MAX_DROPPED_TRACKING` 后为下界) */
69
- get dropped() {
70
- return this.over.size;
71
- }
72
- /** 清空配额(`reset()` 用)—— 与 `capabilities` / `models` / `scores` 三张表同步清 */
73
- reset() {
74
- this.assigned.clear();
75
- this.over.clear();
76
- }
77
- }
78
- /**
79
- * 时长统计:环形窗口(算窗口内精确分位)+ 累积直方图(算可聚合的 bucket)。
80
- * run / 能力 / 模型共用同一个实现,保证三种粒度的口径与取整完全一致。
81
- */
82
- class DurationStat {
83
- windowSize;
84
- bounds;
85
- ring = [];
86
- cursor = 0;
87
- /** 每桶非累积计数:counts[i] 对应 le = bounds[i];末位是 +Inf 桶 */
88
- counts;
89
- total = 0;
90
- sum = 0;
91
- constructor(windowSize, bounds) {
92
- this.windowSize = windowSize;
93
- this.bounds = bounds;
94
- this.counts = new Array(bounds.length + 1).fill(0);
95
- }
96
- add(ms) {
97
- this.total++;
98
- this.sum += ms;
99
- if (this.ring.length < this.windowSize)
100
- this.ring.push(ms);
101
- else {
102
- this.ring[this.cursor] = ms;
103
- this.cursor = (this.cursor + 1) % this.windowSize;
104
- }
105
- let i = 0;
106
- while (i < this.bounds.length && ms > this.bounds[i])
107
- i++;
108
- this.counts[i]++;
109
- }
110
- get count() {
111
- return this.total;
112
- }
113
- get sumMs() {
114
- return this.sum;
115
- }
116
- get windowSamples() {
117
- return this.ring.length;
118
- }
119
- /**
120
- * 窗口内精确分位(Prometheus 的 quantile 语义:取第 ceil(q·n) 个样本)。
121
- * 算法单源在 `core/stats.ts` —— 与 `report.ts` 的时长 p50/p95 必须同口径,
122
- * 否则同一条 trace 在看板与报告里会有两个 p95。
123
- */
124
- percentile(q) {
125
- if (this.ring.length === 0)
126
- return 0;
127
- const sorted = [...this.ring].sort((a, b) => a - b);
128
- return percentile(sorted, q);
129
- }
130
- /** 累积 bucket 计数(长度 = bounds+1,末位为 +Inf = count)—— Prometheus 文本语义 */
131
- cumulative() {
132
- const out = [];
133
- let running = 0;
134
- for (const c of this.counts) {
135
- running += c;
136
- out.push(running);
137
- }
138
- return out;
139
- }
140
- /** 每桶**非累积**计数 —— OTLP histogram 的 bucketCounts 语义(与 cumulative() 千万别混用) */
141
- perBucket() {
142
- return [...this.counts];
143
- }
144
- get boundsList() {
145
- return this.bounds;
146
- }
147
- reset() {
148
- this.ring.length = 0;
149
- this.cursor = 0;
150
- this.counts.fill(0);
151
- this.total = 0;
152
- this.sum = 0;
153
- }
154
- }
155
- /** run 时长:取根 span(`trace.rootSpanId`)的起止;根没收尾(如 sink 在失败路径拿到半截)时返回 undefined */
156
- function runDurationMs(trace) {
157
- const root = trace.spans.find((s) => s.spanId === trace.rootSpanId);
158
- if (!root || root.endedAt === undefined)
159
- return undefined;
160
- return root.endedAt - root.startedAt;
161
- }
162
- function num(v) {
163
- return typeof v === 'number' && Number.isFinite(v) ? v : 0;
164
- }
165
- /**
166
- * 评分键 → 指标标签。键有两种形态:**折叠桶**(`__other__`,整个键就是标签,不含 `\t`)
167
- * 与正常的 `name\tsource`(source 缺省为 '')。两个出口(Prometheus 文本 / OTLP)共用一处
168
- * 解码 —— 各切各的必然有一处把折叠桶切成乱码。
169
- */
170
- function scoreLabels(key) {
171
- if (key === OTHER_LABEL)
172
- return { name: OTHER_LABEL, source: '' };
173
- const tab = key.indexOf('\t');
174
- return { name: key.slice(0, tab), source: key.slice(tab + 1) };
175
- }
176
15
  export function metricsSink(opts = {}) {
177
16
  const format = opts.export ?? 'prometheus';
178
17
  if (format !== 'prometheus' && format !== 'otlp') {
@@ -210,478 +49,32 @@ export function metricsSink(opts = {}) {
210
49
  const timeoutMs = opts.timeoutMs ?? 10_000;
211
50
  const serviceName = opts.serviceName ?? 'agentia';
212
51
  const startedAtMs = Date.now();
213
- let runs = 0;
214
- let failed = 0;
215
- let costUsd = 0;
216
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
217
- const runStat = new DurationStat(windowSize, buckets);
218
- const capabilities = new Map();
219
- const models = new Map();
220
- /** 评分累加器:key = `${name}\t${source}`(source 缺省 '';\t 不会出现在正常评分名里,做天然分隔符) */
221
- const scores = new Map();
222
- // 三个维度的键空间都封在这些配额里 —— 没有它们,三张 Map 与各自的时长窗口都是无界的
223
- const capBudget = new KeyBudget(maxCapabilities);
224
- const modelBudget = new KeyBudget(maxModels);
225
- const scoreBudget = new KeyBudget(maxScores);
226
- const newCapability = () => ({
227
- calls: 0,
228
- errors: 0,
229
- stat: new DurationStat(windowSize, buckets),
230
- tokens: null,
231
- costUsd: null,
52
+ const state = new MetricsState({
53
+ windowSize,
54
+ maxCapabilities,
55
+ maxModels,
56
+ maxScores,
57
+ labelMode,
58
+ buckets,
232
59
  });
233
- /** 能力标签分配:labelMode 决定粒度,capBudget 决定基数上限 */
234
- const labelFor = (kind, name) => {
235
- if (labelMode === 'none')
236
- return null;
237
- if (labelMode === 'kind')
238
- return kind;
239
- return capBudget.take(`${kind}:${name}`);
240
- };
241
- const accumulate = (trace) => {
242
- runs++;
243
- if (trace.status === 'error')
244
- failed++;
245
- const u = trace.totalUsage;
246
- tokens.input += u.inputTokens;
247
- tokens.output += u.outputTokens;
248
- tokens.cacheRead += u.cacheReadTokens;
249
- tokens.cacheCreation += u.cacheCreationTokens;
250
- if (u.costEstimate != null)
251
- costUsd += u.costEstimate;
252
- const d = runDurationMs(trace);
253
- if (d !== undefined)
254
- runStat.add(d);
255
- // —— 评分(R7):只认根 span 的 score 事件(attachScore 的写入位置);
256
- // body 宽容读取 —— name 不是 string / value 不是有限 number 就跳过,观测不击穿业务
257
- const rootSpan = trace.spans.find((s) => s.spanId === trace.rootSpanId);
258
- if (rootSpan) {
259
- for (const e of rootSpan.events) {
260
- if (e.name !== 'score')
261
- continue;
262
- const body = e.body;
263
- if (!body || typeof body !== 'object')
264
- continue;
265
- if (typeof body.name !== 'string')
266
- continue;
267
- if (typeof body.value !== 'number' || !Number.isFinite(body.value))
268
- continue;
269
- const source = typeof body.source === 'string' ? body.source : '';
270
- const key = scoreBudget.take(`${body.name}\t${source}`);
271
- const acc = scores.get(key) ?? { value: 0, count: 0, sum: 0 };
272
- acc.value = body.value; // gauge 语义:覆盖为最近一次
273
- acc.count++;
274
- acc.sum += body.value;
275
- scores.set(key, acc);
276
- }
277
- }
278
- for (const span of trace.spans) {
279
- if (span.kind === 'capability') {
280
- const label = labelFor(capabilityKindOf(span), span.name);
281
- // 时长与错误无论哪种 labelMode 都要累计;labelMode:'none' 时整块跳过
282
- if (label !== null) {
283
- const acc = capabilities.get(label) ?? newCapability();
284
- acc.calls++;
285
- if (span.status === 'error')
286
- acc.errors++;
287
- if (span.endedAt !== undefined)
288
- acc.stat.add(Math.max(0, span.endedAt - span.startedAt));
289
- // capability.usage = 子孙 llm.turn 聚合(tracer 写入);工具没有这个语义
290
- if (span.usage) {
291
- const sum = span.usage.inputTokens +
292
- span.usage.outputTokens +
293
- span.usage.cacheReadTokens +
294
- span.usage.cacheCreationTokens;
295
- acc.tokens = (acc.tokens ?? 0) + sum;
296
- if (span.usage.costEstimate != null)
297
- acc.costUsd = (acc.costUsd ?? 0) + span.usage.costEstimate;
298
- }
299
- capabilities.set(label, acc);
300
- }
301
- continue;
302
- }
303
- if (span.kind === 'llm.turn') {
304
- const model = modelBudget.take(span.name);
305
- const acc = models.get(model) ?? {
306
- turns: 0,
307
- tokens: 0,
308
- costUsd: 0,
309
- unpricedTurns: 0,
310
- stat: new DurationStat(windowSize, buckets),
311
- };
312
- acc.turns++;
313
- if (span.usage) {
314
- acc.tokens +=
315
- span.usage.inputTokens +
316
- span.usage.outputTokens +
317
- span.usage.cacheReadTokens +
318
- span.usage.cacheCreationTokens;
319
- if (span.usage.costEstimate != null)
320
- acc.costUsd += span.usage.costEstimate;
321
- else
322
- acc.unpricedTurns++; // 有计量却算不出成本 = 模型不在价格表内
323
- }
324
- if (span.endedAt !== undefined)
325
- acc.stat.add(Math.max(0, span.endedAt - span.startedAt));
326
- models.set(model, acc);
327
- // 普通工具的耗时/成败在 turn 的 tool.output 事件上(E1)—— 能力指标的另一路数据源
328
- for (const e of span.events) {
329
- if (e.name !== 'tool.output')
330
- continue;
331
- const body = e.body;
332
- if (!body || typeof body !== 'object' || typeof body.tool !== 'string')
333
- continue;
334
- const label = labelFor('tool', body.tool);
335
- if (label === null)
336
- continue;
337
- const tacc = capabilities.get(label) ?? newCapability();
338
- tacc.calls++;
339
- if (body.ok === false)
340
- tacc.errors++;
341
- tacc.stat.add(Math.max(0, num(body.durationMs)));
342
- capabilities.set(label, tacc);
343
- }
344
- }
345
- }
346
- };
347
- const snapshot = () => {
348
- const capabilityOut = {};
349
- if (labelMode !== 'none') {
350
- for (const [label, acc] of capabilities) {
351
- capabilityOut[label] = {
352
- calls: acc.calls,
353
- errors: acc.errors,
354
- latencyP50: acc.stat.percentile(0.5),
355
- latencyP95: acc.stat.percentile(0.95),
356
- tokens: acc.tokens,
357
- costUsd: acc.costUsd,
358
- };
359
- }
360
- }
361
- const modelOut = {};
362
- for (const [model, acc] of models) {
363
- modelOut[model] = {
364
- turns: acc.turns,
365
- tokens: acc.tokens,
366
- costUsd: acc.costUsd,
367
- unpricedTurns: acc.unpricedTurns,
368
- latencyP50: acc.stat.percentile(0.5),
369
- latencyP95: acc.stat.percentile(0.95),
370
- };
371
- }
372
- const scoreOut = {};
373
- for (const [key, acc] of scores) {
374
- // 折叠桶没有 `name@source` 可黏(也不该有),原样以 `__other__` 出
375
- if (key === OTHER_LABEL) {
376
- scoreOut[OTHER_LABEL] = { ...acc };
377
- continue;
378
- }
379
- const { name, source } = scoreLabels(key);
380
- scoreOut[source === '' ? name : `${name}@${source}`] = { ...acc };
381
- }
382
- return {
383
- runs,
384
- failed,
385
- latencyP50: runStat.percentile(0.5),
386
- latencyP95: runStat.percentile(0.95),
387
- tokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheCreation,
388
- costUsd,
389
- capabilities: capabilityOut,
390
- models: modelOut,
391
- scores: scoreOut,
392
- droppedCapabilities: capBudget.dropped,
393
- droppedModels: modelBudget.dropped,
394
- droppedScores: scoreBudget.dropped,
395
- };
396
- };
397
- /**
398
- * 一个指标家族的完整块:HELP/TYPE 各**恰好一行**,后接全部样本行。
399
- * expfmt 对同名指标的第二条 HELP/TYPE 是**硬错误**(整次 scrape 失败),
400
- * 所以家族头必须集中在这里发一次,绝不能让每条样本自带;同名指标也只能有一种 TYPE
401
- * (histogram 与分位 gauge 因此拆成 `*_duration_ms` 与 `*_duration_ms_last` 两个名字)。
402
- */
403
- const family = (name, type, help, samples) => `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${samples.join('\n')}\n`;
404
- /** Prometheus label 值转义:\、"、换行必须转义,否则一个含引号的能力名/模型名就损坏整页 exposition */
405
- const escLabel = (v) => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
406
- /** 一个 stat 的 histogram 样本(bucket 累积 + sum + count),labels 形如 `{k="v"}` */
407
- const histogramSamples = (name, stat, labels = '') => {
408
- const inner = labels ? labels.slice(1, -1) + ',' : ''; // 去掉外层 {} 再补逗号
409
- const at = (extra) => `{${inner}${extra}}`;
410
- const out = [];
411
- const cum = stat.cumulative();
412
- for (let i = 0; i < stat.boundsList.length; i++) {
413
- out.push(`${name}_bucket${at(`le="${stat.boundsList[i]}"`)} ${cum[i]}`);
414
- }
415
- out.push(`${name}_bucket${at(`le="+Inf"`)} ${stat.count}`);
416
- out.push(`${name}_sum${labels} ${stat.sumMs}`);
417
- out.push(`${name}_count${labels} ${stat.count}`);
418
- return out;
419
- };
420
- const render = () => {
421
- const out = [];
422
- out.push(family(`${p}runs_total`, 'counter', 'run 总数(成功 + 失败)', [`${p}runs_total ${runs}`]));
423
- out.push(family(`${p}runs_failed_total`, 'counter', '失败的 run 数(trace.status=error)', [
424
- `${p}runs_failed_total ${failed}`,
425
- ]));
426
- out.push(family(`${p}tokens_total`, 'counter', 'token 累计(kind 分项:input / output / cache_read / cache_creation)', [
427
- `${p}tokens_total{kind="input"} ${tokens.input}`,
428
- `${p}tokens_total{kind="output"} ${tokens.output}`,
429
- `${p}tokens_total{kind="cache_read"} ${tokens.cacheRead}`,
430
- `${p}tokens_total{kind="cache_creation"} ${tokens.cacheCreation}`,
431
- ]));
432
- out.push(family(`${p}cost_usd_total`, 'counter', '累计成本估算(美元)', [
433
- `${p}cost_usd_total ${costUsd}`,
434
- ]));
435
- // 基数上限的**可见性**:被折叠掉的不同键数。与 `snapshot()` 的同名字段一一对应 ——
436
- // 此前只有 snapshot() 有、render() 没有,于是「按文档把 metricsSink 接到 /metrics」的部署
437
- // **完全看不见折叠发生**(静默丢失);而同一份文件对「算不出成本的 turn」专门发了
438
- // `model_unpriced_turns_total`,口径不一致。
439
- // 与 unpriced 不同:**恒定发三行**(不是 >0 才发)—— 「0 → N」这个变化本身就是要告警的信号。
440
- out.push(family(`${p}dropped_keys`, 'gauge', '因基数上限被折叠的不同键数(kind 分项)', [
441
- `${p}dropped_keys{kind="capability"} ${capBudget.dropped}`,
442
- `${p}dropped_keys{kind="model"} ${modelBudget.dropped}`,
443
- `${p}dropped_keys{kind="score"} ${scoreBudget.dropped}`,
444
- ]));
445
- // 时长:histogram(可跨实例聚合)+ 窗口内精确分位(单实例好读),两种口径并存。
446
- // 分位 gauge 必须用另一个名字 `*_last` —— 同名指标只允许一种 TYPE,
447
- // 先发 histogram 再发 gauge 会被 expfmt 判硬错误,整次 scrape 失败。
448
- out.push(family(`${p}run_duration_ms`, 'histogram', 'run 时长(毫秒)', [
449
- ...histogramSamples(`${p}run_duration_ms`, runStat),
450
- ]));
451
- out.push(family(`${p}run_duration_ms_last`, 'gauge', 'run 时长分位(毫秒,滑动窗口内精确值)', [
452
- `${p}run_duration_ms_last{quantile="0.5"} ${runStat.percentile(0.5)}`,
453
- `${p}run_duration_ms_last{quantile="0.95"} ${runStat.percentile(0.95)}`,
454
- ]));
455
- // —— 能力维度(E2):同一家族的样本跨 label 聚合,家族头只发一次 ——
456
- const capLabels = [...capabilities.keys()].sort();
457
- if (capLabels.length > 0) {
458
- const calls = [];
459
- const errors = [];
460
- const durations = [];
461
- const durationQuantiles = [];
462
- const capabilityTokens = [];
463
- const capabilityCosts = [];
464
- for (const label of capLabels) {
465
- const acc = capabilities.get(label);
466
- const lv = escLabel(label);
467
- const l = `{capability="${lv}"}`;
468
- calls.push(`${p}capability_calls_total${l} ${acc.calls}`);
469
- errors.push(`${p}capability_errors_total${l} ${acc.errors}`);
470
- durations.push(...histogramSamples(`${p}capability_duration_ms`, acc.stat, l));
471
- durationQuantiles.push(`${p}capability_duration_ms_last{capability="${lv}",quantile="0.5"} ${acc.stat.percentile(0.5)}`, `${p}capability_duration_ms_last{capability="${lv}",quantile="0.95"} ${acc.stat.percentile(0.95)}`);
472
- if (acc.tokens !== null)
473
- capabilityTokens.push(`${p}capability_tokens_total${l} ${acc.tokens}`);
474
- if (acc.costUsd !== null)
475
- capabilityCosts.push(`${p}capability_cost_usd_total${l} ${acc.costUsd}`);
476
- }
477
- out.push(family(`${p}capability_calls_total`, 'counter', '能力调用次数', calls));
478
- out.push(family(`${p}capability_errors_total`, 'counter', '能力失败次数', errors));
479
- out.push(family(`${p}capability_duration_ms`, 'histogram', '能力调用耗时(毫秒)', durations));
480
- out.push(family(`${p}capability_duration_ms_last`, 'gauge', '能力调用耗时分位(窗口内精确值)', durationQuantiles));
481
- if (capabilityTokens.length > 0) {
482
- out.push(family(`${p}capability_tokens_total`, 'counter', 'skill/subagent 的子孙 token 合计', capabilityTokens));
483
- }
484
- if (capabilityCosts.length > 0) {
485
- out.push(family(`${p}capability_cost_usd_total`, 'counter', 'skill/subagent 的估算成本(美元)', capabilityCosts));
486
- }
487
- }
488
- // —— 模型维度(E3)——
489
- const modelNames = [...models.keys()].sort();
490
- if (modelNames.length > 0) {
491
- const turns = [];
492
- const modelTokens = [];
493
- const modelCosts = [];
494
- const unpriced = [];
495
- const durations = [];
496
- const durationQuantiles = [];
497
- for (const model of modelNames) {
498
- const acc = models.get(model);
499
- const mv = escLabel(model);
500
- const l = `{model="${mv}"}`;
501
- turns.push(`${p}model_turns_total${l} ${acc.turns}`);
502
- modelTokens.push(`${p}model_tokens_total${l} ${acc.tokens}`);
503
- modelCosts.push(`${p}model_cost_usd_total${l} ${acc.costUsd}`);
504
- if (acc.unpricedTurns > 0)
505
- unpriced.push(`${p}model_unpriced_turns_total${l} ${acc.unpricedTurns}`);
506
- durations.push(...histogramSamples(`${p}model_duration_ms`, acc.stat, l));
507
- durationQuantiles.push(`${p}model_duration_ms_last{model="${mv}",quantile="0.5"} ${acc.stat.percentile(0.5)}`, `${p}model_duration_ms_last{model="${mv}",quantile="0.95"} ${acc.stat.percentile(0.95)}`);
508
- }
509
- out.push(family(`${p}model_turns_total`, 'counter', '模型往返次数', turns));
510
- out.push(family(`${p}model_tokens_total`, 'counter', '模型 token 合计(四类之和)', modelTokens));
511
- out.push(family(`${p}model_cost_usd_total`, 'counter', '模型估算成本(美元,仅已定价部分)', modelCosts));
512
- if (unpriced.length > 0) {
513
- out.push(family(`${p}model_unpriced_turns_total`, 'counter', '算不出成本的 turn 数(模型不在价格表内)', unpriced));
514
- }
515
- out.push(family(`${p}model_duration_ms`, 'histogram', '模型往返耗时(毫秒)', durations));
516
- out.push(family(`${p}model_duration_ms_last`, 'gauge', '模型往返耗时分位(窗口内精确值)', durationQuantiles));
517
- }
518
- // —— 评分维度(R7):gauge 记最近一次值、counter 记条数,label 为 name × source ——
519
- if (scores.size > 0) {
520
- const gaugeSamples = [];
521
- const counterSamples = [];
522
- for (const key of [...scores.keys()].sort()) {
523
- const acc = scores.get(key);
524
- const { name, source } = scoreLabels(key);
525
- const l = `{name="${escLabel(name)}",source="${escLabel(source)}"}`;
526
- gaugeSamples.push(`${p}score${l} ${acc.value}`);
527
- counterSamples.push(`${p}score_total${l} ${acc.count}`);
528
- }
529
- out.push(family(`${p}score`, 'gauge', '最近一次评分(label 为评分维度与来源)', gaugeSamples));
530
- out.push(family(`${p}score_total`, 'counter', '评分条数', counterSamples));
531
- }
532
- return out.join('');
533
- };
534
- // —— OTLP/JSON 导出(E5)——
535
- // epoch 毫秒 ×1e6 ≈ 1.7e18 > 2^53,double 直接乘会丢精度 —— 必须先取整再转 BigInt
536
- const nanos = (ms) => String(BigInt(Math.round(ms)) * 1000000n);
537
- const strAttr = (key, v) => ({ key, value: { stringValue: v } });
538
- const buildOtlpPayload = () => {
539
- const now = nanos(Date.now());
540
- const start = nanos(startedAtMs);
541
- const s = snapshot();
542
- const metricTable = new Map();
543
- /** 查表取已建 Metric,没有则建;同名只保留首次的 description(冲突描述本身是 semantic error) */
544
- const takeMetric = (name, help) => {
545
- let m = metricTable.get(name);
546
- if (!m) {
547
- m = { name, description: help };
548
- metricTable.set(name, m);
549
- }
550
- return m;
551
- };
552
- const sum = (name, value, help, attrs, monotonic = true) => {
553
- const m = takeMetric(name, help);
554
- m.sum ??= { aggregationTemporality: 2, isMonotonic: monotonic, dataPoints: [] };
555
- m.sum.dataPoints.push({
556
- attributes: attrs,
557
- startTimeUnixNano: start,
558
- timeUnixNano: now,
559
- asInt: String(value),
560
- });
561
- };
562
- // 浮点指标(成本)必须走 asDouble —— OTLP 的 asInt 是 string 编码 int64,
563
- // 塞浮点(如 0.25)会让 collector 直接拒收整批数据
564
- const sumDouble = (name, value, help, attrs) => {
565
- const m = takeMetric(name, help);
566
- m.sum ??= { aggregationTemporality: 2, isMonotonic: true, dataPoints: [] };
567
- m.sum.dataPoints.push({
568
- attributes: attrs,
569
- startTimeUnixNano: start,
570
- timeUnixNano: now,
571
- asDouble: value,
572
- });
573
- };
574
- // gauge 没有理由只收 int:评分值是浮点,统一走 asDouble
575
- const gauge = (name, value, help, attrs) => {
576
- const m = takeMetric(name, help);
577
- m.gauge ??= { dataPoints: [] };
578
- m.gauge.dataPoints.push({
579
- attributes: attrs,
580
- startTimeUnixNano: start,
581
- timeUnixNano: now,
582
- asDouble: value,
583
- });
584
- };
585
- const hist = (name, stat, help, attrs) => {
586
- const m = takeMetric(name, help);
587
- m.histogram ??= { aggregationTemporality: 2, dataPoints: [] };
588
- m.histogram.dataPoints.push({
589
- attributes: attrs,
590
- startTimeUnixNano: start,
591
- timeUnixNano: now,
592
- count: stat.count,
593
- sum: stat.sumMs,
594
- // OTLP 的 bucketCounts 是每桶**非累积**计数(Prometheus 文本才是累积语义)
595
- bucketCounts: stat.perBucket(),
596
- explicitBounds: [...stat.boundsList],
597
- });
598
- };
599
- sum(`${p}runs_total`, s.runs, 'run 总数', []);
600
- sum(`${p}runs_failed_total`, s.failed, '失败的 run 数', []);
601
- // tokens_total:四类 kind 分项收进**同一个** Metric;description 与 Prometheus 侧
602
- // render() 的 family() 统一成同一句总述(同名 Metric 各带一份描述是 semantic error)
603
- const tokensHelp = 'token 累计(kind 分项:input / output / cache_read / cache_creation)';
604
- sum(`${p}tokens_total`, tokens.input, tokensHelp, [strAttr('kind', 'input')]);
605
- sum(`${p}tokens_total`, tokens.output, tokensHelp, [strAttr('kind', 'output')]);
606
- sum(`${p}tokens_total`, tokens.cacheRead, tokensHelp, [strAttr('kind', 'cache_read')]);
607
- sum(`${p}tokens_total`, tokens.cacheCreation, tokensHelp, [strAttr('kind', 'cache_creation')]);
608
- sumDouble(`${p}cost_usd_total`, s.costUsd, '累计成本估算(美元)', []);
609
- hist(`${p}run_duration_ms`, runStat, 'run 时长(毫秒)', []);
610
- for (const label of [...capabilities.keys()].sort()) {
611
- const acc = capabilities.get(label);
612
- const attrs = [strAttr('capability', label)];
613
- sum(`${p}capability_calls_total`, acc.calls, '能力调用次数', attrs);
614
- sum(`${p}capability_errors_total`, acc.errors, '能力失败次数', attrs);
615
- hist(`${p}capability_duration_ms`, acc.stat, '能力调用耗时(毫秒)', attrs);
616
- if (acc.tokens !== null)
617
- sum(`${p}capability_tokens_total`, acc.tokens, '子孙 token 合计', attrs);
618
- if (acc.costUsd !== null)
619
- sumDouble(`${p}capability_cost_usd_total`, acc.costUsd, '估算成本(美元)', attrs);
620
- }
621
- for (const model of [...models.keys()].sort()) {
622
- const acc = models.get(model);
623
- const attrs = [strAttr('model', model)];
624
- sum(`${p}model_turns_total`, acc.turns, '模型往返次数', attrs);
625
- sum(`${p}model_tokens_total`, acc.tokens, '模型 token 合计', attrs);
626
- sumDouble(`${p}model_cost_usd_total`, acc.costUsd, '模型估算成本(美元)', attrs);
627
- if (acc.unpricedTurns > 0) {
628
- sum(`${p}model_unpriced_turns_total`, acc.unpricedTurns, '未定价 turn 数', attrs);
629
- }
630
- hist(`${p}model_duration_ms`, acc.stat, '模型往返耗时(毫秒)', attrs);
631
- }
632
- for (const key of [...scores.keys()].sort()) {
633
- const acc = scores.get(key);
634
- const { name, source } = scoreLabels(key);
635
- const attrs = [strAttr('name', name), strAttr('source', source)];
636
- gauge(`${p}score`, acc.value, '最近一次评分', attrs);
637
- sum(`${p}score_total`, acc.count, '评分条数', attrs);
638
- }
639
- // 基数上限可见性(与 Prometheus 侧 `*_dropped_keys{kind=…}` 同名同义):
640
- // 三个 kind 收进同一个 gauge Metric
641
- const droppedHelp = '因基数上限被折叠的不同键数';
642
- gauge(`${p}dropped_keys`, capBudget.dropped, droppedHelp, [strAttr('kind', 'capability')]);
643
- gauge(`${p}dropped_keys`, modelBudget.dropped, droppedHelp, [strAttr('kind', 'model')]);
644
- gauge(`${p}dropped_keys`, scoreBudget.dropped, droppedHelp, [strAttr('kind', 'score')]);
645
- const metrics = [...metricTable.values()];
646
- const resourceAttrs = [
647
- strAttr('service.name', serviceName),
648
- ...Object.entries(opts.resourceAttributes ?? {}).map(([k, v]) => strAttr(k, v)),
649
- ];
650
- return {
651
- resourceMetrics: [
652
- {
653
- resource: { attributes: resourceAttrs },
654
- scopeMetrics: [{ scope: { name: 'agentia' }, metrics }],
655
- },
656
- ],
657
- };
658
- };
60
+ const snapshot = () => state.snapshot();
61
+ const render = () => renderPrometheus(state, p);
659
62
  const flush = async () => {
660
63
  if (format !== 'otlp')
661
64
  return; // prometheus 模式:拉取式,无主动导出
662
- let payload;
663
- try {
664
- payload = buildOtlpPayload();
665
- }
666
- catch (e) {
667
- opts.onExportError?.(e);
668
- return;
669
- }
670
- try {
671
- const res = await fetch(`${otlpEndpoint}/v1/metrics`, {
672
- method: 'POST',
673
- headers: { 'content-type': 'application/json' },
674
- body: JSON.stringify(payload),
675
- ...(timeoutMs > 0 ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
676
- });
677
- if (!res.ok) {
678
- const text = (await res.text()).slice(0, 200);
679
- throw new MetricsExportError(res.status, `OTLP metrics 导出失败: HTTP ${res.status} ${text}`);
680
- }
681
- }
682
- catch (e) {
683
- opts.onExportError?.(e); // 观测失败不得击穿业务
684
- }
65
+ await flushOtlpMetrics(state, {
66
+ prefix: p,
67
+ serviceName,
68
+ startedAtMs,
69
+ // 构造期校验保证 otlp 模式下 endpoint 必填
70
+ endpoint: otlpEndpoint,
71
+ timeoutMs,
72
+ // exactOptionalPropertyTypes:可选字段不能赋 undefined,只能条件展开
73
+ ...(opts.resourceAttributes !== undefined
74
+ ? { resourceAttributes: opts.resourceAttributes }
75
+ : {}),
76
+ ...(opts.onExportError !== undefined ? { onExportError: opts.onExportError } : {}),
77
+ });
685
78
  };
686
79
  const timer = format === 'otlp' && intervalMs > 0
687
80
  ? setInterval(() => {
@@ -692,7 +85,7 @@ export function metricsSink(opts = {}) {
692
85
  timer?.unref?.();
693
86
  return {
694
87
  export(trace) {
695
- accumulate(trace);
88
+ state.accumulate(trace);
696
89
  // intervalMs=0:立即导出并由框架 await;否则只累加,定时器负责导出
697
90
  if (format === 'otlp' && intervalMs === 0)
698
91
  return flush();
@@ -706,20 +99,7 @@ export function metricsSink(opts = {}) {
706
99
  clearInterval(timer);
707
100
  },
708
101
  reset() {
709
- runs = 0;
710
- failed = 0;
711
- costUsd = 0;
712
- tokens.input = 0;
713
- tokens.output = 0;
714
- tokens.cacheRead = 0;
715
- tokens.cacheCreation = 0;
716
- runStat.reset();
717
- capabilities.clear();
718
- models.clear();
719
- scores.clear();
720
- capBudget.reset();
721
- modelBudget.reset();
722
- scoreBudget.reset();
102
+ state.reset();
723
103
  },
724
104
  };
725
105
  }