Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 110 additions & 4 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
> 本文件是 OTel GenAI 插件实现的硬约束,由 spec-kit 流程的 review 阶段引用。
> 任何插件实现违反以下条款,视为缺陷。

**版本**:1.0.0
**版本**:1.1.0
**适用范围**:`opentelemetry-instrumentation-<agent>` 系列(claude / codex / 未来新增)
**最后更新**:2026-05-14
**最后更新**:2026-05-26

---

Expand All @@ -32,7 +32,7 @@
| `gen_ai.provider.name`(如 `openai` / `anthropic`) | 必须 |
| `gen_ai.request.model` / `gen_ai.response.model` | 必须 / 推荐 |
| `gen_ai.response.finish_reasons` | 推荐 |
| `gen_ai.usage.input_tokens` / `output_tokens` / `total_tokens` | 推荐(三个都要) |
| `gen_ai.usage.input_tokens` / `output_tokens` / `total_tokens` | 推荐(三个都要;⚠️ Anthropic provider 见 C11) |
| `gen_ai.usage.cache_creation.input_tokens` / `cache_read.input_tokens` | 推荐(若 provider 支持) |
| `gen_ai.input.messages` / `gen_ai.output.messages` | 内容采集开启时(C3) |
| `gen_ai.system_instructions` / `gen_ai.tool.definitions` | 内容采集开启时(C3) |
Expand Down Expand Up @@ -195,11 +195,117 @@ EOF

---

## C11. Anthropic provider 下 input_tokens 必须为全量值

Anthropic API 返回的 `usage.input_tokens` **仅为未命中 prompt cache 的 token 数**。当 agent 启用 prompt caching(Claude Code 默认全量 caching),该值可能只是真实值的 1/100。

**强制做法**:当 LLM provider 为 Anthropic 时,上报到 OTel 和 JSONL 的 `gen_ai.usage.input_tokens` 必须为:

```
totalInput = api_input_tokens + cache_read_input_tokens + cache_creation_input_tokens
```

`gen_ai.usage.total_tokens` 同样使用全量 input 计算。

**适用范围**:所有使用 Anthropic API 的 agent 插件(claude、openclaw、未来新增)。OpenAI 等 provider 的 `prompt_tokens` 已含全量,无需特殊处理。

**踩过的坑**:claude 插件早期直接使用 `ev.input_tokens`,导致 ARMS 上看到的 token 消耗比实际低 10-100 倍,无法用于成本分析。

---

## C12. JSONL 日志必须包含 trace_id / span_id / parent_span_id

每条 JSONL record 均需含 W3C Trace Context 格式的关联 ID:

| 字段 | 格式 | 说明 |
|---|---|---|
| `trace_id` | 32 位 hex(16 bytes) | 同一 turn 内所有 record 共享 |
| `span_id` | 16 位 hex(8 bytes) | 当前 record 对应的 span |
| `parent_span_id` | 16 位 hex(8 bytes) | 父 span(LLM/TOOL → STEP,STEP → AGENT) |

**两种模式的 ID 来源**:

| 模式 | ID 来源 |
|---|---|
| 非 logOnly(有 OTLP endpoint) | 从真实 OTel span 的 `spanContext()` 提取 |
| logOnly(仅日志) | 使用 `crypto.randomBytes` 自生成 |

**设计要求**:
- 非 logOnly 模式下,`replayEventsAsSpans()` 在事件对象上标注 `_otel_span_id` / `_otel_step_span_id`,后续 `generateTurnLogRecords()` 读取
- logOnly 模式下,事件无标注,回退到自生成
- 三个字段均不得为 `null` 或空字符串

**踩过的坑**:claude 插件早期 logOnly 模式全部 `trace_id: null`,非 logOnly 模式无 `span_id`/`parent_span_id` 字段,导致 JSONL 数据无法与 trace 关联,也无法独立构建 span 树。

---

## C13. CONFIG_DIR 环境变量兼容

