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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 duyanta123
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PUBLISHING.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Publishing
|
|
2
|
+
|
|
3
|
+
## 命名说明(重要)
|
|
4
|
+
|
|
5
|
+
- npm 包名:`dsh-local-telemetry`。`dsh-telemetry` 在 npm 上已被第三方(tudamu,2026-08-20 发布 0.0.1,同为 DeepSeek Harness 遥测方向)占用,故改名;已验证 `dsh-local-telemetry` 未注册。
|
|
6
|
+
- GitHub 仓库名保持 `dsh-telemetry`(小写,与 dsh-repo-scanner 惯例一致)。
|
|
7
|
+
- bin 命令:`telemetry` 与 `dsh-local-telemetry`;exports 子路径 `dsh-local-telemetry/telemetry`;cordis.patch.yml 插件行 id/name 均为 `dsh-local-telemetry`(manifest 契约测试强制与 package.json name 一致)。
|
|
8
|
+
|
|
9
|
+
## 发布前检查清单
|
|
10
|
+
|
|
11
|
+
1. 运行 `npm test`(127/127 全绿)与 `npm run check`(全模块语法检查),确保全部通过。
|
|
12
|
+
2. 运行 `npm pack --dry-run`,确认包含 `plugin/index.js`、`cordis.patch.yml`、`skills/`、`src/`、`bin/`、`web/`、`docs/`、`examples/`、`README.md`、`CHANGELOG.md`、`LICENSE`、`PUBLISHING.md`、`DSH-TELEMETRY-开发计划.md`。
|
|
13
|
+
3. 版本一致性:`package.json` version、`bin/telemetry.mjs` USAGE 版本号、`src/aggregate.mjs` `tool.version`、`CHANGELOG.md` 发布段、git tag 五处保持一致(manifest 契约测试覆盖前三处)。
|
|
14
|
+
|
|
15
|
+
## DSH bundle 契约(对齐 2026-09 现行契约)
|
|
16
|
+
|
|
17
|
+
- `package.json` 声明 `dsh.bundle.patch: ./cordis.patch.yml`——harness 只激活声明该字段的包。
|
|
18
|
+
- `cordis.patch.yml` 为 config-tree `- insert:` 补丁格式;harness 加载 `main`(`plugin/index.js`)。
|
|
19
|
+
- `plugin/index.js` 经官方 `@deepseek-ai/dsh-skill-filesystem` 的 `FileSystemSkillProvider` 注册 `skills/` 为技能根(includeDefaultRoots: false)。
|
|
20
|
+
- `skills/telemetry-runbook/SKILL.md` frontmatter 必填 `name`(kebab-case)+ `description`。
|
|
21
|
+
- 扫描内核经 exports 子路径 `dsh-local-telemetry/telemetry` 暴露;CLI bin 为 `telemetry` / `dsh-local-telemetry`。
|
|
22
|
+
|
|
23
|
+
## 发布渠道
|
|
24
|
+
|
|
25
|
+
### GitHub
|
|
26
|
+
|
|
27
|
+
1. push `main`,确认 GitHub Actions CI 全绿(Node 18/20/22 × Windows/Ubuntu)。
|
|
28
|
+
2. 打 tag `v0.1.0` 并推送。
|
|
29
|
+
3. 给仓库添加 GitHub topic `dsh-plugin`(awesome 收录门槛之一)。
|
|
30
|
+
|
|
31
|
+
### npm
|
|
32
|
+
|
|
33
|
+
1. `npm login`(bugcome 账号)。
|
|
34
|
+
2. `npm publish`(首次发布非 scope 公有包无需 `--access public`;`prepublishOnly` 会先跑 `npm test`)。
|
|
35
|
+
3. 发布后核对 `npm view dsh-local-telemetry version` 与 dist-tags。
|
|
36
|
+
|
|
37
|
+
### awesome-dsh-plugin 收录(可选,参照 dsh-repo-scanner 经验)
|
|
38
|
+
|
|
39
|
+
- 仓库需创建满 1 天且 ≥10 个提交(自动检查,过滤一次性投稿仓;本仓历史提交偏少,可先补收尾提交再投)。
|
|
40
|
+
- 仓库 `package.json` 必须声明 `dsh.bundle`(根包或 packages/ 子包)。
|
|
41
|
+
- 需给仓库加 GitHub topic `dsh-plugin`。
|
|
42
|
+
- 检查失败后向同一分支推送修复即可,无需重开 PR。
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# dsh-telemetry
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://github.com/topics/dsh-plugin)
|
|
5
|
+
[](https://github.com/duyanta123/dsh-telemetry/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-local-telemetry)
|
|
7
|
+
[](CHANGELOG.md)
|
|
8
|
+
|
|
9
|
+
本地优先的 Harness 运行遥测插件:记录请求、模型、工具与插件生命周期指标(延迟、Token、成本、错误、缓存),默认不采集内容。
|
|
10
|
+
|
|
11
|
+
> npm 包名为 `dsh-local-telemetry`(`dsh-telemetry` 在 npm 上已被第三方占用);GitHub 仓库名保持 `dsh-telemetry`,两者指向同一项目。
|
|
12
|
+
|
|
13
|
+
## 定位
|
|
14
|
+
|
|
15
|
+
dsh-telemetry 是 Harness 运行可观测性插件,不负责业务分析,不负责修改请求内容,也不负责把用户对话上传到第三方平台。
|
|
16
|
+
|
|
17
|
+
它回答:
|
|
18
|
+
- 一次请求花了多少时间?
|
|
19
|
+
- 时间消耗在模型、工具、插件还是排队?
|
|
20
|
+
- 输入/输出 Token 和重试成本是多少?
|
|
21
|
+
- 哪些工具调用最慢、最容易失败?
|
|
22
|
+
- 哪些插件发生异常或阻塞?
|
|
23
|
+
- 缓存是否命中,模型路由是否节省了成本?
|
|
24
|
+
- 是否存在上下文过大、循环工具调用和异常重试?
|
|
25
|
+
|
|
26
|
+
一句话定位:
|
|
27
|
+
|
|
28
|
+
> Make Harness behavior measurable without collecting sensitive conversation content by default.
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
作为 DSH 插件(推荐):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile web add "github:duyanta123/dsh-telemetry#main"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
或从 npm 安装(作为库或独立 CLI 使用):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install dsh-local-telemetry
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
安装后重启 `dsh --profile web`,即可通过 `telemetry-runbook` 技能使用查询 CLI:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node bin/telemetry.mjs --status
|
|
48
|
+
node bin/telemetry.mjs --summary --since 24h
|
|
49
|
+
node bin/telemetry.mjs --trace <trace_id>
|
|
50
|
+
node bin/telemetry.mjs --ui --port 47610
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 快速开始
|
|
54
|
+
|
|
55
|
+
### 1. 作为宿主集成代码使用
|
|
56
|
+
|
|
57
|
+
事件经显式适配器接入(当前推荐的唯一方式):
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { createRecorder } from 'dsh-local-telemetry/telemetry';
|
|
61
|
+
|
|
62
|
+
const recorder = createRecorder({
|
|
63
|
+
config: {
|
|
64
|
+
path: '~/.dsh/telemetry',
|
|
65
|
+
capture_metadata: 'safe', // 默认 none,不采集内容
|
|
66
|
+
hash_names: false,
|
|
67
|
+
sample_rate: 1,
|
|
68
|
+
errors_always_sample: true,
|
|
69
|
+
slow_request_ms: 10000,
|
|
70
|
+
retention_days: 7,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await recorder.start();
|
|
74
|
+
|
|
75
|
+
// 记录一条事件(缺失的 id 和 timestamp 会自动补全)
|
|
76
|
+
recorder.record({
|
|
77
|
+
event: 'model.completed',
|
|
78
|
+
trace_id: 'trace-001',
|
|
79
|
+
span_id: 'span-003',
|
|
80
|
+
parent_id: 'span-001',
|
|
81
|
+
timestamp: '2026-08-23T12:00:00.000Z',
|
|
82
|
+
model: { provider: 'deepseek', name: 'deepseek-chat', request_type: 'chat' },
|
|
83
|
+
usage: { input_tokens: 4200, output_tokens: 860, cached_input_tokens: 0, reasoning_tokens: null },
|
|
84
|
+
result: { status: 'success', finish_reason: 'stop' },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 退出前 flush;失败不阻塞退出
|
|
88
|
+
await recorder.close();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 2. 聚合读取(上层插件可引用)
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { openStore, aggregateEvents, buildTraceView } from 'dsh-local-telemetry/telemetry';
|
|
95
|
+
|
|
96
|
+
const store = await openStore({ store: 'jsonl', path: '~/.dsh/telemetry' });
|
|
97
|
+
const { events } = await store.readEvents({ fromMs: Date.now() - 3600e3 });
|
|
98
|
+
const summary = aggregateEvents(events, { catalog: null });
|
|
99
|
+
|
|
100
|
+
console.log(`P95 latency: ${summary.requests.latency.p95}ms, Input tokens: ${summary.tokens.input}`);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 3. 作为 DSH 技能调用(CLI 由技能指引)
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node bin/telemetry.mjs --summary --since 1h --group-by model
|
|
107
|
+
node bin/telemetry.mjs --export TELEMETRY-REPORT.md --since 7d --format markdown
|
|
108
|
+
node bin/telemetry.mjs --purge --before 30d
|
|
109
|
+
node bin/telemetry.mjs --ui --port 47610
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## CLI 参数
|
|
113
|
+
|
|
114
|
+
| 参数 | 默认 | 说明 |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `--store jsonl\|sqlite` | jsonl | 存储后端(sqlite 需 Node ≥22.5) |
|
|
117
|
+
| `--path <dir>` | ~/.dsh/telemetry | 数据目录 |
|
|
118
|
+
| `--config <file>` | - | 配置文件 |
|
|
119
|
+
| `--since <duration\|ts>` | - | 时间窗起点(如 1h / 7d / ISO 时间戳) |
|
|
120
|
+
| `--until <duration\|ts>` | - | 时间窗终点 |
|
|
121
|
+
| `--profile <name>` | - | 按 profile 过滤 |
|
|
122
|
+
| `--model <name>` | - | 按模型过滤 |
|
|
123
|
+
| `--plugin <name>` | - | 按插件过滤 |
|
|
124
|
+
| `--event <name\|prefix.*>` | - | 按事件过滤(如 model.*) |
|
|
125
|
+
| `--group-by <key>` | - | 分组:model\|plugin\|tool\|profile\|day |
|
|
126
|
+
| `--format text\|json\|markdown` | text | 输出格式(`--export` 未指定时按扩展名 `.json`/`.md` 推断) |
|
|
127
|
+
| `--errors-only` | - | 只看错误与取消 |
|
|
128
|
+
| `--slow-over-ms <N>` | - | 只看耗时 ≥ N 的请求 |
|
|
129
|
+
| `--sample-rate <0..1>` | - | 采样率(录制侧配置) |
|
|
130
|
+
| `--capture-metadata none\|safe` | - | metadata 采集(录制侧配置) |
|
|
131
|
+
| `--purge --before <d>` | - | 保留期清理 |
|
|
132
|
+
|
|
133
|
+
## 隐私与安全
|
|
134
|
+
|
|
135
|
+
- **默认不采集内容**:prompt、response、文件内容、命令参数、环境变量和密钥。
|
|
136
|
+
- **脱敏策略**:敏感字段(Authorization、Cookie、token、password、api_key 等)整键丢弃;URL 凭据与 query token 脱敏;绝对路径可配置为 basename 或哈希。
|
|
137
|
+
- **名称哈希**:工具、插件、模型名与 profile 可配置哈希化,稳定但不可直接还原。
|
|
138
|
+
- **本地存储**:默认 `~/.dsh/telemetry`(JSONL 按日期分文件,SQLite 可选),不联网。
|
|
139
|
+
- **只读 UI**:`--ui` 只绑定 127.0.0.1,禁止默认暴露到局域网。
|
|
140
|
+
|
|
141
|
+
## 版本规划
|
|
142
|
+
|
|
143
|
+
### v0.1.0
|
|
144
|
+
|
|
145
|
+
- 本地 JSONL + 可选 SQLite(Node ≥22.5 内置 `node:sqlite`)
|
|
146
|
+
- request/model/tool/plugin 基础事件
|
|
147
|
+
- 启停配置、fail-open、`--status`、`--summary`、`--trace`、`--export`、`--purge`、`--ui`
|
|
148
|
+
- 默认不采集内容
|
|
149
|
+
- 采样、慢请求、错误保留策略
|
|
150
|
+
- 成本目录、脱敏、隐私块、保留期清理
|
|
151
|
+
- Markdown 报告
|
|
152
|
+
- Trace/span 树与时间线视图
|
|
153
|
+
|
|
154
|
+
## 许可证
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-local-telemetry — 查询 CLI(计划 §10)。
|
|
4
|
+
*
|
|
5
|
+
* node bin/telemetry.mjs --status
|
|
6
|
+
* node bin/telemetry.mjs --summary --since 24h --group-by model
|
|
7
|
+
* node bin/telemetry.mjs --trace <trace_id>
|
|
8
|
+
* node bin/telemetry.mjs --export report.json --since 7d
|
|
9
|
+
* node bin/telemetry.mjs --export TELEMETRY-REPORT.md --since 7d --format markdown
|
|
10
|
+
* node bin/telemetry.mjs --config telemetry.json --summary
|
|
11
|
+
* node bin/telemetry.mjs --purge --before 30d
|
|
12
|
+
* node bin/telemetry.mjs --ui --port 47610
|
|
13
|
+
*
|
|
14
|
+
* 全部命令只读本地存储(--purge 除外);遥测查询不联网。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFile } from "node:fs/promises";
|
|
18
|
+
import { resolveDataPath, sinceUntilRange, parseDurationOrTimestamp, loadConfigFile, resolveConfig } from "../src/config.mjs";
|
|
19
|
+
import { openStore } from "../src/store.mjs";
|
|
20
|
+
import { aggregateEvents, aggregateGrouped, slowTraceIds, buildTraceView } from "../src/aggregate.mjs";
|
|
21
|
+
import { loadPriceCatalog } from "../src/cost.mjs";
|
|
22
|
+
import { renderTextSummary, renderMarkdownReport, renderTraceText, renderGroupedText } from "../src/report.mjs";
|
|
23
|
+
import { createTelemetryServer } from "../src/server.mjs";
|
|
24
|
+
|
|
25
|
+
const USAGE = `dsh-local-telemetry — 本地 Harness 遥测查询 CLI(v0.1.0)
|
|
26
|
+
|
|
27
|
+
用法:
|
|
28
|
+
node bin/telemetry.mjs --status
|
|
29
|
+
node bin/telemetry.mjs --summary [--since 1h] [--until <t>] [--group-by model|plugin|tool|profile|day]
|
|
30
|
+
node bin/telemetry.mjs --trace <trace_id>
|
|
31
|
+
node bin/telemetry.mjs --export <file> [--since 7d] [--format text|json|markdown]
|
|
32
|
+
node bin/telemetry.mjs --purge --before 30d
|
|
33
|
+
node bin/telemetry.mjs --ui [--port 47610]
|
|
34
|
+
|
|
35
|
+
参数:
|
|
36
|
+
--store jsonl|sqlite 存储后端(默认 jsonl;sqlite 需 Node ≥22.5)
|
|
37
|
+
--path <dir> 数据目录(默认 ~/.dsh/telemetry)
|
|
38
|
+
--config <file> 配置文件(解析失败时按「关闭」安全默认)
|
|
39
|
+
--since <duration|ts> 时间窗起点(如 1h / 7d / ISO 时间戳)
|
|
40
|
+
--until <duration|ts> 时间窗终点
|
|
41
|
+
--profile <name> 按 profile 过滤
|
|
42
|
+
--model <name> 按模型过滤
|
|
43
|
+
--plugin <name> 按插件过滤
|
|
44
|
+
--event <name|prefix.*> 按事件过滤(如 model.*)
|
|
45
|
+
--group-by <key> 分组:model | plugin | tool | profile | day
|
|
46
|
+
--format text|json|markdown 输出格式(默认 text;--export 未指定时按扩展名 .json/.md 推断)
|
|
47
|
+
--errors-only 只看错误与取消
|
|
48
|
+
--slow-over-ms <N> 只看耗时 ≥ N 的请求
|
|
49
|
+
--sample-rate <0..1> 采样率(录制侧配置,查询命令接受但仅用于配置覆盖)
|
|
50
|
+
--capture-metadata none|safe metadata 采集(录制侧配置,同上)
|
|
51
|
+
--json 等价于 --format json
|
|
52
|
+
--help 本帮助
|
|
53
|
+
|
|
54
|
+
说明:所有摘要标注时间范围、样本数与分位数方法(nearest-rank);成本为估算值。
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function parseArgs(argv) {
|
|
58
|
+
const flags = new Map();
|
|
59
|
+
const positional = [];
|
|
60
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
61
|
+
const arg = argv[i];
|
|
62
|
+
if (arg.startsWith("--")) {
|
|
63
|
+
const key = arg.slice(2);
|
|
64
|
+
const next = argv[i + 1];
|
|
65
|
+
const takesValue = !["status", "summary", "purge", "ui", "errors-only", "json", "help"].includes(key);
|
|
66
|
+
if (takesValue && next !== undefined && !next.startsWith("--")) {
|
|
67
|
+
flags.set(key, next);
|
|
68
|
+
i += 1;
|
|
69
|
+
} else {
|
|
70
|
+
flags.set(key, true);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
positional.push(arg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { flags, positional };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fail(message, code = 2) {
|
|
80
|
+
console.error(`dsh-local-telemetry: ${message}`);
|
|
81
|
+
process.exit(code);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** --json 显式优先;其次 --format;--export 时按目标扩展名推断(.json→json,.md→markdown);默认 text。 */
|
|
85
|
+
function resolveFormat(flags) {
|
|
86
|
+
if (flags.get("json")) return "json";
|
|
87
|
+
const explicit = flags.get("format");
|
|
88
|
+
if (explicit) return String(explicit);
|
|
89
|
+
const exportTarget = flags.get("export");
|
|
90
|
+
if (typeof exportTarget === "string") {
|
|
91
|
+
if (exportTarget.endsWith(".json")) return "json";
|
|
92
|
+
if (exportTarget.endsWith(".md")) return "markdown";
|
|
93
|
+
}
|
|
94
|
+
return "text";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const { flags, positional } = parseArgs(process.argv.slice(2));
|
|
99
|
+
if (flags.get("help") || (flags.size === 0 && positional.length === 0)) {
|
|
100
|
+
process.stdout.write(USAGE);
|
|
101
|
+
process.exit(flags.get("help") ? 0 : 2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- 配置:文件 + CLI 覆盖 ----
|
|
105
|
+
let fileConfig = undefined;
|
|
106
|
+
if (flags.get("config")) {
|
|
107
|
+
const loaded = loadConfigFile(String(flags.get("config")));
|
|
108
|
+
fileConfig = loaded.config;
|
|
109
|
+
if (!loaded.ok) {
|
|
110
|
+
for (const error of loaded.errors) console.error(`[config] ${error}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const overrides = {};
|
|
114
|
+
if (flags.get("store")) overrides.store = String(flags.get("store"));
|
|
115
|
+
if (flags.get("path")) overrides.path = String(flags.get("path"));
|
|
116
|
+
if (flags.get("sample-rate")) overrides.sample_rate = Number(flags.get("sample-rate"));
|
|
117
|
+
if (flags.get("capture-metadata")) overrides.capture_metadata = String(flags.get("capture-metadata"));
|
|
118
|
+
const resolution = resolveConfig({ file: fileConfig, overrides });
|
|
119
|
+
if (!resolution.ok) {
|
|
120
|
+
for (const error of resolution.errors) console.error(`[config] ${error}`);
|
|
121
|
+
}
|
|
122
|
+
const config = resolution.config;
|
|
123
|
+
const format = resolveFormat(flags);
|
|
124
|
+
const dataPath = flags.get("path") ? resolveDataPath(String(flags.get("path"))) : resolveDataPath(config.path);
|
|
125
|
+
|
|
126
|
+
if (flags.get("store") === "sqlite" && config.store === "sqlite") {
|
|
127
|
+
const { isSqliteSupported } = await import("../src/sink-sqlite.mjs");
|
|
128
|
+
if (!(await isSqliteSupported())) {
|
|
129
|
+
fail("sqlite backend requires Node >= 22.5 (node:sqlite); use --store jsonl on this runtime", 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const store = await openStore({ store: config.store, path: dataPath });
|
|
134
|
+
|
|
135
|
+
// ---- --status ----
|
|
136
|
+
if (flags.get("status")) {
|
|
137
|
+
const status = await store.status();
|
|
138
|
+
if (typeof store.close === "function") store.close();
|
|
139
|
+
if (format === "json") {
|
|
140
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
141
|
+
} else {
|
|
142
|
+
const lines = [
|
|
143
|
+
`Store: ${status.store}`,
|
|
144
|
+
`Path: ${status.path}`,
|
|
145
|
+
status.unavailable ? `Note: ${status.unavailable}` : null,
|
|
146
|
+
`Files: ${status.files.length} (${status.total_bytes} bytes)${status.rows !== undefined ? `, rows: ${status.rows}` : ""}`,
|
|
147
|
+
`Counters: written=${status.counters.written} dropped(write=${status.counters.dropped_write} queue=${status.counters.dropped_queue} oversize=${status.counters.dropped_oversize} invalid=${status.counters.dropped_invalid}) sampled_out=${status.counters.sampled_out}`,
|
|
148
|
+
].filter(Boolean);
|
|
149
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- --purge --before <duration> ----
|
|
155
|
+
if (flags.get("purge")) {
|
|
156
|
+
const before = flags.get("before");
|
|
157
|
+
if (!before || before === true) fail("--purge requires --before <duration> (e.g. 30d)");
|
|
158
|
+
const parsed = parseDurationOrTimestamp(String(before));
|
|
159
|
+
if (!parsed) fail(`invalid --before value: ${before}`);
|
|
160
|
+
const cutoffMs = parsed.relative ? Date.now() - parsed.absolute : parsed.absolute;
|
|
161
|
+
const result = await store.purge({ beforeMs: cutoffMs });
|
|
162
|
+
if (typeof store.close === "function") store.close();
|
|
163
|
+
if (format === "json") {
|
|
164
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
165
|
+
} else {
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
`Purged before ${new Date(cutoffMs).toISOString()}: removed_files=${result.removed_files} removed_bytes=${result.removed_bytes}${result.removed_rows !== undefined ? ` removed_rows=${result.removed_rows}` : ""}\n`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- --ui ----
|
|
174
|
+
if (flags.get("ui")) {
|
|
175
|
+
const port = Number(flags.get("port") ?? 47610);
|
|
176
|
+
let catalog = null;
|
|
177
|
+
if (config.price_catalog) {
|
|
178
|
+
const loaded = loadPriceCatalog(resolveDataPath(config.price_catalog));
|
|
179
|
+
catalog = loaded.catalog;
|
|
180
|
+
}
|
|
181
|
+
const server = await createTelemetryServer({ store, catalog, catalogPath: config.price_catalog });
|
|
182
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
183
|
+
server.listen(port, "127.0.0.1", resolveListen);
|
|
184
|
+
server.on("error", rejectListen);
|
|
185
|
+
});
|
|
186
|
+
console.log(`dsh-local-telemetry UI: http://127.0.0.1:${port}/ (localhost only; Ctrl+C to stop)`);
|
|
187
|
+
const shutdown = () => {
|
|
188
|
+
server.close();
|
|
189
|
+
if (typeof store.close === "function") store.close();
|
|
190
|
+
process.exit(0);
|
|
191
|
+
};
|
|
192
|
+
process.on("SIGINT", shutdown);
|
|
193
|
+
process.on("SIGTERM", shutdown);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- --summary ----
|
|
198
|
+
if (flags.get("summary") || flags.get("export")) {
|
|
199
|
+
const range = sinceUntilRange(flags.get("since") ?? null, flags.get("until") ?? null);
|
|
200
|
+
const filters = {
|
|
201
|
+
fromMs: range.from,
|
|
202
|
+
toMs: range.to,
|
|
203
|
+
profile: flags.get("profile") ? String(flags.get("profile")) : null,
|
|
204
|
+
model: flags.get("model") ? String(flags.get("model")) : null,
|
|
205
|
+
plugin: flags.get("plugin") ? String(flags.get("plugin")) : null,
|
|
206
|
+
event: flags.get("event") ? String(flags.get("event")) : null,
|
|
207
|
+
errorsOnly: Boolean(flags.get("errors-only")),
|
|
208
|
+
};
|
|
209
|
+
const { events, skipped } = await store.readEvents(filters);
|
|
210
|
+
let effective = events;
|
|
211
|
+
if (flags.get("slow-over-ms")) {
|
|
212
|
+
const threshold = Number(flags.get("slow-over-ms"));
|
|
213
|
+
if (!Number.isFinite(threshold) || threshold < 0) fail("invalid --slow-over-ms value");
|
|
214
|
+
const ids = slowTraceIds(events, threshold);
|
|
215
|
+
effective = events.filter((event) => ids.has(event.trace_id ?? event.span_id));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let catalog = null;
|
|
219
|
+
let catalogPath = null;
|
|
220
|
+
if (config.price_catalog) {
|
|
221
|
+
catalogPath = resolveDataPath(config.price_catalog);
|
|
222
|
+
const loaded = loadPriceCatalog(catalogPath);
|
|
223
|
+
if (!loaded.ok && loaded.errors.length > 0) {
|
|
224
|
+
for (const error of loaded.errors) console.error(`[price_catalog] ${error}`);
|
|
225
|
+
}
|
|
226
|
+
catalog = loaded.catalog;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const groupBy = flags.get("group-by") ? String(flags.get("group-by")) : null;
|
|
230
|
+
const storeStatus = await store.status();
|
|
231
|
+
const meta = {
|
|
232
|
+
store: storeStatus.store,
|
|
233
|
+
path: storeStatus.path,
|
|
234
|
+
dropped: storeStatus.counters,
|
|
235
|
+
priceCatalogPath: catalogPath,
|
|
236
|
+
command: `node bin/telemetry.mjs ${process.argv.slice(2).join(" ")}`,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (groupBy) {
|
|
240
|
+
const rows = aggregateGrouped(effective, { groupBy, catalog, catalogPath });
|
|
241
|
+
if (!rows) fail(`invalid --group-by value: ${groupBy} (expected model|plugin|tool|profile|day)`);
|
|
242
|
+
const payload = { generated_at: new Date().toISOString(), group_by: groupBy, window: { since: flags.get("since") ?? null, until: flags.get("until") ?? null }, rows };
|
|
243
|
+
await outputResult(payload, format, flags, () => renderGroupedText(rows, groupBy), meta, "summary");
|
|
244
|
+
if (typeof store.close === "function") store.close();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const summary = aggregateEvents(effective, { catalog, catalogPath });
|
|
249
|
+
summary.skipped = skipped;
|
|
250
|
+
await outputResult(summary, format, flags, () => renderTextSummary(summary, { dropped: meta.dropped, store: meta.store }), meta, "summary");
|
|
251
|
+
if (typeof store.close === "function") store.close();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- --trace <id> ----
|
|
256
|
+
if (flags.get("trace")) {
|
|
257
|
+
const traceId = String(flags.get("trace"));
|
|
258
|
+
const { events, skipped } = await store.readEvents({ traceId });
|
|
259
|
+
if (events.length === 0) {
|
|
260
|
+
console.error(`dsh-local-telemetry: no events found for trace ${traceId} (skipped: invalid=${skipped.invalid} schema=${skipped.schema_incompatible})`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
const view = buildTraceView(events);
|
|
264
|
+
if (format === "json") {
|
|
265
|
+
process.stdout.write(`${JSON.stringify(view, null, 2)}\n`);
|
|
266
|
+
} else {
|
|
267
|
+
process.stdout.write(`${renderTraceText(view)}\n`);
|
|
268
|
+
}
|
|
269
|
+
if (typeof store.close === "function") store.close();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fail("no command given (use --status | --summary | --trace <id> | --export <file> | --purge --before <d> | --ui)");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function outputResult(payload, format, flags, renderText, meta, kind) {
|
|
277
|
+
const exportTarget = flags.get("export");
|
|
278
|
+
let body;
|
|
279
|
+
if (format === "json") body = `${JSON.stringify(payload, null, 2)}\n`;
|
|
280
|
+
else if (format === "markdown") body = `${renderMarkdownReport(payload, meta)}\n`;
|
|
281
|
+
else body = `${renderText()}\n`;
|
|
282
|
+
|
|
283
|
+
if (exportTarget && typeof exportTarget === "string") {
|
|
284
|
+
const target = resolveDataPath(exportTarget);
|
|
285
|
+
await writeFile(target, body, "utf8");
|
|
286
|
+
console.error(`exported ${kind} to ${target} (format: ${format})`);
|
|
287
|
+
} else {
|
|
288
|
+
process.stdout.write(body);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
main().catch((error) => {
|
|
293
|
+
console.error(`dsh-local-telemetry: ${error?.stack ?? error}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-local-telemetry — DSH bundle patch。
|
|
2
|
+
#
|
|
3
|
+
# 当某个 profile 在 dsh.profile.bundles 中列出本包时(dsh plugin add 即完成
|
|
4
|
+
# 此事),dsh 启动时把本补丁打入配置树:插入本包插件行,加载 package.json
|
|
5
|
+
# 的 main(plugin/index.js),将自带 skills/ 注册为技能根。
|
|
6
|
+
#
|
|
7
|
+
# 遥测是旁路能力:插件入口只注册技能根、按配置惰性创建 sink,并在关闭时
|
|
8
|
+
# flush;不注册任何会改变请求语义的人设或工具。
|
|
9
|
+
- insert:
|
|
10
|
+
- id: dsh-local-telemetry
|
|
11
|
+
name: dsh-local-telemetry
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# dsh-telemetry 配置文档
|
|
2
|
+
|
|
3
|
+
dsh-telemetry 支持通过配置文件、CLI 参数和环境变量覆盖配置。本文档描述所有可配置项、默认值和语义。
|
|
4
|
+
|
|
5
|
+
## 配置文件
|
|
6
|
+
|
|
7
|
+
配置文件为 JSON 格式,通过 `--config <file>` 指定:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node bin/telemetry.mjs --config telemetry.json --summary
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 完整配置项
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"enabled": true,
|
|
18
|
+
"store": "jsonl",
|
|
19
|
+
"path": "~/.dsh/telemetry",
|
|
20
|
+
"sample_rate": 1,
|
|
21
|
+
"errors_always_sample": true,
|
|
22
|
+
"slow_request_ms": 10000,
|
|
23
|
+
"capture_metadata": "none",
|
|
24
|
+
"hash_names": false,
|
|
25
|
+
"retention_days": 7,
|
|
26
|
+
"max_file_mb": 100,
|
|
27
|
+
"flush_interval_ms": 1000,
|
|
28
|
+
"batch_size": 100,
|
|
29
|
+
"max_queue_events": 1000,
|
|
30
|
+
"price_catalog": null,
|
|
31
|
+
"redact_rules": []
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 配置项说明
|
|
36
|
+
|
|
37
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `enabled` | boolean | true | 是否启用遥测(关闭后不记录事件,不创建日志文件) |
|
|
40
|
+
| `store` | string | jsonl | 存储后端:`jsonl` 或 `sqlite`(sqlite 需要 Node ≥22.5) |
|
|
41
|
+
| `path` | string | ~/.dsh/telemetry | 数据目录路径(支持 `~` 展开家目录) |
|
|
42
|
+
| `sample_rate` | number | 1 | 采样率(0..1),按 trace 整体决策 |
|
|
43
|
+
| `errors_always_sample` | boolean | true | 错误/取消事件是否绕过采样 |
|
|
44
|
+
| `slow_request_ms` | number | 10000 | 慢请求阈值(超过则保留) |
|
|
45
|
+
| `capture_metadata` | string | none | metadata 采集模式:`none`(不采集)或 `safe`(脱敏后采集) |
|
|
46
|
+
| `hash_names` | boolean | false | 是否哈希化工具/插件/模型名/profile |
|
|
47
|
+
| `retention_days` | number | 7 | 保留期(天数),超过此期限的数据会被清理 |
|
|
48
|
+
| `max_file_mb` | number | 100 | 单文件上限(MB),超过时轮转(JSONL)或清理最旧日期(SQLite) |
|
|
49
|
+
| `flush_interval_ms` | number | 1000 | 定时 flush 间隔(毫秒) |
|
|
50
|
+
| `batch_size` | number | 100 | 批量写入大小(事件数) |
|
|
51
|
+
| `max_queue_events` | number | 1000 | 队列上限(事件数),超过时丢弃新到事件 |
|
|
52
|
+
| `price_catalog` | string | null | 版本化价格目录 JSON 文件路径(用于成本计算) |
|
|
53
|
+
| `redact_rules` | array | [] | 自定义脱敏规则列表(见下方) |
|
|
54
|
+
|
|
55
|
+
## 自定义脱敏规则
|
|
56
|
+
|
|
57
|
+
每个规则对象包含 `name` 和 `pattern`(正则字符串):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"redact_rules": [
|
|
62
|
+
{ "name": "employee_id", "pattern": "EMP-\\d+" },
|
|
63
|
+
{ "name": "internal_token", "pattern": "ITK_[A-Z0-9]{32}" }
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
规则在 metadata 字符串值上按序匹配,命中后替换为 `[redacted:<name>]`。
|
|
69
|
+
|
|
70
|
+
## 资源预算(计划 §7.2)
|
|
71
|
+
|
|
72
|
+
以下硬预算在代码中强制执行,配置值超出时会被钳制:
|
|
73
|
+
|
|
74
|
+
| 预算项 | 默认上限 | 说明 |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| 单事件最大字节 | 64 KB | 超过的事件被丢弃并计数 |
|
|
77
|
+
| 队列最大事件数 | 1000 | 超过的新事件被丢弃并计数 |
|
|
78
|
+
| 最大批量大小 | 1000 | 超过时钳制到 1000 |
|
|
79
|
+
| 最小 flush 间隔 | 50 ms | 超过时钳制到 50 ms |
|
|
80
|
+
|
|
81
|
+
## 采样策略
|
|
82
|
+
|
|
83
|
+
- 决策粒度:按 `trace_id` 整体决策(同一 trace 的所有事件同命运)。
|
|
84
|
+
- 决策方法:`SHA256(salt + trace_id)` 取 hash,与 `sample_rate` 比较。
|
|
85
|
+
- 保留例外:`errors_always_sample=true` 时,失败/取消事件强制保留;慢请求强制保留。
|
|
86
|
+
- 元数据写入:保留的事件会写入 `sampling` 块(rate/strategy/errors_always_sample),便于解释聚合结果。
|
|
87
|
+
|
|
88
|
+
## 价格目录格式
|
|
89
|
+
|
|
90
|
+
用于成本计算,必须包含 `currency`、`effective_at` 和 `models` 映射:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"currency": "USD",
|
|
95
|
+
"effective_at": "2026-08-23T00:00:00Z",
|
|
96
|
+
"models": {
|
|
97
|
+
"deepseek-chat": {
|
|
98
|
+
"input_per_million": 0.27,
|
|
99
|
+
"cached_input_per_million": 0.07,
|
|
100
|
+
"output_per_million": 1.1
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
成本计算公式:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
cost = (input_tokens / 1_000_000) * input_per_million
|
|
110
|
+
+ (cached_input_tokens / 1_000_000) * cached_input_per_million
|
|
111
|
+
+ (output_tokens / 1_000_000) * output_per_million
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
若模型名、usage 或价格缺失,`cost` 为 `null` 并在摘要中标记缺失原因。
|
|
115
|
+
|
|
116
|
+
## 环境变量
|
|
117
|
+
|
|
118
|
+
不通过环境变量配置敏感信息(密钥不属于遥测配置)。
|
|
119
|
+
|
|
120
|
+
## 配置解析失败行为
|
|
121
|
+
|
|
122
|
+
- 文件不存在/损坏 → 配置降级为 `{ enabled: false }`,并在 stderr 打印一次性警告。
|
|
123
|
+
- 字段类型错误 → 采用安全默认值(如 `sample_rate` 回退到 1),不猜测用户意图。
|
|
124
|
+
- 非法值(如 `store: "mongodb"`)→ 回退到 `jsonl`。
|
|
125
|
+
|
|
126
|
+
## 例子
|
|
127
|
+
|
|
128
|
+
### 仅保留错误和慢请求
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"enabled": true,
|
|
133
|
+
"sample_rate": 0.1,
|
|
134
|
+
"errors_always_sample": true,
|
|
135
|
+
"slow_request_ms": 5000,
|
|
136
|
+
"capture_metadata": "safe"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### SQLite 后端 + 成本计算
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"store": "sqlite",
|
|
145
|
+
"path": "~/.dsh/telemetry/sqlite",
|
|
146
|
+
"price_catalog": "~/.dsh/telemetry/prices.json"
|
|
147
|
+
}
|
|
148
|
+
```
|