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 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 buildCodexManagedBlock(endpoint, hookDest, logsEndpoint) {
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(`AI_OTEL_LOGS_ENDPOINT=${logsEndpoint} node "${hookDest}"`)}`,
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
- // 三步去重:先剥离上一次的 managed 块,再清掉旧 schema 残留
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
- const logsEndpoint = logsEndpointFromGrpc(endpoint);
316
- const managed = buildCodexManagedBlock(endpoint, hookDest, logsEndpoint);
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: `node "${hookDest}"`,
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: `node "${hookScriptDest}"`,
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: `node "${hookScriptDest}"`,
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",
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
- 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);