当目标 agent 支持自定义配置目录的环境变量时(如 `CLAUDE_CONFIG_DIR`、`CODEX_CONFIG_DIR`),插件的配置文件路径必须跟随。**通用模板**(把 `<AGENT>` 替换为目标 agent 的标识,如 `CLAUDE` / `CODEX`):

```js
// 形如 ~/.claude / ~/.codex,默认目录由 agent 决定
const DEFAULT_DIR = path.join(os.homedir(), ".<agent>");
// 优先 env var;<AGENT>_CONFIG_DIR 由目标 agent 自己定义,插件不能编造
const configDir = process.env.<AGENT>_CONFIG_DIR || DEFAULT_DIR;
const CONFIG_PATH = path.join(configDir, "otel-config.json");
```

**涉及位置**:
- `otel-config.json` 读取路径
- `install` 命令写入 agent 的 `settings.json` 的路径
- `check-env` 诊断输出中引用的配置路径

**不能硬编码** `~/.<agent>/`。Harbor / CI 等容器化场景通过该环境变量将配置挂载到非默认位置。

**踩过的坑**:claude 插件硬编码 `~/.claude/settings.json`,Harbor 场景设置 `CLAUDE_CONFIG_DIR=/app/config` 后 install 写入位置与 Claude Code 读取位置不一致,hook 无法生效。

---

## C14. Hook 与 Transcript 多数据源对齐:优先用显式关联键

当插件**同时消费 hook 事件流(SessionState)和 transcript 文件**两套数据源,需要把 transcript 中的 LLM/token 数据归属到正确的 hook turn 上时,**对齐策略必须优先选择"显式关联键"**;只有当数据源确实没有可用关联键时,才退化到 fallback 策略,且 fallback 策略必须文档化并经回归测试覆盖。

**禁止**依赖"两个数据流的事件顺序天然对应"做隐式对齐,这种假设在以下任一场景下都会崩坏:Stop hook 按 turn 触发(state 跨 turn 周期不同)、transcript 持久累加(数据源生命周期不同)、同 session 中途装插件(基线偏移)、心跳/重发事件(出现重复)。

### 关联键优先级(从强到弱)

1. **`turn_id` 类显式 ID** — transcript 事件携带与 hook 同名的 turn 标识(codex 的 `task_started.turn_id` / `turn_context.turn_id`)。最可靠
2. **`message_id` / `response_id`** — transcript 事件携带 LLM 调用粒度的唯一 ID(Anthropic SDK 的 `message.id`)。可靠,但 hook 端通常只能拿到时间戳,需要一次额外的组装
3. **`byteOffset` 增量边界** — 不依赖事件本身的字段,而是把 transcript 文件的字节偏移作为"已消费水位线"持久化到 state,每次 Stop 只读 nextOffset 之后的字节。**适用于无 ID 但 transcript 单调追加的场景**
4. **wall-clock 时间窗 / 锚点对齐** — 用 hook 的 PreToolUse / Stop 时间作为锚点,在 transcript 事件序列上做后向配对。**最弱**,只在前 3 种都不可用时使用

### 强制做法

1. **plan 阶段必须回答**:
- 本插件用哪种关联键?为什么?
- state 在 hook 触发周期(per-turn / per-session)上如何持久化关联水位线?
2. **跨 hook 调用边界,状态必须透传**:Stop hook 按 turn 触发时,**禁止**用 `clearState` 删除整个 state 文件;改为清空 `events` 数组并保留对齐水位线(`transcript_offset` / `last_consumed_id` / `last_emitted_usage` 等)
3. **fallback 策略必须显式且可测**:
- 关联键命中失败时,代码路径必须有清晰注释说明"为什么这种情况下退化是可接受的"
- 回归测试必须覆盖至少一个 fallback 触发场景
4. **transcript 持久累加场景必须用增量读取**:`parseTranscript(path, byteOffset)` 形式,返回 `nextOffset` 供下次使用;**禁止**每次 Stop 都全量重读 transcript

### 适用范围

仅限**同时**消费 hook 事件流 + transcript 文件的 agent 插件(目前 claude / codex,未来 openclaw 等同架构插件)。

