标准信封
每次运行都共享相同的结构。你无需为每个命令单独声明 schema。
{
"event": "run",
"command": "sync",
"durationMs": 412,
"outcome": "success",
"flags": { "dryRun": true, "output": "<set>" },
"tool": { "name": "my-tool", "version": "1.0.0" },
"env": {
"node": "20.11",
"ci": false,
"provider": null,
"tty": true,
"agent": "cursor" // 标准环境:cursor、claude、codex、……或 null
},
"machineId": "ab3f…", // 已哈希;在临时 CI 中省略
"custom": { "itemsSynced": 42 }
}
扩展 schema
上面的信封是固定的:每个工具都会发送相同的顶层结构,因此 parseIngestBody() 和公开声明都能保持可预测。你不能在 command 或 durationMs 旁边添加字段。
你的产品 schema 分布在两个扩展区域中:
| 区域 | 由谁设置 | 放什么内容 |
|---|---|---|
flags | citty (auto) + collect.flags | 你传入的 Flags——值为布尔值/数字,字符串仅在允许列表中时使用 |
custom | 你通过 telemetry.set() + collect.fields 设置 | 业务计数器、分类维度、schema 版本 |
可以把它理解为两个契约:
- Transport contract (
RunEvent):由@evlog/telemetry所有,在各个工具之间保持稳定 - Product contract (
custom+ declaredflags):由你所有,在collect中声明,并在服务器端镜像
只声明一次你的扩展
你收集的所有内容,除了标准信封之外,都在同一个 withTelemetry() / createTelemetry() 调用中内联声明:
withTelemetry(command, {
name: 'acme-cli',
version: '2.1.0',
endpoint: 'https://telemetry.acme.dev/api/ingest',
collect: {
flags: {
format: ['json', 'yaml', 'table'],
target: ['staging', 'production'],
},
fields: {
product: ['cli', 'action', 'migrator'],
plan: ['free', 'pro', 'enterprise'],
framework: ['nuxt', 'next', 'remix'],
},
},
})
在处理器内部,添加计数器和维度:
await t.run('deploy', async () => {
const result = await deployServices()
telemetry.set({
servicesDeployed: result.count, // number — 始终允许
rollbackUsed: false, // boolean — 始终允许
framework: 'nuxt', // string — 允许(在 collect.fields 中)
schemaVersion: 2, // number — 为你的自定义形状做版本管理
})
})
未声明的字符串会在运行时被丢弃,绝不会抛出异常。这就是隐私保证:custom 中不会意外包含路径、令牌或自由格式的 PII。
TelemetryHandle.set() 的自动补全涵盖已声明的 collect.fields 键。使用环境中的 telemetry.set() 为同一次运行添加额外的数字/布尔值计数器;事件记录前,两者都会合并到 custom 中。在服务器端镜像
你的 ingest 处理器是第二道门。传入你在 CLI 中声明的相同工具名和自定义键:
import { parseIngestBody } from '@evlog/telemetry/ingest'
const ALLOWED_CUSTOM = [
'servicesDeployed', 'rollbackUsed', 'framework',
'product', 'plan', 'schemaVersion',
'ghaAction', 'ghaEvent',
] as const
export async function POST(req: Request) {
const raw = await req.text()
const events = parseIngestBody(raw, {
allowedTools: ['acme-cli'],
allowedCustomKeys: { 'acme-cli': ALLOWED_CUSTOM },
})
// 按 idempotencyKey 去重,然后持久化
}
即使客户端以某种方式发送了你未列出的 custom 键,parseIngestBody() 也会将其剥离。
映射到你的数据仓库 schema
验证之后,把事件整理成你自己的数据库结构。evlog 的信封只是传输格式;你的表结构由你决定:
function toRow(event: RunEvent) {
return {
command: event.command,
duration_ms: event.durationMs,
outcome: event.outcome,
tool_version: event.tool.version,
format: event.flags.format,
target: event.flags.target,
services_deployed: event.custom.servicesDeployed,
framework: event.custom.framework,
plan: event.custom.plan,
schema_version: event.custom.schemaVersion ?? 1,
is_ci: event.env.ci,
node: event.env.node,
received_at: new Date(),
}
}
公司实际上就是在这里“修改 schema”:不是更改 RunEvent,而是定义如何将 custom 映射到内部分析表中。
为 custom 中的破坏性变更做版本管理
当你的产品计数器演进时,在 custom 中提升一个数字型 schemaVersion(不需要允许列表),并在 ingest 映射器中分支处理:
const version = (event.custom.schemaVersion as number | undefined) ?? 1
if (version === 1) return mapV1(event)
return mapV2(event)
每当 collect 变更时,提交更新后的 TELEMETRY.md,这样披露内容就能与发布保持一致。
多个工具,共用一个 endpoint
如果多个 CLI 会 POST 到同一个 ingest URL,请给自定义键加命名空间,或者依赖 tool.name:
// 方案 A —— 为每条产品线前缀化键名
telemetry.set({ acme_product: 'cli', deployCount: 3 })
// 方案 B —— 在 parseIngestBody 中按 tool.name 分别设置 allowedCustomKeys
allowedCustomKeys: {
'acme-cli': ['deployCount', 'framework'],
'acme-action': ['jobDuration', 'ghaAction', 'ghaEvent'],
}
v1 不支持什么
| 需求 | v1 的答案 | 解决办法 |
|---|---|---|
新增顶层字段(tenantId) | 不在线上传输 | 在 custom 中使用扁平键并加入允许列表,或在服务器端从 machineId 映射 |
嵌套对象(custom.user.plan) | 仅支持扁平记录 | 扁平化:使用 collect.fields 配置 userPlan: 'pro' |
| 自由格式字符串(路径、电子邮件) | 设计上会被阻止 | Flags 记录 "<set>" 而不是实际值;在 telemetry.set() 之前进行哈希 |
| 每个命令使用不同的 schema | 每次运行一个信封 | 约定:每个命令只设置相关键 |
| 在 outbox 之前转换 | 目前没有 hook | 在服务器端映射(推荐),或参见下面计划中的 v2 |
TELEMETRY.md,并且属于一个封闭集合或数字,那它就应该放在 collect + custom 中。其他内容都留在服务器端。计划中的 v2 扩展
尚未发布。这些是 v1 扩展区域不够用时的设计目标:
enrich hook。 在净化之后、追加到 outbox 之前运行。允许你注入计算出的字段,而无需分叉软件包:
// 计划中的 API — 在 v1 中不可用
createTelemetry({
name: 'acme-cli',
version: '3.0.0',
collect: { fields: { product: ['cli', 'action'] } },
enrich(event) {
return {
...event,
custom: {
...event.custom,
schemaVersion: 2,
},
}
},
})
该 hook 无法绕过净化规则,字符串仍然需要 collect.fields。服务器端的 allowedCustomKeys 仍是事实来源。
Namespaced keys。 一种可选的 acme.product 约定,用于多工具 endpoint,避免前缀冲突。披露内容中会将其记录为 acme.product: cli | action。
如果你需要在 v2 发布前使用其中一项,请在仓库中发起讨论。v1 路径(custom + 服务器端映射)可以满足大多数产品分析需求。
隐私
原始 argv 从不读取。仅对 citty-parsed 标志应用净化:
- 布尔值 / 数字 → 存储其值(
json: true、limit: 50) - 字符串 →
"<set>"(output: "<set>"),除非在collect.flags中列入允许列表 telemetry.set()→ 数字和布尔值始终保留;字符串仅通过collect.fields保留(未声明的值会在运行时丢弃,绝不会抛出异常)
collect: {
flags: { format: ['json', 'csv'] }, // --format json → "json";--format yaml → "<set>"
fields: { framework: ['nuxt', 'next'] }, // telemetry.set({ framework: 'nuxt' }) 正常
}
事件构建前会丢弃三类解析器噪声,因此 flags 反映的是用户实际请求的内容,而不是解析器填充的内容:
- Positionals:citty 的
_bucket 保存的是参数值,而不是标志名称 - Defaults:仍然处于声明的
default值的标志,并不代表任何人做出了选择 - Kebab-case duplicates:
--min-score会同时以minScore和min-score的形式到达;只保留 camelCase 名称,这也是collect.flags允许列表中的名称
否定形式仍会被记录:对于 write: { default: true },--no-write 会记录为 write: false。
在同一个包含 collect 的 withTelemetry() / createTelemetry() 调用中声明允许列表,不需要单独的配置文件。
公开声明
generateDisclosure() 会基于标准信封以及你的 collect 扩展生成 markdown + JSON。请提交输出结果(例如 TELEMETRY.md),以确保它与各个版本保持同步:
import { writeFile } from 'node:fs/promises'
import { generateDisclosure } from '@evlog/telemetry'
const { markdown } = generateDisclosure('my-tool', {
flags: { format: ['json', 'csv'] },
})
await writeFile('TELEMETRY.md', markdown)
当你接入 defineTelemetryCommands() 后,用户也可以在运行时通过 my-tool telemetry status 读取它。
同意与可靠性
退出优先级: DO_NOT_TRACK=1 → EVLOG_TELEMETRY=0 → 已持久化的偏好(disableTelemetry() / telemetry disable)。退出会清除未投递的 outbox。
绝不会损害主机: telemetry 绝不会抛出错误,也绝不会阻塞退出;flush() 的硬性上限为 500ms。
Outbox: 在任何网络尝试之前,事件会先本地追加。离线机器和 CI 矩阵作业会在你的 ingestion endpoint 返回 2xx 后,于下一次调用时清空积压。
Endpoint: EVLOG_TELEMETRY_ENDPOINT 环境变量 → CLI 中内置的 endpoint 选项。本地开发时两者都可省略;当你的 ingest handler 可用时,再部署该 URL。