ai-otel-setup 1.0.6 → 1.0.8

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.
Files changed (2) hide show
  1. package/cli.js +212 -15
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -24,19 +24,47 @@
24
24
  const fs = require("fs");
25
25
  const path = require("path");
26
26
  const os = require("os");
27
+ const { execFileSync } = require("child_process");
27
28
 
28
29
  const PKG_VERSION = require("./package.json").version;
29
30
 
31
+ // Windows 8.3 短路径:把 "C:\Program Files\nodejs\node.exe" 这种带空格的长路径
32
+ // 转成 "C:\Progra~1\nodejs\node.exe" 形式。**必要性**:codex 在 Windows 下走
33
+ // PowerShell 解析 hooks command,PS 把外层引号脱掉之后会按空白拆 token,导致
34
+ // "C:\Program Files\..." 被切成 "C:\Program" + "Files\...",hook 进程起不来,
35
+ // exit code 1。用 8.3 短路径就根本没空格,cmd.exe 和 PowerShell 都能正确解析。
36
+ // 拿不到短路径(NTFS 卷禁用了 8.3 名)就回退原路径——比起死掉,至少 cmd.exe 还能跑。
37
+ function toWindowsShortPath(p) {
38
+ if (process.platform !== "win32" || !p) return p;
39
+ try {
40
+ const out = execFileSync(
41
+ "cmd.exe",
42
+ ["/c", `for %A in ("${p}") do @echo %~sA`],
43
+ { encoding: "utf8", windowsHide: true, timeout: 3000 },
44
+ );
45
+ const short = out.trim();
46
+ if (short && short.length > 0) return short;
47
+ } catch (_) {
48
+ /* 卷禁用了 8.3 / cmd.exe 不可用:吞掉,回退原路径 */
49
+ }
50
+ return p;
51
+ }
52
+
30
53
  // 安装时这台机器的 node 绝对路径,给 hook 命令做兜底(见 buildHookCommand)。
31
- const NODE_BIN = process.execPath;
54
+ // Windows 上立刻转 8.3 短路径,规避 PowerShell 解析空格切 token 的问题。
55
+ const NODE_BIN = toWindowsShortPath(process.execPath);
32
56
 
33
- // 跨平台 hook 命令:固定形式 `<NODE_BIN> <launcher> <hook>`,三段都是绝对路径,
34
- // shell 完全透明(POSIX sh / cmd.exe / PowerShell 5.1+ / PowerShell 7+ 全 cover)。
57
+ // 跨平台 hook 命令:固定形式 `<NODE_BIN> <launcher> <hook>`,三段都是绝对路径。
58
+ // 三段均做 8.3 短路径转换(仅 Windows 生效,POSIX 直接透传):node Program Files、
59
+ // 或者用户名/目录里有空格(如 "C:\Users\张 三\.codex\...")都靠这步消歧义。
35
60
  // "PATH 上 node 优先 → 否则用 baked 绝对路径" 的兜底逻辑放在 launch-hook.js 里做,
36
61
  // 不再依赖 shell `||` 操作符——PS 5.1 不支持 `||`,cc/gemini 在 Windows 上默认就
37
62
  // 是 PS,会被坑。
38
63
  function buildHookCommand(launcherPath, scriptPath) {
39
- return `"${NODE_BIN}" "${launcherPath}" "${scriptPath}"`;
64
+ const node = NODE_BIN;
65
+ const launcher = toWindowsShortPath(launcherPath);
66
+ const script = toWindowsShortPath(scriptPath);
67
+ return `"${node}" "${launcher}" "${script}"`;
40
68
  }
41
69
 
42
70
  // 把 launcher 模板拷到 hook 同目录,返回 launcher 的绝对路径
@@ -131,6 +159,164 @@ function mergeNoProxy(existing, host) {
131
159
  return list.join(",");
132
160
  }
133
161
 
