From ece59cbe3bb27b0101c16eadcc8723cf738a9c70 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 24 Apr 2026 17:02:03 +0800 Subject: [PATCH 01/23] feat(claude): per-turn independent traces + STEP = LLM reasoning cycle - Each conversation turn now produces an independent trace (new traceId), instead of cumulative session-wide traces. All turns share gen_ai.session.id. - STEP spans now map to LLM reasoning cycles (one llm_call + resulting tool calls), not conversation turn boundaries, matching ARMS semantic conventions. - gen_ai.session.id is set on ALL spans (ENTRY, AGENT, STEP, LLM, TOOL), not just ENTRY. - Orphaned pre_tool_use events (where Claude Code drops PostToolUse hooks) now produce TOOL spans with tool.orphaned=true, preventing missing tools in traces. - Events are cleared after successful export in cmdStop(), fixing duplicate trace generation when Stop hook fires per-turn. - Add message-converter module for Anthropic/OpenAI/Responses protocol conversion to ARMS semantic format. - Update README trace hierarchy diagram and export flow description. Co-Authored-By: Claude Opus 4.6 --- .../README.md | 237 +++++- .../package-lock.json | 26 + .../package.json | 5 + .../scripts/setup-alias.sh | 2 +- .../src/cli.js | 801 +++++++++--------- .../src/hooks.js | 45 + .../src/message-converter.js | 321 +++++++ .../test/cli.test.js | 374 +++++--- .../test/message-converter.test.js | 366 ++++++++ opentelemetry-util-genai/package.json | 12 +- opentelemetry-util-genai/tsconfig.cjs.json | 11 + 11 files changed, 1604 insertions(+), 596 deletions(-) create mode 100644 opentelemetry-instrumentation-claude/src/message-converter.js create mode 100644 opentelemetry-instrumentation-claude/test/message-converter.test.js create mode 100644 opentelemetry-util-genai/tsconfig.cjs.json diff --git a/opentelemetry-instrumentation-claude/README.md b/opentelemetry-instrumentation-claude/README.md index 866c15a..1a0bb8e 100644 --- a/opentelemetry-instrumentation-claude/README.md +++ b/opentelemetry-instrumentation-claude/README.md @@ -2,20 +2,25 @@ 为 Claude Code 提供 OpenTelemetry 追踪能力,通过 Hook 机制自动采集 session 级别的 trace,并通过 `intercept.js` 捕获每次 LLM API 调用的 token 用量和消息内容。 +Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/arms_docs/trace/gen-ai.md),使用 `@loongsuite/opentelemetry-util-genai` SDK 的 `ExtendedTelemetryHandler` 生成标准化 Span。 + --- -## ✨ 特性 +## 特性 +- **ARMS 语义规范兼容**:Span 层级遵循 ENTRY → AGENT → STEP → TOOL/LLM 标准结构,属性名、消息格式完全符合 ARMS GenAI Trace 规范 - **Hook 驱动**:利用 Claude Code 的 `settings.json` hook 机制(`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop` 等),无需修改任何业务代码 - **LLM 调用级追踪**:`intercept.js` 在进程内拦截 HTTP 请求,记录 Anthropic / OpenAI API 的 token 用量、输入输出消息,写入 JSONL 日志 +- **标准化消息格式**:输入/输出消息自动转换为 ARMS JSON Schema 格式(`InputMessage`、`OutputMessage`、`SystemInstruction`),支持 Anthropic、OpenAI Chat、OpenAI Responses 三种协议 - **嵌套 Subagent 支持**:完整的父→子 Span 层级,适用于多 Agent 协作场景 +- **语义方言支持**:自动检测 Sunfire 端点,切换 `gen_ai.span_kind_name`(ALIBABA_GROUP)/ `gen_ai.span.kind`(默认)属性名 - **原子状态写入**:基于 `rename` 的原子文件写入,防止并发 hook 进程读取到半写文件 - **自动 alias 注入**:安装后 `claude` 命令自动携带 `NODE_OPTIONS=--require intercept.js`,无需手动配置 - **一键安装**:`npm install -g` 后 postinstall 自动完成全部配置,或 `bash scripts/install.sh` 源码安装 --- -## 📦 环境要求 +## 环境要求 | 依赖 | 版本 | |------|------| @@ -24,7 +29,7 @@ --- -## ⚡ 快速安装(一行命令) +## 快速安装(一行命令) ```bash curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/remote-install.sh | bash -s -- \ @@ -57,7 +62,7 @@ curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttr --- -## 🚀 安装方法 +## 安装方法 ### 方式一:npm 全局安装(推荐) @@ -85,7 +90,7 @@ bash scripts/install.sh --- -## ⚙️ 配置说明 +## 配置说明 所有配置通过**环境变量**完成,无需配置文件。 @@ -96,8 +101,11 @@ bash scripts/install.sh | `OTEL_SERVICE_NAME` | Trace 中的 service name | `claude-agents` | | `OTEL_RESOURCE_ATTRIBUTES` | 附加资源属性,如 `env=prod,team=infra` | — | | `CLAUDE_TELEMETRY_DEBUG` | 设为 `1` 启用 Console 输出(调试用,无需后端) | — | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | 设为 `gen_ai_latest_experimental` 启用 GenAI 语义规范实验特性(消息内容捕获的前置条件) | —(alias 已自动设置) | +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | 消息内容捕获模式:`SPAN_ONLY`(写入 Span 属性)、`EVENT_ONLY`(作为 Event 发出)、`SPAN_AND_EVENT`(两者都写)、`NO_CONTENT`(不捕获) | `SPAN_ONLY`(alias 已自动设置) | | `OTEL_CLAUDE_HOOK_CMD` | 自定义 hook 命令名称 | `otel-claude-hook` | | `OTEL_CLAUDE_LANG` | 强制指定语言(`zh` 或 `en`),不设则自动检测 `$LANGUAGE`、`$LC_ALL`、`$LANG` | 自动检测 | +| `LOONGSUITE_SEMCONV_DIALECT_NAME` | 语义规范方言:`ALIBABA_GROUP` 使用 `gen_ai.span_kind_name`,默认使用 `gen_ai.span.kind` | 自动检测 | ### 示例:接入 Honeycomb @@ -115,7 +123,7 @@ export CLAUDE_TELEMETRY_DEBUG=1 --- -## 📖 使用方法 +## 使用方法 ### 快速开始 @@ -138,13 +146,13 @@ claude "帮我写一个 Python hello world" 安装后,`~/.bashrc` 中会新增一行: ```bash -alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 NODE_OPTIONS="--require $HOME/.cache/opentelemetry.instrumentation.claude/intercept.js" npx -y @anthropic-ai/claude-code@latest' +alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require $HOME/.cache/opentelemetry.instrumentation.claude/intercept.js" npx -y @anthropic-ai/claude-code@latest' ``` 这意味着: - 每次执行 `claude` 命令,`intercept.js` 会在进程启动时自动加载 - `intercept.js` 拦截 Anthropic/OpenAI HTTP 请求,记录 token 用量和消息内容 -- 这些数据会在 session 结束时(`stop` hook)合并进 OTel trace +- 这些数据会在每轮对话结束时(`stop` hook)合并进 OTel trace,每轮生成独立的 trace ### 验证安装 @@ -161,34 +169,66 @@ cat ~/.claude/settings.json --- -## 🌲 Trace 层级结构 +## Trace 层级结构(ARMS 语义规范) -一次 Claude session 会生成如下树状 Span 结构: +每轮对话(turn)生成一个独立的 trace,同一 session 的所有 turn 共享 `gen_ai.session.id`: ``` -🤖 (claude.session 根 Span) -├── 👤 Turn 1: <用户输入> -│ ├── 🔧 Bash: ls -la /tmp -│ ├── 🔧 Read: /path/to/file.py -│ └── 🧠 LLM call (claude-sonnet-4-5) ← intercept.js 捕获 -├── 👤 Turn 2: <下一轮输入> -│ └── 🔧 Write: /path/to/output.py -├── 🧠 LLM call (claude-sonnet-4-5) ← intercept.js 捕获 -├── 🗜️ Context compaction ← PreCompact hook -├── 🔔 Notification: 任务完成 ← Notification hook -└── 🤖 Subagent: <子任务描述> ← SubagentStop hook - ├── 🔧 Bash: pytest tests/ - └── 🧠 LLM call (claude-haiku-4-5) +Session (gen_ai.session.id = "abc-123") +├── Turn 1 (traceId = A) +│ ENTRY: enter_ai_application_system ← gen_ai.span.kind=ENTRY +│ └── AGENT: invoke_agent claude-code ← gen_ai.span.kind=AGENT +│ ├── STEP: react step (round=1) ← gen_ai.span.kind=STEP +│ │ ├── LLM: chat claude-sonnet-4-5 ← gen_ai.span.kind=LLM (finish=tool_use) +│ │ ├── TOOL: execute_tool Bash ← gen_ai.span.kind=TOOL +│ │ └── TOOL: execute_tool Read ← gen_ai.span.kind=TOOL +│ ├── STEP: react step (round=2) +│ │ ├── LLM: chat claude-sonnet-4-5 ← (finish=tool_use) +│ │ └── TOOL: execute_tool Write +│ └── STEP: react step (round=3) +│ └── LLM: chat claude-sonnet-4-5 ← (finish=stop) +├── Turn 2 (traceId = B) +│ ENTRY → AGENT → +│ ├── STEP (round=1): LLM + TOOL(Agent) +│ │ └── AGENT: invoke_agent Explore ← 子 Agent +│ └── STEP (round=2): LLM (finish=stop) +└── Turn 3 (traceId = C) + ENTRY → AGENT → STEP (round=1): LLM (finish=stop) ``` -每个 Span 上会携带: -- **session Span**:`session_id`、`gen_ai.usage.input_tokens`、`gen_ai.usage.output_tokens`、`turns`、`tools_used` -- **tool Span**:`gen_ai.tool.name`、`gen_ai.tool.call.arguments`、`gen_ai.tool.call.result`、`input.*`、`response.*` -- **LLM call Span**:`gen_ai.request.model`、`gen_ai.usage.input_tokens`、`gen_ai.input.messages`、`gen_ai.output.messages` +STEP = 一次 LLM 推理周期 + 由该推理触发的工具调用(0 或多个)。 + +### 各层级 Span 属性 + +所有 Span 均携带 `gen_ai.session.id`(公共属性)。 + +| Span 类型 | 关键属性 | +|-----------|---------| +| **ENTRY** | `gen_ai.session.id`, `gen_ai.operation.name=enter_ai_application_system` | +| **AGENT** | `gen_ai.agent.name`, `gen_ai.provider.name`, `gen_ai.conversation.id`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reasons` | +| **STEP** | `gen_ai.react.round`, `gen_ai.operation.name=react_step` | +| **TOOL** | `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result` | +| **LLM** | `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read.input_tokens`, `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions` | + +### 消息格式 + +输入/输出消息遵循 ARMS JSON Schema 定义: + +**InputMessage**: `{ role, parts: [TextPart | ToolCallPart | ToolCallResponsePart | ReasoningPart] }` + +**OutputMessage**: `{ role, parts: [...], finishReason }` + +**SystemInstruction**: `[{ type: "text", content: "..." }]` + +支持的消息类型: +- `TextPart`: `{ type: "text", content: "..." }` +- `ToolCallPart`: `{ type: "tool_call", id, name, arguments }` +- `ToolCallResponsePart`: `{ type: "tool_call_response", id, response }` +- `ReasoningPart`: `{ type: "reasoning", content: "..." }` --- -## 🖥️ CLI 命令参考 +## CLI 命令参考 ```bash # 安装管理 @@ -213,31 +253,39 @@ otel-claude-hook notification # Notification hook --- -## 📁 项目结构 +## 项目结构 ``` opentelemetry-instrumentation-claude/ -├── package.json # 包描述,name: @agenttrack/opentelemetry-instrumentation-claude -├── README.md # 本文档 -├── scripts/install.sh # 源码安装脚本(bash scripts/install.sh) -└── scripts/remote-install.sh # 远程一键安装脚本(curl | bash) +├── package.json +├── README.md ├── bin/ -│ └── otel-claude-hook # CLI 入口(#!/usr/bin/env node,commander 驱动) +│ └── otel-claude-hook # CLI 入口(#!/usr/bin/env node,commander 驱动) ├── src/ -│ ├── index.js # 包入口,导出核心 API -│ ├── cli.js # 全部 hook 命令实现 + replayEventsAsSpans + exportSessionTrace -│ ├── state.js # session 状态文件读写(原子写入,格式与 Python 版兼容) -│ ├── telemetry.js # OTel TracerProvider 配置(OTLP/HTTP + Console) -│ ├── hooks.js # 工具格式化函数(createToolTitle、createEventData 等) -│ └── intercept.js # HTTP 拦截器(从 Python 包复制,支持 Node.js + Bun) -└── scripts/ - ├── setup-alias.sh # 向 .bashrc/.zshrc 添加 claude alias - └── uninstall.sh # 卸载脚本 +│ ├── index.js # 包入口,导出核心 API +│ ├── cli.js # hook 命令实现 + replayEventsAsSpans + exportSessionTrace +│ ├── message-converter.js # LLM 消息格式转换(Anthropic/OpenAI → ARMS 语义规范) +│ ├── state.js # session 状态文件读写(原子写入) +│ ├── telemetry.js # OTel TracerProvider 配置(OTLP/HTTP + Console) +│ ├── hooks.js # 工具格式化函数 + extractToolResult/extractToolError +│ └── intercept.js # HTTP 拦截器(支持 Node.js + Bun) +├── scripts/ +│ ├── install.sh # 源码安装脚本 +│ ├── remote-install.sh # 远程一键安装脚本 +│ ├── setup-alias.sh # 向 .bashrc/.zshrc 添加 claude alias +│ └── uninstall.sh # 卸载脚本 +└── test/ + ├── cli.test.js # CLI + replayEventsAsSpans + exportSessionTrace 测试 + ├── message-converter.test.js # 消息格式转换测试(3 协议 × 多场景) + ├── hooks.test.js # hooks 工具函数测试 + ├── state.test.js # 状态文件读写测试 + ├── intercept.test.js # HTTP 拦截器测试 + └── telemetry.test.js # TracerProvider 配置测试 ``` --- -## 🔧 工作原理 +## 工作原理 1. **hook 命令注册**:`otel-claude-hook install` 将 8 个 hook 命令写入 `~/.claude/settings.json`。Claude Code 在每个生命周期事件时以子进程方式调用对应命令,并将事件 JSON 通过 stdin 传入。 @@ -255,13 +303,108 @@ opentelemetry-instrumentation-claude/ 拦截到的 LLM 调用写入 JSONL 文件: ``` - ~/.cache/opentelemetry.instrumentation.claude/sessions/proxy_events_.jsonl + ~/.cache/opentelemetry.instrumentation.claude/sessions/proxy_events_.jsonl ``` -4. **trace 导出**:`stop` hook 触发时,读取全部 session 事件 + intercept.js JSONL 日志,时间轴合并后按父子关系构建 OTel Span 树,导出到配置的 OTLP 后端,然后执行 `forceFlush` + `shutdown` 确保数据发送完毕。 +4. **消息格式转换**:`message-converter.js` 将 intercept.js 捕获的原始 LLM 请求/响应数据转换为 ARMS 语义规范格式: + - Anthropic API(`content blocks`)→ `InputMessage` / `OutputMessage` + - OpenAI Chat API(`tool_calls` / `role:tool`)→ `InputMessage` / `OutputMessage` + - OpenAI Responses API(`function_call_output`)→ `InputMessage` / `OutputMessage` + +5. **trace 导出**:`stop` hook 在每轮对话结束时触发,`exportSessionTrace` 通过 `ExtendedTelemetryHandler` SDK 构建标准化 Span 树: + - 按 `user_prompt_submit` 事件将累积事件拆分为独立 turn + - 每个 turn 创建独立的 ENTRY → AGENT Span 层级(新 traceId),共享 `gen_ai.session.id` + - 每次 `llm_call` 事件开启新的 STEP Span,后续 TOOL Span 挂在该 STEP 下 + - 嵌套 Subagent 递归处理子事件流 + - 导出成功后清空已导出事件,避免下轮重复导出 + - 执行 `forceFlush` + `shutdown` 确保数据发送完毕 + +--- + +## 本地开发与测试 + +### 前置准备 + +```bash +# 进入 monorepo 根目录 +cd loongsuite-js-plugins + +# 先构建 SDK 依赖(包含 CJS 产物) +cd opentelemetry-util-genai +npm install +npm run build +cd .. + +# 安装插件依赖 +cd opentelemetry-instrumentation-claude +npm install +``` + +### 运行测试 + +```bash +# 运行全部测试(含覆盖率) +# 注意:如果环境中有 NODE_OPTIONS="--require intercept.js",需要清除 +NODE_OPTIONS="" npm test + +# 仅运行某个测试文件 +NODE_OPTIONS="" npx jest test/cli.test.js --no-coverage + +# 监听模式(开发时实时运行) +NODE_OPTIONS="" npx jest --watch +``` + +### 本地端到端测试 + +1. **Debug 模式(Console 输出,无需后端)** + +```bash +# 安装 hooks 到 ~/.claude/settings.json +node bin/otel-claude-hook install --user + +# 设置 debug 模式,trace 输出到 stderr +export CLAUDE_TELEMETRY_DEBUG=1 + +# 启动 claude,intercept.js 自动加载 +source ~/.bashrc +claude "hello" + +# session 结束后,终端会输出完整的 span 数据 +``` + +2. **本地 OTLP 后端(如 Jaeger)** + +```bash +# 启动 Jaeger all-in-one(Docker) +docker run -d --name jaeger \ + -p 16686:16686 \ + -p 4318:4318 \ + jaegertracing/all-in-one:latest + +# 配置 OTLP 端点 +export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" +export OTEL_SERVICE_NAME="claude-agents-dev" + +# 使用 claude +source ~/.bashrc +claude "帮我列出当前目录的文件" + +# 打开 Jaeger UI 查看 trace +open http://localhost:16686 +``` + +3. **验证 Span 层级** + +在 Jaeger UI 或 debug 输出中确认: +- 根 Span 为 `enter_ai_application_system`(ENTRY) +- 下一层为 `invoke_agent claude-code`(AGENT) +- 每轮对话为 `react step`(STEP),带 `gen_ai.react.round` 属性 +- 工具调用为 `execute_tool `(TOOL) +- LLM 调用为 `chat `(LLM),带完整的 `gen_ai.input.messages` / `gen_ai.output.messages` +- 子 Agent 为嵌套的 `invoke_agent `(AGENT) --- -## 📝 License +## License Apache-2.0 diff --git a/opentelemetry-instrumentation-claude/package-lock.json b/opentelemetry-instrumentation-claude/package-lock.json index 1d1ad39..37243ae 100644 --- a/opentelemetry-instrumentation-claude/package-lock.json +++ b/opentelemetry-instrumentation-claude/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@loongsuite/opentelemetry-util-genai": "^0.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", @@ -27,6 +28,27 @@ "node": ">=18.0.0" } }, + "../opentelemetry-util-genai": { + "name": "@loongsuite/opentelemetry-util-genai", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.1.3", + "typescript": "^5.0.0", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -912,6 +934,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loongsuite/opentelemetry-util-genai": { + "resolved": "../opentelemetry-util-genai", + "link": true + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", diff --git a/opentelemetry-instrumentation-claude/package.json b/opentelemetry-instrumentation-claude/package.json index 8cfe11f..5eb42fa 100644 --- a/opentelemetry-instrumentation-claude/package.json +++ b/opentelemetry-instrumentation-claude/package.json @@ -54,6 +54,10 @@ "lines": 45, "branches": 35 }, + "./src/message-converter.js": { + "lines": 70, + "branches": 50 + }, "./src/state.js": { "lines": 80 }, @@ -63,6 +67,7 @@ } }, "dependencies": { + "@loongsuite/opentelemetry-util-genai": "^0.1.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", diff --git a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh index b8d0770..9d48db6 100755 --- a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh +++ b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh @@ -11,7 +11,7 @@ INTERCEPT_PATH="$HOME/.cache/opentelemetry.instrumentation.claude/intercept.js" # If claude_identity is set at invocation time, it takes priority over OTEL_SERVICE_NAME. # The alias uses a shell substitution so the check happens each time claude is run, # not at install time — no code changes required. -ALIAS_LINE="alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SERVICE_NAME=\"\${claude_identity:-\${OTEL_SERVICE_NAME:-}}\" NODE_OPTIONS=\"--require $INTERCEPT_PATH\" npx -y @anthropic-ai/claude-code@2.1.112'" +ALIAS_LINE="alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY OTEL_SERVICE_NAME=\"\${claude_identity:-\${OTEL_SERVICE_NAME:-}}\" NODE_OPTIONS=\"--require $INTERCEPT_PATH\" npx -y @anthropic-ai/claude-code@2.1.112'" # --------------------------------------------------------------------------- # 语言检测 / Language detection diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 6bb23ad..fcb0086 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -11,10 +11,20 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); + const { trace, context } = require("@opentelemetry/api"); const { loadState, saveState, clearState, readAndDeleteChildState, STATE_DIR } = require("./state"); const { configureTelemetry, shutdownTelemetry } = require("./telemetry"); -const { createToolTitle, createEventData, addResponseToEventData, MAX_CONTENT_LENGTH } = require("./hooks"); +const { createToolTitle, createEventData, addResponseToEventData, extractToolResult, extractToolError, MAX_CONTENT_LENGTH } = require("./hooks"); +const { + ExtendedTelemetryHandler, + createEntryInvocation, createInvokeAgentInvocation, + createReactStepInvocation, createExecuteToolInvocation, createLLMInvocation, +} = require("@loongsuite/opentelemetry-util-genai"); +const { + convertSystemPrompt, convertInputMessages, convertOutputMessages, + extractRequestParams, convertToolDefinitions, +} = require("./message-converter"); // --------------------------------------------------------------------------- // Semantic convention dialect @@ -27,6 +37,7 @@ const SPAN_KIND_ATTR = process.env.LOONGSUITE_SEMCONV_DIALECT_NAME === "ALIBABA_GROUP" || _sunfireDetected ? "gen_ai.span_kind_name" : "gen_ai.span.kind"; +const NEEDS_DIALECT_ATTR = process.env.LOONGSUITE_SEMCONV_DIALECT_NAME === "ALIBABA_GROUP" || _sunfireDetected; // --------------------------------------------------------------------------- // 语言检测 / Language detection @@ -189,45 +200,28 @@ function resolveClaudePid() { if (fs.existsSync(candidate)) return pid; } - // No matching file found — return best guess (grandparent if available) - return candidates[0] || null; + // No matching file — the claude process is likely dead (Stop hook runs + // after exit). Return null so readProxyEvents() falls back to scanning + // ALL proxy files and filtering by the session time window. + debug("resolveClaudePid: no proxy file matched candidates, returning null (time-window fallback)"); + return null; } function readProxyEvents(startTime, stopTime, deleteAfterRead = false, pid = null) { if (!fs.existsSync(PROXY_EVENTS_DIR)) return []; - // Cleanup stale proxy files from dead processes - try { - const allFiles = fs.readdirSync(PROXY_EVENTS_DIR) - .filter(f => f.startsWith("proxy_events_") && f.endsWith(".jsonl")); - for (const f of allFiles) { - const pidStr = f.replace("proxy_events_", "").replace(".jsonl", ""); - const filePid = parseInt(pidStr, 10); - if (isNaN(filePid) || filePid === (pid || 0)) continue; - // Check if the process is still alive (signal 0: just check existence) - try { - process.kill(filePid, 0); - // process exists, skip - } catch { - // process does not exist, safe to delete - try { fs.unlinkSync(path.join(PROXY_EVENTS_DIR, f)); } catch {} - } - } - } catch {} - const bufferedStart = startTime - 5.0; const bufferedStop = stopTime + 5.0; const events = []; // If pid is specified, only read that pid's file (safe for concurrent sessions). - // If pid is null (platform not supported), fall back to time-window scan WITHOUT - // deleting — avoids data loss for other concurrent sessions. + // If pid is null (claude process already dead), fall back to time-window scan + // across ALL files — do NOT delete to avoid data loss for concurrent sessions. let fileNames = fs.readdirSync(PROXY_EVENTS_DIR) .filter((f) => f.startsWith("proxy_events_") && f.endsWith(".jsonl")); if (pid !== null) { fileNames = fileNames.filter((f) => f === `proxy_events_${pid}.jsonl`); } else { - // Unknown PID: read all but do NOT delete (safe fallback) deleteAfterRead = false; } const files = fileNames.map((f) => path.join(PROXY_EVENTS_DIR, f)).sort(); @@ -279,21 +273,58 @@ function tsNs(sec) { } // --------------------------------------------------------------------------- -// _replayEventsAsSpans — full port of the Python function +// _replayEventsAsSpans — replay recorded events into OTel spans using +// the @loongsuite/opentelemetry-util-genai SDK for standardized span creation. +// Conforms to ARMS semantic conventions: ENTRY → AGENT → STEP → TOOL/LLM // --------------------------------------------------------------------------- -function replayEventsAsSpans(tracer, events, parentCtx, stopTime) { - const subagentSpanStack = []; // { agentId, startTs } — open subagent entries waiting to be matched - const openAgentPreSpans = []; // { span, ctx, toolUseId } — Agent pre spans kept open for subagents - const openSubagentsByAgentId = {}; // agentId → { agentId, agentName, startTs, stopTs, stopAttrs, childState } - let currentTurnSpan = null; - let currentTurnCtx = null; - let turnIdx = 0; +function dialectAttrs(spanKindValue) { + return NEEDS_DIALECT_ATTR ? { "gen_ai.span_kind_name": spanKindValue } : {}; +} + +function sessionAttrs(sessionId, spanKindValue) { + return { + ...dialectAttrs(spanKindValue), + "gen_ai.session.id": sessionId, + }; +} + +function splitEventsByTurn(events) { + const turns = []; + let current = null; + + for (const ev of events) { + if (ev.type === "user_prompt_submit") { + if (current) { + current.endTime = ev.timestamp || current.startTime; + } + current = { + prompt: ev.prompt || "", + startTime: ev.timestamp || Date.now() / 1000, + endTime: null, + events: [], + }; + turns.push(current); + } else if (current) { + current.events.push(ev); + } + } + + return turns; +} + +function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessionId) { + const subagentSpanStack = []; + const openAgentToolInvs = []; + const openSubagentsByAgentId = {}; + let currentStepInv = null; + let stepRound = 0; + let prevStepEndTime = stopTime; + + const sAttrs = (kind) => sessionId ? sessionAttrs(sessionId, kind) : dialectAttrs(kind); - function parentContext(eventTs, endTs) { + function resolveParentContext(eventTs, endTs) { if (eventTs !== undefined) { - // Find subagent time windows containing this event. - // If endTs is provided, both start and end must be within the window. const active = []; for (const [agentId, win] of Object.entries(subagentWindowMap)) { const startInWindow = eventTs >= win.startTs && eventTs <= win.stopTs; @@ -305,7 +336,6 @@ function replayEventsAsSpans(tracer, events, parentCtx, stopTime) { } if (active.length === 1) return active[0].ctx; if (active.length > 1) { - // Concurrent subagents: pick window whose center is closest to midpoint of the span const midPoint = endTs !== undefined ? (eventTs + endTs) / 2 : eventTs; active.sort((a, b) => { const centerA = (a.startTs + a.stopTs) / 2; @@ -315,157 +345,166 @@ function replayEventsAsSpans(tracer, events, parentCtx, stopTime) { return active[0].ctx; } } - if (currentTurnCtx) return currentTurnCtx; + if (currentStepInv && currentStepInv.contextToken) return currentStepInv.contextToken; return parentCtx; } - // Pre-scan 1: build preToolUseMap for post_tool_use to look up matching pre + // Pre-scan 1: build preToolUseMap and compute orphan end times const preToolUseMap = {}; - for (const ev of events) { + const consumedToolUseIds = new Set(); + const orphanContextMap = {}; + const orphanEndTimeMap = {}; + for (let i = 0; i < events.length; i++) { + const ev = events[i]; if (ev.type === "pre_tool_use" && ev.tool_use_id) { preToolUseMap[ev.tool_use_id] = ev; + for (let j = i + 1; j < events.length; j++) { + if (events[j].timestamp) { + orphanEndTimeMap[ev.tool_use_id] = events[j].timestamp; + break; + } + } } } - // Pre-scan 2: compute [startTs, stopTs] for each subagent (FIFO match start→stop) - const subagentWindowMap = {}; // agentId → { startTs, stopTs } + // Pre-scan 2: compute subagent time windows + const subagentWindowMap = {}; { - const pendingStarts = []; // { agentId, startTs } + const pendingStarts = []; for (const ev of events) { if (ev.type === "subagent_start" && ev.agent_id) { pendingStarts.push({ agentId: ev.agent_id, startTs: ev.timestamp || 0 }); } else if (ev.type === "subagent_stop" && pendingStarts.length > 0) { - const entry = pendingStarts.shift(); // FIFO + const entry = pendingStarts.shift(); subagentWindowMap[entry.agentId] = { startTs: entry.startTs, stopTs: ev.timestamp || stopTime }; } } - // Remaining starts without stops for (const entry of pendingStarts) { subagentWindowMap[entry.agentId] = { startTs: entry.startTs, stopTs: stopTime }; } } - // Map agentId → { span, ctx } for time-window parent selection const openSubagentCtxByAgentId = {}; for (const ev of events) { const evType = ev.type || ""; const evTs = ev.timestamp || stopTime; - if (evType === "user_prompt_submit") { - if (currentTurnSpan !== null) { - currentTurnSpan.end(hrTime(evTs)); - currentTurnSpan = null; - currentTurnCtx = null; + if (evType === "llm_call") { + // STEP = one LLM reasoning cycle + resulting tool calls + // Close previous STEP before starting a new one + if (currentStepInv) { + handler.stopReactStep(currentStepInv, hrTime(prevStepEndTime)); + currentStepInv = null; } - turnIdx++; - const p = ev.prompt || ""; - const preview = p.length > 50 ? p.slice(0, 50) + "..." : p; - const label = preview ? `👤 Turn ${turnIdx}: ${preview}` : `👤 Turn ${turnIdx}`; - currentTurnSpan = tracer.startSpan( - label, - { - startTime: hrTime(evTs), - attributes: { - "turn.index": turnIdx, - "gen_ai.input.messages": p, - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "STEP", - }, - }, - parentCtx - ); - currentTurnCtx = trace.setSpan(context.active(), currentTurnSpan); + const model = ev.model || "unknown"; + const requestStart = ev.request_start_time || evTs; + const protocol = ev.protocol || "anthropic"; + + stepRound++; + currentStepInv = createReactStepInvocation({ + round: stepRound, + attributes: sAttrs("STEP"), + }); + handler.startReactStep(currentStepInv, parentCtx, hrTime(requestStart)); + + const reqParams = extractRequestParams(ev.request_body); + const llmInv = createLLMInvocation({ + operationName: "chat", + requestModel: model, + responseModelName: model, + provider: "anthropic", + responseId: ev.response_id || null, + inputTokens: ev.input_tokens || 0, + outputTokens: ev.output_tokens || 0, + usageCacheReadInputTokens: ev.cache_read_input_tokens || 0, + usageCacheCreationInputTokens: ev.cache_creation_input_tokens || 0, + finishReasons: ev.stop_reason ? [ev.stop_reason] : null, + inputMessages: convertInputMessages(ev.input_messages, protocol), + outputMessages: convertOutputMessages(ev.output_content, ev.stop_reason), + systemInstruction: convertSystemPrompt(ev.system_prompt, protocol), + ...reqParams, + attributes: sAttrs("LLM"), + }); + + if (ev.request_body && ev.request_body.tools) { + llmInv.toolDefinitions = convertToolDefinitions(ev.request_body.tools); + } + + handler.startLlm(llmInv, currentStepInv.contextToken, hrTime(requestStart)); + + if (ev.is_error) { + handler.failLlm(llmInv, { + message: ev.error_message || "unknown error", + type: "LLMError", + }, hrTime(evTs)); + } else { + handler.stopLlm(llmInv, hrTime(evTs)); + } + prevStepEndTime = evTs; } else if (evType === "pre_tool_use") { const toolName = ev.tool_name || "unknown"; const toolInput = ev.tool_input || {}; const toolUseId = ev.tool_use_id || ""; + if (toolUseId && toolName !== "Agent" && toolName !== "agent") { + orphanContextMap[toolUseId] = currentStepInv?.contextToken || parentCtx; + } + if (toolName === "Agent" || toolName === "agent") { - // Agent tool: create span immediately, keep open so subagents nest under it - const toolTitle = createToolTitle(toolName, toolInput); - const eventData = createEventData(toolName, toolInput); - const attrs = { - "gen_ai.tool.name": toolName, - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "TOOL", - }; - for (const [k, v] of Object.entries(eventData)) { - if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { - attrs[k] = v; - } - } - const span = tracer.startSpan( - `🔧 ${toolTitle}`, - { startTime: hrTime(evTs), attributes: attrs }, - parentContext(evTs) - ); - const spanCtx = trace.setSpan(context.active(), span); - openAgentPreSpans.push({ - span, ctx: spanCtx, toolUseId, - subagentType: (toolInput.subagent_type || ""), + const toolInv = createExecuteToolInvocation(toolName, { + toolCallId: toolUseId, + toolCallArguments: toolInput, + attributes: sAttrs("TOOL"), + }); + handler.startExecuteTool(toolInv, resolveParentContext(evTs), hrTime(evTs)); + openAgentToolInvs.push({ + inv: toolInv, + toolUseId, + subagentType: toolInput.subagent_type || "", matched: false, }); - // Do NOT end span — closed in post_tool_use } - // Non-Agent tools: span created at post_tool_use time } else if (evType === "post_tool_use") { const toolUseId = ev.tool_use_id || ""; + if (toolUseId) consumedToolUseIds.add(toolUseId); const toolName = ev.tool_name || "unknown"; const toolResponse = ev.tool_response; const postAgentId = (toolResponse && (toolResponse.agentId || toolResponse.agent_id)) || ""; if (toolName === "Agent" || toolName === "agent") { - const preIdx = openAgentPreSpans.findIndex(e => e.toolUseId === toolUseId); + const preIdx = openAgentToolInvs.findIndex(e => e.toolUseId === toolUseId); if (preIdx !== -1) { - const { span: preSpan, ctx: preCtx } = openAgentPreSpans.splice(preIdx, 1)[0]; - // Add result attrs to the pre span - const eventData = { "gen_ai.tool.name": toolName }; - addResponseToEventData(eventData, toolResponse); - for (const [k, v] of Object.entries(eventData)) { - if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { - preSpan.setAttribute(k, v); - } - } - if (postAgentId) preSpan.setAttribute("agent.agent_id", postAgentId); - // Use the subagent span already created at subagent_start + const { inv: toolInv } = openAgentToolInvs.splice(preIdx, 1)[0]; + toolInv.toolCallResult = extractToolResult(toolResponse); if (postAgentId && openSubagentsByAgentId[postAgentId]) { const subEntry = openSubagentsByAgentId[postAgentId]; - const subSpan = subEntry.span; - if (subSpan) { - for (const [k, v] of Object.entries(subEntry.stopAttrs || {})) { - subSpan.setAttribute(k, v); - } - if (subEntry.childState && Array.isArray(subEntry.childState.events) && subEntry.childState.events.length > 0) { - const subCtx = (openSubagentCtxByAgentId[postAgentId] || {}).ctx || trace.setSpan(context.active(), subSpan); - replayEventsAsSpans(tracer, subEntry.childState.events, subCtx, subEntry.childState.stop_time || evTs); - } - subSpan.end(hrTime(subEntry.stopTs || evTs)); + if (subEntry.childState && Array.isArray(subEntry.childState.events) && subEntry.childState.events.length > 0) { + replayEventsAsSpans(handler, tracer, subEntry.childState.events, + subEntry.agentInv.contextToken, subEntry.childState.stop_time || evTs, sessionId); } + handler.stopInvokeAgent(subEntry.agentInv, hrTime(subEntry.stopTs || evTs)); delete openSubagentsByAgentId[postAgentId]; delete openSubagentCtxByAgentId[postAgentId]; const idx = subagentSpanStack.findIndex(e => e.agentId === postAgentId); if (idx !== -1) subagentSpanStack.splice(idx, 1); } - preSpan.end(hrTime(evTs)); + const toolErr = extractToolError(toolResponse); + if (toolErr) handler.failExecuteTool(toolInv, toolErr, hrTime(evTs)); + else handler.stopExecuteTool(toolInv, hrTime(evTs)); } else { - // No matching real pre span — use the subagent span already created at subagent_start const subEntry = postAgentId ? openSubagentsByAgentId[postAgentId] : null; - if (subEntry && subEntry.span) { - for (const [k, v] of Object.entries(subEntry.stopAttrs || {})) { - subEntry.span.setAttribute(k, v); - } + if (subEntry && subEntry.agentInv) { if (subEntry.childState && Array.isArray(subEntry.childState.events) && subEntry.childState.events.length > 0) { - const subCtx = (openSubagentCtxByAgentId[postAgentId] || {}).ctx || trace.setSpan(context.active(), subEntry.span); - replayEventsAsSpans(tracer, subEntry.childState.events, subCtx, subEntry.childState.stop_time || evTs); + replayEventsAsSpans(handler, tracer, subEntry.childState.events, + subEntry.agentInv.contextToken, subEntry.childState.stop_time || evTs, sessionId); } - subEntry.span.end(hrTime(subEntry.stopTs || evTs)); - // Close synthetic pre span if it was created at subagent_start time - if (subEntry.syntheticPreSpan) { - subEntry.syntheticPreSpan.end(hrTime(evTs)); + handler.stopInvokeAgent(subEntry.agentInv, hrTime(subEntry.stopTs || evTs)); + if (subEntry.syntheticToolInv) { + handler.stopExecuteTool(subEntry.syntheticToolInv, hrTime(evTs)); } delete openSubagentsByAgentId[postAgentId]; delete openSubagentCtxByAgentId[postAgentId]; @@ -474,60 +513,51 @@ function replayEventsAsSpans(tracer, events, parentCtx, stopTime) { } } } else { - // Regular (non-Agent) tool: create combined span from pre+post + // Regular tool: create combined span from pre+post const preEv = preToolUseMap[toolUseId] || {}; const effectiveName = preEv.tool_name || toolName; const effectiveInput = preEv.tool_input || {}; const startTs = preEv.timestamp || evTs; - const toolTitle = createToolTitle(effectiveName, effectiveInput); - const eventData = createEventData(effectiveName, effectiveInput); - addResponseToEventData(eventData, toolResponse); - const attrs = { - "gen_ai.tool.name": effectiveName, - "claude_code.hook.type": "tool_use", - [SPAN_KIND_ATTR]: "TOOL", - }; - for (const [k, v] of Object.entries(eventData)) { - if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") { - attrs[k] = v; - } - } - const toolSpan = tracer.startSpan( - `🔧 ${toolTitle}`, - { startTime: hrTime(startTs), attributes: attrs }, - parentContext(evTs) - ); - toolSpan.end(hrTime(evTs)); + + const toolInv = createExecuteToolInvocation(effectiveName, { + toolCallId: toolUseId, + toolCallArguments: effectiveInput, + toolCallResult: extractToolResult(toolResponse), + attributes: sAttrs("TOOL"), + }); + handler.startExecuteTool(toolInv, resolveParentContext(evTs), hrTime(startTs)); + const toolErr = extractToolError(toolResponse); + if (toolErr) handler.failExecuteTool(toolInv, toolErr, hrTime(evTs)); + else handler.stopExecuteTool(toolInv, hrTime(evTs)); } + prevStepEndTime = evTs; } else if (evType === "pre_compact") { - const span = tracer.startSpan( - "🗜️ Context compaction", - { - startTime: hrTime(evTs), - attributes: { - "compact.trigger": ev.trigger || "unknown", - "compact.has_custom_instructions": !!ev.has_custom_instructions, - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "TASK", - }, + const span = tracer.startSpan("compact", { + startTime: hrTime(evTs), + attributes: { + "gen_ai.operation.name": "compact", + [SPAN_KIND_ATTR]: "TASK", + ...(sessionId ? { "gen_ai.session.id": sessionId } : {}), + "compact.trigger": ev.trigger || "unknown", + "compact.has_custom_instructions": !!ev.has_custom_instructions, }, - parentCtx - ); + }, parentCtx); span.end(hrTime(evTs)); } else if (evType === "notification") { const msg = ev.message || ""; const span = tracer.startSpan( - msg ? `🔔 ${msg.slice(0, 60)}` : "🔔 Notification", + msg ? `notification ${msg.slice(0, 60)}` : "notification", { startTime: hrTime(evTs), attributes: { + "gen_ai.operation.name": "notification", + [SPAN_KIND_ATTR]: "TASK", + ...(sessionId ? { "gen_ai.session.id": sessionId } : {}), "notification.message": msg, "notification.level": ev.level || "info", "notification.title": ev.title || "", - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "TASK", }, }, parentCtx @@ -537,323 +567,255 @@ function replayEventsAsSpans(tracer, events, parentCtx, stopTime) { } else if (evType === "subagent_start") { const agentId = ev.agent_id || ""; const agentName = ev.agent_type || ""; - const agentTag = agentName ? ` [${agentName}]` : ""; - // Find matching real Agent pre span (by subagentType or first unmatched) - let agentPreCtx = currentTurnCtx || parentCtx; - let syntheticPreSpan = null; - const matchedPreIdx = openAgentPreSpans.findIndex(e => + let agentParentCtx = currentStepInv?.contextToken || parentCtx; + let syntheticToolInv = null; + const matchedPreIdx = openAgentToolInvs.findIndex(e => !e.matched && (!e.subagentType || e.subagentType === agentName) ); if (matchedPreIdx !== -1) { - openAgentPreSpans[matchedPreIdx].matched = true; - agentPreCtx = openAgentPreSpans[matchedPreIdx].ctx; + openAgentToolInvs[matchedPreIdx].matched = true; + agentParentCtx = openAgentToolInvs[matchedPreIdx].inv.contextToken; } else { - // No real pre span — create synthetic Agent pre span under Turn - syntheticPreSpan = tracer.startSpan( - `🔧 Agent - ${agentName || "subagent"}`, - { - startTime: hrTime(evTs), - attributes: { - "gen_ai.tool.name": "Agent", - "gen_ai.agent.name": agentName, - "claude_code.hook.type": "pre_tool_use", - [SPAN_KIND_ATTR]: "TOOL", - }, - }, - currentTurnCtx || parentCtx - ); - agentPreCtx = trace.setSpan(context.active(), syntheticPreSpan); + syntheticToolInv = createExecuteToolInvocation("Agent", { + toolCallArguments: { subagent_type: agentName }, + attributes: { ...sAttrs("TOOL"), "gen_ai.agent.name": agentName }, + }); + handler.startExecuteTool(syntheticToolInv, currentStepInv?.contextToken || parentCtx, hrTime(evTs)); + agentParentCtx = syntheticToolInv.contextToken; } - // Create subagent span under agentPreCtx (real or synthetic) - const subSpanForCtx = tracer.startSpan( - `🤖 Subagent${agentTag}`, - { - startTime: hrTime(evTs), - attributes: { - "subagent.session_id": ev.subagent_session_id || "", - "gen_ai.agent.name": agentName, - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "AGENT", - }, + const subAgentInv = createInvokeAgentInvocation("anthropic", { + agentName, + agentId, + attributes: { + ...sAttrs("AGENT"), + "subagent.session_id": ev.subagent_session_id || "", }, - agentPreCtx - ); - const subCtxForWindow = trace.setSpan(context.active(), subSpanForCtx); - openSubagentCtxByAgentId[agentId] = { span: subSpanForCtx, ctx: subCtxForWindow }; - if (agentId) { - openSubagentsByAgentId[agentId] = { - agentId, - agentName, - startTs: evTs, - stopTs: undefined, - stopAttrs: {}, - childState: null, - span: subSpanForCtx, - syntheticPreSpan, // may be null if real pre was found - }; - } + }); + handler.startInvokeAgent(subAgentInv, agentParentCtx, hrTime(evTs)); + openSubagentCtxByAgentId[agentId] = { inv: subAgentInv, ctx: subAgentInv.contextToken }; + openSubagentsByAgentId[agentId] = { + agentId, + agentName, + startTs: evTs, + stopTs: undefined, + childState: null, + agentInv: subAgentInv, + syntheticToolInv, + }; subagentSpanStack.push({ agentId, startTs: evTs }); } else if (evType === "subagent_stop") { const childState = ev._child_state; if (subagentSpanStack.length > 0) { - const { agentId } = subagentSpanStack.shift(); // FIFO + const { agentId } = subagentSpanStack.shift(); if (agentId && openSubagentsByAgentId[agentId]) { - openSubagentsByAgentId[agentId].stopTs = evTs; - openSubagentsByAgentId[agentId].stopAttrs = { - "subagent.stop_reason": ev.stop_reason || "end_turn", - "gen_ai.usage.input_tokens": ev.input_tokens || 0, - "gen_ai.usage.output_tokens": ev.output_tokens || 0, - "gen_ai.usage.cache_read.input_tokens": ev.cache_read_input_tokens || 0, - "gen_ai.usage.cache_creation.input_tokens": ev.cache_creation_input_tokens || 0, - }; + const entry = openSubagentsByAgentId[agentId]; + entry.stopTs = evTs; + entry.agentInv.inputTokens = ev.input_tokens || 0; + entry.agentInv.outputTokens = ev.output_tokens || 0; + entry.agentInv.usageCacheReadInputTokens = ev.cache_read_input_tokens || 0; + entry.agentInv.usageCacheCreationInputTokens = ev.cache_creation_input_tokens || 0; + entry.agentInv.finishReasons = [ev.stop_reason || "end_turn"]; if (childState) { - openSubagentsByAgentId[agentId].childState = childState; + entry.childState = childState; + if (childState.model) entry.agentInv.requestModel = childState.model; } } } - // Extra stops (stack empty) are silently ignored - // Fallback: if no agentId tracking, handle child_state with an inline span + // Fallback: orphan subagent_stop with child_state if (subagentSpanStack.length === 0 && !Object.keys(openSubagentsByAgentId).length && childState && Array.isArray(childState.events) && childState.events.length > 0) { - const childSid = ev.subagent_session_id || "unknown"; - const childPrompt = childState.prompt || ""; - const childPreview = childPrompt.length > 50 ? childPrompt.slice(0, 50) + "..." : childPrompt; - const childMetrics = childState.metrics || {}; const childStart = childState.start_time || evTs; const childStop = childState.stop_time || evTs; - const containerSpan = tracer.startSpan( - childPreview ? `🤖 Subagent: ${childPreview}` : "🤖 Subagent", - { - startTime: hrTime(childStart), - attributes: { - "subagent.session_id": childSid, - "subagent.stop_reason": ev.stop_reason || "end_turn", - "gen_ai.usage.input_tokens": childMetrics.input_tokens || ev.input_tokens || 0, - "gen_ai.usage.output_tokens": childMetrics.output_tokens || ev.output_tokens || 0, - "gen_ai.request.model": childState.model || "unknown", - "claude_code.hook.type": evType, - [SPAN_KIND_ATTR]: "AGENT", - }, - }, - parentContext(evTs) - ); - const containerCtx = trace.setSpan(context.active(), containerSpan); - replayEventsAsSpans(tracer, childState.events, containerCtx, childStop); - containerSpan.end(hrTime(childStop)); - } - - } else if (evType === "llm_call") { - const model = ev.model || "unknown"; - const inputMessages = ev.input_messages || []; - - let lastUserPreview = ""; - if (Array.isArray(inputMessages)) { - for (let i = inputMessages.length - 1; i >= 0; i--) { - const m = inputMessages[i]; - if (m && m.role === "user") { - const content = m.content; - if (typeof content === "string") { - lastUserPreview = content.slice(0, 40); - } else if (Array.isArray(content)) { - for (const block of content) { - if (block && block.type === "text") { - lastUserPreview = (block.text || "").slice(0, 40); - break; - } - } - } - break; - } - } - } - - const label = lastUserPreview - ? `🧠 LLM: ${lastUserPreview}...` - : `🧠 LLM call (${model})`; - - const requestStart = ev.request_start_time || evTs; - const llmSpan = tracer.startSpan( - label, - { - startTime: hrTime(requestStart), + const childMetrics = childState.metrics || {}; + const containerInv = createInvokeAgentInvocation("anthropic", { + agentName: "subagent", + inputTokens: childMetrics.input_tokens || ev.input_tokens || 0, + outputTokens: childMetrics.output_tokens || ev.output_tokens || 0, + finishReasons: [ev.stop_reason || "end_turn"], + requestModel: childState.model || "unknown", attributes: { - "gen_ai.system": "anthropic", - "gen_ai.request.model": model, - "gen_ai.response.model": model, - "gen_ai.usage.input_tokens": ev.input_tokens || 0, - "gen_ai.usage.output_tokens": ev.output_tokens || 0, - "gen_ai.usage.cache_read_input_tokens": ev.cache_read_input_tokens || 0, - "gen_ai.usage.cache_creation_input_tokens": ev.cache_creation_input_tokens || 0, - "claude_code.hook.type": "llm_call", - [SPAN_KIND_ATTR]: "LLM", + ...sAttrs("AGENT"), + "subagent.session_id": ev.subagent_session_id || "unknown", }, - }, - parentContext(requestStart, evTs) - ); - - // Attach input/output messages (best-effort, max 1MB each) - try { - let rawInput = ev.input_messages || []; - const systemPrompt = ev.system_prompt; - if (systemPrompt !== null && systemPrompt !== undefined) { - let systemText = ""; - if (Array.isArray(systemPrompt)) { - systemText = systemPrompt - .map((item) => (typeof item === "string" ? item : (item && item.text) || "")) - .join("\n"); - } else { - systemText = String(systemPrompt); - } - rawInput = [{ role: "system", content: systemText }, ...(Array.isArray(rawInput) ? rawInput : [])]; - } - if (rawInput && (Array.isArray(rawInput) ? rawInput.length > 0 : true)) { - let serialized = typeof rawInput === "string" - ? rawInput - : JSON.stringify(rawInput); - if (serialized.length > MAX_CONTENT_LENGTH) { - serialized = serialized.slice(0, MAX_CONTENT_LENGTH) + "...(truncated)"; - } - llmSpan.setAttribute("gen_ai.input.messages", serialized); - } - } catch {} - - try { - const rawOutput = ev.output_content; - if (rawOutput !== null && rawOutput !== undefined) { - let serialized = typeof rawOutput === "string" - ? rawOutput - : JSON.stringify(rawOutput); - if (serialized.length > MAX_CONTENT_LENGTH) { - serialized = serialized.slice(0, MAX_CONTENT_LENGTH) + "...(truncated)"; - } - llmSpan.setAttribute("gen_ai.output.messages", serialized); - } - } catch {} - - if (ev.is_error) { - llmSpan.setAttribute("error", true); - llmSpan.setAttribute("error.message", ev.error_message || ""); + }); + handler.startInvokeAgent(containerInv, resolveParentContext(evTs), hrTime(childStart)); + replayEventsAsSpans(handler, tracer, childState.events, containerInv.contextToken, childStop, sessionId); + handler.stopInvokeAgent(containerInv, hrTime(childStop)); } - llmSpan.end(hrTime(evTs)); } } // Close any subagents that never got a post_tool_use for (const [agentId, subEntry] of Object.entries(openSubagentsByAgentId)) { - let span = subEntry.span; - if (!span) { - // Defensive fallback: span should have been created at subagent_start - const agentTag = subEntry.agentName ? ` [${subEntry.agentName}]` : ""; - span = tracer.startSpan( - `🤖 Subagent${agentTag}`, - { - startTime: hrTime(subEntry.startTs), - attributes: { - "gen_ai.agent.name": subEntry.agentName || "", - "claude_code.hook.type": "subagent_start", - [SPAN_KIND_ATTR]: "AGENT", - }, - }, - parentContext() - ); - } - for (const [k, v] of Object.entries(subEntry.stopAttrs || {})) { - span.setAttribute(k, v); - } if (subEntry.childState && Array.isArray(subEntry.childState.events) && subEntry.childState.events.length > 0) { - const subCtx = (openSubagentCtxByAgentId[agentId] || {}).ctx || trace.setSpan(context.active(), span); - replayEventsAsSpans(tracer, subEntry.childState.events, subCtx, subEntry.childState.stop_time || stopTime); + replayEventsAsSpans(handler, tracer, subEntry.childState.events, + subEntry.agentInv.contextToken, subEntry.childState.stop_time || stopTime, sessionId); } - span.end(hrTime(subEntry.stopTs || stopTime)); - // Close synthetic pre span if it was created at subagent_start time - if (subEntry.syntheticPreSpan) { - subEntry.syntheticPreSpan.end(hrTime(subEntry.stopTs || stopTime)); + const inv = subEntry.agentInv; + if (!inv.inputTokens && !inv.outputTokens) { + const childStart = subEntry.startTs; + const childStop = subEntry.stopTs || stopTime; + for (const ce of events) { + if (ce.type !== "llm_call") continue; + const ceTs = ce.timestamp || 0; + if (ceTs >= childStart && ceTs <= childStop) { + inv.inputTokens = (inv.inputTokens || 0) + (ce.input_tokens || 0); + inv.outputTokens = (inv.outputTokens || 0) + (ce.output_tokens || 0); + inv.usageCacheReadInputTokens = (inv.usageCacheReadInputTokens || 0) + (ce.cache_read_input_tokens || 0); + inv.usageCacheCreationInputTokens = (inv.usageCacheCreationInputTokens || 0) + (ce.cache_creation_input_tokens || 0); + if (!inv.requestModel && ce.model) inv.requestModel = ce.model; + } + } + } + handler.stopInvokeAgent(subEntry.agentInv, hrTime(subEntry.stopTs || stopTime)); + if (subEntry.syntheticToolInv) { + handler.stopExecuteTool(subEntry.syntheticToolInv, hrTime(subEntry.stopTs || stopTime)); } } - // Close unclosed Agent pre spans (no matching post arrived) - for (const { span } of openAgentPreSpans) { - span.end(hrTime(stopTime)); + // Close unclosed Agent tool invocations + for (const { inv } of openAgentToolInvs) { + handler.stopExecuteTool(inv, hrTime(stopTime)); } - if (currentTurnSpan !== null) { - currentTurnSpan.end(hrTime(stopTime)); + + // Recover orphaned pre_tool_use events (Claude Code drops ~30% of PostToolUse hooks) + for (const [toolUseId, preEv] of Object.entries(preToolUseMap)) { + if (consumedToolUseIds.has(toolUseId)) continue; + const toolName = preEv.tool_name || "unknown"; + if (toolName === "Agent" || toolName === "agent") continue; + const toolInput = preEv.tool_input || {}; + const startTs = preEv.timestamp || stopTime; + const toolInv = createExecuteToolInvocation(toolName, { + toolCallId: toolUseId, + toolCallArguments: toolInput, + attributes: { ...sAttrs("TOOL"), "tool.orphaned": true }, + }); + const orphanParent = orphanContextMap[toolUseId] || currentStepInv?.contextToken || parentCtx; + const endTs = orphanEndTimeMap[toolUseId] || stopTime; + handler.startExecuteTool(toolInv, orphanParent, hrTime(startTs)); + handler.stopExecuteTool(toolInv, hrTime(endTs)); + } + + if (currentStepInv) { + handler.stopReactStep(currentStepInv, hrTime(stopTime)); } } // --------------------------------------------------------------------------- -// export_session_trace +// export_session_trace — per-turn independent traces with ENTRY → AGENT → STEP → LLM/TOOL // --------------------------------------------------------------------------- async function exportSessionTrace(state, stopReason = "end_turn") { - // configureTelemetry throws Error if no backend is configured — let it propagate - // so cmdStop can catch it and warn without crashing. - configureTelemetry(); + const provider = configureTelemetry(); if (!state || typeof state !== "object") { throw new Error("exportSessionTrace: invalid state object"); } const sessionId = state.session_id || "unknown"; - const prompt = state.prompt || ""; - const metrics = state.metrics || {}; - let events = Array.isArray(state.events) ? state.events : []; const startTime = typeof state.start_time === "number" ? state.start_time : Date.now() / 1000; const stopTime = typeof state.stop_time === "number" ? state.stop_time : Date.now() / 1000; - const promptPreview = prompt.length > 60 ? prompt.slice(0, 60) + "..." : prompt; - const spanTitle = prompt ? `🤖 ${promptPreview}` : "Claude Session"; - + const handler = new ExtendedTelemetryHandler({ tracerProvider: provider }); const tracer = trace.getTracer("opentelemetry-instrumentation-claude"); - // Merge proxy events from intercept.js. - // resolveClaudePid() walks the process tree to find the claude PID whose - // proxy_events_.jsonl file we should read and delete. + // Merge proxy events from intercept.js + let allEvents = Array.isArray(state.events) ? [...state.events] : []; try { const claudePid = resolveClaudePid(); - const proxyEvents = readProxyEvents(startTime, stopTime, false, claudePid); + const proxyEvents = readProxyEvents(startTime, stopTime, true, claudePid); if (proxyEvents.length > 0) { const getSortKey = (e) => { if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; return e.timestamp || 0; }; - events = [...events, ...proxyEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); + allEvents = [...allEvents, ...proxyEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); } } catch {} - const sessionSpan = tracer.startSpan(spanTitle, { - startTime: hrTime(startTime), - attributes: { - "gen_ai.input.messages": prompt, - session_id: sessionId, - "gen_ai.conversation.id": sessionId, - "gen_ai.system": "anthropic", - "gen_ai.request.model": state.model || "unknown", - "gen_ai.response.model": state.model || "unknown", - "gen_ai.usage.input_tokens": metrics.input_tokens || 0, - "gen_ai.usage.output_tokens": metrics.output_tokens || 0, - tools_used: metrics.tools_used || 0, - tool_names: (state.tools_used || []).join(","), - turns: metrics.turns || 0, - stop_reason: stopReason, - [SPAN_KIND_ATTR]: "TASK", - }, - }); - const sessionCtx = trace.setSpan(context.active(), sessionSpan); + // Split into per-turn groups + const turns = splitEventsByTurn(allEvents); + if (turns.length === 0) return; + + // Set endTime for last turn + turns[turns.length - 1].endTime = stopTime; + + // Export each turn as an independent trace + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + const isLast = i === turns.length - 1; + const turnStopReason = isLast ? stopReason : "end_turn"; + + // Aggregate per-turn tokens from llm_call events + let turnInputTokens = 0, turnOutputTokens = 0; + let turnCacheRead = 0, turnCacheCreate = 0; + let turnModel = state.model || "unknown"; + const llmEvents = turn.events.filter(e => e.type === "llm_call"); + for (const lev of llmEvents) { + turnInputTokens += lev.input_tokens || 0; + turnOutputTokens += lev.output_tokens || 0; + turnCacheRead += lev.cache_read_input_tokens || 0; + turnCacheCreate += lev.cache_creation_input_tokens || 0; + if (lev.model) turnModel = lev.model; + } - replayEventsAsSpans(tracer, events, sessionCtx, stopTime); - sessionSpan.end(hrTime(stopTime)); + // Determine turn output (last llm_call's output_content) + const lastLlm = llmEvents.length > 0 ? llmEvents[llmEvents.length - 1] : null; + const turnOutputMessages = lastLlm + ? convertOutputMessages(lastLlm.output_content, lastLlm.stop_reason) + : []; + + // ENTRY span — no parent → new traceId + const entryInv = createEntryInvocation({ + sessionId, + inputMessages: turn.prompt + ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] + : [], + outputMessages: turnOutputMessages, + attributes: sessionAttrs(sessionId, "ENTRY"), + }); + handler.startEntry(entryInv, undefined, hrTime(turn.startTime)); + + // AGENT span + const agentInv = createInvokeAgentInvocation("anthropic", { + agentName: "claude-code", + agentId: sessionId, + conversationId: sessionId, + requestModel: turnModel, + responseModelName: turnModel, + inputTokens: turnInputTokens, + outputTokens: turnOutputTokens, + usageCacheReadInputTokens: turnCacheRead, + usageCacheCreationInputTokens: turnCacheCreate, + finishReasons: [turnStopReason], + inputMessages: turn.prompt + ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] + : [], + outputMessages: turnOutputMessages, + attributes: sessionAttrs(sessionId, "AGENT"), + }); + handler.startInvokeAgent(agentInv, entryInv.contextToken, hrTime(turn.startTime)); + + // Replay this turn's events as child spans + replayEventsAsSpans(handler, tracer, turn.events, agentInv.contextToken, turn.endTime, sessionId); + + // Close spans + handler.stopInvokeAgent(agentInv, hrTime(turn.endTime)); + handler.stopEntry(entryInv, hrTime(turn.endTime)); + } await shutdownTelemetry(); - const duration = stopTime - startTime; + const totalIn = turns.reduce((s, t) => + s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.input_tokens || 0), 0), 0); + const totalOut = turns.reduce((s, t) => + s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.output_tokens || 0), 0), 0); console.error( - `✅ Session traced | ` + - `${metrics.input_tokens || 0} in, ` + - `${metrics.output_tokens || 0} out | ` + - `${metrics.tools_used || 0} tools | ` + - `${duration.toFixed(1)}s` + `✅ Session traced | ${turns.length} turn(s) | ` + + `${totalIn} in, ${totalOut} out | ` + + `${(stopTime - startTime).toFixed(1)}s` ); } @@ -986,10 +948,11 @@ function cmdPostToolUse() { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); + const toolName = event.tool_name || "unknown"; state.events.push({ type: "post_tool_use", timestamp: Date.now() / 1000, - tool_name: event.tool_name || "unknown", + tool_name: toolName, tool_response: event.tool_response, tool_use_id: event.tool_use_id || null, }); @@ -1089,6 +1052,11 @@ async function cmdStop() { // otherwise Claude Code may treat the hook as broken. try { await exportSessionTrace(state, stopReason); + // Clear exported events so subsequent Stop calls (which fire after every + // turn, not just session end) don't re-export old turns as duplicates. + state.events = []; + state.stop_time = null; + saveState(sessionId, state); } catch (err) { console.error( "[otel-claude-hook] telemetry export failed (agent unaffected):", @@ -1165,13 +1133,13 @@ async function cmdInstall(opts = {}) { log( installMsg( "启用 LLM 输入输出追踪,请使用以下方式启动 Claude Code:\n" + - ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + + ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + "\n或在 Shell 配置文件中添加以下别名(已通过 setup-alias.sh 自动配置):\n" + - ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n`, + ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n`, "To enable LLM call tracing, launch Claude Code with:\n" + - ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + + ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + "\nOr add the following alias to your shell profile (auto-configured via setup-alias.sh):\n" + - ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n` + ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n` ) ); } @@ -1401,6 +1369,7 @@ module.exports = { }, _resolveClaudePid: typeof resolveClaudePid !== "undefined" ? resolveClaudePid : null, _replayEventsAsSpans: replayEventsAsSpans, + _splitEventsByTurn: splitEventsByTurn, _exportSessionTrace: exportSessionTrace, _installIntercept: installIntercept, _removeAliasFromFile: removeAliasFromFile, diff --git a/opentelemetry-instrumentation-claude/src/hooks.js b/opentelemetry-instrumentation-claude/src/hooks.js index dfe9cf4..a1d359f 100644 --- a/opentelemetry-instrumentation-claude/src/hooks.js +++ b/opentelemetry-instrumentation-claude/src/hooks.js @@ -239,11 +239,56 @@ function addResponseToEventData(eventData, toolResponse) { } } +/** + * Extract a clean result value from Claude Code's tool response for `toolCallResult`. + * @param {*} toolResponse + * @returns {string|object|null} + */ +function extractToolResult(toolResponse) { + if (toolResponse == null) return null; + if (typeof toolResponse === "string") return toolResponse; + if (typeof toolResponse !== "object" || Array.isArray(toolResponse)) return toolResponse; + + if (toolResponse.error || toolResponse.isError) { + return `Error: ${toolResponse.error || "Unknown error"}`; + } + + for (const key of ["result", "content", "message", "output", "stdout"]) { + if (!(key in toolResponse)) continue; + const raw = toolResponse[key]; + if (Array.isArray(raw)) { + const texts = raw + .filter(item => item && typeof item === "object" && item.type === "text" && item.text) + .map(item => item.text); + if (texts.length > 0) return texts.join(""); + } + return raw; + } + + return toolResponse; +} + +/** + * Detect if a tool response indicates an error. + * @param {*} toolResponse + * @returns {{ message: string, type: string } | null} + */ +function extractToolError(toolResponse) { + if (!toolResponse || typeof toolResponse !== "object" || Array.isArray(toolResponse)) return null; + if (!toolResponse.error && !toolResponse.isError) return null; + return { + message: String(toolResponse.error || "Unknown error"), + type: "ToolError", + }; +} + module.exports = { createToolTitle, createEventData, addResponseToEventData, truncateForDisplay, smartTruncateValue, + extractToolResult, + extractToolError, MAX_CONTENT_LENGTH, }; diff --git a/opentelemetry-instrumentation-claude/src/message-converter.js b/opentelemetry-instrumentation-claude/src/message-converter.js new file mode 100644 index 0000000..32bc5ef --- /dev/null +++ b/opentelemetry-instrumentation-claude/src/message-converter.js @@ -0,0 +1,321 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +/** + * message-converter.js — Convert intercept.js raw LLM event data + * to ARMS semantic convention message formats compatible with + * @loongsuite/opentelemetry-util-genai SDK types. + * + * Target schemas: + * InputMessage: { role, parts: [TextPart | ToolCallPart | ToolCallResponsePart] } + * OutputMessage: { role, parts: [...], finishReason } + * SystemInstruction (MessagePart[]): [{ type: "text", content }] + */ + +// --------------------------------------------------------------------------- +// Stop-reason mapping: protocol-native → SDK FinishReason +// --------------------------------------------------------------------------- +const STOP_REASON_MAP = { + end_turn: "stop", + stop: "stop", + completed: "stop", + tool_use: "tool_calls", + tool_calls: "tool_calls", + max_tokens: "length", + length: "length", + content_filter: "content_filter", + error: "error", +}; + +function mapStopReason(raw) { + if (!raw) return "stop"; + return STOP_REASON_MAP[raw] || raw; +} + +// --------------------------------------------------------------------------- +// convertSystemPrompt(systemPrompt, protocol) → MessagePart[] +// --------------------------------------------------------------------------- +function convertSystemPrompt(systemPrompt, protocol) { + if (systemPrompt == null) return []; + + if (typeof systemPrompt === "string") { + return systemPrompt ? [{ type: "text", content: systemPrompt }] : []; + } + + if (!Array.isArray(systemPrompt)) return []; + + const parts = []; + for (const item of systemPrompt) { + if (typeof item === "string") { + if (item) parts.push({ type: "text", content: item }); + } else if (item && typeof item === "object") { + if (protocol === "openai-chat") { + const text = item.content || ""; + if (text) parts.push({ type: "text", content: text }); + } else { + const text = item.text || item.content || ""; + if (text) parts.push({ type: "text", content: text }); + } + } + } + return parts; +} + +// --------------------------------------------------------------------------- +// convertAnthropicContentBlock(block) → MessagePart +// --------------------------------------------------------------------------- +function convertAnthropicContentBlock(block) { + if (!block || typeof block !== "object") return null; + switch (block.type) { + case "text": + return { type: "text", content: block.text || "" }; + case "tool_use": + return { + type: "tool_call", + id: block.id || null, + name: block.name || "", + arguments: block.input ?? null, + }; + case "tool_result": + return { + type: "tool_call_response", + id: block.tool_use_id || null, + response: block.content ?? null, + }; + case "thinking": + return { type: "reasoning", content: block.thinking || "" }; + default: + if (block.text != null) return { type: "text", content: block.text }; + return { type: block.type || "unknown" }; + } +} + +// --------------------------------------------------------------------------- +// convertInputMessages(messages, protocol) → InputMessage[] +// --------------------------------------------------------------------------- +function convertInputMessages(messages, protocol) { + if (!messages) return []; + if (typeof messages === "string") { + return messages ? [{ role: "user", parts: [{ type: "text", content: messages }] }] : []; + } + if (!Array.isArray(messages)) return []; + + const result = []; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + + if (protocol === "openai-chat") { + result.push(convertOpenAIChatMessage(msg)); + } else if (protocol === "openai-responses") { + const converted = convertOpenAIResponsesItem(msg); + if (converted) result.push(converted); + } else { + result.push(convertAnthropicMessage(msg)); + } + } + + return result; +} + +function convertAnthropicMessage(msg) { + const role = msg.role || "user"; + const content = msg.content; + + if (typeof content === "string") { + return { role, parts: [{ type: "text", content }] }; + } + + if (Array.isArray(content)) { + const parts = []; + for (const block of content) { + const part = convertAnthropicContentBlock(block); + if (part) parts.push(part); + } + return { role, parts }; + } + + return { role, parts: content != null ? [{ type: "text", content: String(content) }] : [] }; +} + +function convertOpenAIChatMessage(msg) { + const role = msg.role || "user"; + const parts = []; + + if (role === "tool" && msg.tool_call_id) { + parts.push({ + type: "tool_call_response", + id: msg.tool_call_id, + response: msg.content ?? null, + }); + return { role: "tool", parts }; + } + + if (msg.content != null) { + if (typeof msg.content === "string") { + if (msg.content) parts.push({ type: "text", content: msg.content }); + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (typeof block === "string") { + parts.push({ type: "text", content: block }); + } else if (block && block.type === "text") { + parts.push({ type: "text", content: block.text || "" }); + } + } + } + } + + if (Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + parts.push({ + type: "tool_call", + id: tc.id || null, + name: tc.function?.name || "", + arguments: tc.function?.arguments ?? null, + }); + } + } + + return { role, parts }; +} + +function convertOpenAIResponsesItem(item) { + if (!item || typeof item !== "object") return null; + + if (typeof item === "string") { + return { role: "user", parts: [{ type: "text", content: item }] }; + } + + if (item.type === "function_call_output") { + return { + role: "tool", + parts: [{ + type: "tool_call_response", + id: item.call_id || null, + response: item.output ?? null, + }], + }; + } + + const role = item.role || "user"; + const content = item.content; + if (typeof content === "string") { + return { role, parts: [{ type: "text", content }] }; + } + if (Array.isArray(content)) { + const parts = content.map(c => { + if (typeof c === "string") return { type: "text", content: c }; + if (c && c.type === "input_text") return { type: "text", content: c.text || "" }; + if (c && c.type === "text") return { type: "text", content: c.text || "" }; + return { type: c?.type || "unknown" }; + }); + return { role, parts }; + } + + return { role, parts: [] }; +} + +// --------------------------------------------------------------------------- +// convertOutputMessages(outputContent, stopReason) → OutputMessage[] +// --------------------------------------------------------------------------- +function convertOutputMessages(outputContent, stopReason) { + if (!outputContent || !Array.isArray(outputContent) || outputContent.length === 0) { + return [{ + role: "assistant", + parts: [], + finishReason: mapStopReason(stopReason), + }]; + } + + const parts = []; + for (const block of outputContent) { + if (!block || typeof block !== "object") continue; + switch (block.type) { + case "text": + parts.push({ type: "text", content: block.text || "" }); + break; + case "tool_use": + parts.push({ + type: "tool_call", + id: block.id || null, + name: block.name || "", + arguments: block.input ?? null, + }); + break; + case "thinking": + parts.push({ type: "reasoning", content: block.thinking || "" }); + break; + default: + if (block.text != null) { + parts.push({ type: "text", content: block.text }); + } + break; + } + } + + return [{ + role: "assistant", + parts, + finishReason: mapStopReason(stopReason), + }]; +} + +// --------------------------------------------------------------------------- +// extractRequestParams(requestBody) → object +// --------------------------------------------------------------------------- +function extractRequestParams(requestBody) { + if (!requestBody || typeof requestBody !== "object") return {}; + const result = {}; + if (requestBody.temperature != null) result.temperature = requestBody.temperature; + if (requestBody.top_p != null) result.topP = requestBody.top_p; + if (requestBody.top_k != null) result.topK = requestBody.top_k; + if (requestBody.max_tokens != null) result.maxTokens = requestBody.max_tokens; + if (requestBody.stop_sequences != null) result.stopSequences = requestBody.stop_sequences; + if (requestBody.stop != null && result.stopSequences == null) { + result.stopSequences = Array.isArray(requestBody.stop) ? requestBody.stop : [requestBody.stop]; + } + if (requestBody.seed != null) result.seed = requestBody.seed; + if (requestBody.frequency_penalty != null) result.frequencyPenalty = requestBody.frequency_penalty; + if (requestBody.presence_penalty != null) result.presencePenalty = requestBody.presence_penalty; + return result; +} + +// --------------------------------------------------------------------------- +// convertToolDefinitions(tools) → ToolDefinition[] +// --------------------------------------------------------------------------- +function convertToolDefinitions(tools) { + if (!Array.isArray(tools)) return []; + return tools.map(tool => { + if (!tool || typeof tool !== "object") return null; + // OpenAI format: { type: "function", function: { name, description, parameters } } + if (tool.type === "function" && tool.function) { + return { + type: "function", + name: tool.function.name || "", + description: tool.function.description || null, + parameters: tool.function.parameters || null, + }; + } + // Anthropic format: { name, description, input_schema } + if (tool.name) { + return { + type: "function", + name: tool.name, + description: tool.description || null, + parameters: tool.input_schema || null, + }; + } + return null; + }).filter(Boolean); +} + +module.exports = { + convertSystemPrompt, + convertInputMessages, + convertOutputMessages, + extractRequestParams, + convertToolDefinitions, + mapStopReason, +}; diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index abde1e1..bffa50a 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -63,8 +63,45 @@ jest.mock("@opentelemetry/api", () => ({ SpanStatusCode: { ERROR: 2 }, })); +// ─── Mock @loongsuite/opentelemetry-util-genai SDK ─────────────────────── +// Jest hoists jest.mock() so we prefix variables with "mock" to allow references. +let mockHandlerInstance; + +jest.mock("@loongsuite/opentelemetry-util-genai", () => { + const mockMakeInv = (extra) => ({ contextToken: {}, attributes: {}, ...extra }); + const handler = { + startEntry: jest.fn((inv) => { inv.contextToken = inv.contextToken || {}; }), + stopEntry: jest.fn(), + failEntry: jest.fn(), + startInvokeAgent: jest.fn((inv) => { inv.contextToken = inv.contextToken || {}; }), + stopInvokeAgent: jest.fn(), + failInvokeAgent: jest.fn(), + startReactStep: jest.fn((inv) => { inv.contextToken = inv.contextToken || {}; }), + stopReactStep: jest.fn(), + failReactStep: jest.fn(), + startExecuteTool: jest.fn((inv) => { inv.contextToken = inv.contextToken || {}; }), + stopExecuteTool: jest.fn(), + failExecuteTool: jest.fn(), + startLlm: jest.fn((inv) => { inv.contextToken = inv.contextToken || {}; }), + stopLlm: jest.fn(), + failLlm: jest.fn(), + }; + + mockHandlerInstance = handler; + + return { + ExtendedTelemetryHandler: jest.fn().mockImplementation(() => handler), + createEntryInvocation: jest.fn((opts) => mockMakeInv({ type: "entry", ...opts })), + createInvokeAgentInvocation: jest.fn((provider, opts) => mockMakeInv({ type: "agent", provider, ...opts })), + createReactStepInvocation: jest.fn((opts) => mockMakeInv({ type: "step", ...opts })), + createExecuteToolInvocation: jest.fn((name, opts) => mockMakeInv({ type: "tool", toolName: name, ...opts })), + createLLMInvocation: jest.fn((opts) => mockMakeInv({ type: "llm", ...opts })), + }; +}); + const stateModule = require("../src/state"); const cli = require("../src/cli"); +const sdk = require("@loongsuite/opentelemetry-util-genai"); afterAll(() => { try { fs.rmSync(TMP_STATE, { recursive: true, force: true }); } catch {} @@ -271,61 +308,81 @@ describe("installIntoSettings", () => { }); }); -// ─── replayEventsAsSpans ─────────────────────────────────────────────────── +// ─── replayEventsAsSpans — SDK handler-based tests ─────────────────────── describe("replayEventsAsSpans", () => { - let mockSpan, mockTracer, mockCtx; + let mockSpan, mockTracer, mockCtx, handler; beforeEach(() => { mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; mockTracer = { startSpan: jest.fn().mockReturnValue(mockSpan) }; mockCtx = {}; - require("@opentelemetry/api").trace.setSpan = jest.fn().mockReturnValue({}); + handler = mockHandlerInstance; + // Reset all handler mocks + Object.values(handler).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); + // Reset SDK factory mocks + Object.values(sdk).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); }); - test("creates turn span on user_prompt_submit", () => { - const events = [{ type: "user_prompt_submit", timestamp: 1000, prompt: "hello" }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); - expect(mockTracer.startSpan).toHaveBeenCalledWith( - expect.stringContaining("Turn 1"), expect.any(Object), mockCtx + test("creates ReactStep on llm_call (not user_prompt_submit)", () => { + const events = [{ + type: "llm_call", + timestamp: 1001, + request_start_time: 1000, + model: "claude-sonnet-4-5", + input_tokens: 100, + output_tokens: 50, + stop_reason: "stop", + }]; + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(sdk.createReactStepInvocation).toHaveBeenCalledWith( + expect.objectContaining({ round: 1 }) ); - expect(mockSpan.end).toHaveBeenCalled(); + expect(handler.startReactStep).toHaveBeenCalled(); + expect(handler.stopReactStep).toHaveBeenCalled(); }); - test("creates tool spans for pre/post_tool_use pair", () => { + test("user_prompt_submit events are ignored (handled by splitEventsByTurn)", () => { + const events = [{ type: "user_prompt_submit", timestamp: 1000, prompt: "hello" }]; + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); + expect(sdk.createReactStepInvocation).not.toHaveBeenCalled(); + expect(handler.startReactStep).not.toHaveBeenCalled(); + }); + + test("creates ExecuteTool via handler for pre/post_tool_use pair", () => { const events = [ { type: "pre_tool_use", timestamp: 1000, tool_name: "Bash", tool_input: { command: "ls" }, tool_use_id: "t1" }, { type: "post_tool_use", timestamp: 1001, tool_name: "Bash", tool_response: { result: "ok" }, tool_use_id: "t1" }, ]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - const spanNames = mockTracer.startSpan.mock.calls.map(c => c[0]); - expect(spanNames.some(n => n.includes("Bash"))).toBe(true); - expect(mockSpan.end).toHaveBeenCalled(); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(sdk.createExecuteToolInvocation).toHaveBeenCalledWith("Bash", expect.any(Object)); + expect(handler.startExecuteTool).toHaveBeenCalled(); + expect(handler.stopExecuteTool).toHaveBeenCalled(); }); test("pre_tool_use for non-Agent tool creates no span (deferred to post_tool_use)", () => { - // Non-Agent tools: span created at post_tool_use time; pre is skipped const events = [{ type: "pre_tool_use", timestamp: 1000, tool_name: "Read", tool_input: {}, tool_use_id: null }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); + expect(handler.startExecuteTool).not.toHaveBeenCalled(); expect(mockTracer.startSpan).not.toHaveBeenCalled(); }); - test("creates notification span", () => { + test("creates notification span via raw tracer", () => { const events = [{ type: "notification", timestamp: 1000, message: "done", level: "info", title: "" }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); expect(mockTracer.startSpan).toHaveBeenCalledWith( expect.stringContaining("done"), expect.any(Object), mockCtx ); }); - test("creates pre_compact span", () => { + test("creates pre_compact span via raw tracer", () => { const events = [{ type: "pre_compact", timestamp: 1000, trigger: "manual", has_custom_instructions: false }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); expect(mockTracer.startSpan).toHaveBeenCalledWith( - expect.stringContaining("compaction"), expect.any(Object), mockCtx + "compact", expect.any(Object), mockCtx ); }); - test("creates llm_call span with token attributes", () => { + test("creates LLM span under a STEP for llm_call", () => { const events = [{ type: "llm_call", timestamp: 1001, @@ -336,36 +393,49 @@ describe("replayEventsAsSpans", () => { input_messages: [{ role: "user", content: "Hi" }], output_content: [{ type: "text", text: "Hello" }], }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - // Token attributes passed via startSpan attributes object - const startSpanCalls = mockTracer.startSpan.mock.calls; - const llmCall = startSpanCalls.find(c => c[0].includes("LLM")); - expect(llmCall).toBeDefined(); - const attrs = llmCall[1].attributes; - expect(attrs["gen_ai.usage.input_tokens"]).toBe(100); - expect(attrs["gen_ai.usage.output_tokens"]).toBe(50); - }); - - test("subagent_start defers span creation to post_tool_use; creates span at stopTime if no post", () => { - // New design: subagent_start stores data only; span created at post_tool_use time - // With agent_id set and no matching post, span is created at end-of-function cleanup - const events = [{ type: "subagent_start", timestamp: 1000, subagent_session_id: "sub-123", agent_id: "ag-1", agent_type: "MyAgent" }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); - // Span is created in end-of-function cleanup for unmatched subagents - expect(mockTracer.startSpan).toHaveBeenCalledWith( - expect.stringContaining("Subagent"), expect.any(Object), expect.anything() + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(sdk.createLLMInvocation).toHaveBeenCalledWith( + expect.objectContaining({ + operationName: "chat", + requestModel: "claude-3-5-sonnet", + provider: "anthropic", + inputTokens: 100, + outputTokens: 50, + }) ); + expect(handler.startLlm).toHaveBeenCalled(); + expect(handler.stopLlm).toHaveBeenCalled(); + // LLM call also creates a STEP (reasoning cycle) + expect(sdk.createReactStepInvocation).toHaveBeenCalledWith( + expect.objectContaining({ round: 1 }) + ); + }); + + test("subagent_start creates InvokeAgent via handler", () => { + const events = [{ type: "subagent_start", timestamp: 1000, subagent_session_id: "sub-123", agent_id: "ag-1", agent_type: "MyAgent" }]; + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); + expect(sdk.createInvokeAgentInvocation).toHaveBeenCalledWith("anthropic", expect.objectContaining({ agentName: "MyAgent" })); + expect(handler.startInvokeAgent).toHaveBeenCalled(); + // Should also close in cleanup since there's no matching post + expect(handler.stopInvokeAgent).toHaveBeenCalled(); }); - test("handles multiple turns correctly", () => { + test("handles multiple LLM calls as separate STEP reasoning cycles", () => { const events = [ - { type: "user_prompt_submit", timestamp: 1000, prompt: "first" }, - { type: "user_prompt_submit", timestamp: 1002, prompt: "second" }, + { type: "llm_call", timestamp: 1001, request_start_time: 1000, model: "claude-sonnet-4-5", + input_tokens: 100, output_tokens: 50, stop_reason: "tool_use" }, + { type: "post_tool_use", timestamp: 1002, tool_name: "Bash", tool_response: { result: "ok" }, tool_use_id: "t1" }, + { type: "llm_call", timestamp: 1003, request_start_time: 1002.5, model: "claude-sonnet-4-5", + input_tokens: 200, output_tokens: 100, stop_reason: "stop" }, ]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1003); - const calls = mockTracer.startSpan.mock.calls; - expect(calls.some(c => c[0].includes("Turn 1"))).toBe(true); - expect(calls.some(c => c[0].includes("Turn 2"))).toBe(true); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1004); + expect(sdk.createReactStepInvocation).toHaveBeenCalledTimes(2); + expect(sdk.createReactStepInvocation).toHaveBeenCalledWith(expect.objectContaining({ round: 1 })); + expect(sdk.createReactStepInvocation).toHaveBeenCalledWith(expect.objectContaining({ round: 2 })); + // First step closed when second LLM call starts, second closed at stopTime + expect(handler.stopReactStep).toHaveBeenCalledTimes(2); + // Tool belongs to first step + expect(handler.startExecuteTool).toHaveBeenCalled(); }); }); @@ -484,113 +554,151 @@ describe("removeAliasFromFile", () => { }); }); -// ─── replayEventsAsSpans — additional event types ───────────────────────── +// ─── replayEventsAsSpans — extended event types ───────────────────────── describe("replayEventsAsSpans — extended", () => { - let mockSpan, mockTracer, mockCtx; + let mockSpan, mockTracer, mockCtx, handler; beforeEach(() => { mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; mockTracer = { startSpan: jest.fn().mockReturnValue(mockSpan) }; mockCtx = {}; - require("@opentelemetry/api").trace.setSpan = jest.fn().mockReturnValue({}); + handler = mockHandlerInstance; + Object.values(handler).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); + Object.values(sdk).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); }); - test("subagent_stop with child_state recurses into child events", () => { + test("subagent_stop with child_state creates container agent and recurses", () => { const events = [{ type: "subagent_stop", timestamp: 1001, subagent_session_id: "child-x", _child_state: { - prompt: "child prompt that is not too long", + prompt: "child prompt", start_time: 990, stop_time: 1000, metrics: { input_tokens: 5, output_tokens: 3 }, model: "claude-sonnet", - events: [{ type: "user_prompt_submit", timestamp: 991, prompt: "child prompt" }], + events: [ + { type: "llm_call", timestamp: 992, request_start_time: 991, + model: "claude-sonnet", input_tokens: 5, output_tokens: 3, stop_reason: "stop" }, + ], }, }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - // At least 2 spans: container + child turn span - expect(mockTracer.startSpan.mock.calls.length).toBeGreaterThanOrEqual(2); - const containerCall = mockTracer.startSpan.mock.calls[0]; - expect(containerCall[0]).toContain("Subagent"); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + // Container agent + recursive child step + expect(handler.startInvokeAgent).toHaveBeenCalled(); + expect(handler.stopInvokeAgent).toHaveBeenCalled(); + // Child events include an llm_call → ReactStep + expect(handler.startReactStep).toHaveBeenCalled(); }); - test("subagent_stop child_state with long prompt gets truncated in title", () => { - const longPrompt = "A".repeat(80); - const events = [{ - type: "subagent_stop", timestamp: 1001, - subagent_session_id: "child-y", - _child_state: { - prompt: longPrompt, start_time: 990, stop_time: 1000, - metrics: {}, model: "claude", - // Need at least one event so the code enters the child_state branch - // that uses childPrompt for the container span title - events: [{ type: "user_prompt_submit", timestamp: 991, prompt: longPrompt }], - }, - }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - const title = mockTracer.startSpan.mock.calls[0][0]; - // longPrompt (80 chars) > 50 → sliced to 50 + "..." - expect(title).toContain("..."); - }); - - test("llm_call with is_error=true marks span as error", () => { + test("llm_call with is_error=true calls failLlm", () => { const events = [{ type: "llm_call", timestamp: 1001, request_start_time: 1000, model: "claude-3", input_tokens: 0, output_tokens: 0, is_error: true, error_message: "rate limit exceeded", }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - expect(mockSpan.setAttribute).toHaveBeenCalledWith("error", true); - expect(mockSpan.setAttribute).toHaveBeenCalledWith("error.message", "rate limit exceeded"); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(handler.failLlm).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ message: "rate limit exceeded", type: "LLMError" }), + expect.any(Array) + ); + expect(handler.stopLlm).not.toHaveBeenCalled(); }); - test("llm_call with array system_prompt joins text entries", () => { + test("llm_call passes message conversions to createLLMInvocation", () => { const events = [{ type: "llm_call", timestamp: 1001, request_start_time: 1000, model: "claude-3", input_tokens: 5, output_tokens: 2, system_prompt: [{ text: "Be helpful." }, { text: "Be concise." }], - input_messages: [], + input_messages: [{ role: "user", content: "Hi" }], + output_content: [{ type: "text", text: "Hello" }], + protocol: "anthropic", }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1002); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - "gen_ai.input.messages", expect.stringContaining("Be helpful.") + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(sdk.createLLMInvocation).toHaveBeenCalledWith( + expect.objectContaining({ + systemInstruction: expect.any(Array), + inputMessages: expect.any(Array), + outputMessages: expect.any(Array), + }) ); }); - test("orphaned non-Agent pre_tool_use (no post) creates no span", () => { - // Non-Agent tools: no span is created without a matching post_tool_use + test("orphaned non-Agent pre_tool_use (no post) creates a TOOL span with tool.orphaned=true", () => { const events = [ - { type: "pre_tool_use", timestamp: 1000, tool_name: "Write", tool_input: {}, tool_use_id: "orphan-1" }, + { type: "pre_tool_use", timestamp: 1000, tool_name: "Write", tool_input: { file_path: "/tmp/test" }, tool_use_id: "orphan-1" }, ]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 2000); - expect(mockTracer.startSpan).not.toHaveBeenCalled(); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 2000); + expect(handler.startExecuteTool).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "Write", + toolCallId: "orphan-1", + toolCallArguments: { file_path: "/tmp/test" }, + }), + expect.anything(), + expect.anything(), + ); + expect(handler.stopExecuteTool).toHaveBeenCalled(); }); - test("Agent pre_tool_use creates span immediately and is closed at stopTime if no post", () => { + test("Agent pre_tool_use creates ExecuteTool span immediately and is closed at stopTime if no post", () => { const events = [ { type: "pre_tool_use", timestamp: 1000, tool_name: "Agent", tool_input: {}, tool_use_id: "agent-1" }, ]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 2000); - expect(mockTracer.startSpan).toHaveBeenCalled(); - expect(mockSpan.end).toHaveBeenCalled(); - const endArg = mockSpan.end.mock.calls[0][0]; - expect(Array.isArray(endArg)).toBe(true); - expect(endArg[0]).toBe(2000); // closed at stopTime + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 2000); + expect(handler.startExecuteTool).toHaveBeenCalled(); + // Closed in cleanup at stopTime + expect(handler.stopExecuteTool).toHaveBeenCalledWith( + expect.any(Object), [2000, 0] + ); }); test("notification with empty message uses generic title", () => { const events = [{ type: "notification", timestamp: 1000, message: "", level: "info", title: "" }]; - cli._replayEventsAsSpans(mockTracer, events, mockCtx, 1001); + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1001); expect(mockTracer.startSpan).toHaveBeenCalledWith( - expect.stringContaining("Notification"), expect.any(Object), mockCtx + "notification", expect.any(Object), mockCtx + ); + }); + + test("post_tool_use with error response calls failExecuteTool", () => { + const events = [ + { type: "pre_tool_use", timestamp: 1000, tool_name: "Bash", tool_input: { command: "rm /" }, tool_use_id: "t-err" }, + { type: "post_tool_use", timestamp: 1001, tool_name: "Bash", tool_response: { error: "Permission denied", isError: true }, tool_use_id: "t-err" }, + ]; + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1002); + expect(handler.failExecuteTool).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ message: "Permission denied", type: "ToolError" }), + expect.any(Array) ); }); + + test("subagent_start + subagent_stop + post_tool_use Agent flow", () => { + const events = [ + { type: "pre_tool_use", timestamp: 1000, tool_name: "Agent", tool_input: { subagent_type: "Explore" }, tool_use_id: "a1" }, + { type: "subagent_start", timestamp: 1001, agent_id: "ag-1", agent_type: "Explore", subagent_session_id: "sub-1" }, + { type: "subagent_stop", timestamp: 1005, subagent_session_id: "sub-1", stop_reason: "end_turn", input_tokens: 10, output_tokens: 5 }, + { type: "post_tool_use", timestamp: 1006, tool_name: "Agent", tool_response: { agent_id: "ag-1", result: "done" }, tool_use_id: "a1" }, + ]; + cli._replayEventsAsSpans(handler, mockTracer, events, mockCtx, 1010); + // Agent tool + subagent invoke + expect(handler.startExecuteTool).toHaveBeenCalled(); + expect(handler.startInvokeAgent).toHaveBeenCalled(); + expect(handler.stopInvokeAgent).toHaveBeenCalled(); + expect(handler.stopExecuteTool).toHaveBeenCalled(); + }); }); // ─── exportSessionTrace ──────────────────────────────────────────────────── describe("exportSessionTrace", () => { - let errSpy; - beforeEach(() => { errSpy = jest.spyOn(console, "error").mockImplementation(() => {}); }); + let errSpy, handler; + beforeEach(() => { + errSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + handler = mockHandlerInstance; + Object.values(handler).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); + Object.values(sdk).forEach(fn => { if (typeof fn === "function" && fn.mockClear) fn.mockClear(); }); + }); afterEach(() => { errSpy.mockRestore(); }); test("throws on invalid state", async () => { @@ -598,7 +706,7 @@ describe("exportSessionTrace", () => { await expect(cli._exportSessionTrace("not-an-object")).rejects.toThrow("invalid state object"); }); - test("exports a valid session state", async () => { + test("exports a valid session state with ENTRY + AGENT hierarchy", async () => { const state = { session_id: "sess-export-test", prompt: "test prompt", @@ -610,38 +718,54 @@ describe("exportSessionTrace", () => { events: [{ type: "user_prompt_submit", timestamp: 1001, prompt: "test prompt" }], }; await expect(cli._exportSessionTrace(state, "end_turn")).resolves.toBeUndefined(); + // ENTRY span + expect(sdk.createEntryInvocation).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "sess-export-test" }) + ); + expect(handler.startEntry).toHaveBeenCalled(); + expect(handler.stopEntry).toHaveBeenCalled(); + // AGENT span + expect(sdk.createInvokeAgentInvocation).toHaveBeenCalledWith( + "anthropic", + expect.objectContaining({ agentName: "claude-code" }) + ); + expect(handler.startInvokeAgent).toHaveBeenCalled(); + expect(handler.stopInvokeAgent).toHaveBeenCalled(); }); - test("handles long prompt (> 60 chars) with ellipsis in span title", async () => { + test("creates ReactStep for llm_call events within a turn", async () => { const state = { - session_id: "sess-long-prompt", - prompt: "A".repeat(80), + session_id: "sess-step-test", + prompt: "hello", model: "claude-3", - start_time: 1000, stop_time: 1002, - metrics: {}, tools_used: [], events: [], + start_time: 1000, + stop_time: 1003, + metrics: { turns: 1 }, + tools_used: [], + events: [ + { type: "user_prompt_submit", timestamp: 1001, prompt: "hello" }, + { type: "llm_call", timestamp: 1002, request_start_time: 1001.5, model: "claude-3", + input_tokens: 100, output_tokens: 50, stop_reason: "stop" }, + ], }; - const { trace } = require("@opentelemetry/api"); - const startSpanSpy = jest.fn().mockReturnValue({ setAttribute: jest.fn(), end: jest.fn() }); - trace.getTracer = jest.fn().mockReturnValue({ startSpan: startSpanSpy }); await cli._exportSessionTrace(state); - const title = startSpanSpy.mock.calls[0][0]; - expect(title).toContain("..."); + expect(handler.startReactStep).toHaveBeenCalled(); + expect(handler.stopReactStep).toHaveBeenCalled(); }); - test("uses 'Claude Session' title when prompt is empty", async () => { + test("handles empty prompt gracefully", async () => { const state = { session_id: "sess-no-prompt", prompt: "", model: "claude-3", start_time: 1000, stop_time: 1001, - metrics: {}, tools_used: [], events: [], + metrics: {}, tools_used: [], + events: [{ type: "user_prompt_submit", timestamp: 1000, prompt: "" }], }; - const { trace } = require("@opentelemetry/api"); - const startSpanSpy = jest.fn().mockReturnValue({ setAttribute: jest.fn(), end: jest.fn() }); - trace.getTracer = jest.fn().mockReturnValue({ startSpan: startSpanSpy }); await cli._exportSessionTrace(state); - const title = startSpanSpy.mock.calls[0][0]; - expect(title).toBe("Claude Session"); + expect(sdk.createEntryInvocation).toHaveBeenCalledWith( + expect.objectContaining({ inputMessages: [] }) + ); }); }); @@ -672,8 +796,6 @@ describe("resolveClaudePid Windows guard", () => { // ─── cmdUninstall ───────────────────────────────────────────────────────── describe("cmdUninstall", () => { test("runs without throwing when nothing is installed (no-user, no-project)", () => { - // Skip user home settings and project settings; only shell profile cleanup runs. - // Shell profile files may not exist, which is handled gracefully. const spy = jest.spyOn(console, "error").mockImplementation(() => {}); expect(() => cli.cmdUninstall({ user: false, project: false })).not.toThrow(); spy.mockRestore(); @@ -684,20 +806,16 @@ describe("cmdUninstall", () => { fs.mkdirSync(tmpDir, { recursive: true }); const settingsPath = path.join(tmpDir, "settings.json"); - // Install hooks cli._installIntoSettings(settingsPath); let settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); expect(settings.hooks).toBeDefined(); expect(Object.keys(settings.hooks).length).toBeGreaterThan(0); - // Simulate uninstallFromSettings by re-writing without hooks - // (cmdUninstall uses process.homedir() paths; we test the settings helper directly) - cli._installIntoSettings(settingsPath); // idempotent — no duplicate + cli._installIntoSettings(settingsPath); // idempotent const raw = fs.readFileSync(settingsPath, "utf-8"); const parsed = JSON.parse(raw); - // All hooks should be unique (no duplicates) const hookCount = Object.values(parsed.hooks).flat().length; - expect(hookCount).toBe(Object.keys(parsed.hooks).length); // 1 matcher per event + expect(hookCount).toBe(Object.keys(parsed.hooks).length); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/opentelemetry-instrumentation-claude/test/message-converter.test.js b/opentelemetry-instrumentation-claude/test/message-converter.test.js new file mode 100644 index 0000000..fd03d14 --- /dev/null +++ b/opentelemetry-instrumentation-claude/test/message-converter.test.js @@ -0,0 +1,366 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const { + convertSystemPrompt, + convertInputMessages, + convertOutputMessages, + extractRequestParams, + convertToolDefinitions, + mapStopReason, +} = require("../src/message-converter"); + +// --------------------------------------------------------------------------- +// mapStopReason +// --------------------------------------------------------------------------- +describe("mapStopReason", () => { + test("maps end_turn to stop", () => expect(mapStopReason("end_turn")).toBe("stop")); + test("maps stop to stop", () => expect(mapStopReason("stop")).toBe("stop")); + test("maps tool_use to tool_calls", () => expect(mapStopReason("tool_use")).toBe("tool_calls")); + test("maps max_tokens to length", () => expect(mapStopReason("max_tokens")).toBe("length")); + test("maps content_filter", () => expect(mapStopReason("content_filter")).toBe("content_filter")); + test("passes unknown values through", () => expect(mapStopReason("custom_reason")).toBe("custom_reason")); + test("null defaults to stop", () => expect(mapStopReason(null)).toBe("stop")); + test("undefined defaults to stop", () => expect(mapStopReason(undefined)).toBe("stop")); +}); + +// --------------------------------------------------------------------------- +// convertSystemPrompt +// --------------------------------------------------------------------------- +describe("convertSystemPrompt", () => { + test("null returns empty array", () => { + expect(convertSystemPrompt(null, "anthropic")).toEqual([]); + }); + + test("undefined returns empty array", () => { + expect(convertSystemPrompt(undefined, "anthropic")).toEqual([]); + }); + + test("string input returns single text part", () => { + expect(convertSystemPrompt("You are helpful", "anthropic")).toEqual([ + { type: "text", content: "You are helpful" }, + ]); + }); + + test("empty string returns empty array", () => { + expect(convertSystemPrompt("", "anthropic")).toEqual([]); + }); + + test("Anthropic array format with text blocks", () => { + const input = [ + { type: "text", text: "You are helpful.", cache_control: { type: "ephemeral" } }, + { type: "text", text: "Be concise." }, + ]; + expect(convertSystemPrompt(input, "anthropic")).toEqual([ + { type: "text", content: "You are helpful." }, + { type: "text", content: "Be concise." }, + ]); + }); + + test("OpenAI Chat array format with role+content", () => { + const input = [ + { role: "system", content: "You are a math tutor" }, + { role: "developer", content: "Be precise" }, + ]; + expect(convertSystemPrompt(input, "openai-chat")).toEqual([ + { type: "text", content: "You are a math tutor" }, + { type: "text", content: "Be precise" }, + ]); + }); + + test("OpenAI Responses string format", () => { + expect(convertSystemPrompt("System instructions", "openai-responses")).toEqual([ + { type: "text", content: "System instructions" }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// convertInputMessages — Anthropic +// --------------------------------------------------------------------------- +describe("convertInputMessages — Anthropic", () => { + test("null returns empty array", () => { + expect(convertInputMessages(null, "anthropic")).toEqual([]); + }); + + test("string content message", () => { + const messages = [{ role: "user", content: "Hello" }]; + expect(convertInputMessages(messages, "anthropic")).toEqual([ + { role: "user", parts: [{ type: "text", content: "Hello" }] }, + ]); + }); + + test("content block array with text", () => { + const messages = [{ + role: "user", + content: [{ type: "text", text: "What is 2+2?" }], + }]; + expect(convertInputMessages(messages, "anthropic")).toEqual([ + { role: "user", parts: [{ type: "text", content: "What is 2+2?" }] }, + ]); + }); + + test("tool_use content block", () => { + const messages = [{ + role: "assistant", + content: [ + { type: "text", text: "Let me check." }, + { type: "tool_use", id: "tool_1", name: "calculator", input: { a: 2, b: 2 } }, + ], + }]; + const result = convertInputMessages(messages, "anthropic"); + expect(result).toEqual([{ + role: "assistant", + parts: [ + { type: "text", content: "Let me check." }, + { type: "tool_call", id: "tool_1", name: "calculator", arguments: { a: 2, b: 2 } }, + ], + }]); + }); + + test("tool_result content block", () => { + const messages = [{ + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_1", content: "4" }], + }]; + const result = convertInputMessages(messages, "anthropic"); + expect(result).toEqual([{ + role: "user", + parts: [{ type: "tool_call_response", id: "tool_1", response: "4" }], + }]); + }); + + test("thinking content block", () => { + const messages = [{ + role: "assistant", + content: [{ type: "thinking", thinking: "I need to reason..." }], + }]; + const result = convertInputMessages(messages, "anthropic"); + expect(result).toEqual([{ + role: "assistant", + parts: [{ type: "reasoning", content: "I need to reason..." }], + }]); + }); +}); + +// --------------------------------------------------------------------------- +// convertInputMessages — OpenAI Chat +// --------------------------------------------------------------------------- +describe("convertInputMessages — OpenAI Chat", () => { + test("simple user message", () => { + const messages = [{ role: "user", content: "Hi" }]; + expect(convertInputMessages(messages, "openai-chat")).toEqual([ + { role: "user", parts: [{ type: "text", content: "Hi" }] }, + ]); + }); + + test("assistant with tool_calls", () => { + const messages = [{ + role: "assistant", + content: null, + tool_calls: [{ + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"SF"}' }, + }], + }]; + const result = convertInputMessages(messages, "openai-chat"); + expect(result).toEqual([{ + role: "assistant", + parts: [{ + type: "tool_call", + id: "call_1", + name: "get_weather", + arguments: '{"city":"SF"}', + }], + }]); + }); + + test("tool role message", () => { + const messages = [{ + role: "tool", + tool_call_id: "call_1", + content: '{"temp":72}', + }]; + const result = convertInputMessages(messages, "openai-chat"); + expect(result).toEqual([{ + role: "tool", + parts: [{ type: "tool_call_response", id: "call_1", response: '{"temp":72}' }], + }]); + }); +}); + +// --------------------------------------------------------------------------- +// convertInputMessages — OpenAI Responses +// --------------------------------------------------------------------------- +describe("convertInputMessages — OpenAI Responses", () => { + test("string input", () => { + expect(convertInputMessages("Hello", "openai-responses")).toEqual([ + { role: "user", parts: [{ type: "text", content: "Hello" }] }, + ]); + }); + + test("array with role+content", () => { + const messages = [{ role: "user", content: "Hi" }]; + expect(convertInputMessages(messages, "openai-responses")).toEqual([ + { role: "user", parts: [{ type: "text", content: "Hi" }] }, + ]); + }); + + test("function_call_output item", () => { + const messages = [{ type: "function_call_output", call_id: "fc_1", output: "result" }]; + const result = convertInputMessages(messages, "openai-responses"); + expect(result).toEqual([{ + role: "tool", + parts: [{ type: "tool_call_response", id: "fc_1", response: "result" }], + }]); + }); +}); + +// --------------------------------------------------------------------------- +// convertOutputMessages +// --------------------------------------------------------------------------- +describe("convertOutputMessages", () => { + test("text output", () => { + const blocks = [{ type: "text", text: "Hello!" }]; + const result = convertOutputMessages(blocks, "end_turn"); + expect(result).toEqual([{ + role: "assistant", + parts: [{ type: "text", content: "Hello!" }], + finishReason: "stop", + }]); + }); + + test("tool_use output", () => { + const blocks = [ + { type: "text", text: "Let me run that." }, + { type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }, + ]; + const result = convertOutputMessages(blocks, "tool_use"); + expect(result).toEqual([{ + role: "assistant", + parts: [ + { type: "text", content: "Let me run that." }, + { type: "tool_call", id: "t1", name: "Bash", arguments: { command: "ls" } }, + ], + finishReason: "tool_calls", + }]); + }); + + test("thinking output", () => { + const blocks = [{ type: "thinking", thinking: "Reasoning..." }]; + const result = convertOutputMessages(blocks, "stop"); + expect(result).toEqual([{ + role: "assistant", + parts: [{ type: "reasoning", content: "Reasoning..." }], + finishReason: "stop", + }]); + }); + + test("null output returns empty parts", () => { + const result = convertOutputMessages(null, "end_turn"); + expect(result).toEqual([{ + role: "assistant", + parts: [], + finishReason: "stop", + }]); + }); + + test("empty array returns empty parts", () => { + const result = convertOutputMessages([], "stop"); + expect(result).toEqual([{ + role: "assistant", + parts: [], + finishReason: "stop", + }]); + }); +}); + +// --------------------------------------------------------------------------- +// extractRequestParams +// --------------------------------------------------------------------------- +describe("extractRequestParams", () => { + test("extracts all params", () => { + const body = { + temperature: 0.7, + top_p: 0.9, + top_k: 40, + max_tokens: 4096, + stop_sequences: ["END"], + seed: 42, + frequency_penalty: 0.1, + presence_penalty: 0.2, + }; + expect(extractRequestParams(body)).toEqual({ + temperature: 0.7, + topP: 0.9, + topK: 40, + maxTokens: 4096, + stopSequences: ["END"], + seed: 42, + frequencyPenalty: 0.1, + presencePenalty: 0.2, + }); + }); + + test("null body returns empty object", () => { + expect(extractRequestParams(null)).toEqual({}); + }); + + test("partial params", () => { + expect(extractRequestParams({ temperature: 0.5 })).toEqual({ temperature: 0.5 }); + }); + + test("OpenAI stop field maps to stopSequences", () => { + expect(extractRequestParams({ stop: ["<|end|>"] })).toEqual({ stopSequences: ["<|end|>"] }); + }); + + test("stop string wraps in array", () => { + expect(extractRequestParams({ stop: "STOP" })).toEqual({ stopSequences: ["STOP"] }); + }); +}); + +// --------------------------------------------------------------------------- +// convertToolDefinitions +// --------------------------------------------------------------------------- +describe("convertToolDefinitions", () => { + test("Anthropic format", () => { + const tools = [{ + name: "calculator", + description: "Multiply numbers", + input_schema: { type: "object", properties: { a: { type: "number" } } }, + }]; + expect(convertToolDefinitions(tools)).toEqual([{ + type: "function", + name: "calculator", + description: "Multiply numbers", + parameters: { type: "object", properties: { a: { type: "number" } } }, + }]); + }); + + test("OpenAI format", () => { + const tools = [{ + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object" }, + }, + }]; + expect(convertToolDefinitions(tools)).toEqual([{ + type: "function", + name: "get_weather", + description: "Get weather", + parameters: { type: "object" }, + }]); + }); + + test("null input returns empty array", () => { + expect(convertToolDefinitions(null)).toEqual([]); + }); + + test("filters null entries", () => { + expect(convertToolDefinitions([null, undefined])).toEqual([]); + }); +}); diff --git a/opentelemetry-util-genai/package.json b/opentelemetry-util-genai/package.json index f3c97b4..c1f8f92 100644 --- a/opentelemetry-util-genai/package.json +++ b/opentelemetry-util-genai/package.json @@ -24,19 +24,23 @@ "instrumentation", "loongsuite" ], - "main": "dist/index.js", + "main": "dist/cjs/index.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/cjs/index.js" } }, "files": [ - "dist" + "dist", + "dist/cjs" ], "scripts": { - "build": "tsc", + "build": "tsc && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "build:esm": "tsc", + "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", "dev": "tsc --watch", "clean": "rm -rf dist", "prepublishOnly": "npm run build", diff --git a/opentelemetry-util-genai/tsconfig.cjs.json b/opentelemetry-util-genai/tsconfig.cjs.json new file mode 100644 index 0000000..12a6d60 --- /dev/null +++ b/opentelemetry-util-genai/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist/cjs", + "declaration": false, + "declarationMap": false, + "sourceMap": false + } +} From e9453f36efc1c06970368dbe849204a0160312c5 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Mon, 27 Apr 2026 11:54:55 +0800 Subject: [PATCH 02/23] init --- opentelemetry-instrumentation-claude/package.json | 2 +- opentelemetry-util-genai/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opentelemetry-instrumentation-claude/package.json b/opentelemetry-instrumentation-claude/package.json index 5eb42fa..1ef2937 100644 --- a/opentelemetry-instrumentation-claude/package.json +++ b/opentelemetry-instrumentation-claude/package.json @@ -67,7 +67,7 @@ } }, "dependencies": { - "@loongsuite/opentelemetry-util-genai": "^0.1.0", + "@loongsuite/opentelemetry-util-genai": "^0.1.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", diff --git a/opentelemetry-util-genai/package.json b/opentelemetry-util-genai/package.json index c1f8f92..4c8e3ac 100644 --- a/opentelemetry-util-genai/package.json +++ b/opentelemetry-util-genai/package.json @@ -1,6 +1,6 @@ { "name": "@loongsuite/opentelemetry-util-genai", - "version": "0.1.0", + "version": "0.1.1", "description": "OpenTelemetry GenAI utility for standardized telemetry collection across LLM, Agent, Embedding, Tool, Retrieval, Rerank, Memory and more", "type": "module", "license": "Apache-2.0", From 1b3ad169d263a2698d9533b8ea4fddf4354b6a25 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Mon, 27 Apr 2026 12:08:46 +0800 Subject: [PATCH 03/23] fix(claude): regenerate package-lock.json to resolve from npm registry The lock file previously contained "link": true pointing to the local monorepo sibling directory, causing fresh clones to symlink instead of downloading from npm. New users got MODULE_NOT_FOUND because the local opentelemetry-util-genai had no dist/cjs/ build output. Co-Authored-By: Claude Opus 4.6 --- .../package-lock.json | 117 +++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/opentelemetry-instrumentation-claude/package-lock.json b/opentelemetry-instrumentation-claude/package-lock.json index 37243ae..6cb1b3e 100644 --- a/opentelemetry-instrumentation-claude/package-lock.json +++ b/opentelemetry-instrumentation-claude/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@loongsuite/opentelemetry-util-genai": "^0.1.0", + "@loongsuite/opentelemetry-util-genai": "^0.1.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", "@opentelemetry/resources": "^1.30.0", @@ -28,27 +28,6 @@ "node": ">=18.0.0" } }, - "../opentelemetry-util-genai": { - "name": "@loongsuite/opentelemetry-util-genai", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.40.0" - }, - "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0", - "@vitest/coverage-v8": "^4.1.3", - "typescript": "^5.0.0", - "vitest": "^4.1.3" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -583,9 +562,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -935,8 +914,19 @@ } }, "node_modules/@loongsuite/opentelemetry-util-genai": { - "resolved": "../opentelemetry-util-genai", - "link": true + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@loongsuite/opentelemetry-util-genai/-/opentelemetry-util-genai-0.1.1.tgz", + "integrity": "sha512-54rcOPyGynpY6+65noY9nGbZa6biIl8wtJ6+8sVHaH1NHe49jpfLAD1tJJolRaWqyCwR4yZfHOH7hOGpGG7yOA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } }, "node_modules/@opentelemetry/api": { "version": "1.9.1", @@ -1368,12 +1358,12 @@ } }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/stack-utils": { @@ -1600,9 +1590,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", - "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1613,9 +1603,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1708,9 +1698,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "dev": true, "funding": [ { @@ -1955,9 +1945,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "dev": true, "license": "ISC" }, @@ -1991,6 +1981,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2234,9 +2234,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3241,9 +3241,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -3493,9 +3493,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3551,12 +3551,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -3864,9 +3865,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/update-browserslist-db": { From 5c9192b63d59971d8c2ffa970e7d8768f4701e66 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Mon, 27 Apr 2026 19:34:57 +0800 Subject: [PATCH 04/23] feat(claude): add multimodal image support using BlobPart/UriPart Support image content blocks across all three LLM API protocols (Anthropic, OpenAI Chat, OpenAI Responses) by converting them to ARMS semantic convention BlobPart (base64) and UriPart (URL) formats. Co-Authored-By: Claude Opus 4.6 --- .../src/message-converter.js | 22 +++++ .../test/message-converter.test.js | 84 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/opentelemetry-instrumentation-claude/src/message-converter.js b/opentelemetry-instrumentation-claude/src/message-converter.js index 32bc5ef..ad8a16d 100644 --- a/opentelemetry-instrumentation-claude/src/message-converter.js +++ b/opentelemetry-instrumentation-claude/src/message-converter.js @@ -84,6 +84,12 @@ function convertAnthropicContentBlock(block) { id: block.tool_use_id || null, response: block.content ?? null, }; + case "image": { + const src = block.source || {}; + const mimeType = src.media_type || "image/unknown"; + const data = src.data || ""; + return { type: "blob", mime_type: mimeType, modality: "image", content: data }; + } case "thinking": return { type: "reasoning", content: block.thinking || "" }; default: @@ -162,6 +168,14 @@ function convertOpenAIChatMessage(msg) { parts.push({ type: "text", content: block }); } else if (block && block.type === "text") { parts.push({ type: "text", content: block.text || "" }); + } else if (block && block.type === "image_url" && block.image_url) { + const url = typeof block.image_url === "string" ? block.image_url : block.image_url.url || ""; + const dataMatch = url.match(/^data:([^;]+);base64,(.+)$/); + if (dataMatch) { + parts.push({ type: "blob", mime_type: dataMatch[1], modality: "image", content: dataMatch[2] }); + } else { + parts.push({ type: "uri", mime_type: "image/unknown", modality: "image", uri: url }); + } } } } @@ -209,6 +223,14 @@ function convertOpenAIResponsesItem(item) { if (typeof c === "string") return { type: "text", content: c }; if (c && c.type === "input_text") return { type: "text", content: c.text || "" }; if (c && c.type === "text") return { type: "text", content: c.text || "" }; + if (c && c.type === "input_image") { + const url = c.image_url || c.url || ""; + const dataMatch = url.match(/^data:([^;]+);base64,(.+)$/); + if (dataMatch) { + return { type: "blob", mime_type: dataMatch[1], modality: "image", content: dataMatch[2] }; + } + return { type: "uri", mime_type: "image/unknown", modality: "image", uri: url }; + } return { type: c?.type || "unknown" }; }); return { role, parts }; diff --git a/opentelemetry-instrumentation-claude/test/message-converter.test.js b/opentelemetry-instrumentation-claude/test/message-converter.test.js index fd03d14..8250c30 100644 --- a/opentelemetry-instrumentation-claude/test/message-converter.test.js +++ b/opentelemetry-instrumentation-claude/test/message-converter.test.js @@ -142,6 +142,24 @@ describe("convertInputMessages — Anthropic", () => { parts: [{ type: "reasoning", content: "I need to reason..." }], }]); }); + + test("image content block converts to BlobPart", () => { + const messages = [{ + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" } }, + ], + }]; + const result = convertInputMessages(messages, "anthropic"); + expect(result).toEqual([{ + role: "user", + parts: [ + { type: "text", content: "What is this?" }, + { type: "blob", mime_type: "image/png", modality: "image", content: "iVBORw0KGgo=" }, + ], + }]); + }); }); // --------------------------------------------------------------------------- @@ -189,6 +207,40 @@ describe("convertInputMessages — OpenAI Chat", () => { parts: [{ type: "tool_call_response", id: "call_1", response: '{"temp":72}' }], }]); }); + + test("image_url with base64 data converts to BlobPart", () => { + const messages = [{ + role: "user", + content: [ + { type: "text", text: "Describe this" }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/4AAQ=" } }, + ], + }]; + const result = convertInputMessages(messages, "openai-chat"); + expect(result).toEqual([{ + role: "user", + parts: [ + { type: "text", content: "Describe this" }, + { type: "blob", mime_type: "image/jpeg", modality: "image", content: "/9j/4AAQ=" }, + ], + }]); + }); + + test("image_url with URL converts to UriPart", () => { + const messages = [{ + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/photo.png" } }, + ], + }]; + const result = convertInputMessages(messages, "openai-chat"); + expect(result).toEqual([{ + role: "user", + parts: [ + { type: "uri", mime_type: "image/unknown", modality: "image", uri: "https://example.com/photo.png" }, + ], + }]); + }); }); // --------------------------------------------------------------------------- @@ -216,6 +268,38 @@ describe("convertInputMessages — OpenAI Responses", () => { parts: [{ type: "tool_call_response", id: "fc_1", response: "result" }], }]); }); + + test("input_image with base64 data converts to BlobPart", () => { + const messages = [{ + role: "user", + content: [ + { type: "input_text", text: "What is this?" }, + { type: "input_image", image_url: "data:image/png;base64,iVBORw0KGgo=" }, + ], + }]; + const result = convertInputMessages(messages, "openai-responses"); + expect(result).toEqual([{ + role: "user", + parts: [ + { type: "text", content: "What is this?" }, + { type: "blob", mime_type: "image/png", modality: "image", content: "iVBORw0KGgo=" }, + ], + }]); + }); + + test("input_image with URL converts to UriPart", () => { + const messages = [{ + role: "user", + content: [{ type: "input_image", url: "https://example.com/img.jpg" }], + }]; + const result = convertInputMessages(messages, "openai-responses"); + expect(result).toEqual([{ + role: "user", + parts: [ + { type: "uri", mime_type: "image/unknown", modality: "image", uri: "https://example.com/img.jpg" }, + ], + }]); + }); }); // --------------------------------------------------------------------------- From 4bd8be64774c2b45c96e1a6b7d97f380dbc0604b Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Tue, 28 Apr 2026 16:24:55 +0800 Subject: [PATCH 05/23] feat(claude): add config file support, JSONL logging, and fix two bugs - Add ~/.claude/otel-config.json support (priority: config file > env var > default) - Add JSONL log collection with chain hash and daily file rotation - Fix -p mode missing LLM/STEP spans (remove setImmediate race in intercept.js) - Fix OTEL_RESOURCE_ATTRIBUTES not fully parsed into Resource - Update README with config file docs, new modules, and JSONL logging Co-Authored-By: Claude Opus 4.6 --- .../README.md | 73 ++++- .../src/cli.js | 197 +++++++++++- .../src/config.js | 92 ++++++ .../src/intercept.js | 78 +++-- .../src/logger.js | 103 ++++++ .../src/telemetry.js | 60 +++- .../test/cli.test.js | 277 +++++++++++++++++ .../test/config.test.js | 215 +++++++++++++ .../test/logger.test.js | 293 ++++++++++++++++++ .../test/telemetry.test.js | 52 ++++ 10 files changed, 1372 insertions(+), 68 deletions(-) create mode 100644 opentelemetry-instrumentation-claude/src/config.js create mode 100644 opentelemetry-instrumentation-claude/src/logger.js create mode 100644 opentelemetry-instrumentation-claude/test/config.test.js create mode 100644 opentelemetry-instrumentation-claude/test/logger.test.js diff --git a/opentelemetry-instrumentation-claude/README.md b/opentelemetry-instrumentation-claude/README.md index 1a0bb8e..2dd4866 100644 --- a/opentelemetry-instrumentation-claude/README.md +++ b/opentelemetry-instrumentation-claude/README.md @@ -16,6 +16,8 @@ Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/ - **语义方言支持**:自动检测 Sunfire 端点,切换 `gen_ai.span_kind_name`(ALIBABA_GROUP)/ `gen_ai.span.kind`(默认)属性名 - **原子状态写入**:基于 `rename` 的原子文件写入,防止并发 hook 进程读取到半写文件 - **自动 alias 注入**:安装后 `claude` 命令自动携带 `NODE_OPTIONS=--require intercept.js`,无需手动配置 +- **配置文件支持**:可通过 `~/.claude/otel-config.json` 配置所有 OTLP 参数,优先于环境变量,避免与本地其他 OTel 工具冲突 +- **JSONL 日志采集**:可选的本地日志功能,支持 chain hash 增量校验和每日文件轮转,与 trace 数据关联 - **一键安装**:`npm install -g` 后 postinstall 自动完成全部配置,或 `bash scripts/install.sh` 源码安装 --- @@ -92,7 +94,41 @@ bash scripts/install.sh ## 配置说明 -所有配置通过**环境变量**完成,无需配置文件。 +支持两种配置方式:**配置文件**和**环境变量**。配置文件优先于环境变量,适用于本地有其他 OTel 工具使用相同环境变量的场景。 + +### 方式一:配置文件(推荐) + +创建 `~/.claude/otel-config.json`: + +```json +{ + "otlp_endpoint": "https://your-otlp-endpoint:4318", + "otlp_headers": "x-api-key=abc123,x-other=val", + "service_name": "my-claude-agent", + "resource_attributes": "env=prod,team=infra", + "debug": false, + "semconv_dialect": "", + "log_enabled": false, + "log_dir": "" +} +``` + +所有字段均为可选,未设置的字段回退到对应的环境变量,再回退到默认值。 + +**优先级:配置文件 > 环境变量 > 默认值** + +| 配置文件字段 | 对应环境变量 | 说明 | 默认值 | +|---|---|---|---| +| `otlp_endpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP 导出端点 | —(必填,或启用 debug) | +| `otlp_headers` | `OTEL_EXPORTER_OTLP_HEADERS` | 导出请求头,逗号分隔 `key=value` | — | +| `service_name` | `OTEL_SERVICE_NAME` | Trace 中的 service name | `claude-agents` | +| `resource_attributes` | `OTEL_RESOURCE_ATTRIBUTES` | 附加资源属性 | — | +| `debug` | `CLAUDE_TELEMETRY_DEBUG` | 启用 Console 输出(调试用) | `false` | +| `semconv_dialect` | `LOONGSUITE_SEMCONV_DIALECT_NAME` | 语义规范方言 | 自动检测 | +| `log_enabled` | `OTEL_CLAUDE_LOG_ENABLED` | 启用 JSONL 日志采集 | `false` | +| `log_dir` | `OTEL_CLAUDE_LOG_DIR` | JSONL 日志目录 | `~/.loongcollector/data/` | + +### 方式二:环境变量 | 环境变量 | 说明 | 默认值 | |----------|------|--------| @@ -106,8 +142,20 @@ bash scripts/install.sh | `OTEL_CLAUDE_HOOK_CMD` | 自定义 hook 命令名称 | `otel-claude-hook` | | `OTEL_CLAUDE_LANG` | 强制指定语言(`zh` 或 `en`),不设则自动检测 `$LANGUAGE`、`$LC_ALL`、`$LANG` | 自动检测 | | `LOONGSUITE_SEMCONV_DIALECT_NAME` | 语义规范方言:`ALIBABA_GROUP` 使用 `gen_ai.span_kind_name`,默认使用 `gen_ai.span.kind` | 自动检测 | +| `OTEL_CLAUDE_LOG_ENABLED` | 设为 `1` 启用 JSONL 日志采集 | — | +| `OTEL_CLAUDE_LOG_DIR` | JSONL 日志文件目录 | `~/.loongcollector/data/` | -### 示例:接入 Honeycomb +### 示例:配置文件接入 Honeycomb + +```json +{ + "otlp_endpoint": "https://api.honeycomb.io", + "otlp_headers": "x-honeycomb-team=", + "resource_attributes": "service.name=my-claude-agent,env=production" +} +``` + +### 示例:环境变量接入 Honeycomb ```bash export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.honeycomb.io" @@ -121,6 +169,14 @@ export OTEL_RESOURCE_ATTRIBUTES="service.name=my-claude-agent,env=production" export CLAUDE_TELEMETRY_DEBUG=1 ``` +或在配置文件中: + +```json +{ + "debug": true +} +``` + --- ## 使用方法 @@ -264,6 +320,8 @@ opentelemetry-instrumentation-claude/ ├── src/ │ ├── index.js # 包入口,导出核心 API │ ├── cli.js # hook 命令实现 + replayEventsAsSpans + exportSessionTrace +│ ├── config.js # 配置加载(~/.claude/otel-config.json + 环境变量 + 默认值) +│ ├── logger.js # JSONL 日志采集(chain hash + 每日文件轮转) │ ├── message-converter.js # LLM 消息格式转换(Anthropic/OpenAI → ARMS 语义规范) │ ├── state.js # session 状态文件读写(原子写入) │ ├── telemetry.js # OTel TracerProvider 配置(OTLP/HTTP + Console) @@ -276,6 +334,8 @@ opentelemetry-instrumentation-claude/ │ └── uninstall.sh # 卸载脚本 └── test/ ├── cli.test.js # CLI + replayEventsAsSpans + exportSessionTrace 测试 + ├── config.test.js # 配置文件加载、优先级、缺失文件兜底测试 + ├── logger.test.js # JSONL 日志、chain hash、文件轮转测试 ├── message-converter.test.js # 消息格式转换测试(3 协议 × 多场景) ├── hooks.test.js # hooks 工具函数测试 ├── state.test.js # 状态文件读写测试 @@ -287,7 +347,9 @@ opentelemetry-instrumentation-claude/ ## 工作原理 -1. **hook 命令注册**:`otel-claude-hook install` 将 8 个 hook 命令写入 `~/.claude/settings.json`。Claude Code 在每个生命周期事件时以子进程方式调用对应命令,并将事件 JSON 通过 stdin 传入。 +1. **配置加载**:`config.js` 在首次访问时读取 `~/.claude/otel-config.json`(如存在),并缓存在内存中。后续所有模块(telemetry、cli、logger)通过 config 模块获取配置,遵循 **配置文件 > 环境变量 > 默认值** 的优先级。 + +2. **hook 命令注册**:`otel-claude-hook install` 将 8 个 hook 命令写入 `~/.claude/settings.json`。Claude Code 在每个生命周期事件时以子进程方式调用对应命令,并将事件 JSON 通过 stdin 传入。 2. **状态持久化**:每个 session 的事件序列存储在: ``` @@ -319,6 +381,11 @@ opentelemetry-instrumentation-claude/ - 导出成功后清空已导出事件,避免下轮重复导出 - 执行 `forceFlush` + `shutdown` 确保数据发送完毕 +6. **JSONL 日志采集**(可选):当 `log_enabled=true` 时,`logger.js` 在 trace 导出完成后将每轮对话的详细记录写入本地 JSONL 文件: + - 文件路径:`/claude-code.jsonl.YYYYMMDD`,按天自动轮转 + - 每条记录包含 `trace_id`(与 OTel trace 关联)、session/turn/step 标识、token 用量、消息内容 + - **Chain hash 增量校验**:使用 `H_n = sha256(H_{n-1} + serialize(msg_n))` 算法检测消息是否被上下文压缩修改。仅在 hash 不匹配时记录完整 `input_messages`,正常情况下只记录增量 `delta`,大幅节省存储 + --- ## 本地开发与测试 diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index fcb0086..c7346e1 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -25,6 +25,11 @@ const { convertSystemPrompt, convertInputMessages, convertOutputMessages, extractRequestParams, convertToolDefinitions, } = require("./message-converter"); +const { + isLogEnabled, writeLogRecords, computeHash, INITIAL_HASH, + shouldLogFullMessages, +} = require("./logger"); +const config = require("./config"); // --------------------------------------------------------------------------- // Semantic convention dialect @@ -32,12 +37,14 @@ const { // default (ALIBABA_CLOUD or unset) → gen_ai.span.kind // Auto-detect: endpoint containing "sunfire" implies ALIBABA_GROUP // --------------------------------------------------------------------------- -const _sunfireDetected = (process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "").includes("sunfire"); +const _dialect = config.getSemconvDialect(); +const _endpoint = config.getEndpoint(); +const _sunfireDetected = _endpoint.includes("sunfire"); const SPAN_KIND_ATTR = - process.env.LOONGSUITE_SEMCONV_DIALECT_NAME === "ALIBABA_GROUP" || _sunfireDetected + _dialect === "ALIBABA_GROUP" || _sunfireDetected ? "gen_ai.span_kind_name" : "gen_ai.span.kind"; -const NEEDS_DIALECT_ATTR = process.env.LOONGSUITE_SEMCONV_DIALECT_NAME === "ALIBABA_GROUP" || _sunfireDetected; +const NEEDS_DIALECT_ATTR = _dialect === "ALIBABA_GROUP" || _sunfireDetected; // --------------------------------------------------------------------------- // 语言检测 / Language detection @@ -703,6 +710,136 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi } } +// --------------------------------------------------------------------------- +// generateTurnLogRecords — JSONL log records for a single turn +// --------------------------------------------------------------------------- + +function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, traceId) { + const records = []; + const turnId = `${sessionId}:t${turnIndex + 1}`; + let stepRound = 0; + let currentStepId = null; + let runningHash = prevHash; + + if (turn.prompt) { + records.push({ + timestamp_ns: Math.round(turn.startTime * 1e9), + trace_id: traceId || null, + "gen_ai.session_id": sessionId, + "gen_ai.turn_id": turnId, + "gen_ai.agent_name": "claude-code", + "gen_ai.role": "user", + "gen_ai.input_messages_delta": JSON.stringify( + [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] + ), + }); + } + + const preToolUseMap = {}; + for (const ev of turn.events) { + if (ev.type === "pre_tool_use" && ev.tool_use_id) { + preToolUseMap[ev.tool_use_id] = ev; + } + } + + for (const ev of turn.events) { + const evTs = ev.timestamp || turn.endTime; + + if (ev.type === "llm_call") { + stepRound++; + currentStepId = `${turnId}:s${stepRound}`; + const responseId = ev.response_id || `${currentStepId}:r`; + const protocol = ev.protocol || "anthropic"; + + const inputMsgs = convertInputMessages(ev.input_messages, protocol); + const currentFullHash = computeHash(INITIAL_HASH, inputMsgs); + + const delta = inputMsgs; + const logFull = shouldLogFullMessages(runningHash, delta, currentFullHash); + + const record = { + timestamp_ns: Math.round((ev.request_start_time || evTs) * 1e9), + trace_id: traceId || null, + "gen_ai.session_id": sessionId, + "gen_ai.turn_id": turnId, + "gen_ai.step_id": currentStepId, + "gen_ai.response_id": responseId, + "gen_ai.agent_id": sessionId, + "gen_ai.agent_name": "claude-code", + "gen_ai.provider_name": "anthropic", + "gen_ai.request_model": ev.model || model, + "gen_ai.response_model": ev.model || model, + "gen_ai.response_finish_reasons": ev.stop_reason || "stop", + "gen_ai.input_tokens": ev.input_tokens || 0, + "gen_ai.output_tokens": ev.output_tokens || 0, + "gen_ai.cache_write_tokens": ev.cache_creation_input_tokens || 0, + "gen_ai.cache_read_tokens": ev.cache_read_input_tokens || 0, + "gen_ai.role": "assistant", + "gen_ai.input_messages_hash": currentFullHash, + "gen_ai.input_messages_delta": JSON.stringify(delta), + "gen_ai.output_messages": JSON.stringify( + convertOutputMessages(ev.output_content, ev.stop_reason) + ), + }; + + if (logFull) { + record["gen_ai.input_messages"] = JSON.stringify(inputMsgs); + } + + if (ev.is_error) { + record["gen_ai.error_type"] = "LLMError"; + record["gen_ai.error_message"] = ev.error_message || "unknown error"; + } + + records.push(record); + runningHash = currentFullHash; + + } else if (ev.type === "post_tool_use") { + const toolName = ev.tool_name || "unknown"; + if (toolName === "Agent" || toolName === "agent") continue; + + const preEv = preToolUseMap[ev.tool_use_id] || {}; + const effectiveName = preEv.tool_name || toolName; + const effectiveInput = preEv.tool_input || {}; + + records.push({ + timestamp_ns: Math.round(evTs * 1e9), + trace_id: traceId || null, + "gen_ai.session_id": sessionId, + "gen_ai.turn_id": turnId, + "gen_ai.step_id": currentStepId || turnId, + "gen_ai.role": "tool", + "gen_ai.tool_name": effectiveName, + "gen_ai.tool_arguments": JSON.stringify(effectiveInput), + "gen_ai.tool_results": JSON.stringify(extractToolResult(ev.tool_response)), + "gen_ai.tool_call_id": ev.tool_use_id || "", + }); + } + } + + const consumedIds = new Set( + turn.events.filter(e => e.type === "post_tool_use" && e.tool_use_id).map(e => e.tool_use_id) + ); + for (const [toolUseId, preEv] of Object.entries(preToolUseMap)) { + if (consumedIds.has(toolUseId)) continue; + const toolName = preEv.tool_name || "unknown"; + if (toolName === "Agent" || toolName === "agent") continue; + records.push({ + timestamp_ns: Math.round((preEv.timestamp || turn.endTime) * 1e9), + trace_id: traceId || null, + "gen_ai.session_id": sessionId, + "gen_ai.turn_id": turnId, + "gen_ai.step_id": currentStepId || turnId, + "gen_ai.role": "tool", + "gen_ai.tool_name": toolName, + "gen_ai.tool_arguments": JSON.stringify(preEv.tool_input || {}), + "gen_ai.tool_call_id": toolUseId, + }); + } + + return { records, hash: runningHash }; +} + // --------------------------------------------------------------------------- // export_session_trace — per-turn independent traces with ENTRY → AGENT → STEP → LLM/TOOL // --------------------------------------------------------------------------- @@ -743,6 +880,7 @@ async function exportSessionTrace(state, stopReason = "end_turn") { turns[turns.length - 1].endTime = stopTime; // Export each turn as an independent trace + const entryInvs = []; for (let i = 0; i < turns.length; i++) { const turn = turns[i]; const isLast = i === turns.length - 1; @@ -777,6 +915,7 @@ async function exportSessionTrace(state, stopReason = "end_turn") { attributes: sessionAttrs(sessionId, "ENTRY"), }); handler.startEntry(entryInv, undefined, hrTime(turn.startTime)); + entryInvs.push(entryInv); // AGENT span const agentInv = createInvokeAgentInvocation("anthropic", { @@ -806,6 +945,33 @@ async function exportSessionTrace(state, stopReason = "end_turn") { handler.stopEntry(entryInv, hrTime(turn.endTime)); } + // Write JSONL logs (independent of trace export — failure doesn't block traces) + if (isLogEnabled()) { + try { + const allLogRecords = []; + let logHash = INITIAL_HASH; + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + let turnTraceId = null; + try { + const entrySpan = trace.getSpan(entryInvs[i].contextToken); + if (entrySpan) turnTraceId = entrySpan.spanContext().traceId; + } catch {} + + const { records, hash } = generateTurnLogRecords( + turn, i, sessionId, state.model || "unknown", logHash, turnTraceId + ); + allLogRecords.push(...records); + logHash = hash; + } + + writeLogRecords(allLogRecords); + } catch (err) { + console.error("[otel-claude-hook] log writing failed (non-fatal):", err?.message || String(err)); + } + } + await shutdownTelemetry(); const totalIn = turns.reduce((s, t) => @@ -1159,21 +1325,33 @@ function cmdShowConfig() { } function cmdCheckEnv() { - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const debug = process.env.CLAUDE_TELEMETRY_DEBUG; + const endpoint = config.getEndpoint(); + const debugMode = config.isDebug(); + + // Show config file status + const cfgFile = config.loadConfigFile(); + const hasCfgFile = Object.keys(cfgFile).length > 0; + if (hasCfgFile) { + console.error(msg(`📄 配置文件: ${config.CONFIG_PATH}`, `📄 Config file: ${config.CONFIG_PATH}`)); + const cfgKeys = Object.keys(cfgFile).filter(k => cfgFile[k] !== null && cfgFile[k] !== undefined && cfgFile[k] !== ""); + if (cfgKeys.length > 0) { + console.error(msg(` 已配置项: ${cfgKeys.join(", ")}`, ` Configured keys: ${cfgKeys.join(", ")}`)); + } + } if (endpoint) { console.error(msg(`📊 OTEL 端点: ${endpoint}`, `📊 OTEL endpoint: ${endpoint}`)); - if (process.env.OTEL_EXPORTER_OTLP_HEADERS) { + const headers = config.getHeaders(); + if (headers) { console.error(msg(" 请求头: ***已配置***", " Headers: ***configured***")); } - } else if (debug) { + } else if (debugMode) { console.error(msg("🔍 调试模式已启用(仅控制台输出)", "🔍 Debug mode active (console output only)")); } else { console.error( msg( - "❌ 未配置遥测后端。\n设置 OTEL_EXPORTER_OTLP_ENDPOINT 或 CLAUDE_TELEMETRY_DEBUG=1", - "❌ No telemetry backend configured.\nSet OTEL_EXPORTER_OTLP_ENDPOINT or CLAUDE_TELEMETRY_DEBUG=1" + "❌ 未配置遥测后端。\n设置 OTEL_EXPORTER_OTLP_ENDPOINT 或配置文件 ~/.claude/otel-config.json", + "❌ No telemetry backend configured.\nSet OTEL_EXPORTER_OTLP_ENDPOINT or config file ~/.claude/otel-config.json" ) ); process.exit(1); @@ -1371,6 +1549,7 @@ module.exports = { _replayEventsAsSpans: replayEventsAsSpans, _splitEventsByTurn: splitEventsByTurn, _exportSessionTrace: exportSessionTrace, + _generateTurnLogRecords: generateTurnLogRecords, _installIntercept: installIntercept, _removeAliasFromFile: removeAliasFromFile, _cmdSubagentStartWithEvent: function(event) { diff --git a/opentelemetry-instrumentation-claude/src/config.js b/opentelemetry-instrumentation-claude/src/config.js new file mode 100644 index 0000000..0a7ab6f --- /dev/null +++ b/opentelemetry-instrumentation-claude/src/config.js @@ -0,0 +1,92 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const CONFIG_PATH = path.join(os.homedir(), ".claude", "otel-config.json"); + +let _configCache = undefined; + +function loadConfigFile() { + if (_configCache !== undefined) return _configCache; + try { + const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + _configCache = JSON.parse(raw); + if (_configCache === null || typeof _configCache !== "object" || Array.isArray(_configCache)) { + _configCache = {}; + } + } catch { + _configCache = {}; + } + return _configCache; +} + +function resetConfigCache() { + _configCache = undefined; +} + +function getConfig(key, envVar, defaultValue) { + const cfg = loadConfigFile(); + if (key in cfg && cfg[key] !== null && cfg[key] !== undefined && cfg[key] !== "") { + return cfg[key]; + } + const envVal = process.env[envVar]; + if (envVal !== undefined && envVal !== "") { + if (typeof defaultValue === "boolean") { + return envVal === "1" || envVal === "true"; + } + return envVal; + } + return defaultValue; +} + +function getEndpoint() { + return getConfig("otlp_endpoint", "OTEL_EXPORTER_OTLP_ENDPOINT", ""); +} + +function getHeaders() { + return getConfig("otlp_headers", "OTEL_EXPORTER_OTLP_HEADERS", ""); +} + +function getServiceName(defaultName) { + return getConfig("service_name", "OTEL_SERVICE_NAME", defaultName || ""); +} + +function getResourceAttributes() { + return getConfig("resource_attributes", "OTEL_RESOURCE_ATTRIBUTES", ""); +} + +function isDebug() { + return getConfig("debug", "CLAUDE_TELEMETRY_DEBUG", false); +} + +function getSemconvDialect() { + return getConfig("semconv_dialect", "LOONGSUITE_SEMCONV_DIALECT_NAME", ""); +} + +function isLogEnabled() { + return getConfig("log_enabled", "OTEL_CLAUDE_LOG_ENABLED", false); +} + +function getLogDir() { + return getConfig("log_dir", "OTEL_CLAUDE_LOG_DIR", ""); +} + +module.exports = { + CONFIG_PATH, + loadConfigFile, + resetConfigCache, + getConfig, + getEndpoint, + getHeaders, + getServiceName, + getResourceAttributes, + isDebug, + getSemconvDialect, + isLogEnabled, + getLogDir, +}; diff --git a/opentelemetry-instrumentation-claude/src/intercept.js b/opentelemetry-instrumentation-claude/src/intercept.js index 1bd7850..2be112e 100755 --- a/opentelemetry-instrumentation-claude/src/intercept.js +++ b/opentelemetry-instrumentation-claude/src/intercept.js @@ -991,19 +991,17 @@ function tryInstallUndici() { }; wrappedHandler.onResponseEnd = (controller, trailers) => { - setImmediate(() => { - try { - const rawBody = Buffer.concat(responseChunks); - appendProxyEvent(buildEvent(requestStartTime, reqFields, responseStatus, rawBody, contentType, contentEncoding, vendorTraceId, protocol)); - } catch (err) { - debug("error processing response: " + err.message); - } - }); + try { + const rawBody = Buffer.concat(responseChunks); + appendProxyEvent(buildEvent(requestStartTime, reqFields, responseStatus, rawBody, contentType, contentEncoding, vendorTraceId, protocol)); + } catch (err) { + debug("error processing response: " + err.message); + } return handler.onResponseEnd?.(controller, trailers); }; wrappedHandler.onResponseError = (controller, err) => { - setImmediate(() => appendProxyEvent(buildErrorEvent(requestStartTime, reqFields, err))); + try { appendProxyEvent(buildErrorEvent(requestStartTime, reqFields, err)); } catch {} return handler.onResponseError?.(controller, err); }; @@ -1065,39 +1063,37 @@ function installHttpsPatch() { }); res.on("end", () => { - setImmediate(() => { - try { - const reqBody = Buffer.concat(reqBodyChunks).toString("utf-8"); - const rawResBody = Buffer.concat(resBodyChunks); - - let reqFields = { messages: null, model: "", system: null, request_body: null }; - try { reqFields = extractRequestFields(reqBody, protocol); } catch {} - - if (protocol === "anthropic" && isInternalCall(reqFields)) { - debug("https.request: skipping internal call"); - return; - } - - const contentType = res.headers["content-type"] || ""; - const contentEncoding = (res.headers["content-encoding"] || "").trim().toLowerCase(); - const statusCode = res.statusCode || 200; - - const event = buildEvent( - requestStartTime, - reqFields, - statusCode, - rawResBody, - contentType, - contentEncoding, - res.headers["eagleeye-traceid"] || "", - protocol - ); - appendProxyEvent(event); - debug(`https.request intercepted → ${event.model} in=${event.input_tokens} out=${event.output_tokens}`); - } catch (err) { - debug("https.request parse error: " + err.message); + try { + const reqBody = Buffer.concat(reqBodyChunks).toString("utf-8"); + const rawResBody = Buffer.concat(resBodyChunks); + + let reqFields = { messages: null, model: "", system: null, request_body: null }; + try { reqFields = extractRequestFields(reqBody, protocol); } catch {} + + if (protocol === "anthropic" && isInternalCall(reqFields)) { + debug("https.request: skipping internal call"); + return; } - }); + + const contentType = res.headers["content-type"] || ""; + const contentEncoding = (res.headers["content-encoding"] || "").trim().toLowerCase(); + const statusCode = res.statusCode || 200; + + const event = buildEvent( + requestStartTime, + reqFields, + statusCode, + rawResBody, + contentType, + contentEncoding, + res.headers["eagleeye-traceid"] || "", + protocol + ); + appendProxyEvent(event); + debug(`https.request intercepted → ${event.model} in=${event.input_tokens} out=${event.output_tokens}`); + } catch (err) { + debug("https.request parse error: " + err.message); + } }); if (callback) callback(res); diff --git a/opentelemetry-instrumentation-claude/src/logger.js b/opentelemetry-instrumentation-claude/src/logger.js new file mode 100644 index 0000000..2fbb205 --- /dev/null +++ b/opentelemetry-instrumentation-claude/src/logger.js @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); +const config = require("./config"); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +function isLogEnabled() { + return config.isLogEnabled(); +} + +function resolveLogDir() { + const dir = config.getLogDir(); + if (dir) return dir; + return path.join(os.homedir(), ".loongcollector", "data"); +} + +function getLogFilePath() { + const dir = resolveLogDir(); + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return path.join(dir, `claude-code.jsonl.${y}${m}${d}`); +} + +// --------------------------------------------------------------------------- +// Chain hash — deterministic serialization + SHA-256 +// --------------------------------------------------------------------------- + +function stableSerialize(obj) { + if (obj === null || obj === undefined) return "null"; + if (typeof obj === "boolean" || typeof obj === "number") return JSON.stringify(obj); + if (typeof obj === "string") return JSON.stringify(obj); + if (Array.isArray(obj)) { + return "[" + obj.map(stableSerialize).join(",") + "]"; + } + if (typeof obj === "object") { + const keys = Object.keys(obj).sort(); + const parts = keys.map(k => JSON.stringify(k) + ":" + stableSerialize(obj[k])); + return "{" + parts.join(",") + "}"; + } + return JSON.stringify(obj); +} + +const INITIAL_HASH = crypto.createHash("sha256").update("").digest("hex").slice(0, 32); + +function hashStep(prevHash, msg) { + const msgBytes = Buffer.from(stableSerialize(msg), "utf-8"); + const combined = Buffer.concat([Buffer.from(prevHash, "utf-8"), msgBytes]); + return crypto.createHash("sha256").update(combined).digest("hex").slice(0, 32); +} + +function computeHash(prevHash, deltaMessages) { + let h = prevHash; + for (const msg of deltaMessages) { + h = hashStep(h, msg); + } + return h; +} + +function shouldLogFullMessages(prevHash, delta, currentHash) { + return computeHash(prevHash, delta) !== currentHash; +} + +// --------------------------------------------------------------------------- +// JSONL file writing +// --------------------------------------------------------------------------- + +function appendLogRecord(record) { + const filePath = getLogFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(record) + "\n", "utf-8"); +} + +function writeLogRecords(records) { + if (!records || records.length === 0) return; + const filePath = getLogFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lines = records.map(r => JSON.stringify(r)).join("\n") + "\n"; + fs.appendFileSync(filePath, lines, "utf-8"); +} + +module.exports = { + isLogEnabled, + resolveLogDir, + getLogFilePath, + stableSerialize, + INITIAL_HASH, + hashStep, + computeHash, + shouldLogFullMessages, + appendLogRecord, + writeLogRecords, +}; diff --git a/opentelemetry-instrumentation-claude/src/telemetry.js b/opentelemetry-instrumentation-claude/src/telemetry.js index 987e8f2..218f502 100644 --- a/opentelemetry-instrumentation-claude/src/telemetry.js +++ b/opentelemetry-instrumentation-claude/src/telemetry.js @@ -22,6 +22,7 @@ const { BatchSpanProcessor, ConsoleSpanExporter } = require("@opentelemetry/sdk- const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-http"); const { Resource } = require("@opentelemetry/resources"); const { trace } = require("@opentelemetry/api"); +const config = require("./config"); // 1 MB attribute length limit (aligned with Python version) const MAX_ATTRIBUTE_LENGTH = 1 * 1024 * 1024; @@ -34,26 +35,53 @@ let _tracerProvider = null; * @returns {string} */ function resolveServiceName(defaultName = "claude-agents") { - const envName = (process.env.OTEL_SERVICE_NAME || "").trim(); - if (envName) return envName; + const cfgName = config.getServiceName("").trim(); + if (cfgName) return cfgName; - for (const attr of (process.env.OTEL_RESOURCE_ATTRIBUTES || "").split(",")) { - const trimmed = attr.trim(); - if (trimmed.startsWith("service.name=")) { - return trimmed.slice("service.name=".length).trim(); - } - } + const attrs = parseResourceAttributes(); + if (attrs["service.name"]) return attrs["service.name"]; return defaultName; } +/** + * Parse all key=value pairs from OTEL_RESOURCE_ATTRIBUTES. + * @returns {Record} + */ +function parseResourceAttributes() { + const attrs = {}; + const raw = config.getResourceAttributes(); + if (!raw) return attrs; + for (const pair of raw.split(",")) { + const idx = pair.indexOf("="); + if (idx === -1) continue; + const key = pair.slice(0, idx).trim(); + const val = pair.slice(idx + 1).trim(); + if (key) attrs[key] = val; + } + return attrs; +} + +/** + * Build the full Resource attributes object. + * Priority: OTEL_SERVICE_NAME > OTEL_RESOURCE_ATTRIBUTES service.name > default + * All other OTEL_RESOURCE_ATTRIBUTES are included as-is. + * @param {string} serviceName + * @returns {Record} + */ +function buildResourceAttrs(serviceName) { + const envAttrs = parseResourceAttributes(); + envAttrs["service.name"] = resolveServiceName(serviceName); + return envAttrs; +} + /** * Parse OTEL_EXPORTER_OTLP_HEADERS into a plain object. * @returns {Record} */ function parseOtlpHeaders() { const headers = {}; - const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS || ""; + const raw = config.getHeaders(); if (!raw) return headers; for (const pair of raw.split(",")) { const idx = pair.indexOf("="); @@ -72,7 +100,7 @@ function parseOtlpHeaders() { * @returns {NodeTracerProvider} */ function configureOtlp(endpoint, serviceName) { - const resource = new Resource({ "service.name": resolveServiceName(serviceName) }); + const resource = new Resource(buildResourceAttrs(serviceName)); const otlpEndpoint = endpoint.endsWith("/v1/traces") ? endpoint : endpoint.replace(/\/$/, "") + "/v1/traces"; @@ -103,7 +131,7 @@ function configureOtlp(endpoint, serviceName) { * @returns {NodeTracerProvider} */ function configureConsole(serviceName) { - const resource = new Resource({ "service.name": resolveServiceName(serviceName) }); + const resource = new Resource(buildResourceAttrs(serviceName)); const provider = new NodeTracerProvider({ resource, spanLimits: { attributeValueLengthLimit: MAX_ATTRIBUTE_LENGTH }, @@ -127,7 +155,7 @@ function configureConsole(serviceName) { function configureTelemetry(serviceName = "claude-agents") { if (_tracerProvider) return _tracerProvider; - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + const endpoint = config.getEndpoint(); if (endpoint) { try { const provider = configureOtlp(endpoint, serviceName); @@ -138,7 +166,7 @@ function configureTelemetry(serviceName = "claude-agents") { } } - if (process.env.CLAUDE_TELEMETRY_DEBUG) { + if (config.isDebug()) { console.error("🔍 Debug mode: telemetry output to console"); return configureConsole(serviceName); } @@ -149,7 +177,9 @@ function configureTelemetry(serviceName = "claude-agents") { "1. Any OTEL backend:\n" + " export OTEL_EXPORTER_OTLP_ENDPOINT=\"https://api.honeycomb.io\"\n" + " export OTEL_EXPORTER_OTLP_HEADERS=\"x-honeycomb-team=your_key\"\n\n" + - "2. Debug mode (console output only):\n" + + "2. Config file (~/.claude/otel-config.json):\n" + + " { \"otlp_endpoint\": \"https://api.honeycomb.io\" }\n\n" + + "3. Debug mode (console output only):\n" + " export CLAUDE_TELEMETRY_DEBUG=1\n" ); } @@ -168,4 +198,4 @@ async function shutdownTelemetry() { } } -module.exports = { configureTelemetry, shutdownTelemetry, resolveServiceName }; +module.exports = { configureTelemetry, shutdownTelemetry, resolveServiceName, parseResourceAttributes, buildResourceAttrs }; diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index bffa50a..9638e4b 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -820,3 +820,280 @@ describe("cmdUninstall", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); }); + +// ─── generateTurnLogRecords ────────────────────────────────────────────── +describe("generateTurnLogRecords", () => { + const { INITIAL_HASH, computeHash } = require("../src/logger"); + const sessionId = "test-session"; + const model = "claude-sonnet-4-5-20250514"; + const traceId = "abc123def456"; + + test("single LLM call turn generates user + assistant records", () => { + const turn = { + prompt: "Hello", + startTime: 1000, + endTime: 1001, + events: [ + { + type: "llm_call", + timestamp: 1000.5, + request_start_time: 1000.1, + model: "claude-sonnet-4-5-20250514", + input_messages: [{ role: "user", content: "Hello" }], + output_content: [{ type: "text", text: "Hi there!" }], + stop_reason: "end_turn", + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + ], + }; + const { records, hash } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, traceId); + + expect(records).toHaveLength(2); + + const userRec = records[0]; + expect(userRec["gen_ai.role"]).toBe("user"); + expect(userRec["gen_ai.session_id"]).toBe(sessionId); + expect(userRec["gen_ai.turn_id"]).toBe(`${sessionId}:t1`); + expect(userRec.trace_id).toBe(traceId); + + const llmRec = records[1]; + expect(llmRec["gen_ai.role"]).toBe("assistant"); + expect(llmRec["gen_ai.step_id"]).toBe(`${sessionId}:t1:s1`); + expect(llmRec["gen_ai.input_tokens"]).toBe(10); + expect(llmRec["gen_ai.output_tokens"]).toBe(5); + expect(llmRec["gen_ai.request_model"]).toBe(model); + expect(llmRec["gen_ai.input_messages_hash"]).toHaveLength(32); + expect(llmRec["gen_ai.output_messages"]).toBeDefined(); + expect(hash).toBe(llmRec["gen_ai.input_messages_hash"]); + }); + + test("multi-step turn generates user + assistant + tool + assistant", () => { + const turn = { + prompt: "List files", + startTime: 1000, + endTime: 1003, + events: [ + { + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "List files" }], + output_content: [ + { type: "text", text: "Let me check." }, + { type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }, + ], + stop_reason: "tool_use", + input_tokens: 20, + output_tokens: 10, + }, + { + type: "pre_tool_use", + timestamp: 1001, + tool_name: "Bash", + tool_input: { command: "ls" }, + tool_use_id: "t1", + }, + { + type: "post_tool_use", + timestamp: 1001.5, + tool_name: "Bash", + tool_response: "file1.txt\nfile2.txt", + tool_use_id: "t1", + }, + { + type: "llm_call", + timestamp: 1002, + input_messages: [ + { role: "user", content: "List files" }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "file1.txt\nfile2.txt" }] }, + ], + output_content: [{ type: "text", text: "Here are the files." }], + stop_reason: "end_turn", + input_tokens: 30, + output_tokens: 8, + }, + ], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, traceId); + + expect(records).toHaveLength(4); + expect(records[0]["gen_ai.role"]).toBe("user"); + expect(records[1]["gen_ai.role"]).toBe("assistant"); + expect(records[1]["gen_ai.step_id"]).toBe(`${sessionId}:t1:s1`); + expect(records[2]["gen_ai.role"]).toBe("tool"); + expect(records[2]["gen_ai.tool_name"]).toBe("Bash"); + expect(records[2]["gen_ai.tool_call_id"]).toBe("t1"); + expect(records[3]["gen_ai.role"]).toBe("assistant"); + expect(records[3]["gen_ai.step_id"]).toBe(`${sessionId}:t1:s2`); + }); + + test("error LLM call includes error fields", () => { + const turn = { + prompt: "test", + startTime: 1000, + endTime: 1001, + events: [{ + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "test" }], + output_content: null, + stop_reason: "error", + is_error: true, + error_message: "rate limit exceeded", + input_tokens: 0, + output_tokens: 0, + }], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); + expect(llmRec["gen_ai.error_type"]).toBe("LLMError"); + expect(llmRec["gen_ai.error_message"]).toBe("rate limit exceeded"); + }); + + test("orphaned pre_tool_use generates tool record without results", () => { + const turn = { + prompt: null, + startTime: 1000, + endTime: 1001, + events: [{ + type: "pre_tool_use", + timestamp: 1000.5, + tool_name: "Read", + tool_input: { file: "test.js" }, + tool_use_id: "orphan1", + }], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + expect(records).toHaveLength(1); + expect(records[0]["gen_ai.role"]).toBe("tool"); + expect(records[0]["gen_ai.tool_name"]).toBe("Read"); + expect(records[0]["gen_ai.tool_call_id"]).toBe("orphan1"); + expect(records[0]["gen_ai.tool_results"]).toBeUndefined(); + }); + + test("Agent tool calls are skipped", () => { + const turn = { + prompt: "test", + startTime: 1000, + endTime: 1001, + events: [ + { type: "pre_tool_use", timestamp: 1000.2, tool_name: "Agent", tool_input: {}, tool_use_id: "a1" }, + { type: "post_tool_use", timestamp: 1000.5, tool_name: "Agent", tool_response: {}, tool_use_id: "a1" }, + ], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + expect(records).toHaveLength(1); // only user record + expect(records[0]["gen_ai.role"]).toBe("user"); + }); + + test("hash chaining — second turn uses first turn hash as prevHash", () => { + const turn1 = { + prompt: "Hello", + startTime: 1000, + endTime: 1001, + events: [{ + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "Hello" }], + output_content: [{ type: "text", text: "Hi" }], + stop_reason: "end_turn", + input_tokens: 5, + output_tokens: 2, + }], + }; + const turn2 = { + prompt: "How are you?", + startTime: 1001, + endTime: 1002, + events: [{ + type: "llm_call", + timestamp: 1001.5, + input_messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "How are you?" }, + ], + output_content: [{ type: "text", text: "Good" }], + stop_reason: "end_turn", + input_tokens: 15, + output_tokens: 3, + }], + }; + + const r1 = cli._generateTurnLogRecords(turn1, 0, sessionId, model, INITIAL_HASH, null); + const r2 = cli._generateTurnLogRecords(turn2, 1, sessionId, model, r1.hash, null); + + const hash1 = r1.records.find(r => r["gen_ai.role"] === "assistant")["gen_ai.input_messages_hash"]; + const hash2 = r2.records.find(r => r["gen_ai.role"] === "assistant")["gen_ai.input_messages_hash"]; + + expect(hash1).not.toBe(hash2); + expect(hash1).toHaveLength(32); + expect(hash2).toHaveLength(32); + }); + + test("first LLM call with matching hash does not log full input_messages", () => { + const turn = { + prompt: "test", + startTime: 1000, + endTime: 1001, + events: [{ + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "test" }], + output_content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + input_tokens: 3, + output_tokens: 1, + }], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); + expect(llmRec["gen_ai.input_messages"]).toBeUndefined(); + expect(llmRec["gen_ai.input_messages_delta"]).toBeDefined(); + }); + + test("logs full input_messages when hash mismatches (context change)", () => { + const turn = { + prompt: "test", + startTime: 1000, + endTime: 1001, + events: [{ + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "test" }], + output_content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + input_tokens: 3, + output_tokens: 1, + }], + }; + const wrongPrevHash = "f".repeat(32); + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, wrongPrevHash, null); + const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); + expect(llmRec["gen_ai.input_messages"]).toBeDefined(); + expect(llmRec["gen_ai.input_messages_delta"]).toBeDefined(); + }); + + test("no prompt — no user record generated", () => { + const turn = { + prompt: null, + startTime: 1000, + endTime: 1001, + events: [{ + type: "llm_call", + timestamp: 1000.5, + input_messages: [{ role: "user", content: "something" }], + output_content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + input_tokens: 3, + output_tokens: 1, + }], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + expect(records.filter(r => r["gen_ai.role"] === "user")).toHaveLength(0); + expect(records.filter(r => r["gen_ai.role"] === "assistant")).toHaveLength(1); + }); +}); diff --git a/opentelemetry-instrumentation-claude/test/config.test.js b/opentelemetry-instrumentation-claude/test/config.test.js new file mode 100644 index 0000000..cfa28d8 --- /dev/null +++ b/opentelemetry-instrumentation-claude/test/config.test.js @@ -0,0 +1,215 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const CONFIG_PATH = path.join(os.homedir(), ".claude", "otel-config.json"); + +describe("config", () => { + let config; + let originalConfigContent; + let configExisted; + + beforeAll(() => { + configExisted = fs.existsSync(CONFIG_PATH); + if (configExisted) { + originalConfigContent = fs.readFileSync(CONFIG_PATH, "utf-8"); + } + }); + + afterAll(() => { + if (configExisted) { + fs.writeFileSync(CONFIG_PATH, originalConfigContent, "utf-8"); + } else { + try { fs.unlinkSync(CONFIG_PATH); } catch {} + } + }); + + beforeEach(() => { + jest.resetModules(); + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_HEADERS; + delete process.env.OTEL_SERVICE_NAME; + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + delete process.env.CLAUDE_TELEMETRY_DEBUG; + delete process.env.LOONGSUITE_SEMCONV_DIALECT_NAME; + delete process.env.OTEL_CLAUDE_LOG_ENABLED; + delete process.env.OTEL_CLAUDE_LOG_DIR; + }); + + afterEach(() => { + if (config) { + config.resetConfigCache(); + config = null; + } + }); + + function writeConfig(obj) { + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(obj), "utf-8"); + } + + function removeConfig() { + try { fs.unlinkSync(CONFIG_PATH); } catch {} + } + + // ---- loadConfigFile ---- + + test("loadConfigFile returns empty object when file does not exist", () => { + removeConfig(); + config = require("../src/config"); + expect(config.loadConfigFile()).toEqual({}); + }); + + test("loadConfigFile reads valid JSON", () => { + writeConfig({ otlp_endpoint: "https://example.com" }); + config = require("../src/config"); + const cfg = config.loadConfigFile(); + expect(cfg.otlp_endpoint).toBe("https://example.com"); + }); + + test("loadConfigFile returns empty object for invalid JSON", () => { + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, "not json {{{", "utf-8"); + config = require("../src/config"); + expect(config.loadConfigFile()).toEqual({}); + }); + + test("loadConfigFile caches result", () => { + writeConfig({ otlp_endpoint: "https://first.com" }); + config = require("../src/config"); + const first = config.loadConfigFile(); + writeConfig({ otlp_endpoint: "https://second.com" }); + const second = config.loadConfigFile(); + expect(first).toBe(second); + expect(second.otlp_endpoint).toBe("https://first.com"); + }); + + test("resetConfigCache clears cache", () => { + writeConfig({ otlp_endpoint: "https://first.com" }); + config = require("../src/config"); + config.loadConfigFile(); + config.resetConfigCache(); + writeConfig({ otlp_endpoint: "https://second.com" }); + const result = config.loadConfigFile(); + expect(result.otlp_endpoint).toBe("https://second.com"); + }); + + // ---- getConfig priority ---- + + test("config file takes priority over env var", () => { + writeConfig({ otlp_endpoint: "https://from-config.com" }); + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://from-env.com"; + config = require("../src/config"); + expect(config.getEndpoint()).toBe("https://from-config.com"); + }); + + test("env var used when config file key is missing", () => { + writeConfig({}); + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://from-env.com"; + config = require("../src/config"); + expect(config.getEndpoint()).toBe("https://from-env.com"); + }); + + test("default used when neither config file nor env var", () => { + removeConfig(); + config = require("../src/config"); + expect(config.getEndpoint()).toBe(""); + }); + + test("empty string in config file falls back to env var", () => { + writeConfig({ otlp_endpoint: "" }); + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://from-env.com"; + config = require("../src/config"); + expect(config.getEndpoint()).toBe("https://from-env.com"); + }); + + test("null in config file falls back to env var", () => { + writeConfig({ otlp_endpoint: null }); + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://from-env.com"; + config = require("../src/config"); + expect(config.getEndpoint()).toBe("https://from-env.com"); + }); + + // ---- boolean config ---- + + test("isDebug returns boolean from config file", () => { + writeConfig({ debug: true }); + config = require("../src/config"); + expect(config.isDebug()).toBe(true); + }); + + test("isDebug parses '1' from env var as true", () => { + removeConfig(); + process.env.CLAUDE_TELEMETRY_DEBUG = "1"; + config = require("../src/config"); + expect(config.isDebug()).toBe(true); + }); + + test("isDebug returns false by default", () => { + removeConfig(); + config = require("../src/config"); + expect(config.isDebug()).toBe(false); + }); + + test("isLogEnabled from config file", () => { + writeConfig({ log_enabled: true }); + config = require("../src/config"); + expect(config.isLogEnabled()).toBe(true); + }); + + test("isLogEnabled from env var", () => { + removeConfig(); + process.env.OTEL_CLAUDE_LOG_ENABLED = "true"; + config = require("../src/config"); + expect(config.isLogEnabled()).toBe(true); + }); + + // ---- convenience functions ---- + + test("getHeaders from config file", () => { + writeConfig({ otlp_headers: "x-api-key=abc" }); + config = require("../src/config"); + expect(config.getHeaders()).toBe("x-api-key=abc"); + }); + + test("getServiceName from config file", () => { + writeConfig({ service_name: "my-svc" }); + config = require("../src/config"); + expect(config.getServiceName("default-svc")).toBe("my-svc"); + }); + + test("getServiceName falls back to default", () => { + removeConfig(); + config = require("../src/config"); + expect(config.getServiceName("default-svc")).toBe("default-svc"); + }); + + test("getResourceAttributes from config file", () => { + writeConfig({ resource_attributes: "k1=v1,k2=v2" }); + config = require("../src/config"); + expect(config.getResourceAttributes()).toBe("k1=v1,k2=v2"); + }); + + test("getSemconvDialect from config file", () => { + writeConfig({ semconv_dialect: "ALIBABA_GROUP" }); + config = require("../src/config"); + expect(config.getSemconvDialect()).toBe("ALIBABA_GROUP"); + }); + + test("getLogDir from config file", () => { + writeConfig({ log_dir: "/tmp/logs" }); + config = require("../src/config"); + expect(config.getLogDir()).toBe("/tmp/logs"); + }); + + // ---- CONFIG_PATH ---- + + test("CONFIG_PATH is ~/.claude/otel-config.json", () => { + config = require("../src/config"); + expect(config.CONFIG_PATH).toBe(path.join(os.homedir(), ".claude", "otel-config.json")); + }); +}); diff --git a/opentelemetry-instrumentation-claude/test/logger.test.js b/opentelemetry-instrumentation-claude/test/logger.test.js new file mode 100644 index 0000000..a6d7ba7 --- /dev/null +++ b/opentelemetry-instrumentation-claude/test/logger.test.js @@ -0,0 +1,293 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); + +const { + isLogEnabled, + resolveLogDir, + getLogFilePath, + stableSerialize, + INITIAL_HASH, + hashStep, + computeHash, + shouldLogFullMessages, + writeLogRecords, +} = require("../src/logger"); + +// --------------------------------------------------------------------------- +// stableSerialize +// --------------------------------------------------------------------------- +describe("stableSerialize", () => { + test("null", () => expect(stableSerialize(null)).toBe("null")); + test("undefined", () => expect(stableSerialize(undefined)).toBe("null")); + test("number", () => expect(stableSerialize(42)).toBe("42")); + test("boolean", () => expect(stableSerialize(true)).toBe("true")); + test("string", () => expect(stableSerialize("hello")).toBe('"hello"')); + test("empty array", () => expect(stableSerialize([])).toBe("[]")); + test("array with values", () => expect(stableSerialize([1, "a"])).toBe('[1,"a"]')); + + test("object keys are sorted", () => { + expect(stableSerialize({ b: 2, a: 1 })).toBe('{"a":1,"b":2}'); + }); + + test("nested objects sort recursively", () => { + const obj = { z: { b: 2, a: 1 }, a: [3, { y: 1, x: 2 }] }; + expect(stableSerialize(obj)).toBe('{"a":[3,{"x":2,"y":1}],"z":{"a":1,"b":2}}'); + }); + + test("deterministic — same input always same output", () => { + const obj = { role: "user", content: "hello", parts: [{ type: "text" }] }; + const s1 = stableSerialize(obj); + const s2 = stableSerialize(obj); + expect(s1).toBe(s2); + }); +}); + +// --------------------------------------------------------------------------- +// INITIAL_HASH +// --------------------------------------------------------------------------- +describe("INITIAL_HASH", () => { + test("equals sha256 of empty string truncated to 32 hex", () => { + const expected = crypto.createHash("sha256").update("").digest("hex").slice(0, 32); + expect(INITIAL_HASH).toBe(expected); + }); + + test("is 32 characters", () => { + expect(INITIAL_HASH).toHaveLength(32); + }); +}); + +// --------------------------------------------------------------------------- +// hashStep +// --------------------------------------------------------------------------- +describe("hashStep", () => { + test("produces 32-char hex string", () => { + const result = hashStep(INITIAL_HASH, { role: "user", content: "hi" }); + expect(result).toHaveLength(32); + expect(result).toMatch(/^[0-9a-f]{32}$/); + }); + + test("different messages produce different hashes", () => { + const h1 = hashStep(INITIAL_HASH, { role: "user", content: "hello" }); + const h2 = hashStep(INITIAL_HASH, { role: "user", content: "world" }); + expect(h1).not.toBe(h2); + }); + + test("different prevHash produces different result", () => { + const msg = { role: "user", content: "test" }; + const h1 = hashStep(INITIAL_HASH, msg); + const h2 = hashStep("a".repeat(32), msg); + expect(h1).not.toBe(h2); + }); + + test("deterministic", () => { + const msg = { role: "user", content: "test" }; + expect(hashStep(INITIAL_HASH, msg)).toBe(hashStep(INITIAL_HASH, msg)); + }); +}); + +// --------------------------------------------------------------------------- +// computeHash +// --------------------------------------------------------------------------- +describe("computeHash", () => { + test("empty delta returns prevHash", () => { + expect(computeHash(INITIAL_HASH, [])).toBe(INITIAL_HASH); + }); + + test("single message", () => { + const msg = { role: "user", content: "hi" }; + const expected = hashStep(INITIAL_HASH, msg); + expect(computeHash(INITIAL_HASH, [msg])).toBe(expected); + }); + + test("chaining — H2 depends on H1", () => { + const m1 = { role: "user", content: "a" }; + const m2 = { role: "assistant", content: "b" }; + const h1 = hashStep(INITIAL_HASH, m1); + const h2 = hashStep(h1, m2); + expect(computeHash(INITIAL_HASH, [m1, m2])).toBe(h2); + }); + + test("order matters", () => { + const m1 = { role: "user", content: "a" }; + const m2 = { role: "assistant", content: "b" }; + const forward = computeHash(INITIAL_HASH, [m1, m2]); + const reverse = computeHash(INITIAL_HASH, [m2, m1]); + expect(forward).not.toBe(reverse); + }); +}); + +// --------------------------------------------------------------------------- +// shouldLogFullMessages +// --------------------------------------------------------------------------- +describe("shouldLogFullMessages", () => { + test("returns false when hash matches (normal incremental append)", () => { + const msgs = [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]; + const currentHash = computeHash(INITIAL_HASH, msgs); + expect(shouldLogFullMessages(INITIAL_HASH, msgs, currentHash)).toBe(false); + }); + + test("returns true when hash mismatches (context compression)", () => { + const msgs = [{ role: "user", content: "a" }]; + const currentHash = computeHash(INITIAL_HASH, msgs); + const wrongPrevHash = "f".repeat(32); + expect(shouldLogFullMessages(wrongPrevHash, msgs, currentHash)).toBe(true); + }); + + test("returns true on first call (prevHash = INITIAL_HASH but delta != full context)", () => { + const fullContext = [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]; + const currentHash = computeHash(INITIAL_HASH, fullContext); + const delta = [{ role: "assistant", content: "b" }]; + expect(shouldLogFullMessages(INITIAL_HASH, delta, currentHash)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// isLogEnabled +// --------------------------------------------------------------------------- +describe("isLogEnabled", () => { + const origEnv = process.env.OTEL_CLAUDE_LOG_ENABLED; + + afterEach(() => { + if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_ENABLED; + else process.env.OTEL_CLAUDE_LOG_ENABLED = origEnv; + }); + + test("returns true when OTEL_CLAUDE_LOG_ENABLED=1", () => { + process.env.OTEL_CLAUDE_LOG_ENABLED = "1"; + expect(isLogEnabled()).toBe(true); + }); + + test("returns false when unset", () => { + delete process.env.OTEL_CLAUDE_LOG_ENABLED; + expect(isLogEnabled()).toBe(false); + }); + + test("returns true for 'true' string", () => { + process.env.OTEL_CLAUDE_LOG_ENABLED = "true"; + expect(isLogEnabled()).toBe(true); + }); + + test("returns false for other values", () => { + process.env.OTEL_CLAUDE_LOG_ENABLED = "no"; + expect(isLogEnabled()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveLogDir +// --------------------------------------------------------------------------- +describe("resolveLogDir", () => { + const origEnv = process.env.OTEL_CLAUDE_LOG_DIR; + + afterEach(() => { + if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_DIR; + else process.env.OTEL_CLAUDE_LOG_DIR = origEnv; + }); + + test("uses OTEL_CLAUDE_LOG_DIR when set", () => { + process.env.OTEL_CLAUDE_LOG_DIR = "/tmp/custom-logs"; + expect(resolveLogDir()).toBe("/tmp/custom-logs"); + }); + + test("defaults to ~/.loongcollector/data/", () => { + delete process.env.OTEL_CLAUDE_LOG_DIR; + expect(resolveLogDir()).toBe(path.join(os.homedir(), ".loongcollector", "data")); + }); +}); + +// --------------------------------------------------------------------------- +// getLogFilePath +// --------------------------------------------------------------------------- +describe("getLogFilePath", () => { + const origEnv = process.env.OTEL_CLAUDE_LOG_DIR; + + afterEach(() => { + if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_DIR; + else process.env.OTEL_CLAUDE_LOG_DIR = origEnv; + }); + + test("filename matches claude-code.jsonl.YYYYMMDD pattern", () => { + process.env.OTEL_CLAUDE_LOG_DIR = "/tmp/test-logs"; + const filePath = getLogFilePath(); + expect(filePath).toMatch(/^\/tmp\/test-logs\/claude-code\.jsonl\.\d{8}$/); + }); + + test("date portion matches today", () => { + process.env.OTEL_CLAUDE_LOG_DIR = "/tmp/test-logs"; + const filePath = getLogFilePath(); + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + expect(filePath).toContain(`${y}${m}${d}`); + }); +}); + +// --------------------------------------------------------------------------- +// writeLogRecords +// --------------------------------------------------------------------------- +describe("writeLogRecords", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "logger-test-")); + process.env.OTEL_CLAUDE_LOG_DIR = tmpDir; + }); + + afterEach(() => { + delete process.env.OTEL_CLAUDE_LOG_DIR; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("writes records as JSONL", () => { + const records = [ + { "gen_ai.role": "user", timestamp_ns: 1000 }, + { "gen_ai.role": "assistant", timestamp_ns: 2000 }, + ]; + writeLogRecords(records); + + const files = fs.readdirSync(tmpDir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^claude-code\.jsonl\.\d{8}$/); + + const content = fs.readFileSync(path.join(tmpDir, files[0]), "utf-8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toEqual(records[0]); + expect(JSON.parse(lines[1])).toEqual(records[1]); + }); + + test("appends to existing file", () => { + writeLogRecords([{ a: 1 }]); + writeLogRecords([{ b: 2 }]); + + const files = fs.readdirSync(tmpDir); + const content = fs.readFileSync(path.join(tmpDir, files[0]), "utf-8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + }); + + test("no-op for empty array", () => { + writeLogRecords([]); + const files = fs.readdirSync(tmpDir); + expect(files).toHaveLength(0); + }); + + test("no-op for null", () => { + writeLogRecords(null); + const files = fs.readdirSync(tmpDir); + expect(files).toHaveLength(0); + }); +}); diff --git a/opentelemetry-instrumentation-claude/test/telemetry.test.js b/opentelemetry-instrumentation-claude/test/telemetry.test.js index b1ab528..cecd5cb 100644 --- a/opentelemetry-instrumentation-claude/test/telemetry.test.js +++ b/opentelemetry-instrumentation-claude/test/telemetry.test.js @@ -61,4 +61,56 @@ describe("telemetry", () => { telemetry = require("../src/telemetry"); expect(telemetry.resolveServiceName()).toBe("claude-agents"); }); + + test("parseResourceAttributes parses all key=value pairs", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = + "service.name=cc-test,acs.cms.workspace=ximing,benchmark.instance_id=autojump"; + telemetry = require("../src/telemetry"); + const attrs = telemetry.parseResourceAttributes(); + expect(attrs).toEqual({ + "service.name": "cc-test", + "acs.cms.workspace": "ximing", + "benchmark.instance_id": "autojump", + }); + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + }); + + test("parseResourceAttributes returns empty object when unset", () => { + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + telemetry = require("../src/telemetry"); + expect(telemetry.parseResourceAttributes()).toEqual({}); + }); + + test("parseResourceAttributes handles values with = signs", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = "key=val=ue,other=ok"; + telemetry = require("../src/telemetry"); + const attrs = telemetry.parseResourceAttributes(); + expect(attrs["key"]).toBe("val=ue"); + expect(attrs["other"]).toBe("ok"); + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + }); + + test("buildResourceAttrs includes all env attrs plus resolved service.name", () => { + process.env.OTEL_SERVICE_NAME = "my-svc"; + process.env.OTEL_RESOURCE_ATTRIBUTES = + "acs.cms.workspace=test-ws,benchmark.model.name=claude-4"; + telemetry = require("../src/telemetry"); + const attrs = telemetry.buildResourceAttrs(); + expect(attrs["service.name"]).toBe("my-svc"); + expect(attrs["acs.cms.workspace"]).toBe("test-ws"); + expect(attrs["benchmark.model.name"]).toBe("claude-4"); + delete process.env.OTEL_SERVICE_NAME; + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + }); + + test("buildResourceAttrs: OTEL_SERVICE_NAME overrides service.name in OTEL_RESOURCE_ATTRIBUTES", () => { + process.env.OTEL_SERVICE_NAME = "override-name"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=from-attrs,custom=val"; + telemetry = require("../src/telemetry"); + const attrs = telemetry.buildResourceAttrs(); + expect(attrs["service.name"]).toBe("override-name"); + expect(attrs["custom"]).toBe("val"); + delete process.env.OTEL_SERVICE_NAME; + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + }); }); From 3041b1c9484c2c6831eee61ea33586042c7e23db Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 1 May 2026 03:33:43 +0800 Subject: [PATCH 06/23] feat(claude): support log-only mode + configurable log filename format + update OSS paths - Add logOnly branch in exportSessionTrace() to skip OTel span building when only JSONL logging is needed - Add log_filename_format config option ("default" vs "hook") for BaseHookInput compatibility - Update OSS paths from agenttrack to opentelemetry-instrumentation-claude - Fix test flakiness caused by local otel-config.json interfering with env-var-based tests - Document log-only mode in README Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- .../README.md | 34 +++- .../scripts/pack.sh | 12 +- .../scripts/remote-install.sh | 2 +- .../src/cli.js | 151 ++++++++++-------- .../src/config.js | 5 + .../src/logger.js | 3 + .../test/cli.test.js | 18 +++ .../test/logger.test.js | 15 ++ .../test/telemetry.test.js | 10 ++ 10 files changed, 172 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 3f40f51..1d7b6fe 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Both plugins follow the [OpenTelemetry GenAI semantic conventions](https://opent **One-line install (with OTLP backend):** ```bash -curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/remote-install.sh | bash -s -- \ +curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/remote-install.sh | bash -s -- \ --endpoint "https://your-otlp-endpoint:4318" \ --service-name "my-claude-agent" ``` diff --git a/opentelemetry-instrumentation-claude/README.md b/opentelemetry-instrumentation-claude/README.md index 2dd4866..7c86323 100644 --- a/opentelemetry-instrumentation-claude/README.md +++ b/opentelemetry-instrumentation-claude/README.md @@ -18,6 +18,7 @@ Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/ - **自动 alias 注入**:安装后 `claude` 命令自动携带 `NODE_OPTIONS=--require intercept.js`,无需手动配置 - **配置文件支持**:可通过 `~/.claude/otel-config.json` 配置所有 OTLP 参数,优先于环境变量,避免与本地其他 OTel 工具冲突 - **JSONL 日志采集**:可选的本地日志功能,支持 chain hash 增量校验和每日文件轮转,与 trace 数据关联 +- **纯日志模式(Log-only)**:支持仅输出 JSONL 日志而不上报 OTel Trace,适用于与 ai-agent-collector 等第三方采集工具集成 - **一键安装**:`npm install -g` 后 postinstall 自动完成全部配置,或 `bash scripts/install.sh` 源码安装 --- @@ -34,7 +35,7 @@ Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/ ## 快速安装(一行命令) ```bash -curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/remote-install.sh | bash -s -- \ +curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/remote-install.sh | bash -s -- \ --endpoint "https://your-otlp-endpoint:4318" \ --service-name "my-claude-agent" ``` @@ -59,7 +60,7 @@ source ~/.bashrc # 或 source ~/.zshrc **本地调试模式(无需 OTLP 后端):** ```bash -curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/remote-install.sh | bash --debug +curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/remote-install.sh | bash --debug ``` --- @@ -127,6 +128,7 @@ bash scripts/install.sh | `semconv_dialect` | `LOONGSUITE_SEMCONV_DIALECT_NAME` | 语义规范方言 | 自动检测 | | `log_enabled` | `OTEL_CLAUDE_LOG_ENABLED` | 启用 JSONL 日志采集 | `false` | | `log_dir` | `OTEL_CLAUDE_LOG_DIR` | JSONL 日志目录 | `~/.loongcollector/data/` | +| `log_filename_format` | `OTEL_CLAUDE_LOG_FILENAME_FORMAT` | 日志文件名格式:`default` 或 `hook` | `default` | ### 方式二:环境变量 @@ -177,6 +179,34 @@ export CLAUDE_TELEMETRY_DEBUG=1 } ``` +### 纯日志模式(Log-only) + +当只需要本地 JSONL 日志采集而不上报 OTel Trace 时,可以启用纯日志模式。适用于与 ai-agent-collector 集成等场景。 + +在 `~/.claude/otel-config.json` 中: + +```json +{ + "log_enabled": true, + "log_dir": "~/.ai-agent-collector/logs/claude-code" +} +``` + +或通过环境变量: + +```bash +export OTEL_CLAUDE_LOG_ENABLED=1 +export OTEL_CLAUDE_LOG_DIR="~/.ai-agent-collector/logs/claude-code" +``` + +**注意**:纯日志模式下无需配置 `otlp_endpoint` 或 `debug`。插件会跳过 OTel Trace 导出,仅将每轮对话的详细记录写入本地 JSONL 文件。 + +日志文件格式: +- 默认路径:`/claude-code.jsonl.YYYYMMDD` +- 可通过 `log_filename_format: "hook"` 切换为 `/claude-code-YYYY-MM-DD.jsonl`(兼容 ai-agent-collector 的 BaseHookInput) +- 每行一个 JSON 对象,包含 `gen_ai.role`(user/assistant/tool)、`gen_ai.session_id`、token 用量等字段 +- 按天自动轮转 + --- ## 使用方法 diff --git a/opentelemetry-instrumentation-claude/scripts/pack.sh b/opentelemetry-instrumentation-claude/scripts/pack.sh index 93b05f8..e16f5e2 100644 --- a/opentelemetry-instrumentation-claude/scripts/pack.sh +++ b/opentelemetry-instrumentation-claude/scripts/pack.sh @@ -8,11 +8,11 @@ # # 上传到 OSS(需要 ak/sk,让有权限的人执行): # ossutil cp dist/otel-claude-hook.tar.gz \ -# oss://arms-apm-cn-hangzhou-pre/agenttrack/otel-claude-hook.tar.gz \ +# oss://arms-apm-cn-hangzhou-pre/opentelemetry-instrumentation-claude/otel-claude-hook.tar.gz \ # --acl public-read # # 上传后验证: -# curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/otel-claude-hook.tar.gz -o /dev/null -w "%{http_code}\n" +# curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/otel-claude-hook.tar.gz -o /dev/null -w "%{http_code}\n" set -euo pipefail @@ -58,16 +58,16 @@ echo "下一步 — 上传到 OSS(需要有 OSS 写权限的账号执行): echo "" echo " # 方式 1:ossutil(推荐)" echo " ossutil cp $OUTPUT \\" -echo " oss://arms-apm-cn-hangzhou-pre/agenttrack/${PLUGIN_NAME}.tar.gz \\" +echo " oss://arms-apm-cn-hangzhou-pre/opentelemetry-instrumentation-claude/${PLUGIN_NAME}.tar.gz \\" echo " --acl public-read" echo "" echo " # 方式 2:aliyun CLI" echo " aliyun oss cp $OUTPUT \\" -echo " oss://arms-apm-cn-hangzhou-pre/agenttrack/${PLUGIN_NAME}.tar.gz \\" +echo " oss://arms-apm-cn-hangzhou-pre/opentelemetry-instrumentation-claude/${PLUGIN_NAME}.tar.gz \\" echo " --acl public-read" echo "" echo "上传后验证(HTTP 200 = 成功):" -echo " curl -o /dev/null -sI https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/${PLUGIN_NAME}.tar.gz | head -1" +echo " curl -o /dev/null -sI https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/${PLUGIN_NAME}.tar.gz | head -1" echo "" echo "更新后一行安装命令:" -echo " curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/remote-install.sh | bash" +echo " curl -fsSL https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/remote-install.sh | bash" diff --git a/opentelemetry-instrumentation-claude/scripts/remote-install.sh b/opentelemetry-instrumentation-claude/scripts/remote-install.sh index ca1c22a..cb2e0ca 100755 --- a/opentelemetry-instrumentation-claude/scripts/remote-install.sh +++ b/opentelemetry-instrumentation-claude/scripts/remote-install.sh @@ -25,7 +25,7 @@ set -euo pipefail # ============================================================ # Defaults # ============================================================ -DEFAULT_TARBALL_URL="https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/agenttrack/otel-claude-hook.tar.gz" +DEFAULT_TARBALL_URL="https://arms-apm-cn-hangzhou-pre.oss-cn-hangzhou.aliyuncs.com/opentelemetry-instrumentation-claude/otel-claude-hook.tar.gz" TARBALL_URL="${OTEL_CLAUDE_TARBALL_URL:-$DEFAULT_TARBALL_URL}" PLUGIN_NAME="otel-claude-hook" diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index c7346e1..d489ea5 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -845,7 +845,12 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra // --------------------------------------------------------------------------- async function exportSessionTrace(state, stopReason = "end_turn") { - const provider = configureTelemetry(); + const logOnly = isLogEnabled() && !config.getEndpoint() && !config.isDebug(); + + let provider = null; + if (!logOnly) { + provider = configureTelemetry(); + } if (!state || typeof state !== "object") { throw new Error("exportSessionTrace: invalid state object"); @@ -855,9 +860,6 @@ async function exportSessionTrace(state, stopReason = "end_turn") { const startTime = typeof state.start_time === "number" ? state.start_time : Date.now() / 1000; const stopTime = typeof state.stop_time === "number" ? state.stop_time : Date.now() / 1000; - const handler = new ExtendedTelemetryHandler({ tracerProvider: provider }); - const tracer = trace.getTracer("opentelemetry-instrumentation-claude"); - // Merge proxy events from intercept.js let allEvents = Array.isArray(state.events) ? [...state.events] : []; try { @@ -879,70 +881,75 @@ async function exportSessionTrace(state, stopReason = "end_turn") { // Set endTime for last turn turns[turns.length - 1].endTime = stopTime; - // Export each turn as an independent trace + // Export each turn as an independent trace (skip in log-only mode) const entryInvs = []; - for (let i = 0; i < turns.length; i++) { - const turn = turns[i]; - const isLast = i === turns.length - 1; - const turnStopReason = isLast ? stopReason : "end_turn"; - - // Aggregate per-turn tokens from llm_call events - let turnInputTokens = 0, turnOutputTokens = 0; - let turnCacheRead = 0, turnCacheCreate = 0; - let turnModel = state.model || "unknown"; - const llmEvents = turn.events.filter(e => e.type === "llm_call"); - for (const lev of llmEvents) { - turnInputTokens += lev.input_tokens || 0; - turnOutputTokens += lev.output_tokens || 0; - turnCacheRead += lev.cache_read_input_tokens || 0; - turnCacheCreate += lev.cache_creation_input_tokens || 0; - if (lev.model) turnModel = lev.model; - } + if (!logOnly) { + const handler = new ExtendedTelemetryHandler({ tracerProvider: provider }); + const tracer = trace.getTracer("opentelemetry-instrumentation-claude"); + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + const isLast = i === turns.length - 1; + const turnStopReason = isLast ? stopReason : "end_turn"; + + // Aggregate per-turn tokens from llm_call events + let turnInputTokens = 0, turnOutputTokens = 0; + let turnCacheRead = 0, turnCacheCreate = 0; + let turnModel = state.model || "unknown"; + const llmEvents = turn.events.filter(e => e.type === "llm_call"); + for (const lev of llmEvents) { + turnInputTokens += lev.input_tokens || 0; + turnOutputTokens += lev.output_tokens || 0; + turnCacheRead += lev.cache_read_input_tokens || 0; + turnCacheCreate += lev.cache_creation_input_tokens || 0; + if (lev.model) turnModel = lev.model; + } - // Determine turn output (last llm_call's output_content) - const lastLlm = llmEvents.length > 0 ? llmEvents[llmEvents.length - 1] : null; - const turnOutputMessages = lastLlm - ? convertOutputMessages(lastLlm.output_content, lastLlm.stop_reason) - : []; - - // ENTRY span — no parent → new traceId - const entryInv = createEntryInvocation({ - sessionId, - inputMessages: turn.prompt - ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] - : [], - outputMessages: turnOutputMessages, - attributes: sessionAttrs(sessionId, "ENTRY"), - }); - handler.startEntry(entryInv, undefined, hrTime(turn.startTime)); - entryInvs.push(entryInv); - - // AGENT span - const agentInv = createInvokeAgentInvocation("anthropic", { - agentName: "claude-code", - agentId: sessionId, - conversationId: sessionId, - requestModel: turnModel, - responseModelName: turnModel, - inputTokens: turnInputTokens, - outputTokens: turnOutputTokens, - usageCacheReadInputTokens: turnCacheRead, - usageCacheCreationInputTokens: turnCacheCreate, - finishReasons: [turnStopReason], - inputMessages: turn.prompt - ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] - : [], - outputMessages: turnOutputMessages, - attributes: sessionAttrs(sessionId, "AGENT"), - }); - handler.startInvokeAgent(agentInv, entryInv.contextToken, hrTime(turn.startTime)); + // Determine turn output (last llm_call's output_content) + const lastLlm = llmEvents.length > 0 ? llmEvents[llmEvents.length - 1] : null; + const turnOutputMessages = lastLlm + ? convertOutputMessages(lastLlm.output_content, lastLlm.stop_reason) + : []; + + // ENTRY span — no parent → new traceId + const entryInv = createEntryInvocation({ + sessionId, + inputMessages: turn.prompt + ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] + : [], + outputMessages: turnOutputMessages, + attributes: sessionAttrs(sessionId, "ENTRY"), + }); + handler.startEntry(entryInv, undefined, hrTime(turn.startTime)); + entryInvs.push(entryInv); + + // AGENT span + const agentInv = createInvokeAgentInvocation("anthropic", { + agentName: "claude-code", + agentId: sessionId, + conversationId: sessionId, + requestModel: turnModel, + responseModelName: turnModel, + inputTokens: turnInputTokens, + outputTokens: turnOutputTokens, + usageCacheReadInputTokens: turnCacheRead, + usageCacheCreationInputTokens: turnCacheCreate, + finishReasons: [turnStopReason], + inputMessages: turn.prompt + ? [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] + : [], + outputMessages: turnOutputMessages, + attributes: sessionAttrs(sessionId, "AGENT"), + }); + handler.startInvokeAgent(agentInv, entryInv.contextToken, hrTime(turn.startTime)); - // Replay this turn's events as child spans - replayEventsAsSpans(handler, tracer, turn.events, agentInv.contextToken, turn.endTime, sessionId); + // Replay this turn's events as child spans + replayEventsAsSpans(handler, tracer, turn.events, agentInv.contextToken, turn.endTime, sessionId); - // Close spans - handler.stopInvokeAgent(agentInv, hrTime(turn.endTime)); - handler.stopEntry(entryInv, hrTime(turn.endTime)); + // Close spans + handler.stopInvokeAgent(agentInv, hrTime(turn.endTime)); + handler.stopEntry(entryInv, hrTime(turn.endTime)); + } } // Write JSONL logs (independent of trace export — failure doesn't block traces) @@ -954,10 +961,12 @@ async function exportSessionTrace(state, stopReason = "end_turn") { for (let i = 0; i < turns.length; i++) { const turn = turns[i]; let turnTraceId = null; - try { - const entrySpan = trace.getSpan(entryInvs[i].contextToken); - if (entrySpan) turnTraceId = entrySpan.spanContext().traceId; - } catch {} + if (!logOnly && entryInvs[i]) { + try { + const entrySpan = trace.getSpan(entryInvs[i].contextToken); + if (entrySpan) turnTraceId = entrySpan.spanContext().traceId; + } catch {} + } const { records, hash } = generateTurnLogRecords( turn, i, sessionId, state.model || "unknown", logHash, turnTraceId @@ -972,14 +981,16 @@ async function exportSessionTrace(state, stopReason = "end_turn") { } } - await shutdownTelemetry(); + if (!logOnly) { + await shutdownTelemetry(); + } const totalIn = turns.reduce((s, t) => s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.input_tokens || 0), 0), 0); const totalOut = turns.reduce((s, t) => s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.output_tokens || 0), 0), 0); console.error( - `✅ Session traced | ${turns.length} turn(s) | ` + + `✅ Session ${logOnly ? "logged" : "traced"} | ${turns.length} turn(s) | ` + `${totalIn} in, ${totalOut} out | ` + `${(stopTime - startTime).toFixed(1)}s` ); diff --git a/opentelemetry-instrumentation-claude/src/config.js b/opentelemetry-instrumentation-claude/src/config.js index 0a7ab6f..cb4a6a6 100644 --- a/opentelemetry-instrumentation-claude/src/config.js +++ b/opentelemetry-instrumentation-claude/src/config.js @@ -76,6 +76,10 @@ function getLogDir() { return getConfig("log_dir", "OTEL_CLAUDE_LOG_DIR", ""); } +function getLogFilenameFormat() { + return getConfig("log_filename_format", "OTEL_CLAUDE_LOG_FILENAME_FORMAT", "default"); +} + module.exports = { CONFIG_PATH, loadConfigFile, @@ -89,4 +93,5 @@ module.exports = { getSemconvDialect, isLogEnabled, getLogDir, + getLogFilenameFormat, }; diff --git a/opentelemetry-instrumentation-claude/src/logger.js b/opentelemetry-instrumentation-claude/src/logger.js index 2fbb205..c170b51 100644 --- a/opentelemetry-instrumentation-claude/src/logger.js +++ b/opentelemetry-instrumentation-claude/src/logger.js @@ -29,6 +29,9 @@ function getLogFilePath() { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, "0"); const d = String(now.getDate()).padStart(2, "0"); + if (config.getLogFilenameFormat() === "hook") { + return path.join(dir, `claude-code-${y}-${m}-${d}.jsonl`); + } return path.join(dir, `claude-code.jsonl.${y}${m}${d}`); } diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index 9638e4b..56c0dae 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -480,6 +480,24 @@ describe("cmdSubagentStop", () => { // ─── cmdCheckEnv ────────────────────────────────────────────────────────── describe("cmdCheckEnv", () => { + const config = require("../src/config"); + const origReadFileSync = fs.readFileSync; + + beforeEach(() => { + config.resetConfigCache(); + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; + }); + + afterEach(() => { + fs.readFileSync = origReadFileSync; + config.resetConfigCache(); + }); + test("exits 1 when no backend configured", () => { delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; delete process.env.CLAUDE_TELEMETRY_DEBUG; diff --git a/opentelemetry-instrumentation-claude/test/logger.test.js b/opentelemetry-instrumentation-claude/test/logger.test.js index a6d7ba7..803a6f4 100644 --- a/opentelemetry-instrumentation-claude/test/logger.test.js +++ b/opentelemetry-instrumentation-claude/test/logger.test.js @@ -158,10 +158,25 @@ describe("shouldLogFullMessages", () => { // --------------------------------------------------------------------------- describe("isLogEnabled", () => { const origEnv = process.env.OTEL_CLAUDE_LOG_ENABLED; + const config = require("../src/config"); + let origReadFileSync; + + beforeEach(() => { + config.resetConfigCache(); + origReadFileSync = fs.readFileSync; + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; + }); afterEach(() => { + fs.readFileSync = origReadFileSync; if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_ENABLED; else process.env.OTEL_CLAUDE_LOG_ENABLED = origEnv; + config.resetConfigCache(); }); test("returns true when OTEL_CLAUDE_LOG_ENABLED=1", () => { diff --git a/opentelemetry-instrumentation-claude/test/telemetry.test.js b/opentelemetry-instrumentation-claude/test/telemetry.test.js index cecd5cb..e147813 100644 --- a/opentelemetry-instrumentation-claude/test/telemetry.test.js +++ b/opentelemetry-instrumentation-claude/test/telemetry.test.js @@ -2,14 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 "use strict"; +const fs = require("fs"); + describe("telemetry", () => { let telemetry; + const origReadFileSync = fs.readFileSync; beforeEach(() => { jest.resetModules(); + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; }); afterEach(async () => { + fs.readFileSync = origReadFileSync; if (telemetry) { try { await telemetry.shutdownTelemetry(); } catch {} telemetry = null; From 81c8c490b4f9d4482d84fcea50f7fab517dce33e Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Sat, 2 May 2026 01:09:18 +0800 Subject: [PATCH 07/23] feat(claude): migrate JSONL log fields to event_t schema Align generateTurnLogRecords() output with the unified AI Agent EventSchema: - Split combined llm_call records into separate llm.request + llm.response events - Split combined tool records into separate tool.call + tool.result events - Rename all gen_ai.* fields to dotted namespace (event.name, session.id, etc.) - Add event.id (UUID), user.id, agent.type, agent.name to every record - Add usage.total_tokens, tool.result.status, tool.result.duration_ms - Add is_error/error.type/error.message on tool.result for failed tools Co-Authored-By: Claude Opus 4.6 --- .../src/cli.js | 161 +++++++++++------ .../test/cli.test.js | 164 ++++++++++++------ 2 files changed, 218 insertions(+), 107 deletions(-) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index d489ea5..39f3a7b 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -11,6 +11,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const crypto = require("crypto"); const { trace, context } = require("@opentelemetry/api"); const { loadState, saveState, clearState, readAndDeleteChildState, STATE_DIR } = require("./state"); @@ -721,15 +722,26 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra let currentStepId = null; let runningHash = prevHash; + let userId; + try { userId = os.userInfo().username; } catch { userId = ""; } + + const base = { + trace_id: traceId || null, + "session.id": sessionId, + "turn.id": turnId, + "user.id": userId, + "agent.type": "claude-code", + "agent.name": "claude-code", + }; + if (turn.prompt) { records.push({ - timestamp_ns: Math.round(turn.startTime * 1e9), - trace_id: traceId || null, - "gen_ai.session_id": sessionId, - "gen_ai.turn_id": turnId, - "gen_ai.agent_name": "claude-code", - "gen_ai.role": "user", - "gen_ai.input_messages_delta": JSON.stringify( + time_unix_nano: Math.round(turn.startTime * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "llm.request", + ...base, + "message.role": "user", + "input.messages_delta": JSON.stringify( [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] ), }); @@ -757,41 +769,58 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const delta = inputMsgs; const logFull = shouldLogFullMessages(runningHash, delta, currentFullHash); - const record = { - timestamp_ns: Math.round((ev.request_start_time || evTs) * 1e9), - trace_id: traceId || null, - "gen_ai.session_id": sessionId, - "gen_ai.turn_id": turnId, - "gen_ai.step_id": currentStepId, - "gen_ai.response_id": responseId, - "gen_ai.agent_id": sessionId, - "gen_ai.agent_name": "claude-code", - "gen_ai.provider_name": "anthropic", - "gen_ai.request_model": ev.model || model, - "gen_ai.response_model": ev.model || model, - "gen_ai.response_finish_reasons": ev.stop_reason || "stop", - "gen_ai.input_tokens": ev.input_tokens || 0, - "gen_ai.output_tokens": ev.output_tokens || 0, - "gen_ai.cache_write_tokens": ev.cache_creation_input_tokens || 0, - "gen_ai.cache_read_tokens": ev.cache_read_input_tokens || 0, - "gen_ai.role": "assistant", - "gen_ai.input_messages_hash": currentFullHash, - "gen_ai.input_messages_delta": JSON.stringify(delta), - "gen_ai.output_messages": JSON.stringify( - convertOutputMessages(ev.output_content, ev.stop_reason) - ), + const requestRecord = { + time_unix_nano: Math.round((ev.request_start_time || evTs) * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "llm.request", + ...base, + "step.id": currentStepId, + "response.id": responseId, + "agent.id": sessionId, + "message.role": "assistant", + "provider.name": "anthropic", + "request.model": ev.model || model, + "input.messages_hash": currentFullHash, + "input.messages_delta": JSON.stringify(delta), }; if (logFull) { - record["gen_ai.input_messages"] = JSON.stringify(inputMsgs); + requestRecord["input.messages"] = JSON.stringify(inputMsgs); } + records.push(requestRecord); + + const inputTokens = ev.input_tokens || 0; + const outputTokens = ev.output_tokens || 0; + const responseRecord = { + time_unix_nano: Math.round(evTs * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "llm.response", + ...base, + "step.id": currentStepId, + "response.id": responseId, + "message.role": "assistant", + "provider.name": "anthropic", + "request.model": ev.model || model, + "response.model": ev.model || model, + "response.finish_reasons": ev.stop_reason || "stop", + "usage.input_tokens": inputTokens, + "usage.output_tokens": outputTokens, + "usage.cache_write_tokens": ev.cache_creation_input_tokens || 0, + "usage.cache_read_tokens": ev.cache_read_input_tokens || 0, + "usage.total_tokens": inputTokens + outputTokens, + "output.messages": JSON.stringify( + convertOutputMessages(ev.output_content, ev.stop_reason) + ), + }; + if (ev.is_error) { - record["gen_ai.error_type"] = "LLMError"; - record["gen_ai.error_message"] = ev.error_message || "unknown error"; + responseRecord["is_error"] = true; + responseRecord["error.type"] = "LLMError"; + responseRecord["error.message"] = ev.error_message || "unknown error"; } - records.push(record); + records.push(responseRecord); runningHash = currentFullHash; } else if (ev.type === "post_tool_use") { @@ -803,17 +832,43 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const effectiveInput = preEv.tool_input || {}; records.push({ - timestamp_ns: Math.round(evTs * 1e9), - trace_id: traceId || null, - "gen_ai.session_id": sessionId, - "gen_ai.turn_id": turnId, - "gen_ai.step_id": currentStepId || turnId, - "gen_ai.role": "tool", - "gen_ai.tool_name": effectiveName, - "gen_ai.tool_arguments": JSON.stringify(effectiveInput), - "gen_ai.tool_results": JSON.stringify(extractToolResult(ev.tool_response)), - "gen_ai.tool_call_id": ev.tool_use_id || "", + time_unix_nano: Math.round((preEv.timestamp || evTs) * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "tool.call", + ...base, + "step.id": currentStepId || turnId, + "message.role": "tool", + "tool.name": effectiveName, + "tool.call.id": ev.tool_use_id || "", + "tool.arguments": JSON.stringify(effectiveInput), }); + + const toolErr = extractToolError(ev.tool_response); + const durationMs = preEv.timestamp ? (evTs - preEv.timestamp) * 1000 : undefined; + const resultRecord = { + time_unix_nano: Math.round(evTs * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "tool.result", + ...base, + "step.id": currentStepId || turnId, + "message.role": "tool", + "tool.name": effectiveName, + "tool.call.id": ev.tool_use_id || "", + "tool.result": JSON.stringify(extractToolResult(ev.tool_response)), + "tool.result.status": toolErr ? "error" : "success", + }; + + if (durationMs !== undefined) { + resultRecord["tool.result.duration_ms"] = durationMs; + } + + if (toolErr) { + resultRecord["is_error"] = true; + resultRecord["error.type"] = toolErr.type || "ToolError"; + resultRecord["error.message"] = toolErr.message || "unknown error"; + } + + records.push(resultRecord); } } @@ -825,15 +880,15 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const toolName = preEv.tool_name || "unknown"; if (toolName === "Agent" || toolName === "agent") continue; records.push({ - timestamp_ns: Math.round((preEv.timestamp || turn.endTime) * 1e9), - trace_id: traceId || null, - "gen_ai.session_id": sessionId, - "gen_ai.turn_id": turnId, - "gen_ai.step_id": currentStepId || turnId, - "gen_ai.role": "tool", - "gen_ai.tool_name": toolName, - "gen_ai.tool_arguments": JSON.stringify(preEv.tool_input || {}), - "gen_ai.tool_call_id": toolUseId, + time_unix_nano: Math.round((preEv.timestamp || turn.endTime) * 1e9), + "event.id": crypto.randomUUID(), + "event.name": "tool.call", + ...base, + "step.id": currentStepId || turnId, + "message.role": "tool", + "tool.name": toolName, + "tool.call.id": toolUseId, + "tool.arguments": JSON.stringify(preEv.tool_input || {}), }); } diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index 56c0dae..ee05895 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -846,7 +846,18 @@ describe("generateTurnLogRecords", () => { const model = "claude-sonnet-4-5-20250514"; const traceId = "abc123def456"; - test("single LLM call turn generates user + assistant records", () => { + function expectCommonFields(rec, sessionId, traceId) { + expect(rec["event.id"]).toBeDefined(); + expect(rec["event.id"]).toHaveLength(36); + expect(rec["event.name"]).toBeDefined(); + expect(rec["session.id"]).toBe(sessionId); + expect(rec["agent.type"]).toBe("claude-code"); + expect(rec["agent.name"]).toBe("claude-code"); + expect(rec["user.id"]).toBeDefined(); + if (traceId) expect(rec.trace_id).toBe(traceId); + } + + test("single LLM call turn generates user request + llm.request + llm.response", () => { const turn = { prompt: "Hello", startTime: 1000, @@ -869,26 +880,38 @@ describe("generateTurnLogRecords", () => { }; const { records, hash } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, traceId); - expect(records).toHaveLength(2); + expect(records).toHaveLength(3); const userRec = records[0]; - expect(userRec["gen_ai.role"]).toBe("user"); - expect(userRec["gen_ai.session_id"]).toBe(sessionId); - expect(userRec["gen_ai.turn_id"]).toBe(`${sessionId}:t1`); - expect(userRec.trace_id).toBe(traceId); - - const llmRec = records[1]; - expect(llmRec["gen_ai.role"]).toBe("assistant"); - expect(llmRec["gen_ai.step_id"]).toBe(`${sessionId}:t1:s1`); - expect(llmRec["gen_ai.input_tokens"]).toBe(10); - expect(llmRec["gen_ai.output_tokens"]).toBe(5); - expect(llmRec["gen_ai.request_model"]).toBe(model); - expect(llmRec["gen_ai.input_messages_hash"]).toHaveLength(32); - expect(llmRec["gen_ai.output_messages"]).toBeDefined(); - expect(hash).toBe(llmRec["gen_ai.input_messages_hash"]); - }); - - test("multi-step turn generates user + assistant + tool + assistant", () => { + expectCommonFields(userRec, sessionId, traceId); + expect(userRec["event.name"]).toBe("llm.request"); + expect(userRec["message.role"]).toBe("user"); + expect(userRec["turn.id"]).toBe(`${sessionId}:t1`); + expect(userRec["input.messages_delta"]).toBeDefined(); + + const reqRec = records[1]; + expectCommonFields(reqRec, sessionId, traceId); + expect(reqRec["event.name"]).toBe("llm.request"); + expect(reqRec["message.role"]).toBe("assistant"); + expect(reqRec["step.id"]).toBe(`${sessionId}:t1:s1`); + expect(reqRec["request.model"]).toBe(model); + expect(reqRec["input.messages_hash"]).toHaveLength(32); + expect(reqRec["input.messages_delta"]).toBeDefined(); + + const respRec = records[2]; + expectCommonFields(respRec, sessionId, traceId); + expect(respRec["event.name"]).toBe("llm.response"); + expect(respRec["message.role"]).toBe("assistant"); + expect(respRec["step.id"]).toBe(`${sessionId}:t1:s1`); + expect(respRec["usage.input_tokens"]).toBe(10); + expect(respRec["usage.output_tokens"]).toBe(5); + expect(respRec["usage.total_tokens"]).toBe(15); + expect(respRec["request.model"]).toBe(model); + expect(respRec["output.messages"]).toBeDefined(); + expect(hash).toBe(reqRec["input.messages_hash"]); + }); + + test("multi-step turn generates split events for LLM and tools", () => { const turn = { prompt: "List files", startTime: 1000, @@ -937,18 +960,30 @@ describe("generateTurnLogRecords", () => { }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, traceId); - expect(records).toHaveLength(4); - expect(records[0]["gen_ai.role"]).toBe("user"); - expect(records[1]["gen_ai.role"]).toBe("assistant"); - expect(records[1]["gen_ai.step_id"]).toBe(`${sessionId}:t1:s1`); - expect(records[2]["gen_ai.role"]).toBe("tool"); - expect(records[2]["gen_ai.tool_name"]).toBe("Bash"); - expect(records[2]["gen_ai.tool_call_id"]).toBe("t1"); - expect(records[3]["gen_ai.role"]).toBe("assistant"); - expect(records[3]["gen_ai.step_id"]).toBe(`${sessionId}:t1:s2`); - }); - - test("error LLM call includes error fields", () => { + // user(llm.request) + llm.request + llm.response + tool.call + tool.result + llm.request + llm.response + expect(records).toHaveLength(7); + expect(records[0]["event.name"]).toBe("llm.request"); + expect(records[0]["message.role"]).toBe("user"); + expect(records[1]["event.name"]).toBe("llm.request"); + expect(records[1]["message.role"]).toBe("assistant"); + expect(records[1]["step.id"]).toBe(`${sessionId}:t1:s1`); + expect(records[2]["event.name"]).toBe("llm.response"); + expect(records[2]["step.id"]).toBe(`${sessionId}:t1:s1`); + expect(records[3]["event.name"]).toBe("tool.call"); + expect(records[3]["tool.name"]).toBe("Bash"); + expect(records[3]["tool.call.id"]).toBe("t1"); + expect(records[4]["event.name"]).toBe("tool.result"); + expect(records[4]["tool.name"]).toBe("Bash"); + expect(records[4]["tool.call.id"]).toBe("t1"); + expect(records[4]["tool.result.status"]).toBe("success"); + expect(records[4]["tool.result.duration_ms"]).toBe(500); + expect(records[5]["event.name"]).toBe("llm.request"); + expect(records[5]["step.id"]).toBe(`${sessionId}:t1:s2`); + expect(records[6]["event.name"]).toBe("llm.response"); + expect(records[6]["step.id"]).toBe(`${sessionId}:t1:s2`); + }); + + test("error LLM call includes error fields on llm.response", () => { const turn = { prompt: "test", startTime: 1000, @@ -966,12 +1001,13 @@ describe("generateTurnLogRecords", () => { }], }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); - const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); - expect(llmRec["gen_ai.error_type"]).toBe("LLMError"); - expect(llmRec["gen_ai.error_message"]).toBe("rate limit exceeded"); + const respRec = records.find(r => r["event.name"] === "llm.response"); + expect(respRec["is_error"]).toBe(true); + expect(respRec["error.type"]).toBe("LLMError"); + expect(respRec["error.message"]).toBe("rate limit exceeded"); }); - test("orphaned pre_tool_use generates tool record without results", () => { + test("orphaned pre_tool_use generates tool.call without tool.result", () => { const turn = { prompt: null, startTime: 1000, @@ -986,10 +1022,10 @@ describe("generateTurnLogRecords", () => { }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); expect(records).toHaveLength(1); - expect(records[0]["gen_ai.role"]).toBe("tool"); - expect(records[0]["gen_ai.tool_name"]).toBe("Read"); - expect(records[0]["gen_ai.tool_call_id"]).toBe("orphan1"); - expect(records[0]["gen_ai.tool_results"]).toBeUndefined(); + expect(records[0]["event.name"]).toBe("tool.call"); + expect(records[0]["tool.name"]).toBe("Read"); + expect(records[0]["tool.call.id"]).toBe("orphan1"); + expect(records[0]["tool.result"]).toBeUndefined(); }); test("Agent tool calls are skipped", () => { @@ -1003,8 +1039,9 @@ describe("generateTurnLogRecords", () => { ], }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); - expect(records).toHaveLength(1); // only user record - expect(records[0]["gen_ai.role"]).toBe("user"); + expect(records).toHaveLength(1); // only user llm.request + expect(records[0]["event.name"]).toBe("llm.request"); + expect(records[0]["message.role"]).toBe("user"); }); test("hash chaining — second turn uses first turn hash as prevHash", () => { @@ -1044,15 +1081,15 @@ describe("generateTurnLogRecords", () => { const r1 = cli._generateTurnLogRecords(turn1, 0, sessionId, model, INITIAL_HASH, null); const r2 = cli._generateTurnLogRecords(turn2, 1, sessionId, model, r1.hash, null); - const hash1 = r1.records.find(r => r["gen_ai.role"] === "assistant")["gen_ai.input_messages_hash"]; - const hash2 = r2.records.find(r => r["gen_ai.role"] === "assistant")["gen_ai.input_messages_hash"]; + const hash1 = r1.records.find(r => r["event.name"] === "llm.request" && r["message.role"] === "assistant")["input.messages_hash"]; + const hash2 = r2.records.find(r => r["event.name"] === "llm.request" && r["message.role"] === "assistant")["input.messages_hash"]; expect(hash1).not.toBe(hash2); expect(hash1).toHaveLength(32); expect(hash2).toHaveLength(32); }); - test("first LLM call with matching hash does not log full input_messages", () => { + test("first LLM call with matching hash does not log full input.messages", () => { const turn = { prompt: "test", startTime: 1000, @@ -1068,12 +1105,12 @@ describe("generateTurnLogRecords", () => { }], }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); - const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); - expect(llmRec["gen_ai.input_messages"]).toBeUndefined(); - expect(llmRec["gen_ai.input_messages_delta"]).toBeDefined(); + const reqRec = records.find(r => r["event.name"] === "llm.request" && r["message.role"] === "assistant"); + expect(reqRec["input.messages"]).toBeUndefined(); + expect(reqRec["input.messages_delta"]).toBeDefined(); }); - test("logs full input_messages when hash mismatches (context change)", () => { + test("logs full input.messages when hash mismatches (context change)", () => { const turn = { prompt: "test", startTime: 1000, @@ -1090,12 +1127,12 @@ describe("generateTurnLogRecords", () => { }; const wrongPrevHash = "f".repeat(32); const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, wrongPrevHash, null); - const llmRec = records.find(r => r["gen_ai.role"] === "assistant"); - expect(llmRec["gen_ai.input_messages"]).toBeDefined(); - expect(llmRec["gen_ai.input_messages_delta"]).toBeDefined(); + const reqRec = records.find(r => r["event.name"] === "llm.request" && r["message.role"] === "assistant"); + expect(reqRec["input.messages"]).toBeDefined(); + expect(reqRec["input.messages_delta"]).toBeDefined(); }); - test("no prompt — no user record generated", () => { + test("no prompt — no user llm.request generated", () => { const turn = { prompt: null, startTime: 1000, @@ -1111,7 +1148,26 @@ describe("generateTurnLogRecords", () => { }], }; const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); - expect(records.filter(r => r["gen_ai.role"] === "user")).toHaveLength(0); - expect(records.filter(r => r["gen_ai.role"] === "assistant")).toHaveLength(1); + expect(records.filter(r => r["message.role"] === "user")).toHaveLength(0); + // llm.request (assistant) + llm.response + expect(records.filter(r => r["message.role"] === "assistant")).toHaveLength(2); + }); + + test("tool error sets is_error and error fields on tool.result", () => { + const turn = { + prompt: null, + startTime: 1000, + endTime: 1002, + events: [ + { type: "pre_tool_use", timestamp: 1000.5, tool_name: "Bash", tool_input: { command: "rm -rf /" }, tool_use_id: "e1" }, + { type: "post_tool_use", timestamp: 1001.5, tool_name: "Bash", tool_response: { isError: true, error: "Permission denied" }, tool_use_id: "e1" }, + ], + }; + const { records } = cli._generateTurnLogRecords(turn, 0, sessionId, model, INITIAL_HASH, null); + const resultRec = records.find(r => r["event.name"] === "tool.result"); + expect(resultRec["tool.result.status"]).toBe("error"); + expect(resultRec["is_error"]).toBe(true); + expect(resultRec["error.message"]).toContain("Permission denied"); + expect(resultRec["tool.result.duration_ms"]).toBe(1000); }); }); From ba1bc97d9cad15c9600f8ef420f772d35afc303a Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Sat, 2 May 2026 01:15:47 +0800 Subject: [PATCH 08/23] docs(claude): update README JSONL log section for event_t schema Update log field references from gen_ai.* to event_t dotted namespace (event.name, session.id, message.role, etc.) and document the split event model (llm.request/llm.response, tool.call/tool.result). Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/opentelemetry-instrumentation-claude/README.md b/opentelemetry-instrumentation-claude/README.md index 7c86323..bfb12f5 100644 --- a/opentelemetry-instrumentation-claude/README.md +++ b/opentelemetry-instrumentation-claude/README.md @@ -204,7 +204,7 @@ export OTEL_CLAUDE_LOG_DIR="~/.ai-agent-collector/logs/claude-code" 日志文件格式: - 默认路径:`/claude-code.jsonl.YYYYMMDD` - 可通过 `log_filename_format: "hook"` 切换为 `/claude-code-YYYY-MM-DD.jsonl`(兼容 ai-agent-collector 的 BaseHookInput) -- 每行一个 JSON 对象,包含 `gen_ai.role`(user/assistant/tool)、`gen_ai.session_id`、token 用量等字段 +- 每行一个 JSON 对象,遵循 AI Agent EventSchema(event_t)规范,包含 `event.name`(`llm.request`/`llm.response`/`tool.call`/`tool.result`)、`session.id`、`message.role`、token 用量等字段 - 按天自动轮转 --- @@ -413,8 +413,10 @@ opentelemetry-instrumentation-claude/ 6. **JSONL 日志采集**(可选):当 `log_enabled=true` 时,`logger.js` 在 trace 导出完成后将每轮对话的详细记录写入本地 JSONL 文件: - 文件路径:`/claude-code.jsonl.YYYYMMDD`,按天自动轮转 - - 每条记录包含 `trace_id`(与 OTel trace 关联)、session/turn/step 标识、token 用量、消息内容 - - **Chain hash 增量校验**:使用 `H_n = sha256(H_{n-1} + serialize(msg_n))` 算法检测消息是否被上下文压缩修改。仅在 hash 不匹配时记录完整 `input_messages`,正常情况下只记录增量 `delta`,大幅节省存储 + - 每条记录遵循 AI Agent EventSchema(event_t)规范:`event.name` 区分事件类型(`llm.request`/`llm.response`/`tool.call`/`tool.result`),包含 `event.id`、`session.id`、`turn.id`、`step.id`、`message.role`、`user.id`、`agent.type` 等标准字段 + - LLM 请求和响应拆分为独立事件:`llm.request` 携带 `input.messages_delta`/`input.messages_hash`,`llm.response` 携带 `output.messages`/`usage.*` token 用量 + - 工具调用和结果拆分为独立事件:`tool.call` 携带 `tool.arguments`,`tool.result` 携带 `tool.result`/`tool.result.status`/`tool.result.duration_ms` + - **Chain hash 增量校验**:使用 `H_n = sha256(H_{n-1} + serialize(msg_n))` 算法检测消息是否被上下文压缩修改。仅在 hash 不匹配时记录完整 `input.messages`,正常情况下只记录增量 `input.messages_delta`,大幅节省存储 --- From 8f5c00f5c28beecce62d73c8571404ed3600159e Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Tue, 5 May 2026 21:38:08 +0800 Subject: [PATCH 09/23] fix(claude): remove agent.name from event logs, keep agent.type as "claude-code" Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/src/cli.js | 1 - 1 file changed, 1 deletion(-) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 39f3a7b..9690b85 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -731,7 +731,6 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra "turn.id": turnId, "user.id": userId, "agent.type": "claude-code", - "agent.name": "claude-code", }; if (turn.prompt) { From 12f5a1dd10778c31d25fe7687fc249eaa5948cdf Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Tue, 5 May 2026 23:58:02 +0800 Subject: [PATCH 10/23] fix(claude): harden setup-alias.sh and add npm uninstall to uninstall.sh - setup-alias.sh: add file writability check, handle cat >> failure gracefully - uninstall.sh: add npm uninstall -g otel-claude-hook step for global package cleanup Co-Authored-By: Claude Opus 4.6 --- .../scripts/setup-alias.sh | 19 +++++++++++----- .../scripts/uninstall.sh | 22 +++++++++++++++---- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh index 9d48db6..ecc5ecf 100755 --- a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh +++ b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh @@ -65,20 +65,29 @@ add_alias_to_file() { if [ ! -f "$file" ]; then return fi + if [ ! -w "$file" ]; then + msg " ↳ $file 不可写,跳过" \ + " ↳ $file is not writable, skipping" + return + fi if grep -q "# BEGIN otel-claude-hook" "$file" 2>/dev/null; then msg " ↳ 已存在于 $file" \ " ↳ Already present in $file" return fi - cat >> "$file" << ALIAS_BLOCK - + if cat >> "$file" << ALIAS_BLOCK # BEGIN otel-claude-hook $ALIAS_LINE # END otel-claude-hook ALIAS_BLOCK - msg " ✅ 已添加别名到 $file" \ - " ✅ Added alias to $file" - ADDED=1 + then + msg " ✅ 已添加别名到 $file" \ + " ✅ Added alias to $file" + ADDED=1 + else + msg " ↳ 写入 $file 失败,跳过" \ + " ↳ Failed to write $file, skipping" + fi } msg "==> 正在设置 claude 别名..." \ diff --git a/opentelemetry-instrumentation-claude/scripts/uninstall.sh b/opentelemetry-instrumentation-claude/scripts/uninstall.sh index 2ffa804..1f1b135 100755 --- a/opentelemetry-instrumentation-claude/scripts/uninstall.sh +++ b/opentelemetry-instrumentation-claude/scripts/uninstall.sh @@ -5,8 +5,9 @@ # Steps: # 1. 从 ~/.claude/settings.json 删除 otel-claude-hook 相关 hook + enableTelemetry # 2. 删除 ~/.cache/opentelemetry.instrumentation.claude/intercept.js -# 3. 从 shell profile 删除 claude alias 及注释行 -# 4. 打印卸载结果 +# 3. npm uninstall -g otel-claude-hook +# 4. 从 shell profile 删除 claude alias 及注释行 +# 5. 打印卸载结果 set -euo pipefail @@ -138,7 +139,20 @@ else fi echo "" -# 3. 从 shell profile 删除 claude alias 及注释行 +# 3. 清理全局 npm 包 +msg "==> 清理全局 npm 包..." \ + "==> Cleaning up global npm package..." +if npm ls -g otel-claude-hook &>/dev/null; then + npm uninstall -g otel-claude-hook 2>/dev/null || true + msg " ✅ npm uninstall -g otel-claude-hook" \ + " ✅ npm uninstall -g otel-claude-hook" +else + msg " ℹ️ 未找到全局 npm 包,跳过" \ + " ℹ️ No global npm package found, skipping" +fi +echo "" + +# 4. 从 shell profile 删除 claude alias 及注释行 msg "==> 正在清理 shell 别名..." \ "==> Cleaning up shell alias..." @@ -181,7 +195,7 @@ remove_env_block_from_file "$HOME/.zshrc" || true remove_env_block_from_file "$HOME/.bash_profile" || true echo "" -# 4. 完成 +# 5. 完成 msg "==============================" \ "==============================" msg " ✅ 卸载完成!" \ From 43231599e8de5f8a4458a3244de15300a2a13d74 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Wed, 6 May 2026 11:22:01 +0800 Subject: [PATCH 11/23] fix(claude): eliminate PATH dependency with wrapper script for hook execution Same fix as codex plugin: generate hook-entry.sh wrapper at ~/.cache/opentelemetry.instrumentation.claude/ with built-in Node discovery (nvm, homebrew, volta, fnm fallbacks). Register absolute path in settings.json instead of bare `otel-claude-hook` command. - Add generateHookEntry(), packageBinPath(), hookEntryCacheDir() - Add isOtelHookCommand() to match both old and new formats - Extract removeOtelHooksFromSettings() for shared cleanup logic - Modify installIntoSettings() to clean old entries before adding new - Modify uninstallFromSettings() to use isOtelHookCommand() - Update install.sh to use `node "$PKG_DIR/bin/..."` absolute path - Update uninstall.sh cleanup to match hook-entry.sh format Co-Authored-By: Claude Opus 4.6 --- .../scripts/install.sh | 2 +- .../scripts/uninstall.sh | 3 +- .../src/cli.js | 139 ++++++++++++------ 3 files changed, 93 insertions(+), 51 deletions(-) diff --git a/opentelemetry-instrumentation-claude/scripts/install.sh b/opentelemetry-instrumentation-claude/scripts/install.sh index d5a721d..2efb81c 100755 --- a/opentelemetry-instrumentation-claude/scripts/install.sh +++ b/opentelemetry-instrumentation-claude/scripts/install.sh @@ -136,7 +136,7 @@ echo "" # 4. Register hooks in ~/.claude/settings.json + enable built-in telemetry msg "==> 正在注册 Claude Code Hook 并启用内置遥测..." \ "==> Registering Claude Code hooks and enabling built-in telemetry..." -otel-claude-hook install --user +node "$PKG_DIR/bin/otel-claude-hook" install --user echo "" # 5. Set up claude alias in shell profiles diff --git a/opentelemetry-instrumentation-claude/scripts/uninstall.sh b/opentelemetry-instrumentation-claude/scripts/uninstall.sh index 1f1b135..669c8d0 100755 --- a/opentelemetry-instrumentation-claude/scripts/uninstall.sh +++ b/opentelemetry-instrumentation-claude/scripts/uninstall.sh @@ -80,7 +80,8 @@ if (settings.hooks && typeof settings.hooks === "object") { if (!Array.isArray(matchers)) continue; const filtered = matchers.map(matcher => { if (!Array.isArray(matcher.hooks)) return matcher; - const newHooks = matcher.hooks.filter(h => !h.command || !h.command.includes("otel-claude-hook")); + const isOurs = c => c.includes('otel-claude-hook') || c.includes('hook-entry.sh'); + const newHooks = matcher.hooks.filter(h => !h.command || !isOurs(h.command)); if (newHooks.length === matcher.hooks.length) return matcher; changed = true; return newHooks.length > 0 ? { ...matcher, hooks: newHooks } : null; diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 9690b85..91fa7c3 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -122,7 +122,61 @@ function getHookCmd() { return process.env[HOOK_CMD_ENV_VAR] || HOOK_CMD_DEFAULT; } -function buildHookConfig(cmd) { +function packageBinPath() { + return path.resolve(__dirname, "..", "bin", "otel-claude-hook"); +} + +function hookEntryCacheDir() { + return path.join(os.homedir(), ".cache", "opentelemetry.instrumentation.claude"); +} + +function generateHookEntry() { + const binPath = packageBinPath(); + const cacheDir = hookEntryCacheDir(); + const entryPath = path.join(cacheDir, "hook-entry.sh"); + fs.mkdirSync(cacheDir, { recursive: true }); + + const script = [ + "#!/usr/bin/env bash", + "# Auto-generated by otel-claude-hook install", + "set -euo pipefail", + "", + 'NODE_BIN=""', + "if command -v node >/dev/null 2>&1; then", + ' NODE_BIN="node"', + "else", + " for candidate in \\", + ' "$HOME/.nvm/versions/node"/*/bin/node \\', + " /usr/local/bin/node \\", + " /opt/homebrew/bin/node \\", + ' "$HOME/.local/bin/node" \\', + ' "$HOME/.volta/bin/node" \\', + ' "$HOME/.fnm/aliases/default/bin/node"; do', + ' if [[ -x "$candidate" ]]; then', + ' NODE_BIN="$candidate"', + " break", + " fi", + " done", + "fi", + "", + 'if [[ -z "$NODE_BIN" ]]; then', + ' echo "[otel-claude-hook] node runtime not found" >&2', + " exit 0", + "fi", + "", + `exec "$NODE_BIN" ${JSON.stringify(binPath)} "$@"`, + "", + ].join("\n"); + + fs.writeFileSync(entryPath, script, { mode: 0o755 }); + return entryPath; +} + +function isOtelHookCommand(cmd) { + return cmd.includes("otel-claude-hook") || cmd.includes("hook-entry.sh"); +} + +function buildHookConfig(entryPath) { const subcommands = [ ["UserPromptSubmit", "user-prompt-submit"], ["PreToolUse", "pre-tool-use"], @@ -135,7 +189,7 @@ function buildHookConfig(cmd) { ]; const config = {}; for (const [event, sub] of subcommands) { - config[event] = [{ hooks: [{ type: "command", command: `${cmd} ${sub}` }] }]; + config[event] = [{ hooks: [{ type: "command", command: `bash ${entryPath} ${sub}` }] }]; } return config; } @@ -1098,7 +1152,27 @@ function installIntercept() { return dest; } -function installIntoSettings(settingsPath) { +function removeOtelHooksFromSettings(settings) { + if (!settings.hooks || typeof settings.hooks !== "object") return; + for (const [event, matchers] of Object.entries(settings.hooks)) { + if (!Array.isArray(matchers)) continue; + settings.hooks[event] = matchers + .map((matcher) => { + if (!Array.isArray(matcher.hooks)) return matcher; + const newHooks = matcher.hooks.filter( + (h) => !h.command || !isOtelHookCommand(h.command) + ); + if (newHooks.length === matcher.hooks.length) return matcher; + return newHooks.length > 0 ? { ...matcher, hooks: newHooks } : null; + }) + .filter(Boolean) + .filter((m) => m && Array.isArray(m.hooks) && m.hooks.length > 0); + if (settings.hooks[event].length === 0) delete settings.hooks[event]; + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; +} + +function installIntoSettings(settingsPath, entryPath) { settingsPath = path.resolve(settingsPath); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); @@ -1107,20 +1181,14 @@ function installIntoSettings(settingsPath) { try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch {} } + removeOtelHooksFromSettings(settings); + if (!settings.hooks) settings.hooks = {}; - const hookConfig = buildHookConfig(getHookCmd()); + const hookConfig = buildHookConfig(entryPath); for (const [eventName, matchers] of Object.entries(hookConfig)) { if (!settings.hooks[eventName]) settings.hooks[eventName] = []; - const existing = settings.hooks[eventName]; - const hookCmd = matchers[0].hooks[0].command; - - const alreadyPresent = existing.some((matcher) => - (matcher.hooks || []).some((h) => h.command === hookCmd) - ); - if (!alreadyPresent) { - existing.push(...matchers); - } + settings.hooks[eventName].push(...matchers); } fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); @@ -1320,9 +1388,11 @@ async function cmdInstall(opts = {}) { process.exit(1); } - // 1. Register hooks in settings.json + // 1. Generate hook-entry.sh wrapper and register hooks in settings.json + const entryPath = generateHookEntry(); + log(installMsg(`✅ Hook entry: ${entryPath}`, `✅ Hook entry: ${entryPath}`)); for (const settingsPath of targets) { - installIntoSettings(settingsPath); + installIntoSettings(settingsPath, entryPath); log(installMsg(`✅ Hook 已安装到 ${settingsPath}`, `✅ Hooks installed in ${settingsPath}`)); } log(installMsg( @@ -1385,7 +1455,8 @@ async function cmdInstall(opts = {}) { } function cmdShowConfig() { - const snippet = { hooks: buildHookConfig(getHookCmd()) }; + const entryPath = path.join(hookEntryCacheDir(), "hook-entry.sh"); + const snippet = { hooks: buildHookConfig(entryPath) }; console.log(JSON.stringify(snippet, null, 2)); } @@ -1435,39 +1506,9 @@ function uninstallFromSettings(settingsPath) { try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); } catch { return; } - let changed = false; - - // Remove otel-claude-hook entries from hooks - if (settings.hooks && typeof settings.hooks === "object") { - for (const [event, matchers] of Object.entries(settings.hooks)) { - if (!Array.isArray(matchers)) continue; - const filtered = matchers - .map((matcher) => { - if (!Array.isArray(matcher.hooks)) return matcher; - const newHooks = matcher.hooks.filter( - (h) => !h.command || !h.command.includes("otel-claude-hook") - ); - if (newHooks.length === matcher.hooks.length) return matcher; - changed = true; - return newHooks.length > 0 ? { ...matcher, hooks: newHooks } : null; - }) - .filter(Boolean) - .filter((m) => m && Array.isArray(m.hooks) && m.hooks.length > 0); - - if (filtered.length === 0) { - delete settings.hooks[event]; - changed = true; - } else { - settings.hooks[event] = filtered; - } - } - if (Object.keys(settings.hooks).length === 0) { - delete settings.hooks; - changed = true; - } - } - - // (enableTelemetry field was removed in favor of CLAUDE_CODE_ENABLE_TELEMETRY env var) + const before = JSON.stringify(settings); + removeOtelHooksFromSettings(settings); + const changed = JSON.stringify(settings) !== before; if (changed) { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); From e8fbf318c797d638d0fe763d4c274419f4feade3 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Wed, 6 May 2026 12:06:35 +0800 Subject: [PATCH 12/23] fix(claude): add matcher field to hook config for Cursor IDE compatibility Cursor's third-party hooks parser requires the `matcher` field on each hook entry. Missing it causes "Failed to parse claude-user Claude hooks configuration" error. Add `"matcher": "*"` to all registered hook events. Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/src/cli.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 91fa7c3..0acab57 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -189,7 +189,7 @@ function buildHookConfig(entryPath) { ]; const config = {}; for (const [event, sub] of subcommands) { - config[event] = [{ hooks: [{ type: "command", command: `bash ${entryPath} ${sub}` }] }]; + config[event] = [{ matcher: "*", hooks: [{ type: "command", command: `bash ${entryPath} ${sub}` }] }]; } return config; } From 83e85d6c7e3a8fa6651136385e811b2ef2dd6ff4 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Wed, 6 May 2026 17:34:20 +0800 Subject: [PATCH 13/23] fix(claude): compute true incremental delta for input.messages_delta input.messages_delta was recording the full cumulative API context instead of only the new messages added since the last LLM call. Track previous input messages and use slice-based diff to produce correct deltas. Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/src/cli.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 0acab57..a7eb619 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -775,6 +775,7 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra let stepRound = 0; let currentStepId = null; let runningHash = prevHash; + let prevInputMsgs = []; let userId; try { userId = os.userInfo().username; } catch { userId = ""; } @@ -819,7 +820,7 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const inputMsgs = convertInputMessages(ev.input_messages, protocol); const currentFullHash = computeHash(INITIAL_HASH, inputMsgs); - const delta = inputMsgs; + const delta = inputMsgs.slice(prevInputMsgs.length); const logFull = shouldLogFullMessages(runningHash, delta, currentFullHash); const requestRecord = { @@ -875,6 +876,7 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra records.push(responseRecord); runningHash = currentFullHash; + prevInputMsgs = inputMsgs; } else if (ev.type === "post_tool_use") { const toolName = ev.tool_name || "unknown"; From cc02b0728009312102c2c675625e29e4fee8fa99 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Wed, 6 May 2026 18:04:16 +0800 Subject: [PATCH 14/23] fix(claude): skip processing when called by Cursor IDE Cursor reads ~/.claude/settings.json hooks and executes them, causing duplicate data collection alongside Cursor's own hooks. Detect Cursor by checking for cursor_version in the stdin payload and return early. Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/src/cli.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index a7eb619..6a87844 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -1110,6 +1110,10 @@ async function exportSessionTrace(state, stopReason = "end_turn") { // stdin helper // --------------------------------------------------------------------------- +function isCursorCaller(event) { + return !!event.cursor_version; +} + function readStdinJson() { try { // Read stdin synchronously. Claude Code sends hook data as JSON on stdin. @@ -1202,6 +1206,7 @@ function installIntoSettings(settingsPath, entryPath) { function cmdUserPromptSubmit() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const prompt = event.prompt || ""; @@ -1221,6 +1226,7 @@ function cmdUserPromptSubmit() { function cmdPreToolUse() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const toolName = event.tool_name || "unknown"; const toolInput = event.tool_input || {}; @@ -1246,6 +1252,7 @@ function cmdPreToolUse() { function cmdPostToolUse() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); @@ -1262,6 +1269,7 @@ function cmdPostToolUse() { function cmdPreCompact() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); @@ -1276,6 +1284,7 @@ function cmdPreCompact() { function cmdSubagentStart() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); @@ -1291,6 +1300,7 @@ function cmdSubagentStart() { function cmdSubagentStop() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const stopReason = event.stop_reason || "end_turn"; @@ -1327,6 +1337,7 @@ function cmdSubagentStop() { function cmdNotification() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); @@ -1342,6 +1353,7 @@ function cmdNotification() { async function cmdStop() { const event = readStdinJson(); + if (isCursorCaller(event)) return; const sessionId = event.session_id || require("crypto").randomUUID(); const stopReason = event.stop_reason || "end_turn"; From 1e37417f229686f39bcb4e59705bdacb0612ea89 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 8 May 2026 12:26:24 +0800 Subject: [PATCH 15/23] feat(claude): transcript-based tracing, alias env var cleanup, and OOM fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add transcript.js: parse Claude Code native transcript JSONL for LLM call data (replaces intercept.js HTTP interception, works with all Claude Code versions) - Inject GenAI SDK env vars (Group A) in bin/otel-claude-hook entry point, removing them from alias (plugin code handles defaults programmatically) - setup-alias.sh: remove Group A vars + NODE_OPTIONS + intercept.js from alias; add --minimal mode (cleanup only, no alias write) for pilot installations - Add upgrade_old_alias() to auto-clean legacy alias blocks containing intercept.js - Fix O(N²) OOM in transcript parsing: store input_messages as delta instead of cumulative copies (35MB transcript: memory from >4GB to ~98MB) - Log generation: incremental hash computation for delta mode events - Add maybeSaveTranscriptPath() + enrichChildStateWithTranscript() for subagent support - Add comprehensive tests for transcript parsing and new functions Co-Authored-By: Claude Opus 4.6 --- .../bin/otel-claude-hook | 8 + .../scripts/setup-alias.sh | 39 +- .../src/cli.js | 148 ++++-- .../src/state.js | 1 + .../src/transcript.js | 333 ++++++++++++ .../test/cli.test.js | 103 ++++ .../test/transcript.test.js | 495 ++++++++++++++++++ 7 files changed, 1075 insertions(+), 52 deletions(-) create mode 100644 opentelemetry-instrumentation-claude/src/transcript.js create mode 100644 opentelemetry-instrumentation-claude/test/transcript.test.js diff --git a/opentelemetry-instrumentation-claude/bin/otel-claude-hook b/opentelemetry-instrumentation-claude/bin/otel-claude-hook index 2a3d15f..1923198 100755 --- a/opentelemetry-instrumentation-claude/bin/otel-claude-hook +++ b/opentelemetry-instrumentation-claude/bin/otel-claude-hook @@ -4,6 +4,14 @@ "use strict"; +// Ensure GenAI SDK defaults — must run before any SDK module is loaded +if (!process.env.OTEL_SEMCONV_STABILITY_OPT_IN) { + process.env.OTEL_SEMCONV_STABILITY_OPT_IN = "gen_ai_latest_experimental"; +} +if (!process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT) { + process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "SPAN_ONLY"; +} + /** * otel-claude-hook — CLI entry point. * diff --git a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh index ecc5ecf..049091c 100755 --- a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh +++ b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh @@ -2,16 +2,23 @@ # setup-alias.sh — Add the claude alias to shell profiles. # Called by install.sh. Can also be run standalone. # -# The alias ensures every `claude` invocation automatically loads intercept.js -# for LLM call tracing without requiring NODE_OPTIONS to be set manually. +# LLM trace data is obtained from Claude Code's transcript files via hooks. +# NODE_OPTIONS / intercept.js is no longer needed. set -euo pipefail -INTERCEPT_PATH="$HOME/.cache/opentelemetry.instrumentation.claude/intercept.js" +MINIMAL=0 +while [ $# -gt 0 ]; do + case "$1" in + --minimal) MINIMAL=1; shift ;; + *) shift ;; + esac +done + # If claude_identity is set at invocation time, it takes priority over OTEL_SERVICE_NAME. # The alias uses a shell substitution so the check happens each time claude is run, # not at install time — no code changes required. -ALIAS_LINE="alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY OTEL_SERVICE_NAME=\"\${claude_identity:-\${OTEL_SERVICE_NAME:-}}\" NODE_OPTIONS=\"--require $INTERCEPT_PATH\" npx -y @anthropic-ai/claude-code@2.1.112'" +ALIAS_LINE="alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SERVICE_NAME=\"\${claude_identity:-\${OTEL_SERVICE_NAME:-}}\" npx -y @anthropic-ai/claude-code@latest'" # --------------------------------------------------------------------------- # 语言检测 / Language detection @@ -60,6 +67,19 @@ msg() { ADDED=0 +upgrade_old_alias() { + local file="$1" + if [ ! -f "$file" ] || [ ! -w "$file" ]; then return; fi + if grep -q "# BEGIN otel-claude-hook" "$file" 2>/dev/null && \ + grep -q "intercept\.js" "$file" 2>/dev/null; then + # Old alias contains intercept.js — remove the block so it gets re-created + sed -i.otel-bak '/# BEGIN otel-claude-hook/,/# END otel-claude-hook/d' "$file" + rm -f "${file}.otel-bak" + msg " ↳ 已清理旧版 alias(含 intercept.js)于 $file" \ + " ↳ Cleaned up legacy alias (with intercept.js) in $file" + fi +} + add_alias_to_file() { local file="$1" if [ ! -f "$file" ]; then @@ -70,6 +90,8 @@ add_alias_to_file() { " ↳ $file is not writable, skipping" return fi + # Upgrade: remove old alias block that contains intercept.js + upgrade_old_alias "$file" if grep -q "# BEGIN otel-claude-hook" "$file" 2>/dev/null; then msg " ↳ 已存在于 $file" \ " ↳ Already present in $file" @@ -90,6 +112,15 @@ ALIAS_BLOCK fi } +if [ "$MINIMAL" -eq 1 ]; then + upgrade_old_alias "$HOME/.bashrc" + upgrade_old_alias "$HOME/.zshrc" + upgrade_old_alias "$HOME/.bash_profile" + msg "已清理旧版别名(minimal 模式,不写入新别名)" \ + "Legacy alias cleaned up (minimal mode, no new alias written)" + exit 0 +fi + msg "==> 正在设置 claude 别名..." \ "==> Setting up claude alias in shell profiles..." add_alias_to_file "$HOME/.bashrc" diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 6a87844..3f13216 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -164,6 +164,9 @@ function generateHookEntry() { " exit 0", "fi", "", + "# Prevent legacy NODE_OPTIONS --require intercept.js from breaking hook subprocesses", + "unset NODE_OPTIONS 2>/dev/null || true", + "", `exec "$NODE_BIN" ${JSON.stringify(binPath)} "$@"`, "", ].join("\n"); @@ -375,6 +378,32 @@ function splitEventsByTurn(events) { return turns; } +function enrichChildStateWithTranscript(childState) { + if (!childState || childState._transcript_enriched) return; + childState._transcript_enriched = true; + + if (!childState.transcript_path || !Array.isArray(childState.events)) return; + + try { + const { parseClaudeTranscript, alignWithHookEvents } = require("./transcript"); + const startTime = childState.start_time || 0; + const stopTime = childState.stop_time || Date.now() / 1000; + const llmEvents = parseClaudeTranscript(childState.transcript_path, startTime, stopTime); + if (llmEvents.length > 0) { + alignWithHookEvents(llmEvents, childState.events, stopTime); + const getSortKey = (e) => { + if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; + return e.timestamp || 0; + }; + childState.events = [...childState.events, ...llmEvents].sort( + (a, b) => getSortKey(a) - getSortKey(b) + ); + } + } catch (err) { + debug(`subagent transcript parse failed: ${err?.message || String(err)}`); + } +} + function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessionId) { const subagentSpanStack = []; const openAgentToolInvs = []; @@ -681,12 +710,16 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi entry.agentInv.usageCacheCreationInputTokens = ev.cache_creation_input_tokens || 0; entry.agentInv.finishReasons = [ev.stop_reason || "end_turn"]; if (childState) { + enrichChildStateWithTranscript(childState); entry.childState = childState; if (childState.model) entry.agentInv.requestModel = childState.model; } } } // Fallback: orphan subagent_stop with child_state + if (childState && !childState._transcript_enriched) { + enrichChildStateWithTranscript(childState); + } if (subagentSpanStack.length === 0 && !Object.keys(openSubagentsByAgentId).length && childState && Array.isArray(childState.events) && childState.events.length > 0) { const childStart = childState.start_time || evTs; @@ -818,10 +851,16 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const protocol = ev.protocol || "anthropic"; const inputMsgs = convertInputMessages(ev.input_messages, protocol); - const currentFullHash = computeHash(INITIAL_HASH, inputMsgs); - - const delta = inputMsgs.slice(prevInputMsgs.length); - const logFull = shouldLogFullMessages(runningHash, delta, currentFullHash); + let currentFullHash, delta, logFull; + if (ev._input_is_delta) { + delta = inputMsgs; + currentFullHash = computeHash(runningHash, delta); + logFull = false; + } else { + currentFullHash = computeHash(INITIAL_HASH, inputMsgs); + delta = inputMsgs.slice(prevInputMsgs.length); + logFull = shouldLogFullMessages(runningHash, delta, currentFullHash); + } const requestRecord = { time_unix_nano: Math.round((ev.request_start_time || evTs) * 1e9), @@ -876,7 +915,7 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra records.push(responseRecord); runningHash = currentFullHash; - prevInputMsgs = inputMsgs; + prevInputMsgs = ev._input_is_delta ? [] : inputMsgs; } else if (ev.type === "post_tool_use") { const toolName = ev.tool_name || "unknown"; @@ -970,19 +1009,38 @@ async function exportSessionTrace(state, stopReason = "end_turn") { const startTime = typeof state.start_time === "number" ? state.start_time : Date.now() / 1000; const stopTime = typeof state.stop_time === "number" ? state.stop_time : Date.now() / 1000; - // Merge proxy events from intercept.js + // Merge LLM call data from transcript (primary) or proxy events (fallback) let allEvents = Array.isArray(state.events) ? [...state.events] : []; - try { - const claudePid = resolveClaudePid(); - const proxyEvents = readProxyEvents(startTime, stopTime, true, claudePid); - if (proxyEvents.length > 0) { - const getSortKey = (e) => { - if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; - return e.timestamp || 0; - }; - allEvents = [...allEvents, ...proxyEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); + let llmEvents = []; + + // Primary: parse Claude Code transcript + if (state.transcript_path) { + try { + const { parseClaudeTranscript, alignWithHookEvents } = require("./transcript"); + llmEvents = parseClaudeTranscript(state.transcript_path, startTime, stopTime); + if (llmEvents.length > 0) { + alignWithHookEvents(llmEvents, allEvents, stopTime); + } + } catch (err) { + debug(`transcript parse failed: ${err?.message || String(err)}`); } - } catch {} + } + + // Fallback: intercept.js proxy events (for older Claude Code versions) + if (llmEvents.length === 0) { + try { + const claudePid = resolveClaudePid(); + llmEvents = readProxyEvents(startTime, stopTime, true, claudePid); + } catch {} + } + + if (llmEvents.length > 0) { + const getSortKey = (e) => { + if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; + return e.timestamp || 0; + }; + allEvents = [...allEvents, ...llmEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); + } // Split into per-turn groups const turns = splitEventsByTurn(allEvents); @@ -1204,6 +1262,12 @@ function installIntoSettings(settingsPath, entryPath) { // Command handlers (called from bin/otel-claude-hook) // --------------------------------------------------------------------------- +function maybeSaveTranscriptPath(state, event) { + if (!state.transcript_path && event.transcript_path) { + state.transcript_path = event.transcript_path; + } +} + function cmdUserPromptSubmit() { const event = readStdinJson(); if (isCursorCaller(event)) return; @@ -1211,6 +1275,7 @@ function cmdUserPromptSubmit() { const prompt = event.prompt || ""; const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); if (!state.start_time) state.start_time = Date.now() / 1000; if (!state.prompt) state.prompt = prompt; state.metrics.turns = (state.metrics.turns || 0) + 1; @@ -1237,6 +1302,7 @@ function cmdPreToolUse() { const toolUseId = event.tool_use_id || null; const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); state.metrics.tools_used = (state.metrics.tools_used || 0) + 1; if (!state.tools_used.includes(toolName)) state.tools_used.push(toolName); @@ -1256,6 +1322,7 @@ function cmdPostToolUse() { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); const toolName = event.tool_name || "unknown"; state.events.push({ type: "post_tool_use", @@ -1273,6 +1340,7 @@ function cmdPreCompact() { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); state.events.push({ type: "pre_compact", timestamp: Date.now() / 1000, @@ -1288,6 +1356,7 @@ function cmdSubagentStart() { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); state.events.push({ type: "subagent_start", timestamp: Date.now() / 1000, @@ -1310,6 +1379,9 @@ function cmdSubagentStop() { const cacheRead = usage.cache_read_input_tokens || event.cache_read_input_tokens || 0; const cacheCreate = usage.cache_creation_input_tokens || event.cache_creation_input_tokens || 0; + const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); + const childSid = event.subagent_session_id || "unknown"; let childStateSnapshot = null; if (childSid && childSid !== "unknown" && childSid !== sessionId) { @@ -1330,7 +1402,6 @@ function cmdSubagentStop() { evData._child_state = childStateSnapshot; } - const state = loadState(sessionId); state.events.push(evData); saveState(sessionId, state); } @@ -1341,6 +1412,7 @@ function cmdNotification() { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); state.events.push({ type: "notification", timestamp: Date.now() / 1000, @@ -1358,6 +1430,7 @@ async function cmdStop() { const stopReason = event.stop_reason || "end_turn"; const state = loadState(sessionId); + maybeSaveTranscriptPath(state, event); state.stop_time = Date.now() / 1000; saveState(sessionId, state); @@ -1409,15 +1482,8 @@ async function cmdInstall(opts = {}) { installIntoSettings(settingsPath, entryPath); log(installMsg(`✅ Hook 已安装到 ${settingsPath}`, `✅ Hooks installed in ${settingsPath}`)); } - log(installMsg( - "✅ 已启用 Claude Code 内置 OTel 指标 (CLAUDE_CODE_ENABLE_TELEMETRY=1 via alias)", - "✅ Claude Code built-in OTel metrics enabled (CLAUDE_CODE_ENABLE_TELEMETRY=1 via alias)" - )); - - // 2. Copy intercept.js to cache directory - const interceptPath = installIntercept(); - // 3. Set up shell alias via setup-alias.sh (if bash is available) + // 2. Set up shell alias via setup-alias.sh (if bash is available) const setupAliasScript = path.join(__dirname, "..", "scripts", "setup-alias.sh"); if (fs.existsSync(setupAliasScript)) { try { @@ -1433,31 +1499,19 @@ async function cmdInstall(opts = {}) { } } - // 4. Print usage hints (non-quiet only) + // 3. Print usage hints (non-quiet only) log( installMsg( "\n请配置遥测后端:\n" + " export OTEL_EXPORTER_OTLP_ENDPOINT='https://xxx:4318'\n" + - " export OTEL_RESOURCE_ATTRIBUTES='service.name=claude-agents'\n", + " export OTEL_RESOURCE_ATTRIBUTES='service.name=claude-agents'\n" + + "\nLLM 追踪数据通过 Claude Code 的 transcript 文件自动获取,无需额外配置。\n", "\nRemember to configure your telemetry backend:\n" + " export OTEL_EXPORTER_OTLP_ENDPOINT='https://xxx:4318'\n" + - " export OTEL_RESOURCE_ATTRIBUTES='service.name=claude-agents'\n" + " export OTEL_RESOURCE_ATTRIBUTES='service.name=claude-agents'\n" + + "\nLLM trace data is automatically obtained from Claude Code's transcript files — no additional setup required.\n" ) ); - if (interceptPath) { - log( - installMsg( - "启用 LLM 输入输出追踪,请使用以下方式启动 Claude Code:\n" + - ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + - "\n或在 Shell 配置文件中添加以下别名(已通过 setup-alias.sh 自动配置):\n" + - ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n`, - "To enable LLM call tracing, launch Claude Code with:\n" + - ` CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest\n` + - "\nOr add the following alias to your shell profile (auto-configured via setup-alias.sh):\n" + - ` alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require ${interceptPath}" npx -y @anthropic-ai/claude-code@latest'\n` - ) - ); - } } catch (err) { if (quiet) { // In quiet/postinstall mode: warn but don't fail npm install @@ -1558,7 +1612,7 @@ function cmdUninstall(opts = {}) { for (const t of targets) uninstallFromSettings(t); console.error(""); - // 2. Remove intercept.js (or entire cache dir if --purge) + // 2. Clean cache directory (intercept.js + session state) const cacheDir = path.join(os.homedir(), ".cache", "opentelemetry.instrumentation.claude"); if (opts.purge) { console.error(msg("==> --purge: 正在删除整个缓存目录...", "==> --purge: Removing entire cache directory...")); @@ -1569,13 +1623,10 @@ function cmdUninstall(opts = {}) { console.error(msg(` ℹ️ ${cacheDir} 不存在,跳过`, ` ℹ️ ${cacheDir} not found, skipping`)); } } else { - console.error(msg("==> 正在删除 intercept.js...", "==> Removing intercept.js...")); + // Clean up legacy intercept.js if present const interceptFile = path.join(cacheDir, "intercept.js"); if (fs.existsSync(interceptFile)) { - fs.unlinkSync(interceptFile); - console.error(msg(` ✅ 已删除 ${interceptFile}`, ` ✅ Deleted ${interceptFile}`)); - } else { - console.error(msg(` ℹ️ ${interceptFile} 不存在,跳过`, ` ℹ️ ${interceptFile} not found, skipping`)); + try { fs.unlinkSync(interceptFile); } catch {} } } console.error(""); @@ -1672,6 +1723,7 @@ module.exports = { _generateTurnLogRecords: generateTurnLogRecords, _installIntercept: installIntercept, _removeAliasFromFile: removeAliasFromFile, + _maybeSaveTranscriptPath: maybeSaveTranscriptPath, _cmdSubagentStartWithEvent: function(event) { const sessionId = event.session_id || require("crypto").randomUUID(); const state = loadState(sessionId); diff --git a/opentelemetry-instrumentation-claude/src/state.js b/opentelemetry-instrumentation-claude/src/state.js index 11fabcf..a19889e 100644 --- a/opentelemetry-instrumentation-claude/src/state.js +++ b/opentelemetry-instrumentation-claude/src/state.js @@ -82,6 +82,7 @@ function loadState(sessionId) { start_time: Date.now() / 1000, prompt: "", model: "unknown", + transcript_path: null, metrics: { input_tokens: 0, output_tokens: 0, diff --git a/opentelemetry-instrumentation-claude/src/transcript.js b/opentelemetry-instrumentation-claude/src/transcript.js new file mode 100644 index 0000000..7a07a7f --- /dev/null +++ b/opentelemetry-instrumentation-claude/src/transcript.js @@ -0,0 +1,333 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +/** + * transcript.js — Claude Code Transcript JSONL parser. + * + * Reads Claude Code's native session transcript (JSONL at + * ~/.claude/projects//.jsonl) and extracts + * llm_call events compatible with readProxyEvents() output format. + * + * Streaming chunks: Claude Code writes multiple assistant records per + * LLM call sharing the same message.id. Each chunk contains one + * content block. We group by id, merge content, deduplicate. + */ + +const fs = require("fs"); + +const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB safety limit + +/** + * Parse a Claude Code transcript JSONL file and return llm_call events. + * + * @param {string} transcriptPath - Path to the transcript JSONL file + * @param {number} startTime - Session start time (epoch seconds, for timestamp assignment) + * @param {number} stopTime - Session stop time (epoch seconds) + * @returns {Array} Array of llm_call events + */ +function parseClaudeTranscript(transcriptPath, startTime, stopTime) { + if (!transcriptPath || !fs.existsSync(transcriptPath)) return []; + + let content; + try { + const stat = fs.statSync(transcriptPath); + if (stat.size > MAX_TRANSCRIPT_BYTES) { + // Read only the tail — current session records are always at the end + const fd = fs.openSync(transcriptPath, "r"); + try { + const offset = stat.size - MAX_TRANSCRIPT_BYTES; + const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES); + fs.readSync(fd, buf, 0, MAX_TRANSCRIPT_BYTES, offset); + content = buf.toString("utf-8"); + // Discard the first partial line (we likely landed mid-line) + const firstNewline = content.indexOf("\n"); + if (firstNewline >= 0) { + content = content.slice(firstNewline + 1); + } + } finally { + fs.closeSync(fd); + } + } else { + content = fs.readFileSync(transcriptPath, "utf-8"); + } + } catch { + return []; + } + + // Phase 1: Parse all records, group assistant records by message.id + const assistantGroups = new Map(); // message.id → { chunks: [], usage, model, stop_reason } + const conversationRecords = []; // ordered list of { type, data } for input_messages reconstruction + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let record; + try { + record = JSON.parse(trimmed); + } catch { + continue; + } + + const recordType = record.type; + if (!recordType) continue; + + if (recordType === "assistant") { + const msg = record.message; + if (!msg || !msg.id) continue; + + const msgId = msg.id; + if (!assistantGroups.has(msgId)) { + assistantGroups.set(msgId, { + id: msgId, + chunks: [], + usage: null, + model: null, + stop_reason: null, + order: conversationRecords.length, + }); + conversationRecords.push({ type: "assistant", msgId }); + } + + const group = assistantGroups.get(msgId); + + // Collect content blocks from this chunk + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + group.chunks.push(block); + } + } + + // Usage, model, stop_reason — same across chunks, take latest + if (msg.usage) group.usage = msg.usage; + if (msg.model) group.model = msg.model; + if (msg.stop_reason) group.stop_reason = msg.stop_reason; + + } else if (recordType === "user") { + const msg = record.message; + if (!msg) continue; + conversationRecords.push({ type: "user", content: msg.content }); + } + // Ignore other record types (permission-mode, attachment, last-prompt, etc.) + } + + if (assistantGroups.size === 0) return []; + + // Phase 2: Deduplicate content blocks within each assistant group + for (const group of assistantGroups.values()) { + group.mergedContent = deduplicateContentBlocks(group.chunks); + delete group.chunks; + } + + // Phase 3: Build llm_call events with delta input_messages (not cumulative) + // Storing only new messages since the last LLM call avoids O(N²) memory + // from N copies of increasingly large conversation history. + const llmEvents = []; + const conversationHistory = []; + let prevCount = 0; + + for (const rec of conversationRecords) { + if (rec.type === "user") { + conversationHistory.push({ role: "user", content: rec.content }); + } else if (rec.type === "assistant") { + const group = assistantGroups.get(rec.msgId); + if (!group) continue; + + const usage = group.usage || {}; + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const cacheRead = usage.cache_read_input_tokens || 0; + const cacheCreate = usage.cache_creation_input_tokens || 0; + + const delta = conversationHistory.slice(prevCount); + + llmEvents.push({ + type: "llm_call", + timestamp: 0, + request_start_time: 0, + protocol: "anthropic", + model: group.model || "unknown", + input_messages: delta, + _input_is_delta: true, + output_content: group.mergedContent, + stop_reason: group.stop_reason || "end_turn", + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheCreate, + }); + + conversationHistory.push({ + role: "assistant", + content: group.mergedContent, + }); + prevCount = conversationHistory.length; + } + } + + // Phase 4: Assign timestamps (evenly distributed between startTime and stopTime) + if (llmEvents.length > 0) { + assignTimestamps(llmEvents, startTime, stopTime); + } + + return llmEvents; +} + +/** + * Deduplicate content blocks from streaming chunks. + * + * Each streaming chunk contains one content block. We collect all blocks + * and deduplicate by (type + identity): + * - text blocks: deduplicate by type only (merge/keep longest) + * - thinking blocks: deduplicate by type only (merge/keep longest) + * - tool_use blocks: deduplicate by id + * + * @param {Array} blocks - Raw content blocks from all chunks + * @returns {Array} Deduplicated content blocks + */ +function deduplicateContentBlocks(blocks) { + if (!blocks || blocks.length === 0) return []; + + const result = []; + const seenToolUseIds = new Set(); + let bestText = null; + let bestThinking = null; + + for (const block of blocks) { + if (!block || !block.type) continue; + + if (block.type === "text") { + // Keep the longest text block + if (!bestText || (block.text || "").length > (bestText.text || "").length) { + bestText = block; + } + } else if (block.type === "thinking") { + // Keep the longest thinking block + if (!bestThinking || (block.thinking || "").length > (bestThinking.thinking || "").length) { + bestThinking = block; + } + } else if (block.type === "tool_use") { + // Deduplicate by tool_use id + if (block.id && !seenToolUseIds.has(block.id)) { + seenToolUseIds.add(block.id); + result.push(block); + } else if (!block.id) { + result.push(block); + } + } else { + // Other block types (e.g., image) — keep as-is + result.push(block); + } + } + + // Insert text/thinking at the front in natural order (thinking → text → tool_use) + if (bestText) result.unshift(bestText); + if (bestThinking) result.unshift(bestThinking); + + return result; +} + +/** + * Assign timestamps to llm_call events. + * + * Since transcript records have no timestamps, we distribute them + * evenly between startTime and stopTime. The exact timestamps don't + * matter much — they just need correct relative ordering for span + * visualization. + * + * @param {Array} llmEvents - llm_call events (mutated in place) + * @param {number} startTime - Session start time (epoch seconds) + * @param {number} stopTime - Session stop time (epoch seconds) + */ +function assignTimestamps(llmEvents, startTime, stopTime) { + const n = llmEvents.length; + const duration = Math.max(stopTime - startTime, 1); + const interval = duration / (n + 1); + + for (let i = 0; i < n; i++) { + const ts = startTime + interval * (i + 1); + llmEvents[i].timestamp = ts; + llmEvents[i].request_start_time = ts - Math.min(interval * 0.5, 1); + } +} + +/** + * Align transcript llm_call events with hook events for better timestamp accuracy. + * + * Called from exportSessionTrace() after merging transcript events with hook events. + * Uses pre_tool_use/post_tool_use/user_prompt_submit timestamps as anchors. + * + * Pattern: + * user_prompt_submit (t0) + * llm_call #1 → pre_tool_use (t1) → post_tool_use (t2) + * llm_call #2 → pre_tool_use (t3) → post_tool_use (t4) + * llm_call #3 → stop + * + * @param {Array} llmEvents - llm_call events from transcript (mutated in place) + * @param {Array} hookEvents - hook events with timestamps + * @param {number} stopTime - Session stop time + */ +function alignWithHookEvents(llmEvents, hookEvents, stopTime) { + if (llmEvents.length === 0 || hookEvents.length === 0) return; + + // Extract time anchors from hook events + const anchors = []; + let lastAnchorTime = null; + + for (const ev of hookEvents) { + if (ev.type === "user_prompt_submit" && ev.timestamp) { + lastAnchorTime = ev.timestamp; + anchors.push({ type: "start", ts: ev.timestamp }); + } else if (ev.type === "pre_tool_use" && ev.timestamp) { + anchors.push({ type: "pre_tool", ts: ev.timestamp }); + } else if (ev.type === "post_tool_use" && ev.timestamp) { + lastAnchorTime = ev.timestamp; + anchors.push({ type: "post_tool", ts: ev.timestamp }); + } + } + + // Strategy: each llm_call ends just before the next pre_tool_use, + // and starts just after the previous post_tool_use (or user_prompt_submit) + let preToolIdx = 0; + const preToolAnchors = anchors.filter(a => a.type === "pre_tool"); + const startAnchors = anchors.filter(a => a.type === "start" || a.type === "post_tool"); + + for (let i = 0; i < llmEvents.length; i++) { + const ev = llmEvents[i]; + + // request_start_time: use the most recent post_tool or user_prompt_submit before this call + if (i === 0 && startAnchors.length > 0) { + ev.request_start_time = startAnchors[0].ts; + } else if (i > 0) { + // Find the post_tool_use that occurred after the previous llm_call + const prevEnd = llmEvents[i - 1].timestamp; + const postAfterPrev = startAnchors.find(a => a.ts >= prevEnd); + if (postAfterPrev) { + ev.request_start_time = postAfterPrev.ts; + } + } + + // timestamp (response end): use the next pre_tool_use if this is not the last call + if (i < llmEvents.length - 1 && preToolIdx < preToolAnchors.length) { + ev.timestamp = preToolAnchors[preToolIdx].ts; + preToolIdx++; + } else { + // Last llm_call — use stopTime or last anchor + ev.timestamp = stopTime; + } + + // Ensure request_start < timestamp + if (ev.request_start_time >= ev.timestamp) { + ev.request_start_time = ev.timestamp - 0.5; + } + } +} + +module.exports = { + parseClaudeTranscript, + alignWithHookEvents, + deduplicateContentBlocks, + MAX_TRANSCRIPT_BYTES, +}; diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index ee05895..45f3698 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -31,6 +31,7 @@ jest.mock("../src/state", () => { try { return JSON.parse(fs2.readFileSync(sf, "utf-8")); } catch {} } return { session_id: sid, start_time: Date.now() / 1000, prompt: "", model: "unknown", + transcript_path: null, metrics: { input_tokens: 0, output_tokens: 0, tools_used: 0, turns: 0 }, tools_used: [], events: [] }; }, @@ -1171,3 +1172,105 @@ describe("generateTurnLogRecords", () => { expect(resultRec["tool.result.duration_ms"]).toBe(1000); }); }); + +// ─── maybeSaveTranscriptPath ──────────────────────────────────────────────── +describe("maybeSaveTranscriptPath", () => { + test("sets transcript_path when state has none and event has one", () => { + const state = { transcript_path: null }; + const event = { transcript_path: "/path/to/transcript.jsonl" }; + cli._maybeSaveTranscriptPath(state, event); + expect(state.transcript_path).toBe("/path/to/transcript.jsonl"); + }); + + test("does not overwrite existing transcript_path", () => { + const state = { transcript_path: "/existing/path.jsonl" }; + const event = { transcript_path: "/new/path.jsonl" }; + cli._maybeSaveTranscriptPath(state, event); + expect(state.transcript_path).toBe("/existing/path.jsonl"); + }); + + test("no-op when event has no transcript_path", () => { + const state = { transcript_path: null }; + const event = { session_id: "test" }; + cli._maybeSaveTranscriptPath(state, event); + expect(state.transcript_path).toBeNull(); + }); +}); + +// ─── exportSessionTrace transcript integration ────────────────────────────── +describe("exportSessionTrace with transcript", () => { + test("uses transcript when state has transcript_path", async () => { + const sessionId = "transcript-export-" + Date.now(); + const state = stateModule.loadState(sessionId); + state.start_time = 1000; + state.prompt = "test prompt"; + state.model = "claude-opus-4-6"; + state.transcript_path = path.join(TMP_STATE, "fake-transcript.jsonl"); + + // Write a minimal transcript file + const records = [ + { type: "user", message: { content: "hello" } }, + { + type: "assistant", + message: { + id: "msg_t1", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 100, output_tokens: 50 }, + content: [{ type: "text", text: "Hi there!" }], + }, + }, + ]; + fs.writeFileSync(state.transcript_path, records.map(r => JSON.stringify(r)).join("\n") + "\n", "utf-8"); + + state.events = [ + { type: "user_prompt_submit", timestamp: 1000, prompt: "hello" }, + ]; + stateModule.saveState(sessionId, state); + + await cli._exportSessionTrace(state, "end_turn"); + + // Verify the SDK handler was called (entry + llm spans) + expect(mockHandlerInstance.startEntry).toHaveBeenCalled(); + expect(mockHandlerInstance.startLlm).toHaveBeenCalled(); + + stateModule.clearState(sessionId); + try { fs.unlinkSync(state.transcript_path); } catch {} + }); + + test("does not crash when transcript parse fails", async () => { + const sessionId = "transcript-error-" + Date.now(); + const state = stateModule.loadState(sessionId); + state.start_time = 1000; + state.prompt = "test"; + state.model = "test-model"; + state.transcript_path = "/nonexistent/path/transcript.jsonl"; + state.events = [ + { type: "user_prompt_submit", timestamp: 1000, prompt: "test" }, + ]; + stateModule.saveState(sessionId, state); + + // Should not throw + await expect(cli._exportSessionTrace(state, "end_turn")).resolves.not.toThrow(); + + stateModule.clearState(sessionId); + }); + + test("falls back gracefully when no transcript_path", async () => { + const sessionId = "no-transcript-" + Date.now(); + const state = stateModule.loadState(sessionId); + state.start_time = 1000; + state.prompt = "test"; + state.model = "test-model"; + state.transcript_path = null; + state.events = [ + { type: "user_prompt_submit", timestamp: 1000, prompt: "test" }, + ]; + stateModule.saveState(sessionId, state); + + await expect(cli._exportSessionTrace(state, "end_turn")).resolves.not.toThrow(); + expect(mockHandlerInstance.startEntry).toHaveBeenCalled(); + + stateModule.clearState(sessionId); + }); +}); diff --git a/opentelemetry-instrumentation-claude/test/transcript.test.js b/opentelemetry-instrumentation-claude/test/transcript.test.js new file mode 100644 index 0000000..5706b6b --- /dev/null +++ b/opentelemetry-instrumentation-claude/test/transcript.test.js @@ -0,0 +1,495 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const { + parseClaudeTranscript, + alignWithHookEvents, + deduplicateContentBlocks, + MAX_TRANSCRIPT_BYTES, +} = require("../src/transcript"); + +const TMP_DIR = path.join(os.tmpdir(), `otel-transcript-test-${process.pid}`); + +beforeAll(() => fs.mkdirSync(TMP_DIR, { recursive: true })); +afterAll(() => { try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch {} }); + +function tmpFile(name) { return path.join(TMP_DIR, name); } + +function writeJsonl(name, records) { + const p = tmpFile(name); + fs.writeFileSync(p, records.map(r => JSON.stringify(r)).join("\n") + "\n", "utf-8"); + return p; +} + +// ─── parseClaudeTranscript ────────────────────────────────────────────────── +describe("parseClaudeTranscript", () => { + test("returns [] for null path", () => { + expect(parseClaudeTranscript(null, 0, 100)).toEqual([]); + }); + + test("returns [] for nonexistent file", () => { + expect(parseClaudeTranscript("/no/such/file.jsonl", 0, 100)).toEqual([]); + }); + + test("returns [] for empty file", () => { + const p = tmpFile("empty.jsonl"); + fs.writeFileSync(p, "", "utf-8"); + expect(parseClaudeTranscript(p, 0, 100)).toEqual([]); + }); + + test("parses single LLM call (1 user + 1 assistant)", () => { + const p = writeJsonl("single.jsonl", [ + { type: "user", message: { content: "hello" } }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 50, output_tokens: 20, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + content: [{ type: "text", text: "Hello! How can I help?" }], + }, + }, + ]); + + const events = parseClaudeTranscript(p, 100, 200); + expect(events).toHaveLength(1); + + const ev = events[0]; + expect(ev.type).toBe("llm_call"); + expect(ev.protocol).toBe("anthropic"); + expect(ev.model).toBe("claude-opus-4-6"); + expect(ev.stop_reason).toBe("end_turn"); + expect(ev.input_tokens).toBe(50); + expect(ev.output_tokens).toBe(20); + expect(ev.cache_read_input_tokens).toBe(10); + expect(ev.cache_creation_input_tokens).toBe(5); + expect(ev.output_content).toEqual([{ type: "text", text: "Hello! How can I help?" }]); + expect(ev.input_messages).toHaveLength(1); + expect(ev.input_messages[0]).toEqual({ role: "user", content: "hello" }); + expect(ev.timestamp).toBeGreaterThan(100); + expect(ev.timestamp).toBeLessThan(200); + }); + + test("parses multi-step ReAct (2 LLM calls with cumulative input_messages)", () => { + const p = writeJsonl("react.jsonl", [ + { type: "user", message: { content: "list files" } }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "tool_use", + usage: { input_tokens: 100, output_tokens: 30 }, + content: [ + { type: "text", text: "I'll list the files." }, + { type: "tool_use", id: "tu_1", name: "Bash", input: { command: "ls" } }, + ], + }, + }, + { + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: "tu_1", content: "file1.txt\nfile2.txt" }] }, + }, + { + type: "assistant", + message: { + id: "msg_002", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 200, output_tokens: 40 }, + content: [{ type: "text", text: "Here are the files: file1.txt, file2.txt" }], + }, + }, + ]); + + const events = parseClaudeTranscript(p, 100, 200); + expect(events).toHaveLength(2); + + // First LLM call + expect(events[0].input_messages).toHaveLength(1); + expect(events[0].input_messages[0].role).toBe("user"); + expect(events[0].input_tokens).toBe(100); + expect(events[0].stop_reason).toBe("tool_use"); + + // Second LLM call — cumulative input_messages + expect(events[1].input_messages).toHaveLength(3); + expect(events[1].input_messages[0].role).toBe("user"); + expect(events[1].input_messages[1].role).toBe("assistant"); + expect(events[1].input_messages[2].role).toBe("user"); + expect(events[1].input_tokens).toBe(200); + expect(events[1].stop_reason).toBe("end_turn"); + }); + + test("deduplicates streaming chunks with same message.id", () => { + const p = writeJsonl("streaming.jsonl", [ + { type: "user", message: { content: "hello" } }, + // Chunk 1: thinking block + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + usage: { input_tokens: 50, output_tokens: 20 }, + content: [{ type: "thinking", thinking: "Let me think..." }], + }, + }, + // Chunk 2: text block (partial) + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + usage: { input_tokens: 50, output_tokens: 20 }, + content: [{ type: "text", text: "Hi" }], + }, + }, + // Chunk 3: text block (full, longer) + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 50, output_tokens: 20 }, + content: [{ type: "text", text: "Hi there! How can I help?" }], + }, + }, + ]); + + const events = parseClaudeTranscript(p, 0, 100); + expect(events).toHaveLength(1); + + const ev = events[0]; + // Should have thinking + text (deduplicated, longest text kept) + expect(ev.output_content).toHaveLength(2); + expect(ev.output_content[0].type).toBe("thinking"); + expect(ev.output_content[1].type).toBe("text"); + expect(ev.output_content[1].text).toBe("Hi there! How can I help?"); + }); + + test("ignores non-user/assistant record types", () => { + const p = writeJsonl("mixed-types.jsonl", [ + { type: "permission-mode", content: { mode: "auto" } }, + { type: "user", message: { content: "hi" } }, + { type: "attachment", attachment: { path: "/tmp/file" } }, + { type: "last-prompt", prompt: "something" }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: "hello" }], + }, + }, + { type: "system", message: { content: "system msg" } }, + ]); + + const events = parseClaudeTranscript(p, 0, 100); + expect(events).toHaveLength(1); + expect(events[0].input_messages).toHaveLength(1); + }); + + test("assigns timestamps evenly distributed between startTime and stopTime", () => { + const records = [{ type: "user", message: { content: "hi" } }]; + for (let i = 1; i <= 4; i++) { + records.push({ + type: "assistant", + message: { + id: `msg_${i}`, + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: `response ${i}` }], + }, + }); + if (i < 4) { + records.push({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: `t${i}`, content: "ok" }] } }); + } + } + + const p = writeJsonl("timestamps.jsonl", records); + const events = parseClaudeTranscript(p, 100, 200); + expect(events).toHaveLength(4); + + // Timestamps should be strictly increasing + for (let i = 1; i < events.length; i++) { + expect(events[i].timestamp).toBeGreaterThan(events[i - 1].timestamp); + } + // All within bounds + for (const ev of events) { + expect(ev.timestamp).toBeGreaterThan(100); + expect(ev.timestamp).toBeLessThan(200); + expect(ev.request_start_time).toBeLessThan(ev.timestamp); + } + }); + + test("handles malformed JSON lines gracefully", () => { + const p = tmpFile("malformed.jsonl"); + fs.writeFileSync(p, [ + '{"type":"user","message":{"content":"hi"}}', + 'NOT_JSON{{{', + '', + '{"type":"assistant","message":{"id":"m1","model":"test","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"content":[{"type":"text","text":"ok"}]}}', + ].join("\n"), "utf-8"); + + const events = parseClaudeTranscript(p, 0, 100); + expect(events).toHaveLength(1); + }); + + test("reads tail of file exceeding MAX_TRANSCRIPT_BYTES instead of skipping", () => { + const p = tmpFile("huge.jsonl"); + // Fill with padding lines to exceed the limit + const paddingLine = JSON.stringify({ type: "user", message: { content: "x".repeat(1000) } }) + "\n"; + const fd = fs.openSync(p, "w"); + const targetSize = MAX_TRANSCRIPT_BYTES + 1024; + let written = 0; + while (written < targetSize) { + fs.writeSync(fd, paddingLine); + written += Buffer.byteLength(paddingLine); + } + // Append a real assistant record at the very end (within the tail window) + const tailRecord = JSON.stringify({ + type: "assistant", + message: { + id: "msg_tail", + model: "tail-model", + stop_reason: "end_turn", + usage: { input_tokens: 7, output_tokens: 3 }, + content: [{ type: "text", text: "from tail" }], + }, + }) + "\n"; + fs.writeSync(fd, tailRecord); + fs.closeSync(fd); + + const events = parseClaudeTranscript(p, 0, 100); + // Should find the tail record (not return []) + expect(events.length).toBeGreaterThan(0); + const tailEvent = events.find(e => e.model === "tail-model"); + expect(tailEvent).toBeDefined(); + expect(tailEvent.output_content[0].text).toBe("from tail"); + }); + + test("handles assistant record without message.id gracefully", () => { + const p = writeJsonl("no-id.jsonl", [ + { type: "user", message: { content: "hi" } }, + { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }, + ]); + const events = parseClaudeTranscript(p, 0, 100); + expect(events).toEqual([]); + }); + + test("handles missing usage gracefully", () => { + const p = writeJsonl("no-usage.jsonl", [ + { type: "user", message: { content: "hi" } }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + content: [{ type: "text", text: "hello" }], + }, + }, + ]); + const events = parseClaudeTranscript(p, 0, 100); + expect(events).toHaveLength(1); + expect(events[0].input_tokens).toBe(0); + expect(events[0].output_tokens).toBe(0); + }); +}); + +// ─── deduplicateContentBlocks ─────────────────────────────────────────────── +describe("deduplicateContentBlocks", () => { + test("returns [] for empty input", () => { + expect(deduplicateContentBlocks([])).toEqual([]); + expect(deduplicateContentBlocks(null)).toEqual([]); + }); + + test("keeps longest text block from multiple", () => { + const result = deduplicateContentBlocks([ + { type: "text", text: "hi" }, + { type: "text", text: "hello world" }, + { type: "text", text: "hey" }, + ]); + expect(result).toHaveLength(1); + expect(result[0].text).toBe("hello world"); + }); + + test("keeps longest thinking block from multiple", () => { + const result = deduplicateContentBlocks([ + { type: "thinking", thinking: "short" }, + { type: "thinking", thinking: "longer thinking content" }, + ]); + expect(result).toHaveLength(1); + expect(result[0].thinking).toBe("longer thinking content"); + }); + + test("deduplicates tool_use by id", () => { + const result = deduplicateContentBlocks([ + { type: "tool_use", id: "tu_1", name: "Bash", input: { command: "ls" } }, + { type: "tool_use", id: "tu_1", name: "Bash", input: { command: "ls" } }, + { type: "tool_use", id: "tu_2", name: "Read", input: { path: "/tmp" } }, + ]); + expect(result).toHaveLength(2); + expect(result.map(b => b.id)).toEqual(["tu_1", "tu_2"]); + }); + + test("keeps tool_use blocks without id", () => { + const result = deduplicateContentBlocks([ + { type: "tool_use", name: "Bash", input: {} }, + { type: "tool_use", name: "Read", input: {} }, + ]); + expect(result).toHaveLength(2); + }); + + test("orders: thinking → text → tool_use", () => { + const result = deduplicateContentBlocks([ + { type: "tool_use", id: "tu_1", name: "Bash", input: {} }, + { type: "text", text: "I'll help" }, + { type: "thinking", thinking: "Let me think" }, + ]); + expect(result).toHaveLength(3); + expect(result[0].type).toBe("thinking"); + expect(result[1].type).toBe("text"); + expect(result[2].type).toBe("tool_use"); + }); + + test("passes through other block types (e.g., image)", () => { + const result = deduplicateContentBlocks([ + { type: "image", source: { data: "base64..." } }, + { type: "text", text: "hello" }, + ]); + expect(result).toHaveLength(2); + expect(result.find(b => b.type === "image")).toBeDefined(); + }); + + test("skips blocks without type", () => { + const result = deduplicateContentBlocks([ + null, + { text: "no type" }, + { type: "text", text: "valid" }, + ]); + expect(result).toHaveLength(1); + expect(result[0].text).toBe("valid"); + }); +}); + +// ─── alignWithHookEvents ──────────────────────────────────────────────────── +describe("alignWithHookEvents", () => { + test("no-op for empty llmEvents", () => { + const hookEvents = [{ type: "user_prompt_submit", timestamp: 100 }]; + alignWithHookEvents([], hookEvents, 200); + // No crash + }); + + test("no-op for empty hookEvents", () => { + const llmEvents = [ + { type: "llm_call", timestamp: 50, request_start_time: 40 }, + ]; + const origTs = llmEvents[0].timestamp; + alignWithHookEvents(llmEvents, [], 200); + expect(llmEvents[0].timestamp).toBe(origTs); + }); + + test("aligns llm_call timestamps with hook events", () => { + const llmEvents = [ + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + ]; + const hookEvents = [ + { type: "user_prompt_submit", timestamp: 100 }, + { type: "pre_tool_use", timestamp: 110 }, + { type: "post_tool_use", timestamp: 115 }, + ]; + + alignWithHookEvents(llmEvents, hookEvents, 200); + + // First llm_call: request_start = user_prompt_submit, timestamp = pre_tool_use + expect(llmEvents[0].request_start_time).toBe(100); + expect(llmEvents[0].timestamp).toBe(110); + + // Second llm_call (last): timestamp = stopTime + expect(llmEvents[1].timestamp).toBe(200); + }); + + test("last llm_call uses stopTime when no more pre_tool hooks", () => { + const llmEvents = [ + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + ]; + const hookEvents = [ + { type: "user_prompt_submit", timestamp: 100 }, + { type: "pre_tool_use", timestamp: 110 }, + { type: "post_tool_use", timestamp: 115 }, + ]; + + alignWithHookEvents(llmEvents, hookEvents, 300); + + // Only 1 pre_tool anchor, so after first llm_call uses it, the rest get stopTime + expect(llmEvents[2].timestamp).toBe(300); + }); + + test("corrects request_start_time >= timestamp", () => { + const llmEvents = [ + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + ]; + const hookEvents = [ + { type: "user_prompt_submit", timestamp: 200 }, + ]; + + alignWithHookEvents(llmEvents, hookEvents, 150); + + // request_start_time (200) >= timestamp (150), should be corrected + expect(llmEvents[0].request_start_time).toBeLessThan(llmEvents[0].timestamp); + }); +}); + +// ─── llm_call event format compatibility ──────────────────────────────────── +describe("llm_call event format", () => { + test("events have all required fields matching intercept.js format", () => { + const p = writeJsonl("format-check.jsonl", [ + { type: "user", message: { content: "test" } }, + { + type: "assistant", + message: { + id: "msg_fmt", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + content: [{ type: "text", text: "response" }], + }, + }, + ]); + + const events = parseClaudeTranscript(p, 0, 100); + const ev = events[0]; + + // All required fields must exist and have correct types + expect(ev.type).toBe("llm_call"); + expect(ev.protocol).toBe("anthropic"); + expect(typeof ev.model).toBe("string"); + expect(typeof ev.timestamp).toBe("number"); + expect(typeof ev.request_start_time).toBe("number"); + expect(Array.isArray(ev.input_messages)).toBe(true); + expect(Array.isArray(ev.output_content)).toBe(true); + expect(typeof ev.stop_reason).toBe("string"); + expect(typeof ev.input_tokens).toBe("number"); + expect(typeof ev.output_tokens).toBe("number"); + expect(typeof ev.cache_read_input_tokens).toBe("number"); + expect(typeof ev.cache_creation_input_tokens).toBe("number"); + }); +}); From e8c2a81158b42f983d0aaacc5092e1d4443db707 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 8 May 2026 14:36:29 +0800 Subject: [PATCH 16/23] feat(claude): add --no-alias option to install command Allows managed installations (like pilot) to skip shell alias setup during plugin install, preventing redundant alias write + cleanup. Co-Authored-By: Claude Opus 4.6 --- .../bin/otel-claude-hook | 1 + .../src/cli.js | 24 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/opentelemetry-instrumentation-claude/bin/otel-claude-hook b/opentelemetry-instrumentation-claude/bin/otel-claude-hook index 1923198..6f0e329 100755 --- a/opentelemetry-instrumentation-claude/bin/otel-claude-hook +++ b/opentelemetry-instrumentation-claude/bin/otel-claude-hook @@ -106,6 +106,7 @@ program .option("--no-user", "Skip user-level settings") .option("--project", "Install into project-level settings (.claude/settings.json)") .option("--quiet", "Silent mode: warn on error instead of exiting (for postinstall use)") + .option("--no-alias", "Skip shell alias setup (for managed installations like pilot)") .action(async (opts) => { try { await cmdInstall(opts); diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 3f13216..0b7cb4a 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -1484,17 +1484,19 @@ async function cmdInstall(opts = {}) { } // 2. Set up shell alias via setup-alias.sh (if bash is available) - const setupAliasScript = path.join(__dirname, "..", "scripts", "setup-alias.sh"); - if (fs.existsSync(setupAliasScript)) { - try { - const { execSync } = require("child_process"); - execSync(`bash "${setupAliasScript}"`, { stdio: quiet ? "ignore" : "inherit" }); - } catch (err) { - if (!quiet) { - console.error(installMsg( - `⚠️ alias 设置失败(非致命): ${err.message}`, - `⚠️ alias setup failed (non-fatal): ${err.message}` - )); + if (opts.alias !== false) { + const setupAliasScript = path.join(__dirname, "..", "scripts", "setup-alias.sh"); + if (fs.existsSync(setupAliasScript)) { + try { + const { execSync } = require("child_process"); + execSync(`bash "${setupAliasScript}"`, { stdio: quiet ? "ignore" : "inherit" }); + } catch (err) { + if (!quiet) { + console.error(installMsg( + `⚠️ alias 设置失败(非致命): ${err.message}`, + `⚠️ alias setup failed (non-fatal): ${err.message}` + )); + } } } } From 57a37230be93e74dbd0685d373268883e5e11dfa Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 8 May 2026 15:11:27 +0800 Subject: [PATCH 17/23] fix(claude): update tests for delta input_messages, removed agent.name, and config isolation - cli.test: pass entryPath to installIntoSettings so hook dedup works correctly - cli.test: remove agent.name assertion (field was removed in 8f5c00f) - transcript.test: expect delta input_messages with _input_is_delta flag - logger.test: mock otel-config.json to prevent local pilot config from interfering Co-Authored-By: Claude Opus 4.6 --- .../test/cli.test.js | 16 +++---- .../test/logger.test.js | 42 +++++++++++++++++++ .../test/transcript.test.js | 10 ++--- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/opentelemetry-instrumentation-claude/test/cli.test.js b/opentelemetry-instrumentation-claude/test/cli.test.js index 45f3698..856f4c4 100644 --- a/opentelemetry-instrumentation-claude/test/cli.test.js +++ b/opentelemetry-instrumentation-claude/test/cli.test.js @@ -277,9 +277,11 @@ describe("cmdNotification", () => { }); describe("installIntoSettings", () => { + const fakeEntry = "/tmp/fake-otel-claude-hook-entry.sh"; + test("creates settings.json with hooks when file does not exist", () => { const tmp = path.join(os.tmpdir(), `settings-${Date.now()}.json`); - cli._installIntoSettings(tmp); + cli._installIntoSettings(tmp, fakeEntry); const settings = JSON.parse(fs.readFileSync(tmp, "utf-8")); expect(settings.hooks).toBeDefined(); expect(settings.hooks.UserPromptSubmit).toBeDefined(); @@ -288,8 +290,8 @@ describe("installIntoSettings", () => { test("is idempotent — does not duplicate hooks", () => { const tmp = path.join(os.tmpdir(), `settings-idem-${Date.now()}.json`); - cli._installIntoSettings(tmp); - cli._installIntoSettings(tmp); + cli._installIntoSettings(tmp, fakeEntry); + cli._installIntoSettings(tmp, fakeEntry); const settings = JSON.parse(fs.readFileSync(tmp, "utf-8")); expect(settings.hooks.UserPromptSubmit).toHaveLength(1); fs.unlinkSync(tmp); @@ -300,7 +302,7 @@ describe("installIntoSettings", () => { fs.writeFileSync(tmp, JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: "other-tool" }] }] }, }), "utf-8"); - cli._installIntoSettings(tmp); + cli._installIntoSettings(tmp, fakeEntry); const settings = JSON.parse(fs.readFileSync(tmp, "utf-8")); const cmds = settings.hooks.UserPromptSubmit.flatMap(m => m.hooks.map(h => h.command)); expect(cmds.some(c => c.includes("other-tool"))).toBe(true); @@ -824,13 +826,14 @@ describe("cmdUninstall", () => { const tmpDir = path.join(os.tmpdir(), `cl-uninstall-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); const settingsPath = path.join(tmpDir, "settings.json"); + const fakeEntry = "/tmp/fake-otel-claude-hook-entry.sh"; - cli._installIntoSettings(settingsPath); + cli._installIntoSettings(settingsPath, fakeEntry); let settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); expect(settings.hooks).toBeDefined(); expect(Object.keys(settings.hooks).length).toBeGreaterThan(0); - cli._installIntoSettings(settingsPath); // idempotent + cli._installIntoSettings(settingsPath, fakeEntry); // idempotent const raw = fs.readFileSync(settingsPath, "utf-8"); const parsed = JSON.parse(raw); const hookCount = Object.values(parsed.hooks).flat().length; @@ -853,7 +856,6 @@ describe("generateTurnLogRecords", () => { expect(rec["event.name"]).toBeDefined(); expect(rec["session.id"]).toBe(sessionId); expect(rec["agent.type"]).toBe("claude-code"); - expect(rec["agent.name"]).toBe("claude-code"); expect(rec["user.id"]).toBeDefined(); if (traceId) expect(rec.trace_id).toBe(traceId); } diff --git a/opentelemetry-instrumentation-claude/test/logger.test.js b/opentelemetry-instrumentation-claude/test/logger.test.js index 803a6f4..c8896d5 100644 --- a/opentelemetry-instrumentation-claude/test/logger.test.js +++ b/opentelemetry-instrumentation-claude/test/logger.test.js @@ -205,10 +205,25 @@ describe("isLogEnabled", () => { // --------------------------------------------------------------------------- describe("resolveLogDir", () => { const origEnv = process.env.OTEL_CLAUDE_LOG_DIR; + const config = require("../src/config"); + let origReadFileSync; + + beforeEach(() => { + config.resetConfigCache(); + origReadFileSync = fs.readFileSync; + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; + }); afterEach(() => { + fs.readFileSync = origReadFileSync; if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_DIR; else process.env.OTEL_CLAUDE_LOG_DIR = origEnv; + config.resetConfigCache(); }); test("uses OTEL_CLAUDE_LOG_DIR when set", () => { @@ -227,10 +242,25 @@ describe("resolveLogDir", () => { // --------------------------------------------------------------------------- describe("getLogFilePath", () => { const origEnv = process.env.OTEL_CLAUDE_LOG_DIR; + const config = require("../src/config"); + let origReadFileSync; + + beforeEach(() => { + config.resetConfigCache(); + origReadFileSync = fs.readFileSync; + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; + }); afterEach(() => { + fs.readFileSync = origReadFileSync; if (origEnv === undefined) delete process.env.OTEL_CLAUDE_LOG_DIR; else process.env.OTEL_CLAUDE_LOG_DIR = origEnv; + config.resetConfigCache(); }); test("filename matches claude-code.jsonl.YYYYMMDD pattern", () => { @@ -255,15 +285,27 @@ describe("getLogFilePath", () => { // --------------------------------------------------------------------------- describe("writeLogRecords", () => { let tmpDir; + const config = require("../src/config"); + let origReadFileSync; beforeEach(() => { + config.resetConfigCache(); + origReadFileSync = fs.readFileSync; + fs.readFileSync = function (p, ...args) { + if (typeof p === "string" && p.includes("otel-config.json")) { + throw new Error("ENOENT"); + } + return origReadFileSync.call(this, p, ...args); + }; tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "logger-test-")); process.env.OTEL_CLAUDE_LOG_DIR = tmpDir; }); afterEach(() => { + fs.readFileSync = origReadFileSync; delete process.env.OTEL_CLAUDE_LOG_DIR; fs.rmSync(tmpDir, { recursive: true, force: true }); + config.resetConfigCache(); }); test("writes records as JSONL", () => { diff --git a/opentelemetry-instrumentation-claude/test/transcript.test.js b/opentelemetry-instrumentation-claude/test/transcript.test.js index 5706b6b..c5cd15c 100644 --- a/opentelemetry-instrumentation-claude/test/transcript.test.js +++ b/opentelemetry-instrumentation-claude/test/transcript.test.js @@ -111,17 +111,17 @@ describe("parseClaudeTranscript", () => { const events = parseClaudeTranscript(p, 100, 200); expect(events).toHaveLength(2); - // First LLM call + // First LLM call — delta contains initial user message + expect(events[0]._input_is_delta).toBe(true); expect(events[0].input_messages).toHaveLength(1); expect(events[0].input_messages[0].role).toBe("user"); expect(events[0].input_tokens).toBe(100); expect(events[0].stop_reason).toBe("tool_use"); - // Second LLM call — cumulative input_messages - expect(events[1].input_messages).toHaveLength(3); + // Second LLM call — delta only (tool_result from user) + expect(events[1]._input_is_delta).toBe(true); + expect(events[1].input_messages).toHaveLength(1); expect(events[1].input_messages[0].role).toBe("user"); - expect(events[1].input_messages[1].role).toBe("assistant"); - expect(events[1].input_messages[2].role).toBe("user"); expect(events[1].input_tokens).toBe(200); expect(events[1].stop_reason).toBe("end_turn"); }); From 361b8b65176a3e2e7ff154e411cd06ab543d0029 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 8 May 2026 16:15:40 +0800 Subject: [PATCH 18/23] chore(claude): bump version to 0.2.0-beta, update CHANGELOG and README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 0.1.0 → 0.2.0-beta - CHANGELOG: add 0.2.0-beta section with all PR #40 changes - README: document transcript-based tracing, simplified alias, --no-alias option, Cursor compatibility, project structure update Co-Authored-By: Claude Opus 4.6 --- .../CHANGELOG.md | 40 ++++++++++++++--- .../README.md | 45 ++++++++++--------- .../package.json | 2 +- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/opentelemetry-instrumentation-claude/CHANGELOG.md b/opentelemetry-instrumentation-claude/CHANGELOG.md index d15fa9e..a439688 100644 --- a/opentelemetry-instrumentation-claude/CHANGELOG.md +++ b/opentelemetry-instrumentation-claude/CHANGELOG.md @@ -3,18 +3,46 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [Unreleased] +## [0.2.0-beta] - 2026-05-08 ### Added -- Jest test suite: hooks, state, telemetry, intercept parsers, and cli command unit tests -- `cli.js` and `intercept.js` now included in coverage reporting +- **Per-turn independent traces**: each conversation turn produces an independent trace (new traceId); all turns share `gen_ai.session.id` +- **STEP = LLM reasoning cycle**: STEP spans now map to one `llm_call` + resulting tool calls, matching ARMS semantic conventions +- **Transcript-based tracing**: `transcript.js` parses Claude Code native transcript JSONL for LLM call data, replacing reliance on `intercept.js` HTTP interception; works with all Claude Code versions +- **Config file support**: `~/.claude/otel-config.json` with priority: config file > env var > default +- **JSONL log collection**: chain hash incremental validation, daily file rotation, event_t schema (`llm.request`/`llm.response`/`tool.call`/`tool.result`) +- **Log-only mode**: skip OTel Trace export when only JSONL logging is needed (for ai-agent-collector integration) +- **Configurable log filename format**: `"default"` (`claude-code.jsonl.YYYYMMDD`) or `"hook"` (`claude-code-YYYY-MM-DD.jsonl`) +- **Multimodal image support**: `BlobPart` (base64) and `UriPart` (URL) across Anthropic, OpenAI Chat, and OpenAI Responses protocols +- **Message converter module**: Anthropic/OpenAI/Responses protocol conversion to ARMS semantic format +- **`--no-alias` install option**: skip shell alias setup for managed installations (pilot) +- **Cursor IDE compatibility**: `matcher` field on hook entries; auto-skip when called by Cursor +- **Hook wrapper script**: `hook-entry.sh` with built-in Node discovery (nvm, homebrew, volta, fnm fallbacks), eliminating PATH dependency +- Jest test suite: hooks, state, telemetry, intercept parsers, cli commands, transcript, message-converter ### Fixed -- `process.ppid` ≠ claude PID bug: `resolveClaudePid()` now walks the process tree - (`/proc` on Linux, `ps` on macOS) to correctly locate `proxy_events_.jsonl` +- **O(N²) OOM in transcript parsing**: store `input_messages` as delta instead of cumulative copies (35MB transcript: memory from >4GB to ~98MB) +- **`-p` mode missing LLM/STEP spans**: remove `setImmediate` race in `intercept.js` +- **`OTEL_RESOURCE_ATTRIBUTES` not fully parsed** into OTel Resource +- **`input.messages_delta` was cumulative**: now computes true incremental delta via slice-based diff +- **Orphaned `pre_tool_use` events**: produce TOOL spans with `tool.orphaned=true` instead of silently dropping +- **Duplicate trace generation**: events cleared after successful export in `cmdStop()` +- `process.ppid` ≠ claude PID bug: `resolveClaudePid()` walks the process tree - `readProxyEvents` with unknown PID no longer deletes files (safe fallback) - `tool_use_id` fallback aligned between `cmdPreToolUse` and `cmdPostToolUse` (both use `null`) -- `detectLang()` no longer spawns subprocesses (`defaults read`, PowerShell); uses env vars only +- `detectLang()` no longer spawns subprocesses; uses env vars only +- `setup-alias.sh`: add file writability check, handle `cat >>` failure gracefully +- `uninstall.sh`: add `npm uninstall -g` step for global package cleanup +- `package-lock.json` regenerated to resolve from npm registry (fix `link: true` issue) +- Remove `agent.name` from event logs, keep `agent.type` as `"claude-code"` +- Test isolation: mock `otel-config.json` reads to prevent local config interference + +### Changed +- **Alias env var cleanup**: GenAI SDK env vars (Group A) moved from alias to `bin/otel-claude-hook` entry point; `NODE_OPTIONS` + `intercept.js` removed from alias +- `setup-alias.sh`: add `--minimal` mode (cleanup only, no alias write) for pilot installations +- Auto-upgrade legacy alias blocks containing `intercept.js` references +- `gen_ai.session.id` set on ALL spans (ENTRY, AGENT, STEP, LLM, TOOL), not just ENTRY +- JSONL log fields migrated from `gen_ai.*` to event_t dotted namespace ### Performance - Hook subprocess startup latency reduced by removing synchronous OS calls in `detectLang()` diff --git a/opentelemetry-instrumentation-claude/README.md b/opentelemetry-instrumentation-claude/README.md index bfb12f5..02be555 100644 --- a/opentelemetry-instrumentation-claude/README.md +++ b/opentelemetry-instrumentation-claude/README.md @@ -1,6 +1,6 @@ # opentelemetry-instrumentation-claude -为 Claude Code 提供 OpenTelemetry 追踪能力,通过 Hook 机制自动采集 session 级别的 trace,并通过 `intercept.js` 捕获每次 LLM API 调用的 token 用量和消息内容。 +为 Claude Code 提供 OpenTelemetry 追踪能力,通过 Hook 机制自动采集 session 级别的 trace,并通过解析 Claude Code 原生 transcript 或 `intercept.js` HTTP 拦截捕获每次 LLM API 调用的 token 用量和消息内容。 Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/arms_docs/trace/gen-ai.md),使用 `@loongsuite/opentelemetry-util-genai` SDK 的 `ExtendedTelemetryHandler` 生成标准化 Span。 @@ -10,12 +10,15 @@ Trace 数据完全遵循 [ARMS GenAI 语义规范](../arms/semantic-conventions/ - **ARMS 语义规范兼容**:Span 层级遵循 ENTRY → AGENT → STEP → TOOL/LLM 标准结构,属性名、消息格式完全符合 ARMS GenAI Trace 规范 - **Hook 驱动**:利用 Claude Code 的 `settings.json` hook 机制(`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop` 等),无需修改任何业务代码 -- **LLM 调用级追踪**:`intercept.js` 在进程内拦截 HTTP 请求,记录 Anthropic / OpenAI API 的 token 用量、输入输出消息,写入 JSONL 日志 +- **LLM 调用级追踪**:优先解析 Claude Code 原生 transcript JSONL(兼容所有版本),回退到 `intercept.js` 进程内 HTTP 拦截,记录 Anthropic / OpenAI API 的 token 用量、输入输出消息 +- **Per-turn 独立 Trace**:每轮对话生成独立的 trace(新 traceId),同一 session 的所有 turn 共享 `gen_ai.session.id` +- **多模态图片支持**:Anthropic、OpenAI Chat、OpenAI Responses 协议的图片内容块自动转换为 `BlobPart`(base64)/ `UriPart`(URL)格式 - **标准化消息格式**:输入/输出消息自动转换为 ARMS JSON Schema 格式(`InputMessage`、`OutputMessage`、`SystemInstruction`),支持 Anthropic、OpenAI Chat、OpenAI Responses 三种协议 - **嵌套 Subagent 支持**:完整的父→子 Span 层级,适用于多 Agent 协作场景 - **语义方言支持**:自动检测 Sunfire 端点,切换 `gen_ai.span_kind_name`(ALIBABA_GROUP)/ `gen_ai.span.kind`(默认)属性名 - **原子状态写入**:基于 `rename` 的原子文件写入,防止并发 hook 进程读取到半写文件 -- **自动 alias 注入**:安装后 `claude` 命令自动携带 `NODE_OPTIONS=--require intercept.js`,无需手动配置 +- **自动 alias 注入**:安装后 `claude` 命令自动携带 telemetry 环境变量,无需手动配置 +- **Cursor IDE 兼容**:hook 配置包含 `matcher` 字段,自动检测并跳过 Cursor IDE 调用 - **配置文件支持**:可通过 `~/.claude/otel-config.json` 配置所有 OTLP 参数,优先于环境变量,避免与本地其他 OTel 工具冲突 - **JSONL 日志采集**:可选的本地日志功能,支持 chain hash 增量校验和每日文件轮转,与 trace 数据关联 - **纯日志模式(Log-only)**:支持仅输出 JSONL 日志而不上报 OTel Trace,适用于与 ai-agent-collector 等第三方采集工具集成 @@ -232,13 +235,14 @@ claude "帮我写一个 Python hello world" 安装后,`~/.bashrc` 中会新增一行: ```bash -alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY NODE_OPTIONS="--require $HOME/.cache/opentelemetry.instrumentation.claude/intercept.js" npx -y @anthropic-ai/claude-code@latest' +alias claude='CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=otlp OTEL_METRIC_EXPORT_INTERVAL=20000 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta npx -y @anthropic-ai/claude-code@latest' ``` 这意味着: -- 每次执行 `claude` 命令,`intercept.js` 会在进程启动时自动加载 -- `intercept.js` 拦截 Anthropic/OpenAI HTTP 请求,记录 token 用量和消息内容 -- 这些数据会在每轮对话结束时(`stop` hook)合并进 OTel trace,每轮生成独立的 trace +- 每次执行 `claude` 命令,自动携带 telemetry 相关环境变量 +- GenAI SDK 环境变量(`OTEL_SEMCONV_STABILITY_OPT_IN` 等)由 `otel-claude-hook` 入口点自动设置 +- LLM 调用数据优先从 Claude Code 原生 transcript 获取,无需 `intercept.js` HTTP 拦截 +- 每轮对话结束时(`stop` hook)生成独立的 OTel trace ### 验证安装 @@ -320,6 +324,7 @@ STEP = 一次 LLM 推理周期 + 由该推理触发的工具调用(0 或多个 # 安装管理 otel-claude-hook install # 写入 ~/.claude/settings.json hook 配置 otel-claude-hook install --project # 写入 ./.claude/settings.json(项目级别) +otel-claude-hook install --no-alias # 跳过 shell alias 设置(用于 pilot 等托管安装) otel-claude-hook uninstall # 卸载 hooks、intercept.js 和 claude alias otel-claude-hook uninstall --purge # 卸载并删除整个缓存目录(含 sessions) otel-claude-hook uninstall --project # 同时卸载 project-level settings @@ -356,7 +361,8 @@ opentelemetry-instrumentation-claude/ │ ├── state.js # session 状态文件读写(原子写入) │ ├── telemetry.js # OTel TracerProvider 配置(OTLP/HTTP + Console) │ ├── hooks.js # 工具格式化函数 + extractToolResult/extractToolError -│ └── intercept.js # HTTP 拦截器(支持 Node.js + Bun) +│ ├── transcript.js # Claude Code 原生 transcript JSONL 解析(优先数据源) +│ └── intercept.js # HTTP 拦截器(回退数据源,支持 Node.js + Bun) ├── scripts/ │ ├── install.sh # 源码安装脚本 │ ├── remote-install.sh # 远程一键安装脚本 @@ -367,6 +373,7 @@ opentelemetry-instrumentation-claude/ ├── config.test.js # 配置文件加载、优先级、缺失文件兜底测试 ├── logger.test.js # JSONL 日志、chain hash、文件轮转测试 ├── message-converter.test.js # 消息格式转换测试(3 协议 × 多场景) + ├── transcript.test.js # transcript JSONL 解析测试 ├── hooks.test.js # hooks 工具函数测试 ├── state.test.js # 状态文件读写测试 ├── intercept.test.js # HTTP 拦截器测试 @@ -387,23 +394,21 @@ opentelemetry-instrumentation-claude/ ``` 写入采用 `rename` 原子操作,防止并发 hook 进程读到半写文件。 -3. **intercept.js**:通过 `NODE_OPTIONS=--require` 在 Claude Code 进程启动时注入。自动选择最优拦截策略: - - **Node.js + undici 可用** → undici Dispatcher 拦截(最底层,最可靠) - - **https.request patch** → 适用于 bundled claude binary - - **Node.js 无 undici** → monkey-patch `globalThis.fetch` - - **Bun 运行时** → monkey-patch `globalThis.fetch` +3. **Transcript 解析**(优先):`transcript.js` 解析 Claude Code 原生 session transcript(`~/.claude/projects//.jsonl`),提取每次 LLM 调用的模型、token 用量、输入输出消息。支持流式分块合并和去重。不依赖 HTTP 拦截,兼容所有 Claude Code 版本。 - 拦截到的 LLM 调用写入 JSONL 文件: - ``` - ~/.cache/opentelemetry.instrumentation.claude/sessions/proxy_events_.jsonl - ``` +4. **intercept.js**(回退):当 transcript 不可用时,通过 `NODE_OPTIONS=--require` 注入 HTTP 拦截。自动选择最优策略: + - **Node.js + undici** → undici Dispatcher 拦截 + - **https.request patch** → 适用于 bundled binary + - **globalThis.fetch patch** → Bun 运行时 + + 拦截到的 LLM 调用写入:`~/.cache/opentelemetry.instrumentation.claude/sessions/proxy_events_.jsonl` -4. **消息格式转换**:`message-converter.js` 将 intercept.js 捕获的原始 LLM 请求/响应数据转换为 ARMS 语义规范格式: +5. **消息格式转换**:`message-converter.js` 将 LLM 请求/响应数据转换为 ARMS 语义规范格式: - Anthropic API(`content blocks`)→ `InputMessage` / `OutputMessage` - OpenAI Chat API(`tool_calls` / `role:tool`)→ `InputMessage` / `OutputMessage` - OpenAI Responses API(`function_call_output`)→ `InputMessage` / `OutputMessage` -5. **trace 导出**:`stop` hook 在每轮对话结束时触发,`exportSessionTrace` 通过 `ExtendedTelemetryHandler` SDK 构建标准化 Span 树: +6. **trace 导出**:`stop` hook 在每轮对话结束时触发,`exportSessionTrace` 通过 `ExtendedTelemetryHandler` SDK 构建标准化 Span 树: - 按 `user_prompt_submit` 事件将累积事件拆分为独立 turn - 每个 turn 创建独立的 ENTRY → AGENT Span 层级(新 traceId),共享 `gen_ai.session.id` - 每次 `llm_call` 事件开启新的 STEP Span,后续 TOOL Span 挂在该 STEP 下 @@ -411,7 +416,7 @@ opentelemetry-instrumentation-claude/ - 导出成功后清空已导出事件,避免下轮重复导出 - 执行 `forceFlush` + `shutdown` 确保数据发送完毕 -6. **JSONL 日志采集**(可选):当 `log_enabled=true` 时,`logger.js` 在 trace 导出完成后将每轮对话的详细记录写入本地 JSONL 文件: +7. **JSONL 日志采集**(可选):当 `log_enabled=true` 时,`logger.js` 在 trace 导出完成后将每轮对话的详细记录写入本地 JSONL 文件: - 文件路径:`/claude-code.jsonl.YYYYMMDD`,按天自动轮转 - 每条记录遵循 AI Agent EventSchema(event_t)规范:`event.name` 区分事件类型(`llm.request`/`llm.response`/`tool.call`/`tool.result`),包含 `event.id`、`session.id`、`turn.id`、`step.id`、`message.role`、`user.id`、`agent.type` 等标准字段 - LLM 请求和响应拆分为独立事件:`llm.request` 携带 `input.messages_delta`/`input.messages_hash`,`llm.response` 携带 `output.messages`/`usage.*` token 用量 diff --git a/opentelemetry-instrumentation-claude/package.json b/opentelemetry-instrumentation-claude/package.json index 1ef2937..e975678 100644 --- a/opentelemetry-instrumentation-claude/package.json +++ b/opentelemetry-instrumentation-claude/package.json @@ -1,6 +1,6 @@ { "name": "@loongsuite/opentelemetry-instrumentation-claude", - "version": "0.1.0", + "version": "0.2.0-beta", "description": "OpenTelemetry instrumentation for Claude Code — hooks + intercept.js LLM call tracing", "license": "Apache-2.0", "repository": { From 245a1b306f0af500f74f1e982f672f10a2f3951b Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 8 May 2026 16:24:37 +0800 Subject: [PATCH 19/23] chore(claude): sync package-lock.json version to 0.2.0-beta Co-Authored-By: Claude Opus 4.6 --- opentelemetry-instrumentation-claude/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opentelemetry-instrumentation-claude/package-lock.json b/opentelemetry-instrumentation-claude/package-lock.json index 6cb1b3e..c65a2c6 100644 --- a/opentelemetry-instrumentation-claude/package-lock.json +++ b/opentelemetry-instrumentation-claude/package-lock.json @@ -1,12 +1,12 @@ { "name": "@loongsuite/opentelemetry-instrumentation-claude", - "version": "0.1.0", + "version": "0.2.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@loongsuite/opentelemetry-instrumentation-claude", - "version": "0.1.0", + "version": "0.2.0-beta", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { From ee3b021f45a7692968d61bc5aa4d380a6eb0792e Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Mon, 11 May 2026 19:47:30 +0800 Subject: [PATCH 20/23] fix(claude): hook-entry.sh relative path resolution and shell profile newline protection - generateHookEntry() now uses $(dirname "$0") relative path first, with install-time absolute path as fallback, and graceful exit 0 when bin is missing (fixes MODULE_NOT_FOUND on remote dev machines) - setup-alias.sh and remote-install.sh: add trailing newline protection before cat >> to prevent uninstall from eating the user's last line Co-Authored-By: Claude Opus 4.6 --- .../scripts/remote-install.sh | 2 ++ .../scripts/setup-alias.sh | 2 ++ opentelemetry-instrumentation-claude/src/cli.js | 11 ++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/opentelemetry-instrumentation-claude/scripts/remote-install.sh b/opentelemetry-instrumentation-claude/scripts/remote-install.sh index cb2e0ca..f90bccf 100755 --- a/opentelemetry-instrumentation-claude/scripts/remote-install.sh +++ b/opentelemetry-instrumentation-claude/scripts/remote-install.sh @@ -239,6 +239,8 @@ if [ -n "$ENDPOINT" ]; then sed '/# BEGIN otel-claude-hook-env/,/# END otel-claude-hook-env/d' "$file" > "$tmp_clean" mv "$tmp_clean" "$file" fi + # Ensure file ends with a newline before appending + [ -s "$file" ] && [ "$(tail -c1 "$file" | wc -l)" -eq 0 ] && echo "" >> "$file" cat >> "$file" << ENVBLOCK # BEGIN otel-claude-hook-env diff --git a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh index 049091c..ff366a1 100755 --- a/opentelemetry-instrumentation-claude/scripts/setup-alias.sh +++ b/opentelemetry-instrumentation-claude/scripts/setup-alias.sh @@ -97,6 +97,8 @@ add_alias_to_file() { " ↳ Already present in $file" return fi + # Ensure file ends with a newline before appending + [ -s "$file" ] && [ "$(tail -c1 "$file" | wc -l)" -eq 0 ] && echo "" >> "$file" if cat >> "$file" << ALIAS_BLOCK # BEGIN otel-claude-hook $ALIAS_LINE diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 0b7cb4a..10f9405 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -167,7 +167,16 @@ function generateHookEntry() { "# Prevent legacy NODE_OPTIONS --require intercept.js from breaking hook subprocesses", "unset NODE_OPTIONS 2>/dev/null || true", "", - `exec "$NODE_BIN" ${JSON.stringify(binPath)} "$@"`, + "# Resolve bin path: prefer relative (portable), fall back to install-time absolute", + 'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"', + 'BIN_PATH="$SCRIPT_DIR/package/bin/otel-claude-hook"', + `if [[ ! -f "$BIN_PATH" ]]; then BIN_PATH=${JSON.stringify(binPath)}; fi`, + 'if [[ ! -f "$BIN_PATH" ]]; then', + ' echo "[otel-claude-hook] bin not found — please reinstall: curl -fsSL | bash" >&2', + ' exit 0', + 'fi', + "", + 'exec "$NODE_BIN" "$BIN_PATH" "$@"', "", ].join("\n"); From 83961c051c5f965404e000ea110f2d79d5d6a334 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Tue, 12 May 2026 17:09:57 +0800 Subject: [PATCH 21/23] fix(claude): incremental transcript parsing and PostToolUse drop ordering Issue 6: Fix transcript data duplication in interactive sessions by adding byte-offset-based incremental reading to parseClaudeTranscript(). Each Stop hook now reads only new transcript data since the last call. Issue 7: Fix tool.call ordering when PostToolUse hook is dropped (~30% drop rate). alignWithHookEvents now uses prevEnd+0.001 as lower bound for request_start_time, ensuring correct sort order. Co-Authored-By: Claude Opus 4.6 --- .../src/cli.js | 18 +- .../src/transcript.js | 55 ++++- .../test/transcript.test.js | 230 +++++++++++++++++- 3 files changed, 285 insertions(+), 18 deletions(-) diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 10f9405..6398da7 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -1022,11 +1022,17 @@ async function exportSessionTrace(state, stopReason = "end_turn") { let allEvents = Array.isArray(state.events) ? [...state.events] : []; let llmEvents = []; - // Primary: parse Claude Code transcript + // Primary: parse Claude Code transcript (incremental — only new data since last Stop) if (state.transcript_path) { try { const { parseClaudeTranscript, alignWithHookEvents } = require("./transcript"); - llmEvents = parseClaudeTranscript(state.transcript_path, startTime, stopTime); + llmEvents = parseClaudeTranscript( + state.transcript_path, startTime, stopTime, + state.transcript_offset || 0 + ); + if (typeof llmEvents.nextOffset === "number") { + state._next_transcript_offset = llmEvents.nextOffset; + } if (llmEvents.length > 0) { alignWithHookEvents(llmEvents, allEvents, stopTime); } @@ -1285,7 +1291,7 @@ function cmdUserPromptSubmit() { const state = loadState(sessionId); maybeSaveTranscriptPath(state, event); - if (!state.start_time) state.start_time = Date.now() / 1000; + state.start_time = Date.now() / 1000; if (!state.prompt) state.prompt = prompt; state.metrics.turns = (state.metrics.turns || 0) + 1; if (event.model) state.model = event.model; @@ -1449,6 +1455,10 @@ async function cmdStop() { await exportSessionTrace(state, stopReason); // Clear exported events so subsequent Stop calls (which fire after every // turn, not just session end) don't re-export old turns as duplicates. + if (typeof state._next_transcript_offset === "number") { + state.transcript_offset = state._next_transcript_offset; + delete state._next_transcript_offset; + } state.events = []; state.stop_time = null; saveState(sessionId, state); @@ -1687,7 +1697,7 @@ module.exports = { const sessionId = event.session_id || require("crypto").randomUUID(); const prompt = event.prompt || ""; const state = loadState(sessionId); - if (!state.start_time) state.start_time = Date.now() / 1000; + state.start_time = Date.now() / 1000; if (!state.prompt) state.prompt = prompt; state.metrics.turns = (state.metrics.turns || 0) + 1; if (event.model) state.model = event.model; diff --git a/opentelemetry-instrumentation-claude/src/transcript.js b/opentelemetry-instrumentation-claude/src/transcript.js index 7a07a7f..2a8b431 100644 --- a/opentelemetry-instrumentation-claude/src/transcript.js +++ b/opentelemetry-instrumentation-claude/src/transcript.js @@ -25,30 +25,52 @@ const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB safety limit * @param {string} transcriptPath - Path to the transcript JSONL file * @param {number} startTime - Session start time (epoch seconds, for timestamp assignment) * @param {number} stopTime - Session stop time (epoch seconds) - * @returns {Array} Array of llm_call events + * @param {number} [byteOffset=0] - Byte offset to start reading from (for incremental parsing across turns) + * @returns {Array} Array of llm_call events, with `.nextOffset` property indicating the byte position for the next incremental read */ -function parseClaudeTranscript(transcriptPath, startTime, stopTime) { +function parseClaudeTranscript(transcriptPath, startTime, stopTime, byteOffset = 0) { if (!transcriptPath || !fs.existsSync(transcriptPath)) return []; let content; + let fileSize; try { const stat = fs.statSync(transcriptPath); - if (stat.size > MAX_TRANSCRIPT_BYTES) { - // Read only the tail — current session records are always at the end + fileSize = stat.size; + + if (byteOffset >= fileSize) { + const empty = []; + empty.nextOffset = byteOffset; + return empty; + } + + const readFrom = Math.max(byteOffset, 0); + const readLen = fileSize - readFrom; + + if (readLen > MAX_TRANSCRIPT_BYTES) { const fd = fs.openSync(transcriptPath, "r"); try { - const offset = stat.size - MAX_TRANSCRIPT_BYTES; - const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES); - fs.readSync(fd, buf, 0, MAX_TRANSCRIPT_BYTES, offset); + const tailOffset = fileSize - MAX_TRANSCRIPT_BYTES; + const actualOffset = Math.max(tailOffset, readFrom); + const actualLen = fileSize - actualOffset; + const buf = Buffer.alloc(actualLen); + fs.readSync(fd, buf, 0, actualLen, actualOffset); content = buf.toString("utf-8"); - // Discard the first partial line (we likely landed mid-line) - const firstNewline = content.indexOf("\n"); - if (firstNewline >= 0) { - content = content.slice(firstNewline + 1); + if (actualOffset > readFrom) { + const firstNewline = content.indexOf("\n"); + if (firstNewline >= 0) content = content.slice(firstNewline + 1); } } finally { fs.closeSync(fd); } + } else if (readFrom > 0) { + const fd = fs.openSync(transcriptPath, "r"); + try { + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, readFrom); + content = buf.toString("utf-8"); + } finally { + fs.closeSync(fd); + } } else { content = fs.readFileSync(transcriptPath, "utf-8"); } @@ -113,7 +135,11 @@ function parseClaudeTranscript(transcriptPath, startTime, stopTime) { // Ignore other record types (permission-mode, attachment, last-prompt, etc.) } - if (assistantGroups.size === 0) return []; + if (assistantGroups.size === 0) { + const empty = []; + empty.nextOffset = fileSize; + return empty; + } // Phase 2: Deduplicate content blocks within each assistant group for (const group of assistantGroups.values()) { @@ -172,6 +198,7 @@ function parseClaudeTranscript(transcriptPath, startTime, stopTime) { assignTimestamps(llmEvents, startTime, stopTime); } + llmEvents.nextOffset = fileSize; return llmEvents; } @@ -306,6 +333,10 @@ function alignWithHookEvents(llmEvents, hookEvents, stopTime) { const postAfterPrev = startAnchors.find(a => a.ts >= prevEnd); if (postAfterPrev) { ev.request_start_time = postAfterPrev.ts; + } else { + // PostToolUse dropped — place after prevEnd (= PreToolUse timestamp) + // so this llm_call sorts after the orphan PreToolUse hook event + ev.request_start_time = prevEnd + 0.001; } } diff --git a/opentelemetry-instrumentation-claude/test/transcript.test.js b/opentelemetry-instrumentation-claude/test/transcript.test.js index c5cd15c..8561a54 100644 --- a/opentelemetry-instrumentation-claude/test/transcript.test.js +++ b/opentelemetry-instrumentation-claude/test/transcript.test.js @@ -39,7 +39,8 @@ describe("parseClaudeTranscript", () => { test("returns [] for empty file", () => { const p = tmpFile("empty.jsonl"); fs.writeFileSync(p, "", "utf-8"); - expect(parseClaudeTranscript(p, 0, 100)).toEqual([]); + const result = parseClaudeTranscript(p, 0, 100); + expect(result).toHaveLength(0); }); test("parses single LLM call (1 user + 1 assistant)", () => { @@ -283,7 +284,7 @@ describe("parseClaudeTranscript", () => { { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }, ]); const events = parseClaudeTranscript(p, 0, 100); - expect(events).toEqual([]); + expect(events).toHaveLength(0); }); test("handles missing usage gracefully", () => { @@ -306,6 +307,200 @@ describe("parseClaudeTranscript", () => { }); }); +// ─── incremental reading (byteOffset) ───────────────────────────────────── +describe("parseClaudeTranscript incremental reading", () => { + test("returns nextOffset on first full read", () => { + const p = writeJsonl("offset-first.jsonl", [ + { type: "user", message: { content: "hello" } }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: "Hi!" }], + }, + }, + ]); + + const events = parseClaudeTranscript(p, 100, 200); + expect(events).toHaveLength(1); + expect(typeof events.nextOffset).toBe("number"); + expect(events.nextOffset).toBeGreaterThan(0); + }); + + test("incremental read only returns new turn data", () => { + const p = tmpFile("offset-incremental.jsonl"); + + // Write Turn 1 + const turn1Records = [ + { type: "user", message: { content: "question one" } }, + { + type: "assistant", + message: { + id: "msg_t1", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: "answer one" }], + }, + }, + ]; + fs.writeFileSync(p, turn1Records.map(r => JSON.stringify(r)).join("\n") + "\n", "utf-8"); + + // Parse Turn 1 (full read) + const events1 = parseClaudeTranscript(p, 100, 200); + expect(events1).toHaveLength(1); + expect(events1[0].input_messages[0].content).toBe("question one"); + expect(events1[0].output_content[0].text).toBe("answer one"); + const offset1 = events1.nextOffset; + expect(offset1).toBeGreaterThan(0); + + // Append Turn 2 + const turn2Records = [ + { type: "user", message: { content: "question two" } }, + { + type: "assistant", + message: { + id: "msg_t2", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 20, output_tokens: 10 }, + content: [{ type: "text", text: "answer two" }], + }, + }, + ]; + fs.appendFileSync(p, turn2Records.map(r => JSON.stringify(r)).join("\n") + "\n", "utf-8"); + + // Parse Turn 2 (incremental from offset) + const events2 = parseClaudeTranscript(p, 200, 300, offset1); + expect(events2).toHaveLength(1); + expect(events2[0].input_messages[0].content).toBe("question two"); + expect(events2[0].output_content[0].text).toBe("answer two"); + expect(events2[0].input_tokens).toBe(20); + expect(events2.nextOffset).toBeGreaterThan(offset1); + }); + + test("returns empty array when offset equals file size (no new data)", () => { + const p = writeJsonl("offset-noop.jsonl", [ + { type: "user", message: { content: "hi" } }, + { + type: "assistant", + message: { + id: "msg_001", + model: "claude-opus-4-6", + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + content: [{ type: "text", text: "hello" }], + }, + }, + ]); + + const events1 = parseClaudeTranscript(p, 0, 100); + const offset = events1.nextOffset; + + // No new data — should return empty + const events2 = parseClaudeTranscript(p, 100, 200, offset); + expect(events2).toHaveLength(0); + expect(events2.nextOffset).toBe(offset); + }); + + test("three consecutive incremental reads return correct per-turn data", () => { + const p = tmpFile("offset-three-turns.jsonl"); + + // Turn 1 + fs.writeFileSync(p, + JSON.stringify({ type: "user", message: { content: "who are you" } }) + "\n" + + JSON.stringify({ + type: "assistant", + message: { + id: "msg_1", model: "claude-opus-4-6", stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: "text", text: "I am Claude" }], + }, + }) + "\n", + "utf-8" + ); + + const e1 = parseClaudeTranscript(p, 100, 110); + expect(e1).toHaveLength(1); + expect(e1[0].output_content[0].text).toBe("I am Claude"); + const off1 = e1.nextOffset; + + // Turn 2 + fs.appendFileSync(p, + JSON.stringify({ type: "user", message: { content: "what model" } }) + "\n" + + JSON.stringify({ + type: "assistant", + message: { + id: "msg_2", model: "claude-opus-4-6", stop_reason: "end_turn", + usage: { input_tokens: 15, output_tokens: 8 }, + content: [{ type: "text", text: "I use Opus" }], + }, + }) + "\n", + "utf-8" + ); + + const e2 = parseClaudeTranscript(p, 110, 120, off1); + expect(e2).toHaveLength(1); + expect(e2[0].input_messages[0].content).toBe("what model"); + expect(e2[0].output_content[0].text).toBe("I use Opus"); + const off2 = e2.nextOffset; + + // Turn 3 + fs.appendFileSync(p, + JSON.stringify({ type: "user", message: { content: "what can you do" } }) + "\n" + + JSON.stringify({ + type: "assistant", + message: { + id: "msg_3", model: "claude-opus-4-6", stop_reason: "end_turn", + usage: { input_tokens: 20, output_tokens: 12 }, + content: [{ type: "text", text: "I can help with code" }], + }, + }) + "\n", + "utf-8" + ); + + const e3 = parseClaudeTranscript(p, 120, 130, off2); + expect(e3).toHaveLength(1); + expect(e3[0].input_messages[0].content).toBe("what can you do"); + expect(e3[0].output_content[0].text).toBe("I can help with code"); + expect(e3.nextOffset).toBeGreaterThan(off2); + }); + + test("byteOffset=0 reads entire file (backward compatible)", () => { + const p = tmpFile("offset-compat.jsonl"); + fs.writeFileSync(p, + JSON.stringify({ type: "user", message: { content: "q1" } }) + "\n" + + JSON.stringify({ + type: "assistant", + message: { + id: "msg_a", model: "test", stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + content: [{ type: "text", text: "a1" }], + }, + }) + "\n" + + JSON.stringify({ type: "user", message: { content: "q2" } }) + "\n" + + JSON.stringify({ + type: "assistant", + message: { + id: "msg_b", model: "test", stop_reason: "end_turn", + usage: { input_tokens: 2, output_tokens: 2 }, + content: [{ type: "text", text: "a2" }], + }, + }) + "\n", + "utf-8" + ); + + // byteOffset=0 (default) reads everything + const events = parseClaudeTranscript(p, 0, 100, 0); + expect(events).toHaveLength(2); + expect(events[0].output_content[0].text).toBe("a1"); + expect(events[1].output_content[0].text).toBe("a2"); + }); +}); + // ─── deduplicateContentBlocks ─────────────────────────────────────────────── describe("deduplicateContentBlocks", () => { test("returns [] for empty input", () => { @@ -438,6 +633,37 @@ describe("alignWithHookEvents", () => { expect(llmEvents[2].timestamp).toBe(300); }); + test("PostToolUse dropped: next llm_call request_start_time >= prevEnd", () => { + // Scenario: 3 llm_calls, 2 pre_tool_use, but only 1 post_tool_use + // (last tool's PostToolUse is dropped — 30% drop rate) + // + // Expected timeline: + // user_prompt_submit(100) → llm#0 → pre_tool#0(110) → post_tool#0(115) + // → llm#1 → pre_tool#1(120) → [PostToolUse DROPPED] + // → llm#2 (should sort AFTER pre_tool#1) + const llmEvents = [ + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + { type: "llm_call", timestamp: 0, request_start_time: 0 }, + ]; + const hookEvents = [ + { type: "user_prompt_submit", timestamp: 100 }, + { type: "pre_tool_use", timestamp: 110 }, + { type: "post_tool_use", timestamp: 115 }, + { type: "pre_tool_use", timestamp: 120 }, + // no post_tool_use for second tool — dropped + ]; + + alignWithHookEvents(llmEvents, hookEvents, 200); + + // llm#1.timestamp should be pre_tool#1 = 120 (the second pre_tool anchor) + expect(llmEvents[1].timestamp).toBe(120); + + // llm#2.request_start_time must be > llm#1.timestamp (120) + // so that llm#2 sorts AFTER the orphan PreToolUse event at 120 + expect(llmEvents[2].request_start_time).toBeGreaterThan(llmEvents[1].timestamp); + }); + test("corrects request_start_time >= timestamp", () => { const llmEvents = [ { type: "llm_call", timestamp: 0, request_start_time: 0 }, From b79a1c5367fbe744e6dfe7106acf2a1b18931d10 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Thu, 14 May 2026 15:02:55 +0800 Subject: [PATCH 22/23] feat(claude): add fixed gen_ai.agent.system=claude resource attribute Identifies traces from this plugin as originating from Claude Code in ARMS. Always set, not overridable via OTEL_RESOURCE_ATTRIBUTES, so mixed-agent dashboards can reliably filter by agent system. Co-Authored-By: Claude Opus 4.6 --- .../src/telemetry.js | 1 + .../test/telemetry.test.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/opentelemetry-instrumentation-claude/src/telemetry.js b/opentelemetry-instrumentation-claude/src/telemetry.js index 218f502..73dfa85 100644 --- a/opentelemetry-instrumentation-claude/src/telemetry.js +++ b/opentelemetry-instrumentation-claude/src/telemetry.js @@ -72,6 +72,7 @@ function parseResourceAttributes() { function buildResourceAttrs(serviceName) { const envAttrs = parseResourceAttributes(); envAttrs["service.name"] = resolveServiceName(serviceName); + envAttrs["gen_ai.agent.system"] = "claude"; return envAttrs; } diff --git a/opentelemetry-instrumentation-claude/test/telemetry.test.js b/opentelemetry-instrumentation-claude/test/telemetry.test.js index e147813..0f0547e 100644 --- a/opentelemetry-instrumentation-claude/test/telemetry.test.js +++ b/opentelemetry-instrumentation-claude/test/telemetry.test.js @@ -123,4 +123,19 @@ describe("telemetry", () => { delete process.env.OTEL_SERVICE_NAME; delete process.env.OTEL_RESOURCE_ATTRIBUTES; }); + + test("buildResourceAttrs always sets gen_ai.agent.system=claude", () => { + telemetry = require("../src/telemetry"); + const attrs = telemetry.buildResourceAttrs(); + expect(attrs["gen_ai.agent.system"]).toBe("claude"); + }); + + test("gen_ai.agent.system cannot be overridden by OTEL_RESOURCE_ATTRIBUTES", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = "gen_ai.agent.system=other-system,foo=bar"; + telemetry = require("../src/telemetry"); + const attrs = telemetry.buildResourceAttrs(); + expect(attrs["gen_ai.agent.system"]).toBe("claude"); + expect(attrs["foo"]).toBe("bar"); + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + }); }); From 9e8024795842c2774c2e62d3c1efaa153afc82c3 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Wed, 20 May 2026 19:30:22 +0800 Subject: [PATCH 23/23] fix(claude): input_token undercounting, anchor steal, span_id missing, CLAUDE_CONFIG_DIR compat 1. input_tokens now includes cache_read + cache_creation tokens (7 locations) 2. alignWithHookEvents pairs only last N events to prevent historical anchor steal 3. JSONL records now include trace_id/span_id/parent_span_id in all modes 4. Respect CLAUDE_CONFIG_DIR env var for Harbor/CI scenarios (settings + config paths) Co-Authored-By: Claude Opus 4.6 --- .../bin/otel-claude-hook | 1 + .../src/cli.js | 144 ++++++-- .../src/config.js | 5 +- .../src/transcript.js | 26 +- .../test/e2e-verify-fixes.js | 325 ++++++++++++++++++ 5 files changed, 453 insertions(+), 48 deletions(-) create mode 100644 opentelemetry-instrumentation-claude/test/e2e-verify-fixes.js diff --git a/opentelemetry-instrumentation-claude/bin/otel-claude-hook b/opentelemetry-instrumentation-claude/bin/otel-claude-hook index 6f0e329..c5ae905 100755 --- a/opentelemetry-instrumentation-claude/bin/otel-claude-hook +++ b/opentelemetry-instrumentation-claude/bin/otel-claude-hook @@ -107,6 +107,7 @@ program .option("--project", "Install into project-level settings (.claude/settings.json)") .option("--quiet", "Silent mode: warn on error instead of exiting (for postinstall use)") .option("--no-alias", "Skip shell alias setup (for managed installations like pilot)") + .option("--settings-path ", "Explicit path to settings.json (overrides --user/--project)") .action(async (opts) => { try { await cmdInstall(opts); diff --git a/opentelemetry-instrumentation-claude/src/cli.js b/opentelemetry-instrumentation-claude/src/cli.js index 6398da7..f16525a 100644 --- a/opentelemetry-instrumentation-claude/src/cli.js +++ b/opentelemetry-instrumentation-claude/src/cli.js @@ -47,6 +47,13 @@ const SPAN_KIND_ATTR = : "gen_ai.span.kind"; const NEEDS_DIALECT_ATTR = _dialect === "ALIBABA_GROUP" || _sunfireDetected; +function generateTraceId() { + return crypto.randomBytes(16).toString("hex"); +} +function generateSpanId() { + return crypto.randomBytes(8).toString("hex"); +} + // --------------------------------------------------------------------------- // 语言检测 / Language detection // --------------------------------------------------------------------------- @@ -400,13 +407,16 @@ function enrichChildStateWithTranscript(childState) { const llmEvents = parseClaudeTranscript(childState.transcript_path, startTime, stopTime); if (llmEvents.length > 0) { alignWithHookEvents(llmEvents, childState.events, stopTime); - const getSortKey = (e) => { - if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; - return e.timestamp || 0; - }; - childState.events = [...childState.events, ...llmEvents].sort( - (a, b) => getSortKey(a) - getSortKey(b) - ); + const validLlmEvents = llmEvents.filter(e => !e._discarded); + if (validLlmEvents.length > 0) { + const getSortKey = (e) => { + if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; + return e.timestamp || 0; + }; + childState.events = [...childState.events, ...validLlmEvents].sort( + (a, b) => getSortKey(a) - getSortKey(b) + ); + } } } catch (err) { debug(`subagent transcript parse failed: ${err?.message || String(err)}`); @@ -516,7 +526,7 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi responseModelName: model, provider: "anthropic", responseId: ev.response_id || null, - inputTokens: ev.input_tokens || 0, + inputTokens: (ev.input_tokens || 0) + (ev.cache_read_input_tokens || 0) + (ev.cache_creation_input_tokens || 0), outputTokens: ev.output_tokens || 0, usageCacheReadInputTokens: ev.cache_read_input_tokens || 0, usageCacheCreationInputTokens: ev.cache_creation_input_tokens || 0, @@ -533,6 +543,8 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi } handler.startLlm(llmInv, currentStepInv.contextToken, hrTime(requestStart)); + ev._otel_span_id = llmInv.span?.spanContext().spanId || null; + ev._otel_step_span_id = currentStepInv?.span?.spanContext().spanId || null; if (ev.is_error) { handler.failLlm(llmInv, { @@ -626,6 +638,8 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi attributes: sAttrs("TOOL"), }); handler.startExecuteTool(toolInv, resolveParentContext(evTs), hrTime(startTs)); + ev._otel_span_id = toolInv.span?.spanContext().spanId || null; + ev._otel_step_span_id = currentStepInv?.span?.spanContext().spanId || null; const toolErr = extractToolError(toolResponse); if (toolErr) handler.failExecuteTool(toolInv, toolErr, hrTime(evTs)); else handler.stopExecuteTool(toolInv, hrTime(evTs)); @@ -713,7 +727,7 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi if (agentId && openSubagentsByAgentId[agentId]) { const entry = openSubagentsByAgentId[agentId]; entry.stopTs = evTs; - entry.agentInv.inputTokens = ev.input_tokens || 0; + entry.agentInv.inputTokens = (ev.input_tokens || 0) + (ev.cache_read_input_tokens || 0) + (ev.cache_creation_input_tokens || 0); entry.agentInv.outputTokens = ev.output_tokens || 0; entry.agentInv.usageCacheReadInputTokens = ev.cache_read_input_tokens || 0; entry.agentInv.usageCacheCreationInputTokens = ev.cache_creation_input_tokens || 0; @@ -736,8 +750,12 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi const childMetrics = childState.metrics || {}; const containerInv = createInvokeAgentInvocation("anthropic", { agentName: "subagent", - inputTokens: childMetrics.input_tokens || ev.input_tokens || 0, + inputTokens: (childMetrics.input_tokens || ev.input_tokens || 0) + + (childMetrics.cache_read_input_tokens || ev.cache_read_input_tokens || 0) + + (childMetrics.cache_creation_input_tokens || ev.cache_creation_input_tokens || 0), outputTokens: childMetrics.output_tokens || ev.output_tokens || 0, + usageCacheReadInputTokens: childMetrics.cache_read_input_tokens || ev.cache_read_input_tokens || 0, + usageCacheCreationInputTokens: childMetrics.cache_creation_input_tokens || ev.cache_creation_input_tokens || 0, finishReasons: [ev.stop_reason || "end_turn"], requestModel: childState.model || "unknown", attributes: { @@ -766,7 +784,7 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi if (ce.type !== "llm_call") continue; const ceTs = ce.timestamp || 0; if (ceTs >= childStart && ceTs <= childStop) { - inv.inputTokens = (inv.inputTokens || 0) + (ce.input_tokens || 0); + inv.inputTokens = (inv.inputTokens || 0) + (ce.input_tokens || 0) + (ce.cache_read_input_tokens || 0) + (ce.cache_creation_input_tokens || 0); inv.outputTokens = (inv.outputTokens || 0) + (ce.output_tokens || 0); inv.usageCacheReadInputTokens = (inv.usageCacheReadInputTokens || 0) + (ce.cache_read_input_tokens || 0); inv.usageCacheCreationInputTokens = (inv.usageCacheCreationInputTokens || 0) + (ce.cache_creation_input_tokens || 0); @@ -799,6 +817,8 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi const orphanParent = orphanContextMap[toolUseId] || currentStepInv?.contextToken || parentCtx; const endTs = orphanEndTimeMap[toolUseId] || stopTime; handler.startExecuteTool(toolInv, orphanParent, hrTime(startTs)); + preEv._otel_span_id = toolInv.span?.spanContext().spanId || null; + preEv._otel_step_span_id = currentStepInv?.span?.spanContext().spanId || null; handler.stopExecuteTool(toolInv, hrTime(endTs)); } @@ -811,11 +831,15 @@ function replayEventsAsSpans(handler, tracer, events, parentCtx, stopTime, sessi // generateTurnLogRecords — JSONL log records for a single turn // --------------------------------------------------------------------------- -function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, traceId) { +function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, spanCtx) { + if (!spanCtx || typeof spanCtx === "string") { + spanCtx = { traceId: spanCtx || generateTraceId(), entrySpanId: generateSpanId(), agentSpanId: generateSpanId() }; + } const records = []; const turnId = `${sessionId}:t${turnIndex + 1}`; let stepRound = 0; let currentStepId = null; + let currentStepSpanId = null; let runningHash = prevHash; let prevInputMsgs = []; @@ -823,7 +847,7 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra try { userId = os.userInfo().username; } catch { userId = ""; } const base = { - trace_id: traceId || null, + trace_id: spanCtx.traceId || null, "session.id": sessionId, "turn.id": turnId, "user.id": userId, @@ -836,6 +860,8 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra "event.id": crypto.randomUUID(), "event.name": "llm.request", ...base, + span_id: spanCtx.agentSpanId || null, + parent_span_id: spanCtx.entrySpanId || null, "message.role": "user", "input.messages_delta": JSON.stringify( [{ role: "user", parts: [{ type: "text", content: turn.prompt }] }] @@ -856,6 +882,8 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra if (ev.type === "llm_call") { stepRound++; currentStepId = `${turnId}:s${stepRound}`; + currentStepSpanId = ev._otel_step_span_id || generateSpanId(); + const llmSpanId = ev._otel_span_id || generateSpanId(); const responseId = ev.response_id || `${currentStepId}:r`; const protocol = ev.protocol || "anthropic"; @@ -876,6 +904,8 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra "event.id": crypto.randomUUID(), "event.name": "llm.request", ...base, + span_id: llmSpanId, + parent_span_id: currentStepSpanId, "step.id": currentStepId, "response.id": responseId, "agent.id": sessionId, @@ -892,13 +922,18 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra records.push(requestRecord); - const inputTokens = ev.input_tokens || 0; + const apiInputTokens = ev.input_tokens || 0; + const cacheRead = ev.cache_read_input_tokens || 0; + const cacheCreation = ev.cache_creation_input_tokens || 0; + const inputTokens = apiInputTokens + cacheRead + cacheCreation; const outputTokens = ev.output_tokens || 0; const responseRecord = { time_unix_nano: Math.round(evTs * 1e9), "event.id": crypto.randomUUID(), "event.name": "llm.response", ...base, + span_id: llmSpanId, + parent_span_id: currentStepSpanId, "step.id": currentStepId, "response.id": responseId, "message.role": "assistant", @@ -908,8 +943,8 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra "response.finish_reasons": ev.stop_reason || "stop", "usage.input_tokens": inputTokens, "usage.output_tokens": outputTokens, - "usage.cache_write_tokens": ev.cache_creation_input_tokens || 0, - "usage.cache_read_tokens": ev.cache_read_input_tokens || 0, + "usage.cache_write_tokens": cacheCreation, + "usage.cache_read_tokens": cacheRead, "usage.total_tokens": inputTokens + outputTokens, "output.messages": JSON.stringify( convertOutputMessages(ev.output_content, ev.stop_reason) @@ -933,12 +968,16 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra const preEv = preToolUseMap[ev.tool_use_id] || {}; const effectiveName = preEv.tool_name || toolName; const effectiveInput = preEv.tool_input || {}; + const toolSpanId = ev._otel_span_id || generateSpanId(); + const parentStepSpanId = ev._otel_step_span_id || currentStepSpanId; records.push({ time_unix_nano: Math.round((preEv.timestamp || evTs) * 1e9), "event.id": crypto.randomUUID(), "event.name": "tool.call", ...base, + span_id: toolSpanId, + parent_span_id: parentStepSpanId, "step.id": currentStepId || turnId, "message.role": "tool", "tool.name": effectiveName, @@ -953,6 +992,8 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra "event.id": crypto.randomUUID(), "event.name": "tool.result", ...base, + span_id: toolSpanId, + parent_span_id: parentStepSpanId, "step.id": currentStepId || turnId, "message.role": "tool", "tool.name": effectiveName, @@ -982,12 +1023,16 @@ function generateTurnLogRecords(turn, turnIndex, sessionId, model, prevHash, tra if (consumedIds.has(toolUseId)) continue; const toolName = preEv.tool_name || "unknown"; if (toolName === "Agent" || toolName === "agent") continue; + const orphanSpanId = preEv._otel_span_id || generateSpanId(); + const orphanParentSpanId = preEv._otel_step_span_id || currentStepSpanId || spanCtx.agentSpanId; records.push({ time_unix_nano: Math.round((preEv.timestamp || turn.endTime) * 1e9), "event.id": crypto.randomUUID(), "event.name": "tool.call", ...base, "step.id": currentStepId || turnId, + span_id: orphanSpanId, + parent_span_id: orphanParentSpanId, "message.role": "tool", "tool.name": toolName, "tool.call.id": toolUseId, @@ -1050,11 +1095,14 @@ async function exportSessionTrace(state, stopReason = "end_turn") { } if (llmEvents.length > 0) { - const getSortKey = (e) => { - if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; - return e.timestamp || 0; - }; - allEvents = [...allEvents, ...llmEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); + const validLlmEvents = llmEvents.filter(e => !e._discarded); + if (validLlmEvents.length > 0) { + const getSortKey = (e) => { + if (e.type === "llm_call" && e.request_start_time) return e.request_start_time; + return e.timestamp || 0; + }; + allEvents = [...allEvents, ...validLlmEvents].sort((a, b) => getSortKey(a) - getSortKey(b)); + } } // Split into per-turn groups @@ -1066,6 +1114,7 @@ async function exportSessionTrace(state, stopReason = "end_turn") { // Export each turn as an independent trace (skip in log-only mode) const entryInvs = []; + const agentInvs = []; if (!logOnly) { const handler = new ExtendedTelemetryHandler({ tracerProvider: provider }); const tracer = trace.getTracer("opentelemetry-instrumentation-claude"); @@ -1081,10 +1130,13 @@ async function exportSessionTrace(state, stopReason = "end_turn") { let turnModel = state.model || "unknown"; const llmEvents = turn.events.filter(e => e.type === "llm_call"); for (const lev of llmEvents) { - turnInputTokens += lev.input_tokens || 0; + const apiIn = lev.input_tokens || 0; + const cR = lev.cache_read_input_tokens || 0; + const cC = lev.cache_creation_input_tokens || 0; + turnInputTokens += apiIn + cR + cC; turnOutputTokens += lev.output_tokens || 0; - turnCacheRead += lev.cache_read_input_tokens || 0; - turnCacheCreate += lev.cache_creation_input_tokens || 0; + turnCacheRead += cR; + turnCacheCreate += cC; if (lev.model) turnModel = lev.model; } @@ -1125,6 +1177,7 @@ async function exportSessionTrace(state, stopReason = "end_turn") { attributes: sessionAttrs(sessionId, "AGENT"), }); handler.startInvokeAgent(agentInv, entryInv.contextToken, hrTime(turn.startTime)); + agentInvs.push(agentInv); // Replay this turn's events as child spans replayEventsAsSpans(handler, tracer, turn.events, agentInv.contextToken, turn.endTime, sessionId); @@ -1143,16 +1196,24 @@ async function exportSessionTrace(state, stopReason = "end_turn") { for (let i = 0; i < turns.length; i++) { const turn = turns[i]; - let turnTraceId = null; + let spanCtx; if (!logOnly && entryInvs[i]) { try { const entrySpan = trace.getSpan(entryInvs[i].contextToken); - if (entrySpan) turnTraceId = entrySpan.spanContext().traceId; - } catch {} + spanCtx = { + traceId: entrySpan?.spanContext().traceId || generateTraceId(), + entrySpanId: entrySpan?.spanContext().spanId || generateSpanId(), + agentSpanId: agentInvs[i]?.span?.spanContext().spanId || generateSpanId(), + }; + } catch { + spanCtx = { traceId: generateTraceId(), entrySpanId: generateSpanId(), agentSpanId: generateSpanId() }; + } + } else { + spanCtx = { traceId: generateTraceId(), entrySpanId: generateSpanId(), agentSpanId: generateSpanId() }; } const { records, hash } = generateTurnLogRecords( - turn, i, sessionId, state.model || "unknown", logHash, turnTraceId + turn, i, sessionId, state.model || "unknown", logHash, spanCtx ); allLogRecords.push(...records); logHash = hash; @@ -1169,7 +1230,8 @@ async function exportSessionTrace(state, stopReason = "end_turn") { } const totalIn = turns.reduce((s, t) => - s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.input_tokens || 0), 0), 0); + s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => + a + (e.input_tokens || 0) + (e.cache_read_input_tokens || 0) + (e.cache_creation_input_tokens || 0), 0), 0); const totalOut = turns.reduce((s, t) => s + t.events.filter(e => e.type === "llm_call").reduce((a, e) => a + (e.output_tokens || 0), 0), 0); console.error( @@ -1483,11 +1545,16 @@ async function cmdInstall(opts = {}) { try { const targets = []; - if (opts.user !== false) { - targets.push(path.join(os.homedir(), ".claude", "settings.json")); - } - if (opts.project) { - targets.push(path.join(process.cwd(), ".claude", "settings.json")); + if (opts.settingsPath) { + targets.push(opts.settingsPath); + } else { + if (opts.user !== false) { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + targets.push(path.join(configDir, "settings.json")); + } + if (opts.project) { + targets.push(path.join(process.cwd(), ".claude", "settings.json")); + } } if (targets.length === 0 && !quiet) { console.error(installMsg("未指定目标 - 请使用 --user 或 --project。", "No target specified - use --user or --project.")); @@ -1575,8 +1642,8 @@ function cmdCheckEnv() { } else { console.error( msg( - "❌ 未配置遥测后端。\n设置 OTEL_EXPORTER_OTLP_ENDPOINT 或配置文件 ~/.claude/otel-config.json", - "❌ No telemetry backend configured.\nSet OTEL_EXPORTER_OTLP_ENDPOINT or config file ~/.claude/otel-config.json" + `❌ 未配置遥测后端。\n设置 OTEL_EXPORTER_OTLP_ENDPOINT 或配置文件 ${config.CONFIG_PATH}`, + `❌ No telemetry backend configured.\nSet OTEL_EXPORTER_OTLP_ENDPOINT or config file ${config.CONFIG_PATH}` ) ); process.exit(1); @@ -1629,7 +1696,10 @@ function cmdUninstall(opts = {}) { console.error(msg("==> 清理 hooks 配置...", "==> Cleaning up hooks config...")); const targets = []; if (opts.project) targets.push(path.join(process.cwd(), ".claude", "settings.json")); - if (opts.user !== false) targets.push(path.join(os.homedir(), ".claude", "settings.json")); + if (opts.user !== false) { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + targets.push(path.join(configDir, "settings.json")); + } for (const t of targets) uninstallFromSettings(t); console.error(""); diff --git a/opentelemetry-instrumentation-claude/src/config.js b/opentelemetry-instrumentation-claude/src/config.js index cb4a6a6..1b21de4 100644 --- a/opentelemetry-instrumentation-claude/src/config.js +++ b/opentelemetry-instrumentation-claude/src/config.js @@ -7,7 +7,10 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const CONFIG_PATH = path.join(os.homedir(), ".claude", "otel-config.json"); +const CONFIG_PATH = path.join( + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), + "otel-config.json" +); let _configCache = undefined; diff --git a/opentelemetry-instrumentation-claude/src/transcript.js b/opentelemetry-instrumentation-claude/src/transcript.js index 2a8b431..16fd64e 100644 --- a/opentelemetry-instrumentation-claude/src/transcript.js +++ b/opentelemetry-instrumentation-claude/src/transcript.js @@ -316,36 +316,42 @@ function alignWithHookEvents(llmEvents, hookEvents, stopTime) { } // Strategy: each llm_call ends just before the next pre_tool_use, - // and starts just after the previous post_tool_use (or user_prompt_submit) - let preToolIdx = 0; + // and starts just after the previous post_tool_use (or user_prompt_submit). + // Only pair the last N events (N = expected LLM call count) to avoid + // historical transcript data stealing anchors from the current turn. const preToolAnchors = anchors.filter(a => a.type === "pre_tool"); const startAnchors = anchors.filter(a => a.type === "start" || a.type === "post_tool"); - for (let i = 0; i < llmEvents.length; i++) { + const expectedCount = preToolAnchors.length + 1; + const startIdx = Math.max(0, llmEvents.length - expectedCount); + + for (let i = 0; i < startIdx; i++) { + llmEvents[i]._discarded = true; + } + + let preToolIdx = 0; + for (let i = startIdx; i < llmEvents.length; i++) { const ev = llmEvents[i]; + const relIdx = i - startIdx; // request_start_time: use the most recent post_tool or user_prompt_submit before this call - if (i === 0 && startAnchors.length > 0) { + if (relIdx === 0 && startAnchors.length > 0) { ev.request_start_time = startAnchors[0].ts; - } else if (i > 0) { - // Find the post_tool_use that occurred after the previous llm_call + } else if (relIdx > 0) { const prevEnd = llmEvents[i - 1].timestamp; const postAfterPrev = startAnchors.find(a => a.ts >= prevEnd); if (postAfterPrev) { ev.request_start_time = postAfterPrev.ts; } else { - // PostToolUse dropped — place after prevEnd (= PreToolUse timestamp) - // so this llm_call sorts after the orphan PreToolUse hook event ev.request_start_time = prevEnd + 0.001; } } // timestamp (response end): use the next pre_tool_use if this is not the last call - if (i < llmEvents.length - 1 && preToolIdx < preToolAnchors.length) { + if (relIdx < expectedCount - 1 && preToolIdx < preToolAnchors.length) { ev.timestamp = preToolAnchors[preToolIdx].ts; preToolIdx++; } else { - // Last llm_call — use stopTime or last anchor ev.timestamp = stopTime; } diff --git a/opentelemetry-instrumentation-claude/test/e2e-verify-fixes.js b/opentelemetry-instrumentation-claude/test/e2e-verify-fixes.js new file mode 100644 index 0000000..18dc125 --- /dev/null +++ b/opentelemetry-instrumentation-claude/test/e2e-verify-fixes.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node +/** + * End-to-end verification script for the three bug fixes: + * 1. input_token includes cache tokens + * 2. Historical transcript anchor steal is prevented + * 3. logOnly mode generates valid trace_id/span_id/parent_span_id + * + * Usage: node test/e2e-verify-fixes.js + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); + +// ------- Setup: override config to logOnly mode with temp log dir ------- +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-otel-claude-")); +const logDir = path.join(tmpDir, "logs"); +fs.mkdirSync(logDir, { recursive: true }); + +// Temporarily replace ~/.claude/otel-config.json to force logOnly mode +const realConfigPath = path.join(os.homedir(), ".claude", "otel-config.json"); +const backupConfigPath = realConfigPath + ".e2e-backup"; +let configBackedUp = false; +if (fs.existsSync(realConfigPath)) { + fs.copyFileSync(realConfigPath, backupConfigPath); + configBackedUp = true; +} +fs.writeFileSync(realConfigPath, JSON.stringify({ + log_enabled: true, + log_dir: logDir, + log_filename_format: "hook", +})); + +function restoreConfig() { + if (configBackedUp) { + fs.copyFileSync(backupConfigPath, realConfigPath); + fs.unlinkSync(backupConfigPath); + } else { + fs.unlinkSync(realConfigPath); + } +} + +// Ensure no OTLP endpoint (forces logOnly) +delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; +delete process.env.CLAUDE_TELEMETRY_DEBUG; + +// Reset config cache so it re-reads our overridden file +const configModule = require("../src/config"); +configModule.resetConfigCache(); + +// ------- Create realistic transcript JSONL ------- +const transcriptPath = path.join(tmpDir, "session-test.jsonl"); + +const now = Date.now() / 1000; +const sessionStart = now - 30; +const sessionStop = now; + +// Simulate: 3 historical LLM calls (from earlier turn) + 2 current turn LLM calls +// This tests anchor steal fix: only the last 2 should be used +const transcriptRecords = []; + +// User message (first turn - historical) +transcriptRecords.push(JSON.stringify({ + type: "user", + message: { content: [{ type: "text", text: "historical prompt from before plugin install" }] } +})); + +// Historical assistant messages (3 calls) +for (let i = 0; i < 3; i++) { + transcriptRecords.push(JSON.stringify({ + type: "assistant", + message: { + id: `msg_hist_${i}`, + model: "claude-sonnet-4-20250514", + content: [{ type: "text", text: `historical response ${i}` }], + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 5000, cache_creation_input_tokens: 2000 }, + stop_reason: "end_turn", + } + })); +} + +// User message (current turn) +transcriptRecords.push(JSON.stringify({ + type: "user", + message: { content: [{ type: "text", text: "current prompt after plugin installed" }] } +})); + +// Current turn assistant messages (2 calls with heavy caching) +for (let i = 0; i < 2; i++) { + transcriptRecords.push(JSON.stringify({ + type: "assistant", + message: { + id: `msg_curr_${i}`, + model: "claude-sonnet-4-20250514", + content: [ + { type: "text", text: `current response ${i}` }, + ...(i === 0 ? [{ type: "tool_use", id: `tool_${i}`, name: "Bash", input: { command: "ls" } }] : []), + ], + usage: { + input_tokens: 200, // API reports only non-cached portion + output_tokens: 300, + cache_read_input_tokens: 15000, // Bulk of actual input + cache_creation_input_tokens: 3000, // New cache entries + }, + stop_reason: i === 1 ? "end_turn" : "tool_use", + } + })); +} + +fs.writeFileSync(transcriptPath, transcriptRecords.join("\n") + "\n"); + +// ------- Create session state (simulates what hooks would have collected) ------- +const state = { + session_id: "test-session-e2e", + model: "claude-sonnet-4-20250514", + start_time: sessionStart, + stop_time: sessionStop, + transcript_path: transcriptPath, + transcript_offset: 0, + events: [ + // Current turn: user_prompt_submit + pre_tool_use + post_tool_use + { + type: "user_prompt_submit", + timestamp: sessionStart + 20, + prompt: "current prompt after plugin installed", + }, + { + type: "pre_tool_use", + timestamp: sessionStart + 23, + tool_name: "Bash", + tool_use_id: "tool_0", + tool_input: { command: "ls" }, + }, + { + type: "post_tool_use", + timestamp: sessionStart + 25, + tool_name: "Bash", + tool_use_id: "tool_0", + tool_response: "file1.txt\nfile2.txt", + }, + ], +}; + +// ------- Run the export ------- +const cli = require("../src/cli"); + +console.log("=== E2E Verification: Three Bug Fixes ===\n"); +console.log(`Temp dir: ${tmpDir}`); +console.log(`Transcript: ${transcriptPath}`); +console.log(`Log dir: ${logDir}\n`); + +(async () => { + try { + // Suppress stderr from exportSessionTrace + const origStderr = console.error; + const stderrCapture = []; + console.error = (...args) => stderrCapture.push(args.join(" ")); + + await cli._exportSessionTrace(state, "end_turn"); + + console.error = origStderr; + + // Print captured stderr + console.log("--- stderr output ---"); + stderrCapture.forEach(l => console.log(` ${l}`)); + console.log(""); + + // ------- Read JSONL output ------- + const logFiles = fs.readdirSync(logDir).filter(f => f.endsWith(".jsonl")); + if (logFiles.length === 0) { + console.log("FAIL: No JSONL log files generated!"); + process.exit(1); + } + + const logContent = fs.readFileSync(path.join(logDir, logFiles[0]), "utf-8"); + const records = logContent.trim().split("\n").map(l => JSON.parse(l)); + + console.log(`Total JSONL records: ${records.length}\n`); + + // ------- Verify Fix 1: input_tokens includes cache ------- + console.log("=== Fix 1: input_tokens includes cache tokens ==="); + const llmResponses = records.filter(r => r["event.name"] === "llm.response"); + console.log(` llm.response records: ${llmResponses.length}`); + + let fix1Pass = true; + for (const resp of llmResponses) { + const inputTokens = resp["usage.input_tokens"]; + const totalTokens = resp["usage.total_tokens"]; + const outputTokens = resp["usage.output_tokens"]; + console.log(` input_tokens=${inputTokens}, output_tokens=${outputTokens}, total=${totalTokens}`); + + // Each current LLM call has: input=200 + cache_read=15000 + cache_create=3000 = 18200 + // Historical calls have: input=100 + cache_read=5000 + cache_create=2000 = 7100 + // We expect values >> 200 (the raw API value) + if (inputTokens <= 200) { + console.log(` FAIL: input_tokens=${inputTokens} — should be >> 200 (cache not included)`); + fix1Pass = false; + } + } + console.log(` Result: ${fix1Pass ? "PASS" : "FAIL"}\n`); + + // ------- Verify Fix 2: anchor steal prevention ------- + console.log("=== Fix 2: Historical transcript anchor steal ==="); + // In logOnly mode the _discarded filter happens before turns are split. + // With 1 pre_tool_use hook anchor, expectedCount = 1 + 1 = 2. + // Total LLM events from transcript = 5 (3 historical + 2 current). + // startIdx = max(0, 5 - 2) = 3. So first 3 should be discarded. + // Only 2 LLM events should produce llm.request/llm.response records. + const llmRequests = records.filter(r => r["event.name"] === "llm.request"); + // Subtract 1 for the user prompt (which is also llm.request) + const userPromptRecords = llmRequests.filter(r => r["message.role"] === "user" && r["input.messages"]); + const actualLlmRequests = llmRequests.filter(r => r["message.role"] !== "user" || !r["input.messages"]); + + console.log(` llm.request records: ${llmRequests.length}`); + console.log(` llm.response records: ${llmResponses.length}`); + + // We expect 2 LLM responses (only current turn's 2 calls, not all 5) + const fix2Pass = llmResponses.length === 2; + if (!fix2Pass) { + console.log(` FAIL: Expected 2 llm.response records (discarding 3 historical), got ${llmResponses.length}`); + } else { + console.log(` PASS: Only 2 current-turn LLM calls exported (3 historical discarded)`); + } + console.log(""); + + // ------- Verify Fix 3: trace_id/span_id/parent_span_id present ------- + console.log("=== Fix 3: trace_id/span_id/parent_span_id in logOnly mode ==="); + let fix3Pass = true; + const traceIdPattern = /^[0-9a-f]{32}$/; + const spanIdPattern = /^[0-9a-f]{16}$/; + + const traceIds = new Set(); + const spanIds = new Set(); + + for (let i = 0; i < records.length; i++) { + const r = records[i]; + const evName = r["event.name"]; + + // trace_id + if (!r.trace_id || !traceIdPattern.test(r.trace_id)) { + console.log(` FAIL record[${i}] (${evName}): trace_id = ${JSON.stringify(r.trace_id)}`); + fix3Pass = false; + } else { + traceIds.add(r.trace_id); + } + + // span_id + if (!r.span_id || !spanIdPattern.test(r.span_id)) { + console.log(` FAIL record[${i}] (${evName}): span_id = ${JSON.stringify(r.span_id)}`); + fix3Pass = false; + } else { + spanIds.add(r.span_id); + } + + // parent_span_id + if (!r.parent_span_id || !spanIdPattern.test(r.parent_span_id)) { + console.log(` FAIL record[${i}] (${evName}): parent_span_id = ${JSON.stringify(r.parent_span_id)}`); + fix3Pass = false; + } + } + + if (fix3Pass) { + console.log(` PASS: All ${records.length} records have valid trace_id (32 hex), span_id (16 hex), parent_span_id (16 hex)`); + console.log(` Unique trace_ids: ${traceIds.size}, unique span_ids: ${spanIds.size}`); + } + console.log(""); + + // ------- Verify parent-child relationships ------- + console.log("=== Bonus: span hierarchy sanity check ==="); + // All records in same turn should share trace_id + const allSameTrace = traceIds.size === 1; + console.log(` All records share one trace_id: ${allSameTrace ? "PASS" : "FAIL (multiple traces)"}`); + + // LLM request+response pairs should share span_id + const reqSpans = llmRequests.filter(r => r["message.role"] !== "user").map(r => r.span_id); + const respSpans = llmResponses.map(r => r.span_id); + const pairsMatch = reqSpans.length === respSpans.length && + reqSpans.every((s, i) => s === respSpans[i]); + console.log(` LLM req/resp span_id pairs match: ${pairsMatch ? "PASS" : "FAIL"}`); + + // Tool call and result should share span_id + const toolCalls = records.filter(r => r["event.name"] === "tool.call"); + const toolResults = records.filter(r => r["event.name"] === "tool.result"); + const toolPairsMatch = toolCalls.length === toolResults.length && + toolCalls.every((c, i) => c.span_id === toolResults[i].span_id); + console.log(` Tool call/result span_id pairs match: ${toolPairsMatch ? "PASS" : "FAIL"}`); + console.log(""); + + // ------- Summary ------- + console.log("=== SUMMARY ==="); + const allPass = fix1Pass && fix2Pass && fix3Pass; + console.log(` Fix 1 (input_token): ${fix1Pass ? "PASS" : "FAIL"}`); + console.log(` Fix 2 (anchor steal): ${fix2Pass ? "PASS" : "FAIL"}`); + console.log(` Fix 3 (span_id): ${fix3Pass ? "PASS" : "FAIL"}`); + console.log(` Overall: ${allPass ? "ALL PASS" : "SOME FAILED"}`); + console.log(""); + + // Dump sample records for manual inspection + console.log("--- Sample JSONL records (first 3) ---"); + records.slice(0, 3).forEach((r, i) => { + console.log(`[${i}] ${r["event.name"]}:`); + console.log(` trace_id: ${r.trace_id}`); + console.log(` span_id: ${r.span_id}`); + console.log(` parent_span_id: ${r.parent_span_id}`); + if (r["usage.input_tokens"] !== undefined) { + console.log(` usage.input_tokens: ${r["usage.input_tokens"]}`); + } + }); + + // Cleanup + restoreConfig(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + process.exit(allPass ? 0 : 1); + } catch (err) { + console.error = console.error; // restore + console.error("E2E test crashed:", err); + restoreConfig(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + process.exit(1); + } +})();