不适用:
- 仅依赖 hook 数据的插件
- 仅依赖 transcript 数据的插件
- 通过 `intercept.js` 等进程内拦截直接拿 LLM payload 的路径(已经是同一数据源)

### 踩过的坑

- **codex(2026-05-25)**:`cmdStop` 末尾 `clearState(sessionId)` 删整个 state 文件,但 codex transcript 是 session 级持久累加的;每次 cmdStop 重建空 state + 全量重读 transcript → 永远从 token 队列**头部** splice → 第二个 turn 起 `usage.input_tokens` / `output_tokens` / `cache_read_tokens` / `total_tokens` 全部固定为 turn 1 的值。修复:`byteOffset` 增量读取 + 利用 codex transcript 自带的 `task_started.turn_id` / `turn_context.turn_id` 做精确分组(`tokenEventsByTurn: Map<turn_id, TokenUsage[]>`),同时用 `last_emitted_usage` 跨 cmdStop 持久化以识别心跳重发事件

---

## 修订流程

宪法本身有变更时,需以独立 PR 提交,且必须在 PR 描述中说明:
- 哪条新增/修订
- 反向影响:已有插件需要如何对齐
- 不兼容变更的迁移路径

新增条款编号顺延(C11 / C12 / ...),不复用历史编号。
新增条款编号顺延(C14 / C15 / ...),不复用历史编号。
38 changes: 37 additions & 1 deletion .specify/templates/otel-plugin/plan-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,50 @@ opentelemetry-instrumentation-<AGENT>/
- `trust.ts` 复刻目标 agent 的 hash 算法
- install 时清理 stale state,写 BEGIN/END marker block

### 2.5 Hook ↔ Transcript 对齐策略(Constitution C14)

**前置**:本节仅适用于**同时**消费 hook 事件流和 transcript 文件的插件;仅消费一种数据源的可跳过。

plan 阶段必须显式回答以下三个问题:

1. **关联键选择**(优先级:turn_id > message_id > byteOffset > 时间窗)
- 调研目标 agent 的 transcript schema:是否带 `turn_id` / `message_id` / `response_id` 等显式 ID?
- 没有 ID → 选 `byteOffset` 增量(transcript 必须单调追加才适用)
- byteOffset 不可用 → 退化到时间窗锚点(最弱,需文档化原因)

2. **跨 hook 边界状态持久化**
- Stop hook 是 per-turn 触发还是 per-session 触发?(查 agent hook 文档)
- per-turn 触发的话,**禁止 cmdStop 末尾 `clearState` 删 state**;改为清空 `events` + 保留水位线字段(`transcript_offset` / `last_consumed_id` / `last_emitted_usage` 等)
- state schema 必须显式声明这些水位线字段(见 codex 插件 `state.ts`:`transcript_offset` + `transcript_last_token_usage`)

3. **fallback 与去重策略**
- 关联键命中失败时的兜底取法是什么?(从扁平队列尾部取 N 条 / 时间窗近邻 / 跳过)
- 同关联键下的重复事件(心跳 / 快照重发)如何识别?

**对齐流程示意**(以 codex 为例):
```
parseTranscript(path, byteOffset, lastUsage)
→ 增量读 [byteOffset, fileSize) 字节
→ 按 task_started/turn_context 维护 currentTurnId
→ token_count 事件归类到 tokenEventsByTurn.get(currentTurnId)
→ 跨 cmdStop 心跳去重(用上次 lastEmittedUsage 比对)
→ 返回 { tokenEventsByTurn, nextOffset, lastEmittedUsage }

cmdStop:
→ 取 turn 的 token = tokenEventsByTurn.get(turn.turn_id)
→ 持久化 nextOffset / lastEmittedUsage 到 state
→ 清空 events,但保留 state 文件
```

---

## 3. 测试策略

