本教程带你为自定义 Claude Code Skill 构建一套可观测性(observability)体系:通过原生 OpenTelemetry 与零侵入 Hook 双通路,把 Skill 的每一次激活、调用时长、Token 消耗、成功率和成本落盘成结构化数据,再聚合成 CLI 看板,最终用真实数据回答"哪个 Skill 值得用 Opus、哪个 Skill 在偷偷失败"。适合已经写过 1–2 个 Skill、想知道它们在真实会话里表现如何的开发者。
~/.claude/skills/your-skill/SKILL.md)AI 编码 Agent 在一次会话里可能触发上百次工具调用,Skill 只是其中一种触发源。没有遥测时,你只能靠"感觉"判断 Skill 好不好用——它到底被激活了几次?平均跑多久?吃掉了多少上下文预算?哪几次悄悄失败了?这些问题,传统 Evals(在静态测试集上打分)回答不了,因为它衡量的是实验室质量,而非线上表现。
可观测性补齐的就是这一环:让 Skill 从一个"黑盒提示词"变成一个可以被度量、被诊断、被优化的服务。这与"Skill 失效诊断"(坏了才查)不同,可观测性是主动、持续、全程的;也与 Evals 不同,它度量的是真实运行时的每一次调用。
Claude Code 提供两条互补的遥测通路,理解它们的分工是本教程的核心:
| 维度 | 原生 OpenTelemetry(OTel) | 自定义 Hook 采集 |
|---|---|---|
| 数据粒度 | 官方预定义指标与事件 | 完全自定义字段 |
| 是否含 Skill 事件 | ✅ Skill activated event |
✅ 可自行增强 |
| 落地难度 | 改 3 个环境变量即可 | 需写 hook 脚本 |
| 适用场景 | 团队级成本/Token 统计 | Skill 级质量分析与模型选型 |
| 存储后端 | OTel Collector / Grafana / Datadog | 本地 JSONL / SQLite |
实战中两者并用最香:OTel 管宏观成本,Hook 管微观质量。
Claude Code 把指标作为时间序列、事件作为日志/事件协议、(可选)分布式链路作为 trace 协议导出。在 shell 里配置环境变量即可:
# 1. 打开总开关
export CLAUDE_CODE_ENABLE_TELEMETRY=1
# 2. 选择导出器(metrics 与 logs 各自可选)
export OTEL_METRICS_EXPORTER=otlp # 可选: otlp / prometheus / console / none
export OTEL_LOGS_EXPORTER=otlp # 可选: otlp / console / none
# 3. 指定 OTLP 端点
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# 4. 鉴权(如需)
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
# 5. 调试期缩短导出间隔(生产请还原)
export OTEL_METRIC_EXPORT_INTERVAL=10000 # 10 秒,默认 60s
export OTEL_LOGS_EXPORT_INTERVAL=5000 # 5 秒,默认 5s
claude
管理员也可在 managed settings 的 env 块里集中下发同样的变量,实现全组织统一管控。
预期结果: Claude Code 会周期性吐出官方指标。其中与你 Skill 最相关的是这一组——
常见问题: 看不到数据?把 OTEL_METRICS_EXPORTER=console 切到控制台导出,先把 JSON 打到终端确认格式正确,再切回 otlp。企业代理环境下记得配 OTEL_EXPORTER_OTLP_HEADERS 做鉴权。
原生 OTel 给的是聚合指标,但要做 Skill 级的质量分析(每次调用单独成行),最干净的办法是挂 Hook。Hook 是独立进程,完全不需要改动 Skill 本身——这正是"零侵入"的含义。
在 .claude/settings.json 注册四类核心事件:
{
"hooks": {
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": "node ~/.skill-otel/hooks/on-prompt.js",
"timeout": 10
}]
}],
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": "node ~/.skill-otel/hooks/pre-tool.js",
"timeout": 10
}]
}],
"PostToolUse": [{
"hooks": [{
"type": "command",
"command": "node ~/.skill-otel/hooks/post-tool.js",
"timeout": 10
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "node ~/.skill-otel/hooks/on-stop.js",
"timeout": 10
}]
}]
}
}
这套结构与社区项目 claude-code-telemetry 完全一致:UserPromptSubmit 开一条 trace,PreToolUse/PostToolUse 包住每一次工具调用的起止耗时,Stop 关闭会话 span。如果你想监控 Subagent 编排,再加 SubagentStart / SubagentStop 即可(官方支持的全部 12 类事件)。
Claude Code 把一个 JSON 对象通过 stdin 喂给 hook 脚本。这是整个采集链路的命脉,字段在不同事件类型下略有不同:
// pre-tool.js:从 stdin 读入 Claude Code 推送的事件
const fs = require('fs');
let raw = '';
process.stdin.on('data', c => raw += c);
process.stdin.on('end', () => {
const evt = JSON.parse(raw);
// 核心字段(按事件类型出现):
// evt.hook_event_name → "PreToolUse" | "PostToolUse" | ...
// evt.session_id → 会话唯一 ID(trace 根)
// evt.transcript_path → 本次会话的 JSONL 转写文件
// evt.cwd → 工作目录
// evt.tool_name → 工具名(Skill 触发多经 Task/MCP,需结合 Skill 事件判定)
// evt.tool_input → 工具入参(脱敏后落盘!)
// evt.tool_use_id → 工具调用唯一 ID(span 关联键)
// evt.tool_response → 仅 PostToolUse 有,工具返回
// evt.agent_id / evt.agent_type → Subagent 相关
// evt.model → 当前模型
appendSpan(evt); // 你的落盘逻辑
});
关键约束: hook 的 stdout 可以输出一个 JSON 来反控 Claude Code(如 {"decision":"block","reason":"..."} 阻断危险命令);但纯采集用途下,务必让脚本在 timeout(这里 10 秒)内干净退出,否则会拖慢每一次工具调用。采集脚本里不要做网络阻塞调用,先落本地 JSONL,再异步搬运。
落盘格式选 append-only JSONL + 按日期轮转,好处是无需数据库、可 tail -f 实时看、损坏只丢一行。核心写入器:
// lib/jsonl-exporter.js
const fs = require('fs');
const path = require('path');
class JsonlExporter {
constructor(logDir = '~/.skill-otel/logs', maxSizeMB = 50) {
this.logDir = logDir.replace('~', process.env.HOME);
this.maxBytes = maxSizeMB * 1024 * 1024;
if (!fs.existsSync(this.logDir)) fs.mkdirSync(this.logDir, { recursive: true });
}
file() {
const d = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(this.logDir, `telemetry-${d}.jsonl`);
}
write(rec) {
const f = this.file();
// 敏感字段先脱敏:password/token/secret/key
const safe = this.redact(rec);
fs.appendFileSync(f, JSON.stringify(safe) + '\n');
if (fs.statSync(f).size > this.maxBytes) this.rotate(f);
}
redact(o) { /* 递归把 key 匹配 /password|token|secret|key/i 的值替换为 *** */ }
rotate(f) { fs.renameSync(f, f.replace(/\.jsonl$/, `-${Date.now()}.jsonl`)); }
}
module.exports = { JsonlExporter };
PreToolUse 写一条 start 记录并记下 tool_use_id;PostToolUse 用同一个 tool_use_id 回填 end 时间与返回摘要。跨进程的 span 拼接,社区方案是给每个未闭合 span 写一个 .pending-spans/<spanId>.json,收到配对事件后删除——这避免了任何内存状态。
有了 JSONL,聚合就是几行流式统计。围绕 Skill 定义四类核心质量指标:
// dashboard/metrics.js
function summarize(events) {
const bySkill = {};
for (const e of events) {
const s = bySkill[e.skill] ||= { calls: 0, ok: 0, dur: 0, tokens: 0, cost: 0 };
if (e.kind === 'start') { s.calls++; s._start = e.ts; }
if (e.kind === 'end') {
if (!e.error) s.ok++;
s.dur += e.ts - s._start;
s.tokens += e.tokens; s.cost += e.cost;
}
}
return Object.entries(bySkill).map(([skill, s]) => ({
skill,
calls: s.calls,
successRate: s.calls ? (s.ok / s.calls * 100).toFixed(1) + '%' : '—',
avgMs: s.calls ? Math.round(s.dur / s.calls) : 0,
tokensPerCall: s.calls ? Math.round(s.tokens / s.calls) : 0,
costPerCall: s.calls ? (s.cost / s.calls).toFixed(4) : 0,
}));
}
输出形如下表,一眼看出哪个 Skill 在亏 Token、哪个在失败:
| skill | calls | successRate | avgMs | tokensPerCall | costPerCall |
|---|---|---|---|---|---|
| architect | 42 | 97.6% | 8120 | 6200 | $0.031 |
| sql-optimizer | 118 | 84.7% | 2310 | 850 | $0.004 |
| log-rca | 9 | 100% | 14500 | 9800 | $0.052 |
洞察示例: sql-optimizer 调用最多但成功率最低——这是 Evals 看不出的"线上退化",该回去补案例;log-rca 成功率满分却吃掉最多 Token,候选降级到 Sonnet。
无需前端,一个终端看板就能驱动决策:
# Skill 用量报告
node dashboard.js view --report skills
# 按日期 + Skill 过滤
node dashboard.js view --report skills --date 2026-06-17 --skill architect
# 单条 trace 的瀑布视图(Jaeger 风格)
node dashboard.js view --report trace --trace-id <id>
# JSON 输出,方便接 CI
node dashboard.js view --report skills --format json
进阶可像 claude-code-telemetry 那样起 docker compose up -d,拿到 Grafana 三张预置看板:Skill Performance(调用数/耗时/成功率,成功率按红黄绿阈值染色)、Token Analysis(Token 时序 + 上下文预算剩余 gauge)、Model Comparison(Skill → 推荐模型),外加一个 /trace/:traceId 的瀑布回溯。
这是可观测性的终极回报:不再凭感觉,而是用观测数据为每个 Skill 挑模型。社区项目用一套规则评分做推荐,阈值可直接借鉴:
| 推荐模型 | 单次 Token | 上下文占比 | 其他特征 |
|---|---|---|---|
| Opus | ≥ 5000 | ≥ 30% | 复杂度高 |
| Sonnet | 1000–5000 | 10–30% | 成功率 90–98% |
| Haiku | ≤ 1000 | ≤ 10% | 成功率 ≥ 98% |
| Gemini | ≥ 3000 | ≥ 40% | 成功率 ≥ 95% |
把步骤五的 tokensPerCall、successRate 喂进这张表,每个 Skill 自动得到一个"该用哪档模型"的建议。Token 占比低、成功率高的 Skill(如 sql-optimizer)从 Opus 降到 Haiku,单次成本可砍掉一个数量级——这就是数据替你省钱。
graph TB
subgraph 采集["数据采集(零侵入)"]
A[Claude Code 会话] -->|Skill activated event| OT[原生 OTel]
A -->|stdin JSON| H[Hook 脚本]
H -->|append-only| L[telemetry-YYYY-MM-DD.jsonl]
end
subgraph 存储["存储"]
OT -->|OTLP gRPC| COL[OTel Collector]
L -->|按日轮转| DIS[本地磁盘]
end
subgraph 分析["分析与可视化"]
DIS --> M[质量指标聚合]
M --> CLI[CLI 看板]
DIS --> TR[Jaeger 式 Trace 瀑布]
M --> REC[模型选型推荐]
COL --> G[Grafana 成本/Token 看板]
end
REC -.->|反馈| A
把上述步骤拼成一个可跑的端到端示例:
mkdir -p ~/.skill-otel/{hooks,lib,dashboard},放入 pre-tool.js、post-tool.js、jsonl-exporter.js、metrics.js、dashboard.js。~/.claude/settings.json,重启 Claude Code。/your-skill ...)。cat ~/.skill-otel/logs/telemetry-$(date +%F).jsonl | jq . 应看到 Skill 的 start/end 成对记录。node ~/.skill-otel/dashboard/dashboard.js view --report skills,确认成功率/耗时/Token 出表。整套实现控制在 ~200 行,零数据库依赖,落地一个工作日内完成。
appendFile 落盘再异步处理。把 timeout 设为 10s 是上限而非目标,正常应 < 100ms 退出。Skill activated event(OTel)而非某个 tool_name;若用 Hook 判定,需结合 transcript_path 解析或直接订阅原生 Skill 事件。password/token/secret/key 字段脱敏,但 tool_input 里可能藏着 prompt 中的密钥——落盘前过一遍正则。tool_use_id/session_id,不要自造 ID;Subagent 场景额外带上 agent_id。code.claude.com/docs/en/monitoring-usage——OTel 全量配置、指标与事件清单(含 Skill activated event)send_event.py 的 stdin 契约与 settings.json 配置范例[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
