ai-otel-setup 1.0.6 → 1.0.7

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 +181 -11
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -24,6 +24,7 @@
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
 
@@ -131,6 +132,164 @@ function mergeNoProxy(existing, host) {
131
132
  return list.join(",");
132
133
  }
133
134
 
135
+ // ---------- git config 兜底 (跨平台) ----------
136
+ //
137
+ // hook 进程偶有"压根没跑"的场景(网络/超时/进程崩溃),导致 git.user.email/name 永久丢失。
138
+ // 装机时把全局 git config 写到 OTEL_RESOURCE_ATTRIBUTES,CC SDK 自动把 resource attr
139
+ // 带到每条 metric/log,service 端在 SessionStore miss 时用它兜底(参见 translator.js
140
+ // 的 RESOURCE_FALLBACK_KEYS)。
141
+ //
142
+ // 跨平台细节:
143
+ // - execFileSync(cmd, args):不经过 shell,Win/Mac 行为一致
144
+ // - windowsHide:true:Windows 上不弹 cmd 黑窗
145
+ // - stdio[2]="ignore":屏蔽 stderr,避免 git 报错刷屏
146
+ // - timeout:1000:超时直接当成"读不到",不让 installer 卡住
147
+ // - ENOENT (git 没装) / 退出码非 0 (key 没设) 都吞掉返回空串
148
+ function readGlobalGitUser() {
149
+ function readGitVal(key) {
150
+ try {
151
+ return execFileSync("git", ["config", "--global", "--get", key], {
152
+ encoding: "utf8",
153
+ windowsHide: true,
154
+ timeout: 1000,
155
+ stdio: ["ignore", "pipe", "ignore"],
156
+ }).trim();
157
+ } catch (_) {
158
+ return "";
159
+ }
160
+ }
161
+ return {
162
+ name: readGitVal("user.name"),
163
+ email: readGitVal("user.email"),
164
+ };
165
+ }
166
+
167
+ // ---------- 装机上报到 cc-view-server ----------
168
+ //
169
+ // 安装完成时打一发 POST 到 cc-view-server,让运营侧能看到"谁/在哪台机/装了哪个版本"。
170
+ // 设计原则:
171
+ // - fire-and-forget:3s 超时、不重试、任何失败绝不让安装本身退出非 0
172
+ // - 复用用户传给 OTel collector 的 host:172.31.250.57,port 8081 写死
173
+ // - URL 本身是公司内网地址,自带隐式凭据,不带 SSO header
174
+ // - debug 模式下才打错误,正常运行不污染 stdout
175
+
176
+ function buildReportUrl(otelEndpoint) {
177
+ try {
178
+ const u = new URL(otelEndpoint);
179
+ // cc-view-server 跑在同机 :8081(与 collector 4317/4318 同主机)
180
+ return `http://${u.hostname}:8081/api/installer/report`;
181
+ } catch (_) {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function postJsonWithTimeout(targetUrl, payload, timeoutMs) {
187
+ return new Promise((resolve, reject) => {
188
+ let u;
189
+ try {
190
+ u = new URL(targetUrl);
191
+ } catch (e) {
192
+ return reject(e);
193
+ }
194
+ const isHttps = u.protocol === "https:";
195
+ const lib = isHttps ? require("https") : require("http");
196
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
197
+ const req = lib.request(
198
+ {
199
+ method: "POST",
200
+ hostname: u.hostname,
201
+ port: u.port || (isHttps ? 443 : 80),
202
+ path: (u.pathname || "/") + (u.search || ""),
203
+ headers: {
204
+ "Content-Type": "application/json",
205
+ "Content-Length": body.length,
206
+ },
207
+ timeout: timeoutMs,
208
+ },
209
+ (res) => {
210
+ // 排空 body,让 socket 进入 keepalive/释放
211
+ res.on("data", () => {});
212
+ res.on("end", () => resolve(res.statusCode || 0));
213
+ res.on("error", reject);
214
+ }
215
+ );
216
+ req.on("error", reject);
217
+ req.on("timeout", () => {
218
+ req.destroy(new Error("timeout"));
219
+ });
220
+ req.write(body);
221
+ req.end();
222
+ });
223
+ }
224
+
225
+ async function reportInstall(otelEndpoint, gitUser, allResults, debug) {
226
+ if (!gitUser || !gitUser.email) {
227
+ if (debug) console.error("[ai-otel-setup] 跳过装机上报:无 git user.email");
228
+ return;
229
+ }
230
+ const reportUrl = buildReportUrl(otelEndpoint);
231
+ if (!reportUrl) return;
232
+ const findOk = (tool) =>
233
+ allResults.find((r) => r.tool === tool)?.status === "installed";
234
+ const payload = {
235
+ git_email: gitUser.email,
236
+ git_name: gitUser.name || "",
237
+ hostname: os.hostname(),
238
+ installer_version: PKG_VERSION,
239
+ os_platform: os.platform(),
240
+ os_arch: os.arch(),
241
+ node_version: process.version,
242
+ cc_cli_detected: findOk("claude") ? 1 : 0,
243
+ codex_cli_detected: findOk("codex") ? 1 : 0,
244
+ };
245
+ try {
246
+ await postJsonWithTimeout(reportUrl, payload, 3000);
247
+ if (debug) console.error("[ai-otel-setup] 装机上报已发送");
248
+ } catch (e) {
249
+ if (debug) {
250
+ console.error("[ai-otel-setup] 装机上报失败(不影响安装):", e.message || e);
251
+ }
252
+ }
253
+ }
254
+
255
+ // ---------- OTEL_RESOURCE_ATTRIBUTES (W3C baggage 风格) ----------
256
+
257
+ // "k1=urlencoded,k2=urlencoded2" → { k1: "decoded", k2: "decoded2" }
258
+ function parseResourceAttrs(s) {
259
+ const out = {};
260
+ if (!s || typeof s !== "string") return out;
261
+ for (const pair of s.split(",")) {
262
+ const idx = pair.indexOf("=");
263
+ if (idx <= 0) continue;
264
+ const k = pair.slice(0, idx).trim();
265
+ if (!k) continue;
266
+ const raw = pair.slice(idx + 1).trim();
267
+ try {
268
+ out[k] = decodeURIComponent(raw);
269
+ } catch (_) {
270
+ out[k] = raw; // decode 失败原样保留,不抛
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+
276
+ function serializeResourceAttrs(obj) {
277
+ const parts = [];
278
+ for (const [k, v] of Object.entries(obj)) {
279
+ if (v === "" || v === null || v === undefined) continue;
280
+ parts.push(`${k}=${encodeURIComponent(v)}`);
281
+ }
282
+ return parts.join(",");
283
+ }
284
+
285
+ // parse-merge-serialize:保留用户自定义 attr(如 region=us-east),仅注入/覆盖 git.user.*
286
+ function mergeResourceAttrs(existing, gitUser) {
287
+ const attrs = parseResourceAttrs(existing || "");
288
+ if (gitUser.email) attrs["git.user.email"] = gitUser.email;
289
+ if (gitUser.name) attrs["git.user.name"] = gitUser.name;
290
+ return serializeResourceAttrs(attrs);
291
+ }
292
+
134
293
  // ---------- 文件操作 ----------
135
294
 
136
295
  function readJSONSafe(p) {
@@ -164,12 +323,11 @@ function backup(p) {
164
323
  function buildEnv(template, args, endpoint) {
165
324
  const env = { ...template.env };
166
325
  env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint;
167
- // OTEL_RESOURCE_ATTRIBUTES 已废弃:bg/dept/team 不再上报
168
- delete env.OTEL_RESOURCE_ATTRIBUTES;
326
+ // OTEL_RESOURCE_ATTRIBUTES mergeSettings 单独处理(parse-merge 用户已有 + 注入 git.user.*)
169
327
  return env;
170
328
  }
171
329
 
172
- function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHost) {
330
+ function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHost, gitUser) {
173
331
  const merged = { ...existing };
174
332
 
175
333
  // env:plugin 优先(组织规范不允许个人改红线),但保留用户独有的 env
@@ -177,8 +335,14 @@ function mergeSettings(existing, newEnv, hookEntry, promptHookEntry, collectorHo
177
335
  for (const k of OTEL_KEYS) {
178
336
  merged.env[k] = newEnv[k];
179
337
  }
180
- // 清理历史遗留:旧版本 installer 写过 OTEL_RESOURCE_ATTRIBUTES,删掉
181
- delete merged.env.OTEL_RESOURCE_ATTRIBUTES;
338
+
339
+ // OTEL_RESOURCE_ATTRIBUTES:parse-merge 用户已有 attr + 注入 git.user.email/name。
340
+ // 不进 OTEL_KEYS(OTEL_KEYS 走 overwrite,会丢掉用户自定义如 region=us-east)。
341
+ // 只在 readGlobalGitUser 拿到非空值时写;全空时保持用户已有值不动(包括不删)。
342
+ if (gitUser && (gitUser.name || gitUser.email)) {
343
+ const ra = mergeResourceAttrs(merged.env.OTEL_RESOURCE_ATTRIBUTES, gitUser);
344
+ if (ra) merged.env.OTEL_RESOURCE_ATTRIBUTES = ra;
345
+ }
182
346
 
183
347
  // 兜底用户写坏的 HTTP(S)_PROXY:把 collector host 加进 NO_PROXY,让 OTel gRPC 绕过代理
184
348
  // 仅追加,不动用户原有的 NO_PROXY 值,也不动 HTTP_PROXY / HTTPS_PROXY
@@ -424,7 +588,7 @@ function installGemini(home, endpoint) {
424
588
 
425
589
  // ---------- 主流程 ----------
426
590
 
427
- function main() {
591
+ async function main() {
428
592
  const args = parseArgs(process.argv.slice(2));
429
593
 
430
594
  if (args.help || args.h || process.argv.includes("--help")) {
@@ -505,6 +669,10 @@ function main() {
505
669
  logsEndpoint: logsEndpointFromGrpc(endpoint),
506
670
  });
507
671
 
672
+ // 读全局 git config,作为 hook 进程没跑时的 SDK 层兜底来源
673
+ // 失败/缺失返回空串;mergeSettings 见空就跳过 OTEL_RESOURCE_ATTRIBUTES 写入
674
+ const gitUser = readGlobalGitUser();
675
+
508
676
  const existing = readJSONSafe(settingsPath);
509
677
  const bak = backup(settingsPath);
510
678
  const merged = mergeSettings(
@@ -512,7 +680,8 @@ function main() {
512
680
  newEnv,
513
681
  hookEntry,
514
682
  promptHookEntry,
515
- extractHost(endpoint)
683
+ extractHost(endpoint),
684
+ gitUser
516
685
  );
517
686
  writeJSONAtomic(settingsPath, merged);
518
687
 
@@ -555,6 +724,9 @@ function main() {
555
724
  "SessionStart 中 id=" + HOOK_ID + " 与 UserPromptSubmit 中 id=" + PROMPT_HOOK_ID + " 的条目。"
556
725
  );
557
726
  }
727
+
728
+ // 装机上报:fire-and-forget 语义,3s 内完成或放弃;任何错误都不冒泡
729
+ await reportInstall(endpoint, gitUser, allResults, debug);
558
730
  }
559
731
 
560
732
  function printUsage() {
@@ -569,9 +741,7 @@ function printUsage() {
569
741
  `);
570
742
  }
571
743
 
572
- try {
573
- main();
574
- } catch (e) {
744
+ main().catch((e) => {
575
745
  console.error("[ai-otel-setup] 失败:" + (e && e.message ? e.message : e));
576
746
  process.exit(1);
577
- }
747
+ });
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.7",
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",