### 3.1 单元测试(`tests/unit/`)
- transcript.ts:覆盖 token / system / tool 解析的所有分支
- transcript.ts:覆盖 token / system / tool 解析的所有分支;含 byteOffset 增量读取场景(三次连续读取分别返回各 turn 数据)
- replay.ts:turn split / step build / message 构造
- trust.ts(若适用):hash 算法对照官方实现
- cli.ts 多 turn 对齐(若适用 C14):模拟 N 次连续 cmdStop,断言每 turn token 字段 1:1 对齐 fixture,且 state 文件未被删除

### 3.2 E2E(`tests/e2e/`)
- 用 InMemorySpanExporter + mock SessionState + mock TranscriptData
Expand Down
68 changes: 66 additions & 2 deletions .specify/templates/otel-plugin/verification-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
**常见失败点**:
- transcript 解析对边界 case(空文件 / 损坏 JSON 行)处理不当
- replay 的 turn split 算法对单 turn / 多 turn / 含 tool calls / 末尾无 last_assistant_message 等模式覆盖不全
- `alignWithHookEvents` 在历史 transcript 场景(llmEvents 数量 > 实际 turn 内 LLM 调用数)产生 ghost span — 需验证只有最近 N 条被配对,历史事件标记 `_discarded`
- Anthropic provider 下 `input_tokens` 未加 `cache_read + cache_creation`(C11)
- 多 turn session 中 transcript 数据归属错位 — 第二个 turn 起 token 字段固定为 turn 1 的值;根因是 cmdStop 删 state 后 transcript 仍累加,从队列头部取永远拿到旧值(C14)

---

Expand All @@ -51,7 +54,7 @@
4. 调 replaySession → forceFlush
5. `exporter.getFinishedSpans()` → 断言

**6 项必须断言**:
**9 项必须断言**:

| # | 断言 | 对应 Constitution |
|---|---|---|
Expand All @@ -62,10 +65,71 @@
| 5 | `gen_ai.tool.definitions` 同上,parsed function 项 name 与 mock 一致 | C3 |
| **6** | **每个 LLM/TOOL/STEP/AGENT/ENTRY span 的 `endTime - startTime > 0`,且与 mock 事件时间差对应**(防止 hardcoded `endMs = startMs + 1` 类 bug) | **C2** |

**通过条件**:6/6 PASS。
| **7** | **JSONL 每条 record 含非 null 的 `trace_id`(32位hex)、`span_id`(16位hex)、`parent_span_id`(16位hex);同一 turn 内 trace_id 一致** | **C12** |
| **8** | **含 cache token 的 LLM 事件,`usage.input_tokens` = api_input + cache_read + cache_creation(不是仅 api_input)** | **C11** |
| **9** | **多 turn 场景:同一 session 连续 N≥3 个 turn,每个 turn 的 LLM span `gen_ai.usage.input_tokens` / `output_tokens` 与 transcript 真实值 1:1 对齐(各值不同,不固定)** | **C14** |

**通过条件**:9/9 PASS。

> ⚠️ **加 V4.6 的背景**:首例实施 `100-instrumentation-qodercli` 在 V5 PASS 后才被用户发现 LLM span 全是 1ms duration,根因是 `replay.ts:renderLlm` 把 `endMs = startMs + 1` 硬编码(误以为每个 LLM event 只有一个时间戳)。如果 V4 当时就检查了 duration 就能在 e2e 阶段抓到。现已固化为模板要求。

> ⚠️ **加 V4.7 的背景**:`opentelemetry-instrumentation-claude` 早期 logOnly 模式所有 record 的 `trace_id` 为 null,非 logOnly 模式完全缺少 `span_id`/`parent_span_id` 字段,导致 JSONL 数据无法与 trace 关联。现固化为模板要求。

> ⚠️ **加 V4.8 的背景**:`opentelemetry-instrumentation-claude` 直接使用 Anthropic API 的 `usage.input_tokens`(仅非缓存部分),实际上报值比真实值低 10-100 倍。Anthropic 开启 prompt caching 时必须累加全部 token 类别。

