ai-otel-setup 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Proprietary License - iFlyTek BG Internal
2
+
3
+ Copyright (c) 2026 iFlyTek BG Productivity. All rights reserved.
4
+
5
+ The source code in this repository is visible publicly to enable installation
6
+ via `npx github:decent-yu/cc-otel-setup` from corporate machines. Visibility
7
+ does NOT grant any license to use, modify, distribute, or sublicense the code
8
+ outside of authorized iFlyTek internal contexts.
9
+
10
+ Permitted use:
11
+ - Running the installer (`npx -y github:decent-yu/cc-otel-setup ...`)
12
+ on machines authorized to send observability data to iFlyTek-internal
13
+ OTel collectors.
14
+
15
+ Prohibited without prior written permission:
16
+ - Copying, forking, vendoring the source for non-iFlyTek deployments
17
+ - Republishing under a different name or marketplace
18
+ - Embedding the code in third-party products
19
+
20
+ For licensing questions: productivity@iflytek.example
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # AI CLI 上报工具
2
+
3
+ 一键开通团队的 Claude Code / Codex CLI / Gemini CLI 使用数据上报。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npx -y ai-otel-setup url=你的服务器地址
9
+ ```
10
+
11
+ > 国内网络慢可以临时切到淘宝镜像:`npm config set registry https://registry.npmmirror.com`,再执行上面的命令。
12
+ >
13
+ > 兼容旧命令:`npx -y cc-otel-installer url=...` 仍然可用,行为完全一致。
14
+
15
+ 把 `你的服务器地址` 替换成团队提供的实际地址(例如 `url=10.20.30.40`)。具体地址请向团队负责人索取。
16
+
17
+ 装好后直接运行 `claude` / `codex` / `gemini`,上报会自动开始,无需任何额外配置。
18
+
19
+ ## 参数
20
+
21
+ | 参数 | 说明 |
22
+ |---|---|
23
+ | `url`(必填) | 服务器地址。可填 IP / 域名(会自动补端口 `4317`),或完整地址(如 `https://otel.company.io:4317`)。不能包含空格或逗号。 |
24
+
25
+ ## 装好后会做什么
26
+
27
+ - 在 `~/.claude/cc-otel/` 放一个启动脚本
28
+ - 备份你原来的 `~/.claude/settings.json`(带时间戳,可随时还原)
29
+ - 把上报相关配置写进 `~/.claude/settings.json`
30
+
31
+ 你原本的其他设置都会保留;重复运行不会产生重复条目,可以放心重装。
32
+
33
+ ## 采集了哪些数据
34
+
35
+ | | 内容 |
36
+ |---|---|
37
+ | ✅ 会采集 | 调用了哪些工具、每次耗时、是否成功、token 用量、当前目录、Git 信息 |
38
+ | ❌ 不采集 | 你输入的提示词、代码正文、工具入参、API 原始内容 |
39
+
40
+ ## 卸载
41
+
42
+ 还原安装前的备份即可:
43
+
44
+ ```bash
45
+ ls ~/.claude/settings.json.bak.* | tail -1 | xargs -I{} cp {} ~/.claude/settings.json
46
+ rm -rf ~/.claude/cc-otel
47
+ ```
48
+
49
+ ## 排查
50
+
51
+ | 现象 | 怎么办 |
52
+ |---|---|
53
+ | 启动 `claude` 没看到上报动作 | 打开 `~/.claude/settings.json`,确认里面有一项 `id: team:session-start` |
54
+ | 服务器一直收不到数据 | 跑一下 `nc -zv 你的服务器地址 4317`,看端口是否通 |
55
+ | 想换服务器地址 | 直接重跑安装命令即可,会自动覆盖旧配置 |
package/cli.js ADDED
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ai-otel-setup
4
+ *
5
+ * 一行命令配置 Claude Code OTel 上报:
6
+ * npx -y ai-otel-setup url=COLLECTOR_HOST
7
+ *
8
+ * 兼容写法:参数也可以全部塞在一个 argv 里,用逗号分隔:
9
+ * npx -y cc-otel-installer url=COLLECTOR_HOST
10
+ *
11
+ * 该 installer **不走 CC plugin 机制**:直接把 hook 脚本铺到
12
+ * ~/.claude/cc-otel/,并把 12 个 OTel env + SessionStart hook 注入
13
+ * 用户的 ~/.claude/settings.json。安装后 `claude` 立即生效,无需 /plugin install。
14
+ *
15
+ * 关键约束:
16
+ * - 失败时尽量给出可操作信息,不静默
17
+ * - settings.json 写之前会备份到 settings.json.bak(每次覆盖,仅保留上一份)
18
+ * - 多次运行幂等(按 hook id=team:session-start 去重)
19
+ * - 不依赖任何运行时第三方包,只用 Node 标准库
20
+ */
21
+
22
+ "use strict";
23
+
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const os = require("os");
27
+
28
+ const REQUIRED_KEYS = ["url"];
29
+ const HOOK_ID = "team:session-start";
30
+ // UserPromptSubmit 兜底 hook:复用同一脚本,靠 stdin.hook_event_name 分流;
31
+ // 单独 id 是为了让 settings.json 的 SessionStart / UserPromptSubmit 数组各自能按 id 去重
32
+ const PROMPT_HOOK_ID = "team:user-prompt-submit";
33
+ const OTEL_KEYS = [
34
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
35
+ "OTEL_METRICS_EXPORTER",
36
+ "OTEL_LOGS_EXPORTER",
37
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
38
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
39
+ "OTEL_LOGS_EXPORT_INTERVAL",
40
+ "OTEL_METRIC_EXPORT_INTERVAL",
41
+ "OTEL_METRICS_INCLUDE_VERSION",
42
+ "OTEL_LOG_USER_PROMPTS",
43
+ "OTEL_LOG_TOOL_DETAILS",
44
+ "OTEL_LOG_TOOL_CONTENT",
45
+ "OTEL_LOG_RAW_API_BODIES",
46
+ ];
47
+
48
+ // ---------- argv 解析 ----------
49
+
50
+ function parseArgs(argv) {
51
+ const out = {};
52
+ const flat = [];
53
+ for (const a of argv) {
54
+ // 兼容 url=x 单 argv 与 url=x 多 argv(保留逗号分隔,便于未来扩展)
55
+ for (const part of a.split(",")) {
56
+ if (part.trim()) flat.push(part.trim());
57
+ }
58
+ }
59
+ for (const part of flat) {
60
+ const idx = part.indexOf("=");
61
+ if (idx <= 0) continue;
62
+ const k = part.slice(0, idx).trim().toLowerCase();
63
+ const v = part.slice(idx + 1).trim();
64
+ if (k) out[k] = v;
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function validateArgs(args) {
70
+ const errs = [];
71
+ for (const k of REQUIRED_KEYS) {
72
+ if (!args[k]) {
73
+ errs.push(`missing required: ${k}`);
74
+ continue;
75
+ }
76
+ if (/\s/.test(args[k])) errs.push(`${k} 不允许包含空格: "${args[k]}"`);
77
+ if (args[k].includes(",")) errs.push(`${k} 不允许包含逗号: "${args[k]}"`);
78
+ }
79
+ return errs;
80
+ }
81
+
82
+ // ---------- url → endpoint ----------
83
+
84
+ function resolveEndpoint(rawUrl) {
85
+ // 用户传裸 IP 或 host:自动补 http:// 和 :4317(gRPC 默认端口)
86
+ // 用户传完整 URL:直接采用
87
+ if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
88
+ // 如果用户已带端口(如 "1.2.3.4:4317"),保留;否则补默认 4317
89
+ const hasPort = /:\d+$/.test(rawUrl);
90
+ return `http://${rawUrl}${hasPort ? "" : ":4317"}`;
91
+ }
92
+
93
+ function extractHost(endpoint) {
94
+ // 从已 resolve 的 endpoint 取 host(不带端口),用于 NO_PROXY
95
+ try {
96
+ return new URL(endpoint).hostname;
97
+ } catch (_) {
98
+ return endpoint.replace(/^https?:\/\//i, "").split("/")[0].split(":")[0];
99
+ }
100
+ }
101
+
102
+ function mergeNoProxy(existing, host) {
103
+ // 合并保留用户已有 NO_PROXY 值,仅追加 collector host,去重保序
104
+ const list = (existing || "")
105
+ .split(",")
106
+ .map((s) => s.trim())
107
+ .filter(Boolean);
108
+ if (host && !list.includes(host)) list.push(host);
109
+ return list.join(",");
110
+ }
111
+
112
+ // ---------- 文件操作 ----------
113
+
114
+ function readJSONSafe(p) {
115
+ try {
116
+ if (!fs.existsSync(p)) return {};
117
+ const txt = fs.readFileSync(p, "utf8");
118
+ if (!txt.trim()) return {};
119
+ return JSON.parse(txt);
120
+ } catch (e) {
121
+ throw new Error(`读取 ${p} 失败:${e.message}`);
122
+ }
123
+ }
124
+
125
+ function writeJSONAtomic(p, obj) {
126
+ const dir = path.dirname(p);
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ const tmp = `${p}.tmp.${process.pid}`;
129
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
130
+ fs.renameSync(tmp, p);
131
+ }
132
+
133
+ function backup(p) {
134
+ if (!fs.existsSync(p)) return null;
135
+ const bak = `${p}.bak`;
136
+ fs.copyFileSync(p, bak);
137
+ return bak;
138
+ }
139
+
140
+ // ---------- 合并逻辑 ----------
141
+
142
+ function buildEnv(template, args, endpoint) {
143
+ const env = { ...template.env };
144
+ env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint;
145
+ // OTEL_RESOURCE_ATTRIBUTES 已废弃:bg/dept/team 不再上报
146
+ delete env.OTEL_RESOURCE_ATTRIBUTES;
147
+ return env;
148
+ }
149
+
150
+ function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHost) {
151
+ const merged = { ...existing };
152
+
153
+ // env:plugin 优先(组织规范不允许个人改红线),但保留用户独有的 env
154
+ merged.env = { ...(existing.env || {}) };
155
+ for (const k of OTEL_KEYS) {
156
+ merged.env[k] = newEnv[k];
157
+ }
158
+ // 清理历史遗留:旧版本 installer 写过 OTEL_RESOURCE_ATTRIBUTES,删掉
159
+ delete merged.env.OTEL_RESOURCE_ATTRIBUTES;
160
+
161
+ // 兜底用户写坏的 HTTP(S)_PROXY:把 collector host 加进 NO_PROXY,让 OTel gRPC 绕过代理
162
+ // 仅追加,不动用户原有的 NO_PROXY 值,也不动 HTTP_PROXY / HTTPS_PROXY
163
+ if (collectorHost) {
164
+ merged.env.NO_PROXY = mergeNoProxy(merged.env.NO_PROXY, collectorHost);
165
+ merged.env.no_proxy = mergeNoProxy(merged.env.no_proxy, collectorHost);
166
+ }
167
+
168
+ merged.hooks = { ...(existing.hooks || {}) };
169
+
170
+ // hooks.SessionStart:按 id 去重,存在则覆盖,不存在则追加
171
+ const sessionStart = Array.isArray(merged.hooks.SessionStart)
172
+ ? [...merged.hooks.SessionStart]
173
+ : [];
174
+ const idx = sessionStart.findIndex((h) => h && h.id === HOOK_ID);
175
+ if (idx >= 0) sessionStart[idx] = hookEntry;
176
+ else sessionStart.push(hookEntry);
177
+ merged.hooks.SessionStart = sessionStart;
178
+
179
+ // hooks.UserPromptSubmit:兜底 hook,按 PROMPT_HOOK_ID 去重,规则同上
180
+ if (promptHookEntry) {
181
+ const userPromptSubmit = Array.isArray(merged.hooks.UserPromptSubmit)
182
+ ? [...merged.hooks.UserPromptSubmit]
183
+ : [];
184
+ const pidx = userPromptSubmit.findIndex((h) => h && h.id === PROMPT_HOOK_ID);
185
+ if (pidx >= 0) userPromptSubmit[pidx] = promptHookEntry;
186
+ else userPromptSubmit.push(promptHookEntry);
187
+ merged.hooks.UserPromptSubmit = userPromptSubmit;
188
+ }
189
+
190
+ return merged;
191
+ }
192
+
193
+ function logsEndpointFromGrpc(endpoint) {
194
+ try {
195
+ const url = new URL(endpoint);
196
+ if (url.port === "4317") url.port = "4318";
197
+ if (!url.pathname || url.pathname === "/") url.pathname = "/v1/logs";
198
+ return url.toString();
199
+ } catch (_) {
200
+ return "http://localhost:4318/v1/logs";
201
+ }
202
+ }
203
+
204
+ // ---------- Codex config.toml 处理 ----------
205
+ //
206
+ // 真实 schema(参见 https://developers.openai.com/codex/config-reference 与 /codex/hooks):
207
+ //
208
+ // [features]
209
+ // codex_hooks = true ← 没这个 flag,整段 hooks 被忽略
210
+ //
211
+ // [otel]
212
+ // exporter = "otlp-grpc" ← 用 exporter 选 transport,不是 enabled / protocol
213
+ // metrics_exporter = "otlp-grpc"
214
+ // trace_exporter = "otlp-grpc"
215
+ //
216
+ // [otel.exporter.otlp-grpc] ← 端点写在嵌套子表里
217
+ // endpoint = "http://host:4317"
218
+ //
219
+ // [[hooks.SessionStart]] ← codex 真的有 SessionStart
220
+ // matcher = "startup|resume"
221
+ // [[hooks.SessionStart.hooks]] ← 真正的 command 嵌一层
222
+ // type = "command"
223
+ // command = "..."
224
+ //
225
+ // 嵌套子表 + 嵌套数组用 hand-rolled 正则维护太脆,改成"managed 块"风格:
226
+ // 用 BEGIN/END 标记夹住整段我们写的内容,重跑 installer 时整块剥离再追加。
227
+
228
+ const CODEX_MANAGED_BEGIN = "# >>> ai-otel-setup managed >>>";
229
+ const CODEX_MANAGED_END = "# <<< ai-otel-setup managed <<<";
230
+
231
+ function escapeRegex(s) {
232
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
233
+ }
234
+
235
+ function stripCodexManagedBlock(text) {
236
+ const re = new RegExp(
237
+ `\\n?${escapeRegex(CODEX_MANAGED_BEGIN)}[\\s\\S]*?${escapeRegex(CODEX_MANAGED_END)}\\n?`,
238
+ "g"
239
+ );
240
+ return text.replace(re, "\n");
241
+ }
242
+
243
+ function stripLegacyCodexOtel(text) {
244
+ // 旧 installer 写的 [otel](含非法 key enabled = true)整段删除,避免与新 [otel] 冲突
245
+ return text.replace(
246
+ /(?:\n|^)\[otel\][\s\S]*?(?=\n\[|\n\[\[|$)/g,
247
+ (m) => (/enabled\s*=\s*true/.test(m) ? "" : m)
248
+ );
249
+ }
250
+
251
+ function stripLegacyCodexHook(text) {
252
+ // 旧 installer 写的 [[hooks.UserPromptSubmit]] + id = "team:session-start" 整块删除
253
+ return text.replace(
254
+ /(?:\n|^)\[\[hooks\.UserPromptSubmit\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g,
255
+ (m) => (/id\s*=\s*["']team:session-start["']/.test(m) ? "" : m)
256
+ );
257
+ }
258
+
259
+ function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
260
+ // exporter / trace_exporter / metrics_exporter 是 externally-tagged enum:
261
+ // - 写 scalar `exporter = "otlp-grpc"`:codex 解析为 unit variant,因为
262
+ // OtlpGrpc 是 struct variant(带 endpoint 等字段),报
263
+ // "invalid type: unit variant, expected struct variant"。
264
+ // - 同时写 scalar 和 table:报 "cannot extend value of type string"。
265
+ // - 只写 table `[otel.exporter."otlp-grpc"]`:✓ codex 把它解析为
266
+ // OtlpGrpc { endpoint },tag 来自 key 名。
267
+ // 官方 sample 之所以能 `exporter = "none"`,是因为 None 本身就是 unit variant。
268
+ return [
269
+ CODEX_MANAGED_BEGIN,
270
+ "[features]",
271
+ "codex_hooks = true",
272
+ "",
273
+ "[otel]",
274
+ 'environment = "prod"',
275
+ "log_user_prompt = false",
276
+ "",
277
+ '[otel.exporter."otlp-grpc"]',
278
+ `endpoint = ${JSON.stringify(endpoint)}`,
279
+ "",
280
+ '[otel.trace_exporter."otlp-grpc"]',
281
+ `endpoint = ${JSON.stringify(endpoint)}`,
282
+ "",
283
+ '[otel.metrics_exporter."otlp-grpc"]',
284
+ `endpoint = ${JSON.stringify(endpoint)}`,
285
+ "",
286
+ "[[hooks.SessionStart]]",
287
+ 'matcher = "startup|resume"',
288
+ "",
289
+ "[[hooks.SessionStart.hooks]]",
290
+ 'type = "command"',
291
+ `command = ${JSON.stringify(`AI_OTEL_LOGS_ENDPOINT=${logsEndpoint} node "${hookDest}"`)}`,
292
+ CODEX_MANAGED_END,
293
+ ].join("\n");
294
+ }
295
+
296
+ function installCodex(home, endpoint) {
297
+ const codexDir = path.join(home, ".codex");
298
+ if (!fs.existsSync(codexDir)) {
299
+ return { tool: "codex", status: "skipped", reason: "未检测到 ~/.codex" };
300
+ }
301
+ const installDir = path.join(codexDir, "ai-otel");
302
+ const configPath = path.join(codexDir, "config.toml");
303
+ const hookDest = path.join(installDir, "on-session-start.js");
304
+ fs.mkdirSync(installDir, { recursive: true });
305
+ fs.copyFileSync(path.join(__dirname, "templates", "codex", "on-session-start.js"), hookDest);
306
+ fs.chmodSync(hookDest, 0o755);
307
+ const bak = backup(configPath);
308
+ let existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
309
+
310
+ // 三步去重:先剥离上一次的 managed 块,再清掉旧 schema 残留
311
+ existing = stripCodexManagedBlock(existing);
312
+ existing = stripLegacyCodexOtel(existing);
313
+ existing = stripLegacyCodexHook(existing);
314
+
315
+ const logsEndpoint = logsEndpointFromGrpc(endpoint);
316
+ const managed = buildCodexManagedBlock(endpoint, hookDest, logsEndpoint);
317
+ const merged = (existing.trimEnd() + "\n\n" + managed + "\n").replace(/\n{3,}/g, "\n\n");
318
+ fs.writeFileSync(configPath, merged, "utf8");
319
+ return { tool: "codex", status: "installed", path: configPath, backup: bak };
320
+ }
321
+
322
+ function installGemini(home, endpoint) {
323
+ const geminiDir = path.join(home, ".gemini");
324
+ if (!fs.existsSync(geminiDir)) {
325
+ return { tool: "gemini", status: "skipped", reason: "未检测到 ~/.gemini" };
326
+ }
327
+ const installDir = path.join(geminiDir, "ai-otel");
328
+ const settingsPath = path.join(geminiDir, "settings.json");
329
+ const hookDest = path.join(installDir, "on-session-start.js");
330
+ fs.mkdirSync(installDir, { recursive: true });
331
+ fs.copyFileSync(path.join(__dirname, "templates", "gemini", "on-session-start.js"), hookDest);
332
+ fs.chmodSync(hookDest, 0o755);
333
+ const existing = readJSONSafe(settingsPath);
334
+ const bak = backup(settingsPath);
335
+ const merged = { ...existing };
336
+ // ⚠️ Gemini telemetry.target 只支持 "local" 与 "gcp",没有 "otlp" 枚举值。
337
+ // 指向自建 OTLP 接收端的标准用法是 target=local + otlpEndpoint=<url>。
338
+ // 见调研:docs/superpowers/specs/2026-04-29-multi-cli-otel-research.md §2.2
339
+ merged.telemetry = {
340
+ ...(existing.telemetry || {}),
341
+ enabled: true,
342
+ target: "local",
343
+ otlpEndpoint: endpoint,
344
+ otlpProtocol: "grpc",
345
+ logPrompts: false,
346
+ };
347
+ merged.hooks = { ...(existing.hooks || {}) };
348
+ const sessionStart = Array.isArray(merged.hooks.SessionStart)
349
+ ? [...merged.hooks.SessionStart]
350
+ : [];
351
+ const hookEntry = {
352
+ id: HOOK_ID,
353
+ command: `node "${hookDest}"`,
354
+ };
355
+ const idx = sessionStart.findIndex((h) => h && h.id === HOOK_ID);
356
+ if (idx >= 0) sessionStart[idx] = hookEntry;
357
+ else sessionStart.push(hookEntry);
358
+ merged.hooks.SessionStart = sessionStart;
359
+ writeJSONAtomic(settingsPath, merged);
360
+ return { tool: "gemini", status: "installed", path: settingsPath, backup: bak };
361
+ }
362
+
363
+ // ---------- 主流程 ----------
364
+
365
+ function main() {
366
+ const args = parseArgs(process.argv.slice(2));
367
+
368
+ if (args.help || args.h || process.argv.includes("--help")) {
369
+ printUsage();
370
+ return;
371
+ }
372
+
373
+ const errs = validateArgs(args);
374
+ if (errs.length) {
375
+ console.error("[ai-otel-setup] 参数错误:");
376
+ for (const e of errs) console.error(" - " + e);
377
+ console.error("");
378
+ printUsage();
379
+ process.exit(2);
380
+ }
381
+
382
+ const home = os.homedir();
383
+ const claudeDir = path.join(home, ".claude");
384
+ const installDir = path.join(claudeDir, "cc-otel");
385
+ const settingsPath = path.join(claudeDir, "settings.json");
386
+ const hookScriptDest = path.join(installDir, "on-session-start.js");
387
+
388
+ const templateDir = path.join(__dirname, "templates");
389
+ const settingsTemplate = readJSONSafe(path.join(templateDir, "settings.template.json"));
390
+ const hookScriptSrc = path.join(templateDir, "on-session-start.js");
391
+
392
+ if (!fs.existsSync(hookScriptSrc)) {
393
+ console.error(`[ai-otel-setup] 找不到 hook 模板:${hookScriptSrc}`);
394
+ process.exit(1);
395
+ }
396
+
397
+ const endpoint = resolveEndpoint(args.url);
398
+ const newEnv = buildEnv(settingsTemplate, args, endpoint);
399
+
400
+ const hookEntry = {
401
+ matcher: "*",
402
+ hooks: [
403
+ {
404
+ type: "command",
405
+ command: `node "${hookScriptDest}"`,
406
+ timeout: 3,
407
+ },
408
+ ],
409
+ description:
410
+ "ai-otel-setup 注入:补采项目/git/hostname 维度,POST 到 OTLP/HTTP 4318",
411
+ id: HOOK_ID,
412
+ };
413
+
414
+ // UserPromptSubmit 兜底 hook:复用同一脚本,由 stdin.hook_event_name 在脚本内部
415
+ // 分流。客户端做 5 分钟节流,服务端见 entry 已存在则仅补空。用于救 SessionStart
416
+ // 因网络/超时丢失的场景(线上观测约 60% 事件因此空 git/hostname)。
417
+ const promptHookEntry = {
418
+ matcher: "*",
419
+ hooks: [
420
+ {
421
+ type: "command",
422
+ command: `node "${hookScriptDest}"`,
423
+ timeout: 3,
424
+ },
425
+ ],
426
+ description:
427
+ "ai-otel-setup 注入:UserPromptSubmit 兜底,救 SessionStart 漏发场景",
428
+ id: PROMPT_HOOK_ID,
429
+ };
430
+
431
+ fs.mkdirSync(installDir, { recursive: true });
432
+ fs.copyFileSync(hookScriptSrc, hookScriptDest);
433
+ fs.chmodSync(hookScriptDest, 0o755);
434
+
435
+ const existing = readJSONSafe(settingsPath);
436
+ const bak = backup(settingsPath);
437
+ const merged = mergeSettings(
438
+ existing,
439
+ newEnv,
440
+ hookEntry,
441
+ promptHookEntry,
442
+ extractHost(endpoint)
443
+ );
444
+ writeJSONAtomic(settingsPath, merged);
445
+
446
+ const results = [];
447
+ try {
448
+ results.push(installCodex(home, endpoint));
449
+ } catch (e) {
450
+ results.push({ tool: "codex", status: "failed", reason: e.message });
451
+ }
452
+ try {
453
+ results.push(installGemini(home, endpoint));
454
+ } catch (e) {
455
+ results.push({ tool: "gemini", status: "failed", reason: e.message });
456
+ }
457
+
458
+ const debug = !!args.debug || process.argv.includes("--debug") || process.argv.includes("-d");
459
+ const allResults = [{ tool: "claude", status: "installed" }, ...results];
460
+
461
+ console.log("[ai-otel-setup] 安装完成。");
462
+ console.log("");
463
+ console.log(` ${"endpoint".padEnd(12)}: ${endpoint}`);
464
+ for (const r of allResults) {
465
+ console.log(` ${r.tool.padEnd(12)}: ${r.status}${r.reason ? " (" + r.reason + ")" : ""}`);
466
+ }
467
+ if (debug) {
468
+ console.log(` ${"hook script".padEnd(12)}: ${hookScriptDest}`);
469
+ console.log(` ${"settings".padEnd(12)}: ${settingsPath}`);
470
+ if (bak) console.log(` ${"backup".padEnd(12)}: ${bak}`);
471
+ }
472
+ console.log("");
473
+ console.log("接下来:直接运行 `claude` / `codex` / `gemini`,下次会话启动即自动上报。");
474
+ if (debug) {
475
+ console.log(
476
+ "卸载:删除 " +
477
+ installDir +
478
+ " 与 " +
479
+ path.join(claudeDir, "cc-otel-state") +
480
+ "(marker 目录),并从 settings.json 移除 12 个 OTEL_* env、" +
481
+ "SessionStart 中 id=" + HOOK_ID + " 与 UserPromptSubmit 中 id=" + PROMPT_HOOK_ID + " 的条目。"
482
+ );
483
+ }
484
+ }
485
+
486
+ function printUsage() {
487
+ console.log(`Usage:
488
+ npx -y ai-otel-setup url=COLLECTOR_HOST
489
+
490
+ 参数(必填):
491
+ url Collector host(裸 IP/域名,自动补 http://...:4317;也可传完整 URL)
492
+
493
+ 可选:
494
+ debug=1 | --debug 显示安装路径、备份路径与卸载提示
495
+ `);
496
+ }
497
+
498
+ try {
499
+ main();
500
+ } catch (e) {
501
+ console.error("[ai-otel-setup] 失败:" + (e && e.message ? e.message : e));
502
+ process.exit(1);
503
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "ai-otel-setup",
3
+ "version": "1.0.2",
4
+ "description": "One-shot installer for AI CLI OpenTelemetry forwarding. Writes Claude Code, Codex CLI, and Gemini CLI telemetry config in a single npx command.",
5
+ "bin": {
6
+ "ai-otel-setup": "cli.js",
7
+ "cc-otel-installer": "cli.js"
8
+ },
9
+ "main": "./cli.js",
10
+ "preferGlobal": true,
11
+ "engines": {
12
+ "node": ">=14"
13
+ },
14
+ "files": [
15
+ "cli.js",
16
+ "templates/",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "license": "MIT",
21
+ "author": "iFlyTek BG Productivity <productivity@iflytek.example>",
22
+ "keywords": [
23
+ "ai-cli",
24
+ "claude-code",
25
+ "codex",
26
+ "gemini",
27
+ "claude",
28
+ "observability",
29
+ "opentelemetry",
30
+ "otel",
31
+ "telemetry",
32
+ "installer",
33
+ "cli"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/decent-yu/ai-otel-setup.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/decent-yu/ai-otel-setup/issues"
41
+ },
42
+ "homepage": "https://github.com/decent-yu/ai-otel-setup#readme",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { execFileSync } = require("child_process");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const fs = require("fs");
8
+ const http = require("http");
9
+ const https = require("https");
10
+ const { URL } = require("url");
11
+
12
+ function readStdin() {
13
+ return new Promise((resolve) => {
14
+ let data = "";
15
+ process.stdin.setEncoding("utf8");
16
+ process.stdin.on("data", (c) => (data += c));
17
+ process.stdin.on("end", () => resolve(data));
18
+ setTimeout(() => resolve(data), 2000);
19
+ });
20
+ }
21
+
22
+ function safeGit(args) {
23
+ try {
24
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).toString().trim();
25
+ } catch (_) {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ function endpoint() {
31
+ return process.env.AI_OTEL_LOGS_ENDPOINT || "http://localhost:4318/v1/logs";
32
+ }
33
+
34
+ (async () => {
35
+ try {
36
+ const raw = await readStdin();
37
+ let input = {};
38
+ try { input = JSON.parse(raw || "{}"); } catch (_) {}
39
+ const conversation = input.conversation || {};
40
+ const sid = conversation.id || input.conversation_id || input.session_id || "";
41
+ const seen = path.join(os.homedir(), ".codex", "ai-otel", `.session-seen.${sid || "unknown"}`);
42
+ if (sid && fs.existsSync(seen)) process.exit(0);
43
+ // 注意:seen 文件必须在 OTLP 发送成功后才写——见下方 res.on("end")。
44
+ // 之前在此处直接写盘,会导致 collector 暂时不可用时第一次失败也被记为
45
+ // "已上报",从此 codex resume 同一 conversation 永远跳过 hook_session_start。
46
+
47
+ const cwd = input.cwd || process.cwd();
48
+ const event = {
49
+ "tool_kind": "codex",
50
+ "event.name": "hook_session_start",
51
+ "session.id": sid,
52
+ "cwd": cwd,
53
+ "project.name": path.basename(cwd),
54
+ "git.remote": safeGit(["-C", cwd, "config", "--get", "remote.origin.url"]),
55
+ "git.user.email": safeGit(["-C", cwd, "config", "user.email"]),
56
+ "git.user.name": safeGit(["-C", cwd, "config", "user.name"]),
57
+ "hostname": os.hostname() || "",
58
+ "data_source": "hook",
59
+ };
60
+ const payload = JSON.stringify({ resourceLogs: [{ resource: { attributes: [] }, scopeLogs: [{ logRecords: [{ timeUnixNano: `${Date.now()}000000`, body: { stringValue: "hook_session_start" }, attributes: Object.entries(event).map(([key, value]) => ({ key, value: { stringValue: String(value ?? "") } })) }] }] }] });
61
+ const url = new URL(endpoint());
62
+ const markSeen = () => { if (sid) { try { fs.writeFileSync(seen, String(Date.now())); } catch (_) {} } };
63
+ const req = (url.protocol === "https:" ? https : http).request(url, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, timeout: 2000 }, (res) => {
64
+ res.resume();
65
+ res.on("end", () => {
66
+ // 仅 2xx 才视为成功——4xx/5xx 留给下次启动重试,避免静默丢失
67
+ if (res.statusCode >= 200 && res.statusCode < 300) markSeen();
68
+ process.exit(0);
69
+ });
70
+ });
71
+ req.on("error", () => process.exit(0));
72
+ req.on("timeout", () => { req.destroy(); process.exit(0); });
73
+ req.end(payload);
74
+ setTimeout(() => process.exit(0), 2500).unref();
75
+ } catch (_) {
76
+ process.exit(0);
77
+ }
78
+ })();
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { execFileSync } = require("child_process");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const http = require("http");
8
+ const https = require("https");
9
+ const { URL } = require("url");
10
+
11
+ function safeGit(args) {
12
+ try {
13
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).toString().trim();
14
+ } catch (_) {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ function endpoint() {
20
+ const base = process.env.GEMINI_TELEMETRY_OTLP_ENDPOINT || "http://localhost:4317";
21
+ const url = new URL(base);
22
+ if (url.port === "4317") url.port = "4318";
23
+ if (!url.pathname || url.pathname === "/") url.pathname = "/v1/logs";
24
+ return url.toString();
25
+ }
26
+
27
+ try {
28
+ const cwd = process.cwd();
29
+ const event = {
30
+ "tool_kind": "gemini",
31
+ "event.name": "hook_session_start",
32
+ "session.id": process.env.GEMINI_SESSION_ID || "",
33
+ "cwd": cwd,
34
+ "project.name": path.basename(cwd),
35
+ "git.remote": safeGit(["-C", cwd, "config", "--get", "remote.origin.url"]),
36
+ "git.user.email": safeGit(["-C", cwd, "config", "user.email"]),
37
+ "git.user.name": safeGit(["-C", cwd, "config", "user.name"]),
38
+ "hostname": os.hostname() || "",
39
+ "data_source": "hook",
40
+ };
41
+ const payload = JSON.stringify({ resourceLogs: [{ resource: { attributes: [] }, scopeLogs: [{ logRecords: [{ timeUnixNano: `${Date.now()}000000`, body: { stringValue: "hook_session_start" }, attributes: Object.entries(event).map(([key, value]) => ({ key, value: { stringValue: String(value ?? "") } })) }] }] }] });
42
+ const url = new URL(endpoint());
43
+ const req = (url.protocol === "https:" ? https : http).request(url, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, timeout: 2000 }, (res) => { res.resume(); res.on("end", () => process.exit(0)); });
44
+ req.on("error", () => process.exit(0));
45
+ req.on("timeout", () => { req.destroy(); process.exit(0); });
46
+ req.end(payload);
47
+ setTimeout(() => process.exit(0), 2500).unref();
48
+ } catch (_) {
49
+ process.exit(0);
50
+ }
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart / UserPromptSubmit 兜底 hook
4
+ *
5
+ * 职责:
6
+ * 采集 CC 原生 OTel 不覆盖的 5 个字段(cwd / git_remote / git_user_email /
7
+ * git_user_name / hostname),通过 OTLP/HTTP 4318 发给 Collector,
8
+ * 与 OTel 主流合流。
9
+ *
10
+ * 双 hook 复用同一脚本:
11
+ * - SessionStart 触发:每次 claude 启动;生成 hook_kind="session_start"
12
+ * - UserPromptSubmit 触发:每次用户输入 prompt;生成 hook_kind="user_prompt_fallback"
13
+ * 用于救回 SessionStart 因网络/超时丢失的 session(服务端见 entry 已存在则忽略)
14
+ *
15
+ * 关键约束:
16
+ * - 不读源代码,只读 git 元信息
17
+ * - 总耗时 < 3s(hooks 已设 timeout=3)
18
+ * - 失败静默,绝不阻塞 CC
19
+ * - session.id 从 stdin 读
20
+ *
21
+ * 节流(仅对 UserPromptSubmit):
22
+ * - 在 ~/.claude/cc-otel-state/sent-<sid>.flag 写 marker
23
+ * - 5 分钟内同 sid 跳过 OTLP 上报,避免高频敲键狂发
24
+ * - 5 分钟后过期允许重试,给丢包/瞬时故障留救命窗口
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const { execFileSync } = require("child_process");
30
+ const fs = require("fs");
31
+ const os = require("os");
32
+ const path = require("path");
33
+ const http = require("http");
34
+ const https = require("https");
35
+ const { URL } = require("url");
36
+
37
+ // UserPromptSubmit 节流窗口:5 分钟
38
+ const PROMPT_THROTTLE_MS = 5 * 60 * 1000;
39
+
40
+ // -------- 环境变量读取 ----------
41
+
42
+ /**
43
+ * 推导 OTel Collector 的 OTLP/HTTP logs endpoint。
44
+ * 优先级:
45
+ * 1. 显式 OTEL_EXPORTER_OTLP_LOGS_ENDPOINT(用户指定 logs 端点)
46
+ * 2. OTEL_EXPORTER_OTLP_ENDPOINT(通用端点,自动补 /v1/logs,把 4317 换成 4318)
47
+ * 3. fallback http://localhost:4318/v1/logs
48
+ */
49
+ function resolveLogsEndpoint() {
50
+ const logsEndpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;
51
+ if (logsEndpoint) return logsEndpoint;
52
+
53
+ const base = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4317";
54
+ const url = new URL(base);
55
+ // gRPC 默认 4317 → OTLP/HTTP 默认 4318
56
+ if (url.port === "4317") url.port = "4318";
57
+ if (!url.pathname || url.pathname === "/") url.pathname = "/v1/logs";
58
+ return url.toString();
59
+ }
60
+
61
+ // -------- 工具函数 ----------
62
+
63
+ function readStdin() {
64
+ return new Promise((resolve) => {
65
+ let data = "";
66
+ process.stdin.setEncoding("utf8");
67
+ process.stdin.on("data", (c) => (data += c));
68
+ process.stdin.on("end", () => resolve(data));
69
+ // 防挂死:2s 读不到 stdin 就放弃
70
+ setTimeout(() => resolve(data), 2000);
71
+ });
72
+ }
73
+
74
+ // 安全执行 git 命令:execFileSync 不走 shell,cwd 字符串不会被 /bin/sh 解释,
75
+ // 杜绝 cwd 含 `"` / `$(...)` / 反引号 时的命令注入(C-2 修复)
76
+ function safeGit(args) {
77
+ try {
78
+ return execFileSync("git", args, {
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ timeout: 1000,
81
+ })
82
+ .toString()
83
+ .trim();
84
+ } catch (_) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // -------- 主流程 ----------
90
+
91
+ (async () => {
92
+ try {
93
+ const raw = await readStdin();
94
+ let input = {};
95
+ try {
96
+ input = JSON.parse(raw || "{}");
97
+ } catch (_) {
98
+ input = {};
99
+ }
100
+
101
+ const cwd = input.cwd || process.cwd();
102
+ const sessionId = input.session_id || input.sessionId || ""; // MVP 实证:stdin.session_id = OTel session.id
103
+ // CC 在 stdin 里告诉脚本是哪个 hook 触发的;UserPromptSubmit 走"兜底"分支
104
+ const isPromptFallback = input.hook_event_name === "UserPromptSubmit";
105
+
106
+ // 兜底路径节流:sid 维度 5 分钟最多一次(marker 文件 mtime 判断)。
107
+ // 失败重试窗口同时由此控制:marker 过期后允许下次 prompt 再发一次。
108
+ const stateDir = path.join(os.homedir(), ".claude", "cc-otel-state");
109
+ const markerPath = sessionId ? path.join(stateDir, `sent-${sessionId}.flag`) : null;
110
+ if (isPromptFallback && markerPath && fs.existsSync(markerPath)) {
111
+ try {
112
+ const mtime = fs.statSync(markerPath).mtimeMs;
113
+ if (Date.now() - mtime < PROMPT_THROTTLE_MS) {
114
+ process.exit(0);
115
+ }
116
+ } catch (_) {
117
+ // marker 状态读不到就当作过期,继续走上报
118
+ }
119
+ }
120
+
121
+ const event = {
122
+ "tool_kind": "cc",
123
+ "event.name": "hook_session_start",
124
+ "event.timestamp": new Date().toISOString(),
125
+ "session.id": sessionId,
126
+ // 服务端按此字段分流:session_start 走原 new/resume 逻辑;
127
+ // user_prompt_fallback 走"entry 已存在则仅补空字段"逻辑
128
+ "hook_kind": isPromptFallback ? "user_prompt_fallback" : "session_start",
129
+ "cwd": cwd,
130
+ "project.name": path.basename(cwd),
131
+ "git.remote": safeGit(["-C", cwd, "config", "--get", "remote.origin.url"]) || "",
132
+ "git.user.email": safeGit(["-C", cwd, "config", "user.email"]) || "",
133
+ "git.user.name": safeGit(["-C", cwd, "config", "user.name"]) || "",
134
+ "hostname": os.hostname() || "",
135
+ "data_source": "hook", // Collector 端用 insert 而非 upsert 以保留本标签
136
+ };
137
+
138
+ const logsEndpoint = resolveLogsEndpoint();
139
+ const payload = JSON.stringify({
140
+ resourceLogs: [
141
+ {
142
+ resource: {
143
+ attributes: [],
144
+ },
145
+ scopeLogs: [
146
+ {
147
+ logRecords: [
148
+ {
149
+ timeUnixNano: `${Date.now()}000000`,
150
+ body: { stringValue: "hook_session_start" },
151
+ attributes: Object.entries(event).map(([k, v]) => ({
152
+ key: k,
153
+ value: { stringValue: String(v ?? "") },
154
+ })),
155
+ },
156
+ ],
157
+ },
158
+ ],
159
+ },
160
+ ],
161
+ });
162
+
163
+ const url = new URL(logsEndpoint);
164
+ const lib = url.protocol === "https:" ? https : http;
165
+
166
+ // 关键:必须等 HTTP request 真的发出并收到响应(或短时间超时)才退出,
167
+ // 不能 req.end() 之后立刻 process.exit(0) —— 那样 TCP handshake 都
168
+ // 还没做完进程就没了,Collector 永远收不到。
169
+ // Hook timeout 是 3s,这里给自己 2.5s 上限。
170
+ const done = (() => {
171
+ let called = false;
172
+ return () => {
173
+ if (called) return;
174
+ called = true;
175
+ process.exit(0);
176
+ };
177
+ })();
178
+
179
+ const req = lib.request(
180
+ url,
181
+ {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ "Content-Length": Buffer.byteLength(payload),
186
+ ...(process.env.OTEL_EXPORTER_OTLP_HEADERS
187
+ ? parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS)
188
+ : {}),
189
+ },
190
+ timeout: 2000,
191
+ },
192
+ (res) => {
193
+ res.resume();
194
+ res.on("end", done);
195
+ res.on("error", done);
196
+ }
197
+ );
198
+
199
+ req.on("error", done); // 失败静默退出
200
+ req.on("timeout", () => { req.destroy(); done(); });
201
+
202
+ // 在真正发包前 touch marker 文件——把"已尝试上报"持久化下来,
203
+ // 让后续 5 分钟内的 UserPromptSubmit 跳过重复 POST。失败也照写,
204
+ // 因为 5 分钟后 marker 会过期允许重试,不会永久卡住。
205
+ if (markerPath) {
206
+ try {
207
+ fs.mkdirSync(stateDir, { recursive: true });
208
+ fs.writeFileSync(markerPath, "");
209
+ } catch (_) {
210
+ // marker 写入失败不阻塞上报
211
+ }
212
+ }
213
+
214
+ req.write(payload);
215
+ req.end();
216
+
217
+ // 兜底:2.5s 强制退出(CC hook timeout 3s 前先自己结束)
218
+ setTimeout(done, 2500).unref();
219
+ } catch (_) {
220
+ // 兜底:任何异常都不阻塞 CC
221
+ process.exit(0);
222
+ }
223
+ })();
224
+
225
+ function parseHeaders(headerStr) {
226
+ // "Authorization=Bearer xxx,X-Trace=yyy" -> { Authorization: "Bearer xxx", "X-Trace": "yyy" }
227
+ const out = {};
228
+ for (const pair of headerStr.split(",")) {
229
+ const idx = pair.indexOf("=");
230
+ if (idx <= 0) continue;
231
+ const k = pair.slice(0, idx).trim();
232
+ const v = pair.slice(idx + 1).trim();
233
+ if (k) out[k] = v;
234
+ }
235
+ return out;
236
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "env": {
3
+ "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
4
+ "OTEL_METRICS_EXPORTER": "otlp",
5
+ "OTEL_LOGS_EXPORTER": "otlp",
6
+ "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
7
+ "OTEL_EXPORTER_OTLP_ENDPOINT": "PLACEHOLDER_ENDPOINT",
8
+ "OTEL_LOGS_EXPORT_INTERVAL": "300000",
9
+ "OTEL_METRIC_EXPORT_INTERVAL": "600000",
10
+ "OTEL_METRICS_INCLUDE_VERSION": "true",
11
+ "OTEL_LOG_USER_PROMPTS": "0",
12
+ "OTEL_LOG_TOOL_DETAILS": "1",
13
+ "OTEL_LOG_TOOL_CONTENT": "0",
14
+ "OTEL_LOG_RAW_API_BODIES": "0"
15
+ },
16
+ "_comment": "Installer 会替换 OTEL_EXPORTER_OTLP_ENDPOINT (PLACEHOLDER_ENDPOINT)。TOOL_DETAILS=1 是为了采到 skill/plugin 真名,Collector 侧会硬删敏感 JSON blob,详见 v1.2 §3.3。"
17
+ }