162
+ // ---------- git config 兜底 (跨平台) ----------
163
+ //
164
+ // hook 进程偶有"压根没跑"的场景(网络/超时/进程崩溃),导致 git.user.email/name 永久丢失。
165
+ // 装机时把全局 git config 写到 OTEL_RESOURCE_ATTRIBUTES,CC SDK 自动把 resource attr
166
+ // 带到每条 metric/log,service 端在 SessionStore miss 时用它兜底(参见 translator.js
167
+ // 的 RESOURCE_FALLBACK_KEYS)。
168
+ //
169
+ // 跨平台细节:
170
+ // - execFileSync(cmd, args):不经过 shell,Win/Mac 行为一致
171
+ // - windowsHide:true:Windows 上不弹 cmd 黑窗
172
+ // - stdio[2]="ignore":屏蔽 stderr,避免 git 报错刷屏
173
+ // - timeout:1000:超时直接当成"读不到",不让 installer 卡住
174
+ // - ENOENT (git 没装) / 退出码非 0 (key 没设) 都吞掉返回空串
175
+ function readGlobalGitUser() {
176
+ function readGitVal(key) {
177
+ try {
178
+ return execFileSync("git", ["config", "--global", "--get", key], {
179
+ encoding: "utf8",
180
+ windowsHide: true,
181
+ timeout: 1000,
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ }).trim();
184
+ } catch (_) {
185
+ return "";
186
+ }
187
+ }
188
+ return {
189
+ name: readGitVal("user.name"),
190
+ email: readGitVal("user.email"),
191
+ };
192
+ }
193
+
194
+ // ---------- 装机上报到 cc-view-server ----------
195
+ //
196
+ // 安装完成时打一发 POST 到 cc-view-server,让运营侧能看到"谁/在哪台机/装了哪个版本"。
197
+ // 设计原则:
198
+ // - fire-and-forget:3s 超时、不重试、任何失败绝不让安装本身退出非 0
199
+ // - 复用用户传给 OTel collector 的 host:172.31.250.57,port 8081 写死
200
+ // - URL 本身是公司内网地址,自带隐式凭据,不带 SSO header
201
+ // - debug 模式下才打错误,正常运行不污染 stdout
202
+
203
+ function buildReportUrl(otelEndpoint) {
204
+ try {
205
+ const u = new URL(otelEndpoint);
206
+ // cc-view-server 跑在同机 :8081(与 collector 4317/4318 同主机)
207
+ return `http://${u.hostname}:8081/api/installer/report`;
208
+ } catch (_) {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ function postJsonWithTimeout(targetUrl, payload, timeoutMs) {
214
+ return new Promise((resolve, reject) => {
215
+ let u;
216
+ try {
217
+ u = new URL(targetUrl);
218
+ } catch (e) {
219
+ return reject(e);
220
+ }
221
+ const isHttps = u.protocol === "https:";
222
+ const lib = isHttps ? require("https") : require("http");
223
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
224
+ const req = lib.request(
225
+ {
226
+ method: "POST",
227
+ hostname: u.hostname,
228
+ port: u.port || (isHttps ? 443 : 80),
229
+ path: (u.pathname || "/") + (u.search || ""),
230
+ headers: {
231
+ "Content-Type": "application/json",
232
+ "Content-Length": body.length,
233
+ },
234
+ timeout: timeoutMs,
235
+ },
236
+ (res) => {
237
+ // 排空 body,让 socket 进入 keepalive/释放
238
+ res.on("data", () => {});
239
+ res.on("end", () => resolve(res.statusCode || 0));
240
+ res.on("error", reject);
241
+ }
242
+ );
243
+ req.on("error", reject);
244
+ req.on("timeout", () => {
245
+ req.destroy(new Error("timeout"));
246
+ });
247
+ req.write(body);
248
+ req.end();
249
+ });
250
+ }
251
+
252
+ async function reportInstall(otelEndpoint, gitUser, allResults, debug) {
253
+ if (!gitUser || !gitUser.email) {
254
+ if (debug) console.error("[ai-otel-setup] 跳过装机上报:无 git user.email");
255
+ return;
256
+ }
257
+ const reportUrl = buildReportUrl(otelEndpoint);
258
+ if (!reportUrl) return;
259
+ const findOk = (tool) =>
260
+ allResults.find((r) => r.tool === tool)?.status === "installed";
261
+ const payload = {
262
+ git_email: gitUser.email,
263
+ git_name: gitUser.name || "",
264
+ hostname: os.hostname(),
265
+ installer_version: PKG_VERSION,
266
+ os_platform: os.platform(),
267
+ os_arch: os.arch(),
268
+ node_version: process.version,
269
+ cc_cli_detected: findOk("claude") ? 1 : 0,
270
+ codex_cli_detected: findOk("codex") ? 1 : 0,
271
+ };
272
+ try {
273
+ await postJsonWithTimeout(reportUrl, payload, 3000);
274
+ if (debug) console.error("[ai-otel-setup] 装机上报已发送");
275
+ } catch (e) {
276
+ if (debug) {
277
+ console.error("[ai-otel-setup] 装机上报失败(不影响安装):", e.message || e);
278
+ }
279
+ }
280
+ }
281
+
282
+ // ---------- OTEL_RESOURCE_ATTRIBUTES (W3C baggage 风格) ----------
283
+
284
+ // "k1=urlencoded,k2=urlencoded2" → { k1: "decoded", k2: "decoded2" }
285
+ function parseResourceAttrs(s) {
286
+ const out = {};
287
+ if (!s || typeof s !== "string") return out;
288
+ for (const pair of s.split(",")) {
289
+ const idx = pair.indexOf("=");
290
+ if (idx <= 0) continue;
291
+ const k = pair.slice(0, idx).trim();
292
+ if (!k) continue;
293
+ const raw = pair.slice(idx + 1).trim();
294
+ try {
295
+ out[k] = decodeURIComponent(raw);
296
+ } catch (_) {
297
+ out[k] = raw; // decode 失败原样保留,不抛
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+
303
+ function serializeResourceAttrs(obj) {
304
+ const parts = [];
305
+ for (const [k, v] of Object.entries(obj)) {
306
+ if (v === "" || v === null || v === undefined) continue;
307
+ parts.push(`${k}=${encodeURIComponent(v)}`);
308
+ }
309
+ return parts.join(",");
310
+ }
311
+
312
+ // parse-merge-serialize:保留用户自定义 attr(如 region=us-east),仅注入/覆盖 git.user.*
313
+ function mergeResourceAttrs(existing, gitUser) {
314
+ const attrs = parseResourceAttrs(existing || "");
315
+ if (gitUser.email) attrs["git.user.email"] = gitUser.email;
316
+ if (gitUser.name) attrs["git.user.name"] = gitUser.name;
317
+ return serializeResourceAttrs(attrs);
318
+ }
319
+
134
320
  // ---------- 文件操作 ----------
135
321
 
136
322
  function readJSONSafe(p) {
@@ -164,12 +350,11 @@ function backup(p) {
164
350
  function buildEnv(template, args, endpoint) {
165
351
  const env = { ...template.env };
166
352
  env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint;
167
- // OTEL_RESOURCE_ATTRIBUTES 已废弃:bg/dept/team 不再上报
168
- delete env.OTEL_RESOURCE_ATTRIBUTES;
353
+ // OTEL_RESOURCE_ATTRIBUTES mergeSettings 单独处理(parse-merge 用户已有 + 注入 git.user.*)
169
354
  return env;
170
355
  }
171
356
 
172
- function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHost) {
357
+ function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHost, gitUser) {
173
358
  const merged = { ...existing };
174
359
 
175
360
  // env:plugin 优先(组织规范不允许个人改红线),但保留用户独有的 env
@@ -177,8 +362,14 @@ function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHo
177
362
  for (const k of OTEL_KEYS) {
178
363
  merged.env[k] = newEnv[k];
179
364
  }
180
- // 清理历史遗留:旧版本 installer 写过 OTEL_RESOURCE_ATTRIBUTES,删掉
181
- delete merged.env.OTEL_RESOURCE_ATTRIBUTES;
365
+
366
+ // OTEL_RESOURCE_ATTRIBUTES:parse-merge 用户已有 attr + 注入 git.user.email/name。
367
+ // 不进 OTEL_KEYS(OTEL_KEYS 走 overwrite,会丢掉用户自定义如 region=us-east)。
368
+ // 只在 readGlobalGitUser 拿到非空值时写;全空时保持用户已有值不动(包括不删)。
369
+ if (gitUser && (gitUser.name || gitUser.email)) {
370
+ const ra = mergeResourceAttrs(merged.env.OTEL_RESOURCE_ATTRIBUTES, gitUser);
371
+ if (ra) merged.env.OTEL_RESOURCE_ATTRIBUTES = ra;
372
+ }
182
373
 
183
374
  // 兜底用户写坏的 HTTP(S)_PROXY:把 collector host 加进 NO_PROXY,让 OTel gRPC 绕过代理
184
375
  // 仅追加,不动用户原有的 NO_PROXY 值,也不动 HTTP_PROXY / HTTPS_PROXY
@@ -424,7 +615,7 @@ function installGemini(home, endpoint) {
424
615
 
425
616
  // ---------- 主流程 ----------
426
617
 
427
- function main() {
618
+ async function main() {
428
619
  const args = parseArgs(process.argv.slice(2));
429
620
 
430
621
  if (args.help || args.h || process.argv.includes("--help")) {
@@ -505,6 +696,10 @@ function main() {
505
696
  logsEndpoint: logsEndpointFromGrpc(endpoint),
506
697
  });
507
698
 
699
+ // 读全局 git config,作为 hook 进程没跑时的 SDK 层兜底来源
700
+ // 失败/缺失返回空串;mergeSettings 见空就跳过 OTEL_RESOURCE_ATTRIBUTES 写入
701
+ const gitUser = readGlobalGitUser();
702
+
508
703
  const existing = readJSONSafe(settingsPath);
509
704
  const bak = backup(settingsPath);
510
705
  const merged = mergeSettings(
@@ -512,7 +707,8 @@ function main() {
512
707
  newEnv,
513
708
  hookEntry,
514
709
  promptHookEntry,
515
- extractHost(endpoint)
710
+ extractHost(endpoint),
711
+ gitUser
516
712
  );
517
713
  writeJSONAtomic(settingsPath, merged);
518
714
 
@@ -555,6 +751,9 @@ function main() {
555
751
  "SessionStart 中 id=" + HOOK_ID + " 与 UserPromptSubmit 中 id=" + PROMPT_HOOK_ID + " 的条目。"
556
752
  );
557
753
  }
754
+
755
+ // 装机上报:fire-and-forget 语义,3s 内完成或放弃;任何错误都不冒泡
756
+ await reportInstall(endpoint, gitUser, allResults, debug);
558
757
  }
559
758
 
560
759
  function printUsage() {
@@ -569,9 +768,7 @@ function printUsage() {
569
768
  `);
570
769
  }
571
770
 
572
- try {
573
- main();
574
- } catch (e) {
771
+ main().catch((e) => {
575
772
  console.error("[ai-otel-setup] 失败:" + (e && e.message ? e.message : e));
576
773
  process.exit(1);
577
- }
774
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-otel-setup",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",