ai-otel-setup 1.0.5 → 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.
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")) {
@@ -475,7 +639,7 @@ function main() {
475
639
  };
476
640
 
477
641
  // UserPromptSubmit 兜底 hook:复用同一脚本,由 stdin.hook_event_name 在脚本内部
478
- // 分流。客户端做 5 分钟节流,服务端见 entry 已存在则仅补空。用于救 SessionStart
642
+ // 分流。客户端做 2 分钟节流,服务端见 entry 已存在则仅补空。用于救 SessionStart
479
643
  // 因网络/超时丢失的场景(线上观测约 60% 事件因此空 git/hostname)。
480
644
  const promptHookEntry = {
481
645
  matcher: "*",
@@ -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.5",
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",
@@ -20,8 +20,8 @@
20
20
  *
21
21
  * 节流(仅对 UserPromptSubmit):
22
22
  * - 在 ~/.claude/cc-otel-state/sent-<sid>.flag 写 marker
23
- * - 5 分钟内同 sid 跳过 OTLP 上报,避免高频敲键狂发
24
- * - 5 分钟后过期允许重试,给丢包/瞬时故障留救命窗口
23
+ * - 2 分钟内同 sid 跳过 OTLP 上报,避免高频敲键狂发
24
+ * - 2 分钟后过期允许重试,给丢包/瞬时故障留救命窗口
25
25
  */
26
26
 
27
27
  "use strict";
@@ -34,8 +34,8 @@ const http = require("http");
34
34
  const https = require("https");
35
35
  const { URL } = require("url");
36
36
 
37
- // UserPromptSubmit 节流窗口:5 分钟
38
- const PROMPT_THROTTLE_MS = 5 * 60 * 1000;
37
+ // UserPromptSubmit 节流窗口:2 分钟
38
+ const PROMPT_THROTTLE_MS = 2 * 60 * 1000;
39
39
 
40
40
  // -------- 环境变量读取 ----------
41
41
 
@@ -120,7 +120,7 @@ function safeGit(args) {
120
120
  // CC 在 stdin 里告诉脚本是哪个 hook 触发的;UserPromptSubmit 走"兜底"分支
121
121
  const isPromptFallback = input.hook_event_name === "UserPromptSubmit";
122
122
 
123
- // 兜底路径节流:sid 维度 5 分钟最多一次(marker 文件 mtime 判断)。
123
+ // 兜底路径节流:sid 维度 2 分钟最多一次(marker 文件 mtime 判断)。
124
124
  // 失败重试窗口同时由此控制:marker 过期后允许下次 prompt 再发一次。
125
125
  const stateDir = path.join(os.homedir(), ".claude", "cc-otel-state");
126
126
  const markerPath = sessionId ? path.join(stateDir, `sent-${sessionId}.flag`) : null;
@@ -217,8 +217,8 @@ function safeGit(args) {
217
217
  req.on("timeout", () => { req.destroy(); done(); });
218
218
 
219
219
  // 在真正发包前 touch marker 文件——把"已尝试上报"持久化下来,
220
- // 让后续 5 分钟内的 UserPromptSubmit 跳过重复 POST。失败也照写,
221
- // 因为 5 分钟后 marker 会过期允许重试,不会永久卡住。
220
+ // 让后续 2 分钟内的 UserPromptSubmit 跳过重复 POST。失败也照写,
221
+ // 因为 2 分钟后 marker 会过期允许重试,不会永久卡住。
222
222
  if (markerPath) {
223
223
  try {
224
224
  fs.mkdirSync(stateDir, { recursive: true });
@@ -5,8 +5,8 @@
5
5
  "OTEL_LOGS_EXPORTER": "otlp",
6
6
  "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
7
7
  "OTEL_EXPORTER_OTLP_ENDPOINT": "PLACEHOLDER_ENDPOINT",
8
- "OTEL_LOGS_EXPORT_INTERVAL": "300000",
9
- "OTEL_METRIC_EXPORT_INTERVAL": "600000",
8
+ "OTEL_LOGS_EXPORT_INTERVAL": "120000",
9
+ "OTEL_METRIC_EXPORT_INTERVAL": "300000",
10
10
  "OTEL_METRICS_INCLUDE_VERSION": "true",
11
11
  "OTEL_LOG_USER_PROMPTS": "0",
12
12
  "OTEL_LOG_TOOL_DETAILS": "1",