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.
- package/cli.js +212 -15
- 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
|
-
|
|
54
|
+
// Windows 上立刻转 8.3 短路径,规避 PowerShell 解析空格切 token 的问题。
|
|
55
|
+
const NODE_BIN = toWindowsShortPath(process.execPath);
|
|
32
56
|
|
|
33
|
-
// 跨平台 hook 命令:固定形式 `<NODE_BIN> <launcher> <hook
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|