dsh-local-telemetry 0.1.0
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.
- package/CHANGELOG.md +82 -0
- package/DSH-TELEMETRY-/345/274/200/345/217/221/350/256/241/345/210/222.md +954 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +42 -0
- package/README.md +156 -0
- package/bin/telemetry.mjs +295 -0
- package/cordis.patch.yml +11 -0
- package/docs/configuration.md +148 -0
- package/docs/schema.md +165 -0
- package/examples/prices.json +16 -0
- package/examples/telemetry.json +17 -0
- package/package.json +62 -0
- package/plugin/index.js +82 -0
- package/skills/telemetry-runbook/SKILL.md +88 -0
- package/src/adapter.mjs +79 -0
- package/src/aggregate.mjs +677 -0
- package/src/config.mjs +199 -0
- package/src/cost.mjs +118 -0
- package/src/index.mjs +20 -0
- package/src/privacy.mjs +208 -0
- package/src/recorder.mjs +215 -0
- package/src/report.mjs +220 -0
- package/src/sampling.mjs +50 -0
- package/src/schema.mjs +246 -0
- package/src/server.mjs +163 -0
- package/src/sink-jsonl.mjs +368 -0
- package/src/sink-sqlite.mjs +382 -0
- package/src/store.mjs +346 -0
- package/web/app.js +337 -0
- package/web/index.html +87 -0
- package/web/style.css +158 -0
package/src/store.mjs
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-telemetry — 存储读取层(计划 §8)。
|
|
3
|
+
*
|
|
4
|
+
* JSONL 与 SQLite 共享同一读取接口与过滤语义;聚合器只面对事件数组,
|
|
5
|
+
* 因此两种后端对同一事件集产生一致聚合结果(Phase 5 验收)。
|
|
6
|
+
* 读取同样 fail-open:坏行 / 不兼容 schema 跳过并计数,绝不中断查询。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { SUPPORTED_SCHEMA_MAJOR, deserializeEvent } from "./schema.mjs";
|
|
13
|
+
import { resolveDataPath } from "./config.mjs";
|
|
14
|
+
import { JsonlSink } from "./sink-jsonl.mjs";
|
|
15
|
+
|
|
16
|
+
const META_FILE = "meta.json";
|
|
17
|
+
|
|
18
|
+
export function normalizeFilters(filters = {}) {
|
|
19
|
+
return {
|
|
20
|
+
fromMs: filters.fromMs ?? null,
|
|
21
|
+
toMs: filters.toMs ?? null,
|
|
22
|
+
profile: filters.profile ?? null,
|
|
23
|
+
model: filters.model ?? null, // 匹配 model.name 或其末段
|
|
24
|
+
tool: filters.tool ?? null,
|
|
25
|
+
plugin: filters.plugin ?? null,
|
|
26
|
+
event: filters.event ?? null, // 精确事件名或 "request.*" 域通配
|
|
27
|
+
traceId: filters.traceId ?? null,
|
|
28
|
+
errorsOnly: Boolean(filters.errorsOnly),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function modelMatches(modelObj, filterValue) {
|
|
33
|
+
if (!filterValue) return true;
|
|
34
|
+
const name = modelObj?.name;
|
|
35
|
+
if (typeof name !== "string") return false;
|
|
36
|
+
if (name === filterValue) return true;
|
|
37
|
+
const tail = name.split("/").pop();
|
|
38
|
+
return tail === filterValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function eventMatches(eventName, filterValue) {
|
|
42
|
+
if (!filterValue) return true;
|
|
43
|
+
if (filterValue.endsWith(".*")) return eventName?.startsWith(filterValue.slice(0, -1));
|
|
44
|
+
return eventName === filterValue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isFailureEvent(event) {
|
|
48
|
+
const status = event.result?.status;
|
|
49
|
+
return (
|
|
50
|
+
event.event === "model.failed" ||
|
|
51
|
+
event.event === "request.cancelled" ||
|
|
52
|
+
status === "failed" ||
|
|
53
|
+
status === "timeout" ||
|
|
54
|
+
status === "cancelled"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 过滤器应用于单事件。 */
|
|
59
|
+
export function matchesFilters(event, filters) {
|
|
60
|
+
const f = filters;
|
|
61
|
+
if (f.fromMs !== null || f.toMs !== null) {
|
|
62
|
+
const ts = Date.parse(event.timestamp ?? "");
|
|
63
|
+
if (!Number.isFinite(ts)) return false;
|
|
64
|
+
if (f.fromMs !== null && ts < f.fromMs) return false;
|
|
65
|
+
if (f.toMs !== null && ts > f.toMs) return false;
|
|
66
|
+
}
|
|
67
|
+
if (f.profile && event.session?.profile !== f.profile) return false;
|
|
68
|
+
if (!modelMatches(event.model, f.model)) return false;
|
|
69
|
+
if (f.tool && event.tool?.name !== f.tool) return false;
|
|
70
|
+
if (f.plugin && event.plugin?.name !== f.plugin) return false;
|
|
71
|
+
if (f.event && !eventMatches(event.event, f.event)) return false;
|
|
72
|
+
if (f.traceId && event.trace_id !== f.traceId) return false;
|
|
73
|
+
if (f.errorsOnly && !isFailureEvent(event)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** JSONL 目录读取器。 */
|
|
78
|
+
export function createJsonlStore({ path, now = () => Date.now() } = {}) {
|
|
79
|
+
const dir = resolveDataPath(path);
|
|
80
|
+
return {
|
|
81
|
+
kind: "jsonl",
|
|
82
|
+
path: dir,
|
|
83
|
+
async readEvents(filters = {}) {
|
|
84
|
+
const f = normalizeFilters(filters);
|
|
85
|
+
const events = [];
|
|
86
|
+
const skipped = { invalid: 0, schema_incompatible: 0, unreadable_files: 0 };
|
|
87
|
+
let names = [];
|
|
88
|
+
try {
|
|
89
|
+
names = (await readdir(dir)).filter((name) => name.endsWith(".jsonl")).sort();
|
|
90
|
+
} catch {
|
|
91
|
+
return { events, skipped };
|
|
92
|
+
}
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
content = await readFile(join(dir, name), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
skipped.unreadable_files += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
102
|
+
const line = rawLine.trim();
|
|
103
|
+
if (line.length === 0) continue;
|
|
104
|
+
const parsed = deserializeEvent(line);
|
|
105
|
+
if (!parsed.ok) {
|
|
106
|
+
skipped.invalid += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const event = parsed.event;
|
|
110
|
+
const major = Number.parseInt(String(event?.schema_version ?? "").split(".")[0], 10);
|
|
111
|
+
if (!Number.isFinite(major) || major !== SUPPORTED_SCHEMA_MAJOR) {
|
|
112
|
+
skipped.schema_incompatible += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (matchesFilters(event, f)) events.push(event);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { events, skipped };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async status() {
|
|
122
|
+
let files = [];
|
|
123
|
+
let totalBytes = 0;
|
|
124
|
+
try {
|
|
125
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith(".jsonl")).sort();
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const info = await stat(join(dir, name));
|
|
128
|
+
files.push({ name, bytes: info.size, mtime: info.mtimeMs });
|
|
129
|
+
totalBytes += info.size;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
files = [];
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
store: "jsonl",
|
|
136
|
+
path: dir,
|
|
137
|
+
exists: existsSync(dir),
|
|
138
|
+
files,
|
|
139
|
+
total_bytes: totalBytes,
|
|
140
|
+
counters: await readMetaCounters(dir),
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async purge({ olderThanMs = null, beforeMs = null } = {}) {
|
|
145
|
+
const sink = new JsonlSink({ dir });
|
|
146
|
+
sink.now = now;
|
|
147
|
+
return sink.purge({ olderThanMs, beforeMs });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** SQLite 读取器(Node <22.5 时 available=false,readEvents 返回空集与原因)。 */
|
|
153
|
+
export async function createSqliteStore({ path } = {}) {
|
|
154
|
+
const dir = resolveDataPath(path);
|
|
155
|
+
const { sqliteAvailable } = await import("./sink-sqlite.mjs");
|
|
156
|
+
const availability = await sqliteAvailable();
|
|
157
|
+
if (!availability.available) {
|
|
158
|
+
return {
|
|
159
|
+
kind: "sqlite",
|
|
160
|
+
path: dir,
|
|
161
|
+
available: false,
|
|
162
|
+
unavailable_reason: availability.reason,
|
|
163
|
+
async readEvents() {
|
|
164
|
+
return { events: [], skipped: { invalid: 0, schema_incompatible: 0, unreadable_files: 0 }, unavailable: availability.reason };
|
|
165
|
+
},
|
|
166
|
+
async status() {
|
|
167
|
+
return { store: "sqlite", path: dir, exists: false, files: [], total_bytes: 0, rows: 0, counters: await readMetaCounters(dir), unavailable: availability.reason };
|
|
168
|
+
},
|
|
169
|
+
async purge() {
|
|
170
|
+
return { removed_files: 0, removed_bytes: 0, removed_rows: 0, unavailable: availability.reason };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const { DatabaseSync } = availability.module;
|
|
175
|
+
const dbPath = join(dir, "telemetry.sqlite3");
|
|
176
|
+
let db = null;
|
|
177
|
+
if (existsSync(dbPath)) {
|
|
178
|
+
try {
|
|
179
|
+
db = new DatabaseSync(dbPath);
|
|
180
|
+
} catch {
|
|
181
|
+
db = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function requireDb() {
|
|
186
|
+
if (!db) throw new Error("sqlite database not initialized");
|
|
187
|
+
return db;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildWhere(f) {
|
|
191
|
+
const clauses = [];
|
|
192
|
+
const params = [];
|
|
193
|
+
if (f.fromMs !== null) {
|
|
194
|
+
clauses.push("ts >= ?");
|
|
195
|
+
params.push(new Date(f.fromMs).toISOString());
|
|
196
|
+
}
|
|
197
|
+
if (f.toMs !== null) {
|
|
198
|
+
clauses.push("ts <= ?");
|
|
199
|
+
params.push(new Date(f.toMs).toISOString());
|
|
200
|
+
}
|
|
201
|
+
if (f.profile) {
|
|
202
|
+
clauses.push("profile = ?");
|
|
203
|
+
params.push(f.profile);
|
|
204
|
+
}
|
|
205
|
+
if (f.model) {
|
|
206
|
+
clauses.push("(model_name = ? OR model_name = ?)");
|
|
207
|
+
params.push(f.model, f.model.includes("/") ? f.model.split("/").pop() : f.model);
|
|
208
|
+
}
|
|
209
|
+
if (f.tool) {
|
|
210
|
+
clauses.push("tool_name = ?");
|
|
211
|
+
params.push(f.tool);
|
|
212
|
+
}
|
|
213
|
+
if (f.plugin) {
|
|
214
|
+
clauses.push("plugin_name = ?");
|
|
215
|
+
params.push(f.plugin);
|
|
216
|
+
}
|
|
217
|
+
if (f.event) {
|
|
218
|
+
if (f.event.endsWith(".*")) {
|
|
219
|
+
clauses.push("event LIKE ?");
|
|
220
|
+
params.push(`${f.event.slice(0, -1)}%`);
|
|
221
|
+
} else {
|
|
222
|
+
clauses.push("event = ?");
|
|
223
|
+
params.push(f.event);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (f.errorsOnly) {
|
|
227
|
+
clauses.push("(event IN ('model.failed','request.cancelled') OR status IN ('failed','timeout','cancelled'))");
|
|
228
|
+
}
|
|
229
|
+
if (f.traceId) {
|
|
230
|
+
clauses.push("trace_id = ?");
|
|
231
|
+
params.push(f.traceId);
|
|
232
|
+
}
|
|
233
|
+
return { where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", params };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function columnsFromRow(row) {
|
|
237
|
+
const event = JSON.parse(row.data);
|
|
238
|
+
// 列值以库内索引列为准回填缺失的冗余字段(JSON 与列不一致时以 JSON 为准)
|
|
239
|
+
return event;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
kind: "sqlite",
|
|
244
|
+
path: dir,
|
|
245
|
+
available: true,
|
|
246
|
+
async readEvents(filters = {}) {
|
|
247
|
+
const f = normalizeFilters(filters);
|
|
248
|
+
const events = [];
|
|
249
|
+
const skipped = { invalid: 0, schema_incompatible: 0, unreadable_files: 0 };
|
|
250
|
+
if (!db) return { events, skipped };
|
|
251
|
+
try {
|
|
252
|
+
const { where, params } = buildWhere(f);
|
|
253
|
+
const stmt = db.prepare(`SELECT data FROM events ${where} ORDER BY ts ASC`);
|
|
254
|
+
for (const row of stmt.all(...params)) {
|
|
255
|
+
try {
|
|
256
|
+
events.push(columnsFromRow(row));
|
|
257
|
+
} catch {
|
|
258
|
+
skipped.invalid += 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
/* 查询失败按空集处理 */
|
|
263
|
+
}
|
|
264
|
+
return { events, skipped };
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async status() {
|
|
268
|
+
let bytes = 0;
|
|
269
|
+
let rows = 0;
|
|
270
|
+
try {
|
|
271
|
+
bytes = (await stat(dbPath)).size;
|
|
272
|
+
} catch {
|
|
273
|
+
bytes = 0;
|
|
274
|
+
}
|
|
275
|
+
if (db) {
|
|
276
|
+
try {
|
|
277
|
+
rows = db.prepare("SELECT COUNT(*) AS n FROM events").get().n;
|
|
278
|
+
} catch {
|
|
279
|
+
rows = 0;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
store: "sqlite",
|
|
284
|
+
path: dir,
|
|
285
|
+
exists: existsSync(dbPath),
|
|
286
|
+
files: existsSync(dbPath) ? [{ name: "telemetry.sqlite3", bytes }] : [],
|
|
287
|
+
total_bytes: bytes,
|
|
288
|
+
rows,
|
|
289
|
+
counters: await readMetaCounters(dir),
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async purge({ olderThanMs = null, beforeMs = null } = {}) {
|
|
294
|
+
const cutoff = beforeMs ?? (olderThanMs !== null ? Date.now() - olderThanMs : null);
|
|
295
|
+
if (cutoff === null) throw new TypeError("purge requires olderThanMs or beforeMs");
|
|
296
|
+
const cutoffDate = new Date(cutoff).toISOString().slice(0, 10);
|
|
297
|
+
if (!db) return { removed_files: 0, removed_bytes: 0, removed_rows: 0 };
|
|
298
|
+
try {
|
|
299
|
+
const count = db.prepare("SELECT COUNT(*) AS n FROM events WHERE date < ?").get(cutoffDate).n;
|
|
300
|
+
db.prepare("DELETE FROM events WHERE date < ?").run(cutoffDate);
|
|
301
|
+
return { removed_files: 0, removed_bytes: 0, removed_rows: count };
|
|
302
|
+
} catch {
|
|
303
|
+
return { removed_files: 0, removed_bytes: 0, removed_rows: 0 };
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
close() {
|
|
308
|
+
try {
|
|
309
|
+
db?.close();
|
|
310
|
+
} catch {
|
|
311
|
+
/* ignore */
|
|
312
|
+
}
|
|
313
|
+
db = null;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 按配置打开存储(读取侧)。 */
|
|
319
|
+
export async function openStore({ store = "jsonl", path } = {}) {
|
|
320
|
+
if (store === "sqlite") return createSqliteStore({ path });
|
|
321
|
+
return createJsonlStore({ path });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function readMetaCounters(dir) {
|
|
325
|
+
try {
|
|
326
|
+
if (!existsSync(join(dir, META_FILE))) return emptyCounters();
|
|
327
|
+
const meta = JSON.parse(await readFile(join(dir, META_FILE), "utf8"));
|
|
328
|
+
return { ...emptyCounters(), ...(meta?.counters ?? {}) };
|
|
329
|
+
} catch {
|
|
330
|
+
return emptyCounters();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function emptyCounters() {
|
|
335
|
+
return { written: 0, dropped_queue: 0, dropped_oversize: 0, dropped_invalid: 0, dropped_write: 0, sampled_out: 0 };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 删除整个数据目录中的过期文件(JSONL);SQLite 走其 purge。 */
|
|
339
|
+
export async function removeFile(path) {
|
|
340
|
+
try {
|
|
341
|
+
await unlink(path);
|
|
342
|
+
return true;
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
package/web/app.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* dsh-local-telemetry Web UI 逻辑 — 零依赖,只调用本地只读 API。 */
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const state = { range: "24h", traceId: null };
|
|
5
|
+
|
|
6
|
+
const $ = (sel) => document.querySelector(sel);
|
|
7
|
+
const fmtInt = (v) => (v === null || v === undefined ? "n/a" : Number(v).toLocaleString("en-US"));
|
|
8
|
+
const fmtMs = (v) => (v === null || v === undefined ? "n/a" : v >= 10000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`);
|
|
9
|
+
const fmtPct = (v) => (v === null || v === undefined ? "n/a" : `${(v * 100).toFixed(1)}%`);
|
|
10
|
+
const fmtCost = (v, currency) => {
|
|
11
|
+
if (v === null || v === undefined) return "n/a";
|
|
12
|
+
return `${currency === "USD" ? "$" : currency ? currency + " " : ""}${v.toFixed(4)}`;
|
|
13
|
+
};
|
|
14
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
15
|
+
|
|
16
|
+
async function api(path) {
|
|
17
|
+
const res = await fetch(path, { cache: "no-store" });
|
|
18
|
+
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
|
19
|
+
return res.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sinceParam() {
|
|
23
|
+
return state.range === "all" ? null : state.range;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* ---------- 状态 chips ---------- */
|
|
27
|
+
function renderChips(status, summary) {
|
|
28
|
+
const dropped = status.counters ?? {};
|
|
29
|
+
const droppedTotal = dropped.dropped_write + dropped.dropped_queue + dropped.dropped_oversize + dropped.dropped_invalid + dropped.sampled_out;
|
|
30
|
+
const chips = [
|
|
31
|
+
`store <b>${esc(status.store)}</b>`,
|
|
32
|
+
`events <b>${fmtInt(summary.data_completeness.events)}</b>`,
|
|
33
|
+
droppedTotal > 0 ? `dropped <b>${fmtInt(droppedTotal)}</b>(累计)` : `dropped <b>0</b>`,
|
|
34
|
+
status.price_catalog?.path ? `价格目录 <b>${esc(status.price_catalog.currency ?? "")}</b> 生效 ${esc(status.price_catalog.effective_at ?? "n/a")}` : `价格目录 <b>未配置</b>`,
|
|
35
|
+
`<b>127.0.0.1</b> only`,
|
|
36
|
+
];
|
|
37
|
+
$("#chips").innerHTML = chips.map((c) => `<span class="chip">${c}</span>`).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ---------- KPI 卡片 ---------- */
|
|
41
|
+
function renderKpis(s) {
|
|
42
|
+
const cards = [
|
|
43
|
+
{ label: "Requests", value: fmtInt(s.requests.total), note: `成功 ${fmtPct(s.requests.success_rate)} · 取消 ${fmtInt(s.requests.cancelled)}` },
|
|
44
|
+
{ label: "P95 latency", value: fmtMs(s.requests.latency.p95), note: `p50 ${fmtMs(s.requests.latency.p50)} · n=${s.requests.latency.n}` },
|
|
45
|
+
{ label: "TTFT p95", value: fmtMs(s.requests.ttft.p95), note: `排队 p95 ${fmtMs(s.requests.queue.p95)}` },
|
|
46
|
+
{ label: "Tokens in / out", value: fmtInt(s.tokens.input), note: `输出 ${fmtInt(s.tokens.output)} · 缓存 ${fmtInt(s.tokens.cached)}` },
|
|
47
|
+
{ label: "Estimated cost", value: fmtCost(s.cost.amount, s.cost.currency), note: `估算值,非账单 · ${s.cost.priced_events} 事件已计价` },
|
|
48
|
+
{ label: "Tool / Plugin calls", value: fmtInt(s.data_completeness.tool_calls), note: `插件调用 ${fmtInt(s.data_completeness.plugin_calls)} · 重试 ${fmtInt(s.requests.retries)}` },
|
|
49
|
+
];
|
|
50
|
+
$("#kpis").innerHTML = cards
|
|
51
|
+
.map(
|
|
52
|
+
(c) => `<div class="kpi"><div class="label">${c.label}</div><div class="value">${c.value}</div><div class="note">${c.note}</div></div>`
|
|
53
|
+
)
|
|
54
|
+
.join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------- 趋势图(面积 + 柱) ---------- */
|
|
58
|
+
function renderTrend(rows) {
|
|
59
|
+
const wrap = $("#trend");
|
|
60
|
+
if (!rows || rows.length === 0) {
|
|
61
|
+
wrap.innerHTML = `<div class="empty">该窗口内暂无数据。</div>`;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const data = rows.slice(-31);
|
|
65
|
+
const W = 640, H = 190, padL = 40, padB = 22, padT = 10;
|
|
66
|
+
const innerW = W - padL - 8, innerH = H - padB - padT;
|
|
67
|
+
const maxTok = Math.max(...data.map((d) => d.input_tokens + d.output_tokens), 1);
|
|
68
|
+
const maxReq = Math.max(...data.map((d) => d.requests), 1);
|
|
69
|
+
const step = innerW / data.length;
|
|
70
|
+
const x = (i) => padL + i * step + step / 2;
|
|
71
|
+
const yTok = (v) => padT + innerH - (v / maxTok) * innerH;
|
|
72
|
+
const barW = Math.min(step * 0.34, 16);
|
|
73
|
+
|
|
74
|
+
let area = `M ${x(0)} ${yTok(data[0].input_tokens + data[0].output_tokens)}`;
|
|
75
|
+
data.forEach((d, i) => { if (i > 0) area += ` L ${x(i)} ${yTok(d.input_tokens + d.output_tokens)}`; });
|
|
76
|
+
const areaPath = `${area} L ${x(data.length - 1)} ${padT + innerH} L ${x(0)} ${padT + innerH} Z`;
|
|
77
|
+
|
|
78
|
+
const bars = data
|
|
79
|
+
.map((d, i) => `<rect class="bar-row" data-i="${i}" x="${x(i) - barW / 2}" y="${padT + innerH - (d.requests / maxReq) * innerH}" width="${barW}" height="${Math.max((d.requests / maxReq) * innerH, d.requests > 0 ? 2 : 0)}" rx="3" fill="rgba(139,92,246,0.35)"></rect>`)
|
|
80
|
+
.join("");
|
|
81
|
+
const labels = data
|
|
82
|
+
.map((d, i) => (data.length <= 10 || i % Math.ceil(data.length / 10) === 0 ? `<text x="${x(i)}" y="${H - 6}" fill="#71717a" font-size="10" text-anchor="middle">${esc(String(d.group).slice(5))}</text>` : ""))
|
|
83
|
+
.join("");
|
|
84
|
+
const gridY = [0.25, 0.5, 0.75, 1]
|
|
85
|
+
.map((f) => `<line x1="${padL}" y1="${padT + innerH * f}" x2="${W - 8}" y2="${padT + innerH * f}" stroke="#1c1c22" stroke-width="1"></line><text x="${padL - 6}" y="${padT + innerH * f + 3}" fill="#5b5b64" font-size="9.5" text-anchor="end">${compact(maxTok * (1 - f))}</text>`)
|
|
86
|
+
.join("");
|
|
87
|
+
|
|
88
|
+
wrap.innerHTML = `
|
|
89
|
+
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" style="height:190px">
|
|
90
|
+
<defs>
|
|
91
|
+
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
|
92
|
+
<stop offset="0%" stop-color="rgba(6,182,212,0.45)"></stop>
|
|
93
|
+
<stop offset="100%" stop-color="rgba(6,182,212,0.02)"></stop>
|
|
94
|
+
</linearGradient>
|
|
95
|
+
<linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
|
|
96
|
+
<stop offset="0%" stop-color="#8b5cf6"></stop><stop offset="100%" stop-color="#06b6d4"></stop>
|
|
97
|
+
</linearGradient>
|
|
98
|
+
</defs>
|
|
99
|
+
${gridY}
|
|
100
|
+
${bars}
|
|
101
|
+
<path d="${areaPath}" fill="url(#areaFill)"></path>
|
|
102
|
+
<path d="${area.replace("M", "M")}" fill="none" stroke="url(#lineStroke)" stroke-width="2"></path>
|
|
103
|
+
${labels}
|
|
104
|
+
</svg>`;
|
|
105
|
+
|
|
106
|
+
const tip = $("#trend-tip");
|
|
107
|
+
wrap.querySelectorAll(".bar-row").forEach((el) => {
|
|
108
|
+
el.addEventListener("mousemove", (ev) => {
|
|
109
|
+
const d = data[Number(el.dataset.i)];
|
|
110
|
+
tip.style.display = "block";
|
|
111
|
+
tip.style.left = `${ev.clientX + 12}px`;
|
|
112
|
+
tip.style.top = `${ev.clientY + 12}px`;
|
|
113
|
+
tip.innerHTML = `<div class="t">${esc(d.group)}</div>requests <b>${d.requests}</b> · err <b>${d.failed}</b><br>tokens in <b>${fmtInt(d.input_tokens)}</b> / out <b>${fmtInt(d.output_tokens)}</b><br>cost <b>${fmtCost(d.cost)}</b> · p95 <b>${fmtMs(d.p95_ms)}</b>`;
|
|
114
|
+
});
|
|
115
|
+
el.addEventListener("mouseleave", () => { tip.style.display = "none"; });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compact(v) {
|
|
120
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
121
|
+
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
|
122
|
+
return String(Math.round(v));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ---------- 错误分类 ---------- */
|
|
126
|
+
function renderErrors(s) {
|
|
127
|
+
const entries = Object.entries(s.errors.kinds);
|
|
128
|
+
if (entries.length === 0) {
|
|
129
|
+
$("#errors").innerHTML = `<div class="empty">窗口内无错误事件。</div>`;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const total = entries.reduce((acc, [, n]) => acc + n, 0);
|
|
133
|
+
$("#errors").innerHTML = entries
|
|
134
|
+
.sort((a, b) => b[1] - a[1])
|
|
135
|
+
.map(([kind, n]) => {
|
|
136
|
+
const pct = total > 0 ? n / total : 0;
|
|
137
|
+
return `<div style="margin-bottom:10px">
|
|
138
|
+
<div style="display:flex;justify-content:space-between;font-size:12.5px"><span>${esc(kind)}</span><span style="color:var(--muted)">${n} · ${fmtPct(pct)}</span></div>
|
|
139
|
+
<div class="lat-bar" style="margin-top:4px"><i style="width:${Math.round(pct * 100)}%"></i></div>
|
|
140
|
+
</div>`;
|
|
141
|
+
})
|
|
142
|
+
.join("");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ---------- 表格 ---------- */
|
|
146
|
+
function renderModels(models) {
|
|
147
|
+
if (!models || models.length === 0) {
|
|
148
|
+
$("#models").innerHTML = `<div class="empty">窗口内无模型调用。</div>`;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const maxP95 = Math.max(...models.map((m) => m.latency.p95 ?? 0), 1);
|
|
152
|
+
$("#models").innerHTML = `<table><thead><tr>
|
|
153
|
+
<th>provider / model</th><th>type</th><th class="num">attempts</th><th class="num">成功率</th>
|
|
154
|
+
<th class="num">p50</th><th class="num">p95</th><th class="num">p99</th><th>延迟分布</th>
|
|
155
|
+
<th class="num">TTFT p95</th><th class="num">in tok</th><th class="num">out tok</th><th class="num">缓存</th><th class="num">估算成本</th>
|
|
156
|
+
</tr></thead><tbody>
|
|
157
|
+
${models
|
|
158
|
+
.map(
|
|
159
|
+
(m) => `<tr>
|
|
160
|
+
<td class="name">${esc(m.provider ?? "?")} / ${esc(m.name ?? "?")}</td>
|
|
161
|
+
<td>${esc(m.request_type ?? "?")}</td>
|
|
162
|
+
<td class="num">${m.attempts}${m.retries > 0 ? ` <span style="color:var(--warn)" title="重试">↻${m.retries}</span>` : ""}</td>
|
|
163
|
+
<td class="num">${fmtPct(m.success_rate)}</td>
|
|
164
|
+
<td class="num">${fmtMs(m.latency.p50)}</td>
|
|
165
|
+
<td class="num">${fmtMs(m.latency.p95)}</td>
|
|
166
|
+
<td class="num">${fmtMs(m.latency.p99)}</td>
|
|
167
|
+
<td><div class="lat-bar"><i style="width:${Math.round(((m.latency.p95 ?? 0) / maxP95) * 100)}%"></i></div></td>
|
|
168
|
+
<td class="num">${fmtMs(m.ttft.p95)}</td>
|
|
169
|
+
<td class="num">${fmtInt(m.tokens.input)}</td>
|
|
170
|
+
<td class="num">${fmtInt(m.tokens.output)}</td>
|
|
171
|
+
<td class="num">${fmtInt(m.tokens.cached)}</td>
|
|
172
|
+
<td class="num">${fmtCost(m.cost.amount, null)}</td>
|
|
173
|
+
</tr>`
|
|
174
|
+
)
|
|
175
|
+
.join("")}
|
|
176
|
+
</tbody></table>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderTools(tools, hints) {
|
|
180
|
+
if (!tools || tools.length === 0) {
|
|
181
|
+
$("#tools").innerHTML = `<div class="empty">窗口内无工具调用。</div>`;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const hintByTool = {};
|
|
185
|
+
(hints ?? []).forEach((h) => { hintByTool[h.tool] = h; });
|
|
186
|
+
const maxP95 = Math.max(...tools.map((t) => t.latency.p95 ?? 0), 1);
|
|
187
|
+
$("#tools").innerHTML = `<table><thead><tr>
|
|
188
|
+
<th>工具</th><th class="num">调用</th><th class="num">成功</th><th class="num">失败</th><th class="num">超时</th>
|
|
189
|
+
<th class="num">p50</th><th class="num">p95</th><th>耗时分布</th><th class="num">需确认</th>
|
|
190
|
+
</tr></thead><tbody>
|
|
191
|
+
${tools
|
|
192
|
+
.map((t) => {
|
|
193
|
+
const hint = hintByTool[t.name];
|
|
194
|
+
const loop = hint ? ` <span style="color:var(--warn)" title="同 trace 内 ${hint.calls} 次调用(疑似循环)">⟲</span>` : "";
|
|
195
|
+
return `<tr>
|
|
196
|
+
<td class="name">${esc(t.name)}${loop}</td>
|
|
197
|
+
<td class="num">${t.calls}</td><td class="num">${t.success}</td><td class="num">${t.failed}</td><td class="num">${t.timeout}</td>
|
|
198
|
+
<td class="num">${fmtMs(t.latency.p50)}</td><td class="num">${fmtMs(t.latency.p95)}</td>
|
|
199
|
+
<td><div class="lat-bar"><i style="width:${Math.round(((t.latency.p95 ?? 0) / maxP95) * 100)}%"></i></div></td>
|
|
200
|
+
<td class="num">${t.confirm_required || ""}</td>
|
|
201
|
+
</tr>`;
|
|
202
|
+
})
|
|
203
|
+
.join("")}
|
|
204
|
+
</tbody></table>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderPlugins(plugins) {
|
|
208
|
+
if (!plugins || plugins.length === 0) {
|
|
209
|
+
$("#plugins").innerHTML = `<div class="empty">窗口内无插件调用。</div>`;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
$("#plugins").innerHTML = `<table><thead><tr><th>插件</th><th>hook</th><th class="num">调用</th><th class="num">错误</th><th class="num">p95</th></tr></thead><tbody>
|
|
213
|
+
${plugins
|
|
214
|
+
.map((p) => {
|
|
215
|
+
const hooks = Object.entries(p.hooks);
|
|
216
|
+
if (hooks.length === 0) {
|
|
217
|
+
return `<tr><td class="name">${esc(p.name)}</td><td>-</td><td class="num">-</td><td class="num">${p.errors}</td><td class="num">${fmtMs(p.latency.p95)}</td></tr>`;
|
|
218
|
+
}
|
|
219
|
+
return hooks
|
|
220
|
+
.map(
|
|
221
|
+
([hook, data], i) =>
|
|
222
|
+
`<tr>${i === 0 ? `<td class="name" rowspan="${hooks.length}">${esc(p.name)}</td>` : ""}<td>${esc(hook)}</td><td class="num">${data.calls}</td><td class="num">${data.errors}</td><td class="num">${fmtMs(data.latency.p95)}</td></tr>`
|
|
223
|
+
)
|
|
224
|
+
.join("");
|
|
225
|
+
})
|
|
226
|
+
.join("")}
|
|
227
|
+
</tbody></table>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ---------- 请求时间线 + trace ---------- */
|
|
231
|
+
function renderRequests(requests) {
|
|
232
|
+
const el = $("#requests");
|
|
233
|
+
if (!requests || requests.length === 0) {
|
|
234
|
+
el.innerHTML = `<div class="empty">窗口内无请求。</div>`;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
el.innerHTML = requests
|
|
238
|
+
.map(
|
|
239
|
+
(r) => `<div class="req${r.trace_id === state.traceId ? " active" : ""}" data-trace="${esc(r.trace_id)}">
|
|
240
|
+
<span class="dot ${esc(r.status)}" title="${esc(r.status)}"></span>
|
|
241
|
+
<span class="t">${esc((r.started_at ?? "").replace("T", " ").slice(0, 19))}</span>
|
|
242
|
+
<span class="dur">${fmtMs(r.duration_ms)}</span>
|
|
243
|
+
<span class="model">${esc(r.model ?? "—")}${r.retries > 0 ? ` <span style="color:var(--warn)">↻${r.retries}</span>` : ""}${r.tool_calls > 0 ? ` <span style="color:var(--muted)">· ${r.tool_calls} tools</span>` : ""}</span>
|
|
244
|
+
<span class="tok">${fmtInt(r.tokens.input)}/${fmtInt(r.tokens.output)}</span>
|
|
245
|
+
</div>`
|
|
246
|
+
)
|
|
247
|
+
.join("");
|
|
248
|
+
el.querySelectorAll(".req").forEach((node) => {
|
|
249
|
+
node.addEventListener("click", () => {
|
|
250
|
+
state.traceId = node.dataset.trace;
|
|
251
|
+
el.querySelectorAll(".req").forEach((n) => n.classList.remove("active"));
|
|
252
|
+
node.classList.add("active");
|
|
253
|
+
loadTrace(state.traceId);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderTrace(view) {
|
|
259
|
+
$("#trace-title").textContent = view.trace_id ? `· ${view.trace_id}` : "";
|
|
260
|
+
const el = $("#trace");
|
|
261
|
+
const renderNode = (node, depth) => {
|
|
262
|
+
const head = node.events[0] ?? {};
|
|
263
|
+
const who = head.tool ?? head.plugin ?? head.model ?? "";
|
|
264
|
+
const end = [...node.events].reverse().find((e) => Number.isInteger(e.duration_ms));
|
|
265
|
+
const status = node.events.map((e) => e.status).find(Boolean);
|
|
266
|
+
const ms = end ? end.duration_ms : null;
|
|
267
|
+
const hook = head.hook ? `:${head.hook}` : "";
|
|
268
|
+
let html = `<div class="span-row" style="margin-left:${depth * 0}px">
|
|
269
|
+
<span class="ev">${esc(head.event ?? "")}</span>
|
|
270
|
+
${who ? `<span class="who">${esc(who)}${esc(hook)}</span>` : ""}
|
|
271
|
+
${status ? `<span class="st ${esc(status)}">${esc(status)}</span>` : ""}
|
|
272
|
+
${ms !== null && ms !== undefined ? `<span class="ms">${fmtMs(ms)}</span>` : ""}
|
|
273
|
+
</div>`;
|
|
274
|
+
if (node.children && node.children.length > 0) {
|
|
275
|
+
html += `<div class="span-children">${node.children.map((c) => renderNode(c, depth + 1)).join("")}</div>`;
|
|
276
|
+
}
|
|
277
|
+
return html;
|
|
278
|
+
};
|
|
279
|
+
const treeHtml = view.tree.roots.map((r) => renderNode(r, 0)).join("");
|
|
280
|
+
const timelineHtml = view.timeline
|
|
281
|
+
.slice(0, 60)
|
|
282
|
+
.map(
|
|
283
|
+
(e) => `<div class="span-row"><span class="ev">${esc(e.event)}</span>
|
|
284
|
+
${e.tool ?? e.plugin ?? e.model ? `<span class="who">${esc(e.tool ?? e.plugin ?? e.model ?? "")}</span>` : ""}
|
|
285
|
+
${e.status ? `<span class="st ${esc(e.status)}">${esc(e.status)}</span>` : ""}
|
|
286
|
+
${e.error ? `<span class="st failed">${esc(e.error)}</span>` : ""}
|
|
287
|
+
${e.duration_ms !== null ? `<span class="ms">${fmtMs(e.duration_ms)}</span>` : ""}</div>`
|
|
288
|
+
)
|
|
289
|
+
.join("");
|
|
290
|
+
el.innerHTML = `${treeHtml}<div class="desc" style="margin:12px 0 4px">时间线(最近 ${Math.min(view.timeline.length, 60)} / ${view.timeline.length} 事件)</div>${timelineHtml}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function loadTrace(traceId) {
|
|
294
|
+
try {
|
|
295
|
+
const view = await api(`/api/trace/${encodeURIComponent(traceId)}`);
|
|
296
|
+
renderTrace(view);
|
|
297
|
+
} catch {
|
|
298
|
+
$("#trace").innerHTML = `<div class="empty">trace 事件未找到(可能被采样或清理)。</div>`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ---------- 主刷新 ---------- */
|
|
303
|
+
async function refresh() {
|
|
304
|
+
const since = sinceParam();
|
|
305
|
+
const qs = since ? `since=${encodeURIComponent(since)}` : "";
|
|
306
|
+
const [status, summary, grouped, requests] = await Promise.all([
|
|
307
|
+
api("/api/status"),
|
|
308
|
+
api(`/api/summary?${qs}`),
|
|
309
|
+
api(`/api/grouped?by=day&${qs}`),
|
|
310
|
+
api(`/api/requests?limit=60&${qs}`),
|
|
311
|
+
]);
|
|
312
|
+
renderChips(status, summary);
|
|
313
|
+
renderKpis(summary);
|
|
314
|
+
renderTrend(grouped.rows);
|
|
315
|
+
renderErrors(summary);
|
|
316
|
+
renderModels(summary.models);
|
|
317
|
+
renderTools(summary.tools, summary.tool_loop_hints);
|
|
318
|
+
renderPlugins(summary.plugins);
|
|
319
|
+
renderRequests(requests.requests);
|
|
320
|
+
if (state.traceId) await loadTrace(state.traceId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
document.querySelectorAll("#ranges button").forEach((btn) => {
|
|
324
|
+
btn.addEventListener("click", () => {
|
|
325
|
+
document.querySelectorAll("#ranges button").forEach((b) => b.classList.remove("active"));
|
|
326
|
+
btn.classList.add("active");
|
|
327
|
+
state.range = btn.dataset.range;
|
|
328
|
+
refresh().catch(showError);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
$("#refresh").addEventListener("click", () => refresh().catch(showError));
|
|
332
|
+
|
|
333
|
+
function showError(error) {
|
|
334
|
+
$("#chips").innerHTML = `<span class="chip bad">加载失败:${esc(error.message)}(请确认服务运行在本地存储上)</span>`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
refresh().catch(showError);
|