ai-otel-setup 1.0.3 → 1.0.4

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 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,13 @@ function stripLegacyCodexHook(text) {
256
278
  );
257
279
  }
258
280
 
259
- function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
281
+ function stripLegacyCodexHooksFlag(text) {
282
+ // Codex 把 [features].codex_hooks 重命名为 [features].hooks,旧 key 启动时触发 deprecation 警告
283
+ // 删 = true 这行,由 managed 块统一写 hooks = true;= false 是显式 opt-out,保留
284
+ return text.replace(/^[ \t]*codex_hooks[ \t]*=[ \t]*true[ \t]*\r?\n/gm, "");
285
+ }
286
+
287
+ function buildCodexManagedBlock(endpoint, hookDest, launcherDest) {
260
288
  // exporter / trace_exporter / metrics_exporter 是 externally-tagged enum:
261
289
  // - 写 scalar `exporter = "otlp-grpc"`:codex 解析为 unit variant,因为
262
290
  // OtlpGrpc 是 struct variant(带 endpoint 等字段),报
@@ -268,7 +296,7 @@ function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
268
296
  return [
269
297
  CODEX_MANAGED_BEGIN,
270
298
  "[features]",
271
- "codex_hooks = true",
299
+ "hooks = true",
272
300
  "",
273
301
  "[otel]",
274
302
  'environment = "prod"',
@@ -288,7 +316,7 @@ function buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
288
316
  "",
289
317
  "[[hooks.SessionStart.hooks]]",
290
318
  'type = "command"',
291
- `command = ${JSON.stringify(`AI_OTEL_LOGS_ENDPOINT=${logsEndpoint} node "${hookDest}"`)}`,
319
+ `command = ${JSON.stringify(buildHookCommand(launcherDest, hookDest))}`,
292
320
  CODEX_MANAGED_END,
293
321
  ].join("\n");
294
322
  }
@@ -304,16 +332,23 @@ function installCodex(home, endpoint) {
304
332
  fs.mkdirSync(installDir, { recursive: true });
305
333
  fs.copyFileSync(path.join(__dirname, "templates", "codex", "on-session-start.js"), hookDest);
306
334
  fs.chmodSync(hookDest, 0o755);
335
+ const launcherDest = installLauncher(installDir);
307
336
  const bak = backup(configPath);
308
337
  let existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
309
338
 
310
- // 三步去重:先剥离上一次的 managed 块,再清掉旧 schema 残留
339
+ // 四步去重:先剥离上一次的 managed 块,再清掉旧 schema 残留
311
340
  existing = stripCodexManagedBlock(existing);
312
341
  existing = stripLegacyCodexOtel(existing);
313
342
  existing = stripLegacyCodexHook(existing);
343
+ existing = stripLegacyCodexHooksFlag(existing);
314
344
 
315
- const logsEndpoint = logsEndpointFromGrpc(endpoint);
316
- const managed = buildCodexManagedBlock(endpoint, hookDest, logsEndpoint);
345
+ // hook 同目录的 endpoint.json:hook 脚本运行时读它拿 logs endpoint,避免依赖
346
+ // shell 前缀注入 env(cmd.exe 不认那种语法,跨平台必须改成走文件)。
347
+ writeJSONAtomic(path.join(installDir, "endpoint.json"), {
348
+ endpoint,
349
+ logsEndpoint: logsEndpointFromGrpc(endpoint),
350
+ });
351
+ const managed = buildCodexManagedBlock(endpoint, hookDest, launcherDest);
317
352
  const merged = (existing.trimEnd() + "\n\n" + managed + "\n").replace(/\n{3,}/g, "\n\n");
318
353
  fs.writeFileSync(configPath, merged, "utf8");
319
354
  return { tool: "codex", status: "installed", path: configPath, backup: bak };
@@ -330,6 +365,12 @@ function installGemini(home, endpoint) {
330
365
  fs.mkdirSync(installDir, { recursive: true });
331
366
  fs.copyFileSync(path.join(__dirname, "templates", "gemini", "on-session-start.js"), hookDest);
332
367
  fs.chmodSync(hookDest, 0o755);
368
+ const launcherDest = installLauncher(installDir);
369
+ // 同 Codex:endpoint.json 给 hook 脚本读,跨平台不依赖 env 前缀。
370
+ writeJSONAtomic(path.join(installDir, "endpoint.json"), {
371
+ endpoint,
372
+ logsEndpoint: logsEndpointFromGrpc(endpoint),
373
+ });
333
374
  const existing = readJSONSafe(settingsPath);
334
375
  const bak = backup(settingsPath);
335
376
  const merged = { ...existing };
@@ -350,7 +391,7 @@ function installGemini(home, endpoint) {
350
391
  : [];
351
392
  const hookEntry = {
352
393
  id: HOOK_ID,
353
- command: `node "${hookDest}"`,
394
+ command: buildHookCommand(launcherDest, hookDest),
354
395
  };
355
396
  const idx = sessionStart.findIndex((h) => h && h.id === HOOK_ID);
356
397
  if (idx >= 0) sessionStart[idx] = hookEntry;
@@ -384,6 +425,7 @@ function main() {
384
425
  const installDir = path.join(claudeDir, "cc-otel");
385
426
  const settingsPath = path.join(claudeDir, "settings.json");
386
427
  const hookScriptDest = path.join(installDir, "on-session-start.js");
428
+ const launcherDest = path.join(installDir, "launch-hook.js");
387
429
 
388
430
  const templateDir = path.join(__dirname, "templates");
389
431
  const settingsTemplate = readJSONSafe(path.join(templateDir, "settings.template.json"));
@@ -402,7 +444,7 @@ function main() {
402
444
  hooks: [
403
445
  {
404
446
  type: "command",
405
- command: `node "${hookScriptDest}"`,
447
+ command: buildHookCommand(launcherDest, hookScriptDest),
406
448
  timeout: 3,
407
449
  },
408
450
  ],
@@ -419,7 +461,7 @@ function main() {
419
461
  hooks: [
420
462
  {
421
463
  type: "command",
422
- command: `node "${hookScriptDest}"`,
464
+ command: buildHookCommand(launcherDest, hookScriptDest),
423
465
  timeout: 3,
424
466
  },
425
467
  ],
@@ -431,6 +473,7 @@ function main() {
431
473
  fs.mkdirSync(installDir, { recursive: true });
432
474
  fs.copyFileSync(hookScriptSrc, hookScriptDest);
433
475
  fs.chmodSync(hookScriptDest, 0o755);
476
+ installLauncher(installDir);
434
477
 
435
478
  // v1.0.3:把 endpoint 写盘,给 hook 脚本的 resolveLogsEndpoint 当兜底。
436
479
  // 修的是 v1.0.2 的真实事故:settings.json 的 env 不一定能继承到 hook 子进程
@@ -469,6 +512,7 @@ function main() {
469
512
 
470
513
  console.log("[ai-otel-setup] 安装完成。");
471
514
  console.log("");
515
+ console.log(` ${"version".padEnd(12)}: ${PKG_VERSION}`);
472
516
  console.log(` ${"endpoint".padEnd(12)}: ${endpoint}`);
473
517
  for (const r of allResults) {
474
518
  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",
3
+ "version": "1.0.4",
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
- return process.env.AI_OTEL_LOGS_ENDPOINT || "http://localhost:4318/v1/logs";
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
- const base = process.env.GEMINI_TELEMETRY_OTLP_ENDPOINT || "http://localhost:4317";
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);