Telemetry

遥测参考

RunEvent 信封、使用 collect 和自定义字段扩展模式、隐私规则、TELEMETRY.md 披露,以及同意 / outbox 可靠性。

标准信封

每次运行都共享相同的结构。你无需为每个命令单独声明 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() 和公开声明都能保持可预测。你不能在 commanddurationMs 旁边添加字段。

你的产品 schema 分布在两个扩展区域中:

区域由谁设置放什么内容
flagscitty (auto) + collect.flags你传入的 Flags——值为布尔值/数字,字符串仅在允许列表中时使用
custom你通过 telemetry.set() + collect.fields 设置业务计数器、分类维度、schema 版本

可以把它理解为两个契约:

  • Transport contract (RunEvent):由 @evlog/telemetry 所有,在各个工具之间保持稳定
  • Product contract (custom + declared flags):由你所有,在 collect 中声明,并在服务器端镜像

只声明一次你的扩展

你收集的所有内容,除了标准信封之外,都在同一个 withTelemetry() / createTelemetry() 调用中内联声明:

src/index.ts
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'],
    },
  },
})

在处理器内部,添加计数器和维度:

src/commands/deploy.ts
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。

Typing:TelemetryHandle.set() 的自动补全涵盖已声明的 collect.fields 键。使用环境中的 telemetry.set() 为同一次运行添加额外的数字/布尔值计数器;事件记录前,两者都会合并到 custom 中。

在服务器端镜像

你的 ingest 处理器是第二道门。传入你在 CLI 中声明的相同工具名和自定义键:

app/api/telemetry/ingest/route.ts
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 的信封只是传输格式;你的表结构由你决定:

lib/telemetry-store.ts
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: truelimit: 50
  • 字符串"<set>"output: "<set>"),除非在 collect.flags 中列入允许列表
  • telemetry.set() → 数字和布尔值始终保留;字符串仅通过 collect.fields 保留(未声明的值会在运行时丢弃,绝不会抛出异常)
src/index.ts
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 会同时以 minScoremin-score 的形式到达;只保留 camelCase 名称,这也是 collect.flags 允许列表中的名称

否定形式仍会被记录:对于 write: { default: true }--no-write 会记录为 write: false

在同一个包含 collectwithTelemetry() / createTelemetry() 调用中声明允许列表,不需要单独的配置文件。

公开声明

generateDisclosure() 会基于标准信封以及你的 collect 扩展生成 markdown + JSON。请提交输出结果(例如 TELEMETRY.md),以确保它与各个版本保持同步:

scripts/generate-disclosure.ts
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=1EVLOG_TELEMETRY=0 → 已持久化的偏好(disableTelemetry() / telemetry disable)。退出会清除未投递的 outbox。

绝不会损害主机: telemetry 绝不会抛出错误,也绝不会阻塞退出;flush() 的硬性上限为 500ms。

Outbox: 在任何网络尝试之前,事件会先本地追加。离线机器和 CI 矩阵作业会在你的 ingestion endpoint 返回 2xx 后,于下一次调用时清空积压。

Endpoint: EVLOG_TELEMETRY_ENDPOINT 环境变量 → CLI 中内置的 endpoint 选项。本地开发时两者都可省略;当你的 ingest handler 可用时,再部署该 URL。