---
title: "遥测参考"
description: "RunEvent 信封、使用 collect 和自定义字段扩展模式、隐私规则、TELEMETRY.md 披露，以及同意 / outbox 可靠性。"
canonical_url: "https://evlog.zhcndoc.com/use-cases/telemetry/reference"
---
# 遥测参考

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

## 标准信封

每次运行都共享相同的结构。你无需为每个命令单独声明 schema。

```jsonc
{
  "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 分布在两个扩展区域中：

<table>
<thead>
  <tr>
    <th>
      区域
    </th>
    
    <th>
      由谁设置
    </th>
    
    <th>
      放什么内容
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        flags
      </code>
    </td>
    
    <td>
      citty (auto) + <code>
        collect.flags
      </code>
    </td>
    
    <td>
      你传入的 Flags——值为布尔值/数字，字符串仅在允许列表中时使用
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        custom
      </code>
    </td>
    
    <td>
      你通过 <code>
        telemetry.set()
      </code>
      
       + <code>
        collect.fields
      </code>
      
       设置
    </td>
    
    <td>
      业务计数器、分类维度、schema 版本
    </td>
  </tr>
</tbody>
</table>

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

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

### 只声明一次你的扩展

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

```ts [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'],
    },
  },
})
```

在处理器内部，添加计数器和维度：

```ts [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。

<note>

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

</note>

### 在服务器端镜像

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

```ts [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 的信封只是传输格式；你的表结构由你决定：

```ts [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 映射器中分支处理：

```ts
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`：

```ts
// 方案 A —— 为每条产品线前缀化键名
telemetry.set({ acme_product: 'cli', deployCount: 3 })

// 方案 B —— 在 parseIngestBody 中按 tool.name 分别设置 allowedCustomKeys
allowedCustomKeys: {
  'acme-cli': ['deployCount', 'framework'],
  'acme-action': ['jobDuration', 'ghaAction', 'ghaEvent'],
}
```

### v1 不支持什么

<table>
<thead>
  <tr>
    <th>
      需求
    </th>
    
    <th>
      v1 的答案
    </th>
    
    <th>
      解决办法
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      新增顶层字段（<code>
        tenantId
      </code>
      
      ）
    </td>
    
    <td>
      不在线上传输
    </td>
    
    <td>
      在 <code>
        custom
      </code>
      
       中使用扁平键并加入允许列表，或在服务器端从 <code>
        machineId
      </code>
      
       映射
    </td>
  </tr>
  
  <tr>
    <td>
      嵌套对象（<code>
        custom.user.plan
      </code>
      
      ）
    </td>
    
    <td>
      仅支持扁平记录
    </td>
    
    <td>
      扁平化：使用 <code>
        collect.fields
      </code>
      
       配置 <code>
        userPlan: 'pro'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      自由格式字符串（路径、电子邮件）
    </td>
    
    <td>
      设计上会被阻止
    </td>
    
    <td>
      Flags 记录 <code>
        "<set>"
      </code>
      
       而不是实际值；在 <code>
        telemetry.set()
      </code>
      
       之前进行哈希
    </td>
  </tr>
  
  <tr>
    <td>
      每个命令使用不同的 schema
    </td>
    
    <td>
      每次运行一个信封
    </td>
    
    <td>
      约定：每个命令只设置相关键
    </td>
  </tr>
  
  <tr>
    <td>
      在 outbox 之前转换
    </td>
    
    <td>
      目前没有 hook
    </td>
    
    <td>
      在服务器端映射（推荐），或参见下面计划中的 v2
    </td>
  </tr>
</tbody>
</table>

<tip>

**经验法则：** 如果它可以安全地打印到 `TELEMETRY.md`，并且属于一个封闭集合或数字，那它就应该放在 `collect` + `custom` 中。其他内容都留在服务器端。

</tip>

### 计划中的 v2 扩展

尚未发布。这些是 v1 扩展区域不够用时的设计目标：

**enrich hook。** 在净化之后、追加到 outbox 之前运行。允许你注入计算出的字段，而无需分叉软件包：

```ts
// 计划中的 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` 保留（未声明的值会在运行时丢弃，绝不会抛出异常）

```ts [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` 会同时以 `minScore` 和 `min-score` 的形式到达；只保留 camelCase 名称，这也是 `collect.flags` 允许列表中的名称

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

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

## 公开声明

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

```ts [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=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。

---

- [概览](https://evlog.zhcndoc.com/use-cases/telemetry/overview)
- [设置](https://evlog.zhcndoc.com/use-cases/telemetry/setup)
- [摄取](https://evlog.zhcndoc.com/use-cases/telemetry/ingest)


## Sitemap

See the full [sitemap](https://evlog.zhcndoc.com/sitemap.md) for all pages.