> ⚠️ **加 V4.9 的背景**:`opentelemetry-instrumentation-codex` 在 cmdStop 末尾 `clearState` 删整个 state 文件,但 codex transcript 是 session 级持久累加的;每次 cmdStop 全量重读 transcript + 从队列头部 splice,导致同一 session 内**第二个 turn 起**的 LLM span token 字段全部固定为 turn 1 的值。修复后必须靠多 turn 回归测试持续锁定该行为(参考 codex 插件的 `test/cli.test.ts`)。

### V4.7 详细测试方法 — JSONL span_id 完整性

**logOnly 模式测试**:
1. 配置仅 `log_enabled=true`,无 `otlp_endpoint`
2. 构造含 ≥2 个 LLM 调用 + ≥1 个 tool 调用的 mock transcript
3. 调用 `exportSessionTrace()` 产生 JSONL 文件
4. 逐行解析,断言每条 record:
- `trace_id`: 非 null,`/^[0-9a-f]{32}$/`
- `span_id`: 非 null,`/^[0-9a-f]{16}$/`
- `parent_span_id`: 非 null,`/^[0-9a-f]{16}$/`
5. 同一 turn 内所有 record 共享相同 `trace_id`
6. LLM record 的 `parent_span_id` 指向 STEP span
7. TOOL record 的 `parent_span_id` 指向 STEP span

**非 logOnly 模式追加验证**:
1. 配置 `otlp_endpoint`(InMemoryExporter) + `log_enabled=true`
2. 执行后对比:JSONL 中的 `span_id` 与 `InMemoryExporter.getFinishedSpans()` 导出的 span 的 `spanContext().spanId` 一致
3. `trace_id` 与导出的 traceId 一致

### V4.8 详细测试方法 — input_tokens 全量值(Anthropic provider)

1. 构造含 cache token 的 mock LLM 事件:
```js
{ input_tokens: 200, cache_read_input_tokens: 15000, cache_creation_input_tokens: 3000 }
```
2. 调用 `exportSessionTrace()` 产生 JSONL
3. 解析 `event.name == "llm.response"` 的 record,断言:
- `usage.input_tokens` = 18200(不是 200)
- `usage.total_tokens` = 18200 + output_tokens
4. 对 OTel span 属性同样验证:
- `gen_ai.usage.input_tokens` = 18200

### V4.9 详细测试方法 — 多 turn 数据对齐(C14)

**前提**:本插件**同时**消费 hook 事件流 + transcript 文件(仅消费一种数据源的插件可跳过此项)。

**单元层(必做)**:
1. 准备一份 fixture transcript,含 N≥3 个 turn,每个 turn 的 LLM token 字段**互不相同**(避免误报通过)。fixture 必须含真实场景里出现过的"心跳/快照"重发事件(同一 last_token_usage 在 turn 间重发一次),以验证去重
2. 模拟 N 次连续 cmdStop 调用(每次喂 stdin = 当前 turn 的 user_prompt_submit + stop event,transcript 文件按 turn 边界递进追加),断言:
- 每个 turn 写出的 JSONL 中 `usage.input_tokens` / `output_tokens` / `cache_read_tokens` / `total_tokens` **与 fixture 真实值 1:1 对齐**
- state 文件**未被 clearState 删除**(`fs.existsSync(stateFile) === true`)
- state.events 长度为 0(已消费,但其他对齐水位线字段保留)
- 跨调用持久化字段(如 `transcript_offset` / `last_emitted_usage`)随调用单调推进

**E2E 层(强烈推荐)**:
3. 用 `InMemorySpanExporter` 把 N 次 cmdStop 产生的 spans 全部捕获,断言:
- 每个 turn 的 LLM span `gen_ai.usage.input_tokens` 与 fixture 真实值对齐
- AGENT span 的汇总 token 等于本 turn 内各 LLM step 的 token 之和

**反例锁定**:测试断言**必须**包含"turn 2 / turn 3 的 token != turn 1 的 token"——这是直接锁定本类回归的核心断言;只断言"等于真实值"在 fixture 三个 turn 都恰巧相同的情况下也会过,无法防止退化。

---

## V5. 真实 ARMS Trace 验证
Expand Down
Loading