ai-otel-setup 1.0.3 → 1.0.5
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/cli.js +76 -11
- package/package.json +1 -1
- package/templates/codex/on-session-start.js +10 -1
- package/templates/gemini/on-session-start.js +13 -1
- package/templates/launch-hook.js +34 -0
package/cli.js
CHANGED
|
@@ -25,6 +25,28 @@ const fs = require("fs");
|
|
|
25
25
|
const path = require("path");
|
|
26
26
|
const os = require("os");
|
|
27
27
|
|
|
28
|
+
const PKG_VERSION = require("./package.json").version;
|
|
29
|
+
|
|
30
|
+
// 安装时这台机器的 node 绝对路径,给 hook 命令做兜底(见 buildHookCommand)。
|
|
31
|
+
const NODE_BIN = process.execPath;
|
|
32
|
+
|
|
33
|
+
// 跨平台 hook 命令:固定形式 `<NODE_BIN> <launcher> <hook>`,三段都是绝对路径,
|
|
34
|
+
// 对 shell 完全透明(POSIX sh / cmd.exe / PowerShell 5.1+ / PowerShell 7+ 全 cover)。
|
|
35
|
+
// "PATH 上 node 优先 → 否则用 baked 绝对路径" 的兜底逻辑放在 launch-hook.js 里做,
|
|
36
|
+
// 不再依赖 shell `||` 操作符——PS 5.1 不支持 `||`,cc/gemini 在 Windows 上默认就
|
|
37
|
+
// 是 PS,会被坑。
|
|
38
|
+
function buildHookCommand(launcherPath, scriptPath) {
|
|
39
|
+
return `"${NODE_BIN}" "${launcherPath}" "${scriptPath}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 把 launcher 模板拷到 hook 同目录,返回 launcher 的绝对路径
|
|
43
|
+
function installLauncher(installDir) {
|
|
44
|
+
const launcherDest = path.join(installDir, "launch-hook.js");
|
|
45
|
+
fs.copyFileSync(path.join(__dirname, "templates", "launch-hook.js"), launcherDest);
|
|
46
|
+
fs.chmodSync(launcherDest, 0o755);
|
|
47
|
+
return launcherDest;
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
const REQUIRED_KEYS = ["url"];
|
|
29
51
|
const HOOK_ID = "team:session-start";
|
|
30
52
|
// UserPromptSubmit 兜底 hook:复用同一脚本,靠 stdin.hook_event_name 分流;
|
|
@@ -256,7 +278,35 @@ function stripLegacyCodexHook(text) {
|
|
|
256
278
|
);
|
|
257
279
|
}
|
|
258
280
|
|
|
259
|
-
function
|
|
281
|
+
function stripLegacyCodexHooksFlag(text) {
|
|
282
|
+
// Codex 把 [features].codex_hooks 重命名为 [features].hooks,旧 key 启动时触发 deprecation 警告
|
|
283
|
+
// 删 = true 这行,由 ensureFeaturesHooksTrue 统一写 hooks = true;= false 是显式 opt-out,保留
|
|
284
|
+
return text.replace(/^[ \t]*codex_hooks[ \t]*=[ \t]*true[ \t]*\r?\n/gm, "");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ensureFeaturesHooksTrue(text) {
|
|
288
|
+
// 在用户已有的 [features] 块原地插入 hooks = true(如缺失);没有 [features] 就新建。
|
|
289
|
+
// 不能写在 managed 块里——TOML 1.0 禁止同名 table 重复声明,会被严格解析器拒绝。
|
|
290
|
+
const lines = text.split(/\r?\n/);
|
|
291
|
+
let featuresIdx = -1;
|
|
292
|
+
let hooksKeyExists = false;
|
|
293
|
+
for (let i = 0; i < lines.length; i++) {
|
|
294
|
+
if (featuresIdx === -1) {
|
|
295
|
+
if (/^\s*\[features\]\s*$/.test(lines[i])) featuresIdx = i;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (/^\s*\[/.test(lines[i])) break; // 下一个 section,结束 [features] 主块扫描
|
|
299
|
+
if (/^[ \t]*hooks[ \t]*=/.test(lines[i])) hooksKeyExists = true;
|
|
300
|
+
}
|
|
301
|
+
if (featuresIdx >= 0) {
|
|
302
|
+
if (hooksKeyExists) return text; // 任何 hooks = ... 都尊重,不覆盖用户显式选择
|
|
303
|
+
lines.splice(featuresIdx + 1, 0, "hooks = true");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
return text.trimEnd() + "\n\n[features]\nhooks = true\n";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildCodexManagedBlock(endpoint, hookDest, launcherDest) {
|
|
260
310
|
// exporter / trace_exporter / metrics_exporter 是 externally-tagged enum:
|
|
261
311
|
// - 写 scalar `exporter = "otlp-grpc"`:codex 解析为 unit variant,因为
|
|
262
312
|
// OtlpGrpc 是 struct variant(带 endpoint 等字段),报
|
|
@@ -265,11 +315,9 @@ function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
|
|
|
265
315
|
// - 只写 table `[otel.exporter."otlp-grpc"]`:✓ codex 把它解析为
|
|
266
316
|
// OtlpGrpc { endpoint },tag 来自 key 名。
|
|
267
317
|
// 官方 sample 之所以能 `exporter = "none"`,是因为 None 本身就是 unit variant。
|
|
318
|
+
// [features].hooks = true 由 ensureFeaturesHooksTrue 写到用户块里,避免重复声明 [features]
|
|
268
319
|
return [
|
|
269
320
|
CODEX_MANAGED_BEGIN,
|
|
270
|
-
"[features]",
|
|
271
|
-
"codex_hooks = true",
|
|
272
|
-
"",
|
|
273
321
|
"[otel]",
|
|
274
322
|
'environment = "prod"',
|
|
275
323
|
"log_user_prompt = false",
|
|
@@ -288,7 +336,7 @@ function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
|
|
|
288
336
|
"",
|
|
289
337
|
"[[hooks.SessionStart.hooks]]",
|
|
290
338
|
'type = "command"',
|
|
291
|
-
`command = ${JSON.stringify(
|
|
339
|
+
`command = ${JSON.stringify(buildHookCommand(launcherDest, hookDest))}`,
|
|
292
340
|
CODEX_MANAGED_END,
|
|
293
341
|
].join("\n");
|
|
294
342
|
}
|
|
@@ -304,16 +352,24 @@ function installCodex(home, endpoint) {
|
|
|
304
352
|
fs.mkdirSync(installDir, { recursive: true });
|
|
305
353
|
fs.copyFileSync(path.join(__dirname, "templates", "codex", "on-session-start.js"), hookDest);
|
|
306
354
|
fs.chmodSync(hookDest, 0o755);
|
|
355
|
+
const launcherDest = installLauncher(installDir);
|
|
307
356
|
const bak = backup(configPath);
|
|
308
357
|
let existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
|
|
309
358
|
|
|
310
|
-
//
|
|
359
|
+
// 先剥离上一次的 managed 块和旧 schema 残留,再保证用户块里有 hooks = true
|
|
311
360
|
existing = stripCodexManagedBlock(existing);
|
|
312
361
|
existing = stripLegacyCodexOtel(existing);
|
|
313
362
|
existing = stripLegacyCodexHook(existing);
|
|
363
|
+
existing = stripLegacyCodexHooksFlag(existing);
|
|
364
|
+
existing = ensureFeaturesHooksTrue(existing);
|
|
314
365
|
|
|
315
|
-
|
|
316
|
-
|
|
366
|
+
// hook 同目录的 endpoint.json:hook 脚本运行时读它拿 logs endpoint,避免依赖
|
|
367
|
+
// shell 前缀注入 env(cmd.exe 不认那种语法,跨平台必须改成走文件)。
|
|
368
|
+
writeJSONAtomic(path.join(installDir, "endpoint.json"), {
|
|
369
|
+
endpoint,
|
|
370
|
+
logsEndpoint: logsEndpointFromGrpc(endpoint),
|
|
371
|
+
});
|
|
372
|
+
const managed = buildCodexManagedBlock(endpoint, hookDest, launcherDest);
|
|
317
373
|
const merged = (existing.trimEnd() + "\n\n" + managed + "\n").replace(/\n{3,}/g, "\n\n");
|
|
318
374
|
fs.writeFileSync(configPath, merged, "utf8");
|
|
319
375
|
return { tool: "codex", status: "installed", path: configPath, backup: bak };
|
|
@@ -330,6 +386,12 @@ function installGemini(home, endpoint) {
|
|
|
330
386
|
fs.mkdirSync(installDir, { recursive: true });
|
|
331
387
|
fs.copyFileSync(path.join(__dirname, "templates", "gemini", "on-session-start.js"), hookDest);
|
|
332
388
|
fs.chmodSync(hookDest, 0o755);
|
|
389
|
+
const launcherDest = installLauncher(installDir);
|
|
390
|
+
// 同 Codex:endpoint.json 给 hook 脚本读,跨平台不依赖 env 前缀。
|
|
391
|
+
writeJSONAtomic(path.join(installDir, "endpoint.json"), {
|
|
392
|
+
endpoint,
|
|
393
|
+
logsEndpoint: logsEndpointFromGrpc(endpoint),
|
|
394
|
+
});
|
|
333
395
|
const existing = readJSONSafe(settingsPath);
|
|
334
396
|
const bak = backup(settingsPath);
|
|
335
397
|
const merged = { ...existing };
|
|
@@ -350,7 +412,7 @@ function installGemini(home, endpoint) {
|
|
|
350
412
|
: [];
|
|
351
413
|
const hookEntry = {
|
|
352
414
|
id: HOOK_ID,
|
|
353
|
-
command:
|
|
415
|
+
command: buildHookCommand(launcherDest, hookDest),
|
|
354
416
|
};
|
|
355
417
|
const idx = sessionStart.findIndex((h) => h && h.id === HOOK_ID);
|
|
356
418
|
if (idx >= 0) sessionStart[idx] = hookEntry;
|
|
@@ -384,6 +446,7 @@ function main() {
|
|
|
384
446
|
const installDir = path.join(claudeDir, "cc-otel");
|
|
385
447
|
const settingsPath = path.join(claudeDir, "settings.json");
|
|
386
448
|
const hookScriptDest = path.join(installDir, "on-session-start.js");
|
|
449
|
+
const launcherDest = path.join(installDir, "launch-hook.js");
|
|
387
450
|
|
|
388
451
|
const templateDir = path.join(__dirname, "templates");
|
|
389
452
|
const settingsTemplate = readJSONSafe(path.join(templateDir, "settings.template.json"));
|
|
@@ -402,7 +465,7 @@ function main() {
|
|
|
402
465
|
hooks: [
|
|
403
466
|
{
|
|
404
467
|
type: "command",
|
|
405
|
-
command:
|
|
468
|
+
command: buildHookCommand(launcherDest, hookScriptDest),
|
|
406
469
|
timeout: 3,
|
|
407
470
|
},
|
|
408
471
|
],
|
|
@@ -419,7 +482,7 @@ function main() {
|
|
|
419
482
|
hooks: [
|
|
420
483
|
{
|
|
421
484
|
type: "command",
|
|
422
|
-
command:
|
|
485
|
+
command: buildHookCommand(launcherDest, hookScriptDest),
|
|
423
486
|
timeout: 3,
|
|
424
487
|
},
|
|
425
488
|
],
|
|
@@ -431,6 +494,7 @@ function main() {
|
|
|
431
494
|
fs.mkdirSync(installDir, { recursive: true });
|
|
432
495
|
fs.copyFileSync(hookScriptSrc, hookScriptDest);
|
|
433
496
|
fs.chmodSync(hookScriptDest, 0o755);
|
|
497
|
+
installLauncher(installDir);
|
|
434
498
|
|
|
435
499
|
// v1.0.3:把 endpoint 写盘,给 hook 脚本的 resolveLogsEndpoint 当兜底。
|
|
436
500
|
// 修的是 v1.0.2 的真实事故:settings.json 的 env 不一定能继承到 hook 子进程
|
|
@@ -469,6 +533,7 @@ function main() {
|
|
|
469
533
|
|
|
470
534
|
console.log("[ai-otel-setup] 安装完成。");
|
|
471
535
|
console.log("");
|
|
536
|
+
console.log(` ${"version".padEnd(12)}: ${PKG_VERSION}`);
|
|
472
537
|
console.log(` ${"endpoint".padEnd(12)}: ${endpoint}`);
|
|
473
538
|
for (const r of allResults) {
|
|
474
539
|
console.log(` ${r.tool.padEnd(12)}: ${r.status}${r.reason ? " (" + r.reason + ")" : ""}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-otel-setup",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
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
5
|
"bin": {
|
|
6
6
|
"ai-otel-setup": "cli.js",
|
|
@@ -27,8 +27,17 @@ function safeGit(args) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// 解析 OTLP/HTTP logs endpoint。优先级:env 覆盖 → installer 写在 hook 同目录的
|
|
31
|
+
// endpoint.json → localhost 兜底。原本走 shell 前缀 `AI_OTEL_LOGS_ENDPOINT=...` 注入
|
|
32
|
+
// env,但那是 POSIX 独有语法、cmd.exe 把它当程序名就 G 了,所以 v1.0.4 起命令行
|
|
33
|
+
// 不再带前缀,改让脚本自己读 endpoint.json,跨平台统一。env 留作 debug 覆盖口。
|
|
30
34
|
function endpoint() {
|
|
31
|
-
|
|
35
|
+
if (process.env.AI_OTEL_LOGS_ENDPOINT) return process.env.AI_OTEL_LOGS_ENDPOINT;
|
|
36
|
+
try {
|
|
37
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, "endpoint.json"), "utf8"));
|
|
38
|
+
if (cfg && cfg.logsEndpoint) return cfg.logsEndpoint;
|
|
39
|
+
} catch (_) { /* 文件不存在/解析失败:继续走 localhost */ }
|
|
40
|
+
return "http://localhost:4318/v1/logs";
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
(async () => {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const { execFileSync } = require("child_process");
|
|
5
5
|
const os = require("os");
|
|
6
6
|
const path = require("path");
|
|
7
|
+
const fs = require("fs");
|
|
7
8
|
const http = require("http");
|
|
8
9
|
const https = require("https");
|
|
9
10
|
const { URL } = require("url");
|
|
@@ -16,8 +17,19 @@ function safeGit(args) {
|
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
// 解析 OTLP/HTTP logs endpoint。优先级:env 覆盖 → installer 写在 hook 同目录的
|
|
21
|
+
// endpoint.json → localhost 兜底。v1.0.4 起 hook 自己读 endpoint.json,避免依赖
|
|
22
|
+
// shell 前缀注入 env 那种 POSIX-only 写法(cmd.exe 不认)。
|
|
19
23
|
function endpoint() {
|
|
20
|
-
|
|
24
|
+
let base = process.env.GEMINI_TELEMETRY_OTLP_ENDPOINT;
|
|
25
|
+
if (!base) {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, "endpoint.json"), "utf8"));
|
|
28
|
+
if (cfg && cfg.logsEndpoint) return cfg.logsEndpoint;
|
|
29
|
+
if (cfg && cfg.endpoint) base = cfg.endpoint;
|
|
30
|
+
} catch (_) { /* 文件不存在/解析失败:继续走 localhost */ }
|
|
31
|
+
}
|
|
32
|
+
if (!base) base = "http://localhost:4317";
|
|
21
33
|
const url = new URL(base);
|
|
22
34
|
if (url.port === "4317") url.port = "4318";
|
|
23
35
|
if (!url.pathname || url.pathname === "/") url.pathname = "/v1/logs";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// 跨平台 hook 启动器:优先 PATH 上的 node(用户升级 Node 时自动跟新版本),
|
|
5
|
+
// 找不到再用安装时这台机器上 node 的绝对路径(即当前进程的 execPath,一定可用)。
|
|
6
|
+
// 改用 JS 内部兜底替代 shell `||` 操作符链——PowerShell 5.1(Win10/11 默认 shell)
|
|
7
|
+
// 不支持 `||`,cc/gemini 在 Windows 上默认走 PowerShell,会被坑。统一在 JS 里兜底
|
|
8
|
+
// 后,命令字符串变成纯 `<node> <launcher> <hook>` 三段绝对路径调用,对 shell 透明,
|
|
9
|
+
// POSIX sh / cmd.exe / PowerShell 5.1 / PowerShell 7 全 cover。
|
|
10
|
+
//
|
|
11
|
+
// stdio: "inherit":stdin(Codex/CC 传 hook payload JSON)、stderr 都直通给 hook
|
|
12
|
+
// 子进程,hook 那边的 readStdin / process.stderr 行为不受影响。退出码原样转发。
|
|
13
|
+
|
|
14
|
+
const { spawnSync, execFileSync } = require("child_process");
|
|
15
|
+
|
|
16
|
+
const scriptPath = process.argv[2];
|
|
17
|
+
if (!scriptPath) process.exit(0);
|
|
18
|
+
|
|
19
|
+
let nodeBin = process.execPath;
|
|
20
|
+
try {
|
|
21
|
+
// -v 只打版本号立即退出,用来探 PATH 上是否有可执行 node。
|
|
22
|
+
// timeout 防 PATH 上的 "node" 是个会卡住的 wrapper(极少见但存在)。
|
|
23
|
+
execFileSync("node", ["-v"], { stdio: "ignore", timeout: 1500 });
|
|
24
|
+
nodeBin = "node";
|
|
25
|
+
} catch (_) {
|
|
26
|
+
// PATH 上没 node 或探测失败 → 沿用当前进程 execPath(即 installer 焊死的那条
|
|
27
|
+
// 绝对路径)。注意此时如果用户连 baked 那个版本也卸了,那 launcher 自己根本就
|
|
28
|
+
// 启动不起来,根本走不到这里——也就是说 hook 真挂的时候用户会感知到,符合
|
|
29
|
+
// 不静默吞错的预期。
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const r = spawnSync(nodeBin, [scriptPath], { stdio: "inherit" });
|
|
33
|
+
// status 为 null 表示进程被信号杀掉(SIGTERM 等),按 1 处理
|
|
34
|
+
process.exit(r.status === null ? 1 : r.status);
|