Skip to content

Commit 1a17ac8

Browse files
authored
Merge pull request #314 from modelstudioai/feat/telemetry-device-id-persistence
Persist anonymous telemetry install id as AEM uid
2 parents 17033c1 + 32a5598 commit 1a17ac8

6 files changed

Lines changed: 398 additions & 54 deletions

File tree

‎docs/agents/telemetry-change.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
116116

117117
`page` 仅记录有限数值页码;Webhook 等命令使用的不透明字符串游标不得进入本地日志或远端 AEM,即使字符串内容看起来是数字。
118118

119-
事件同时写入两处:
119+
事件写入两处:
120120

121-
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建
121+
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `uid`(只读已有文件,不创建)
122122
2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod`
123123

124-
底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。
124+
旁路状态:`~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`)由 `ensureUid` 在首次远端上报时生成,不是事件落盘目标。
125+
126+
底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及内置的 `device_id`(本机网卡 MAC 的 MD5,保持不变)。稳定匿名 install id 写入 AEM 的 `uid`:来自 `~/.bailian/telemetry-uid`,首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时不上报 `uid`。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带同名 `uid`。注意:这不是账号用户 ID,只是上报到 AEM `uid` 字段的匿名标识;与 Tracker 内置 `device_id` 无关。
127+
128+
`ensureUid` 无锁(对齐 qwen-code `InstallationManager`):稳态只读;缺失或损坏时同目录 temp 写满后 `rename` 发布(内容原子替换;并发首次创建时后写覆盖先写,磁盘最终为一个 UUID)。失败则省略 `uid`,不阻塞业务退出。
125129

126130
从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`:
127131

@@ -180,6 +184,8 @@ AEM 映射:
180184
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
181185
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5`
182186
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
187+
- [ ] `remoteSink` 从调用到 `remoteSendEvent` 入队之间不得出现 `await`(flush 只排空已入队事件)
188+
- [ ] 测试中调用 `remoteSink` 的用例必须使用 `enterIsolatedHome()`,避免写入真实配置目录
183189
- [ ] 遥测发送的 DNS/HTTP/响应体必须受可取消预算控制;flush 超时必须取消在途操作,不能只停止等待,也不能修改全局 DNS/dispatcher 或强制退出宿主
184190
- [ ] 退出生命周期变更保留真实子进程回归:挂起响应头/响应体、DNS 预查询和关闭遥测;检查实际退出及进程仍存活时的连接释放,不以 flush Promise 返回代替验收
185191
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
@@ -190,7 +196,7 @@ AEM 映射:
190196
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
191197
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions|readCaller" packages/core packages/runtime --glob '*.ts'
192198
vp check
193-
vp test packages/core/tests packages/runtime/tests/telemetry-stage.test.ts packages/commands/tests/e2e/auth.e2e.test.ts
199+
vp test packages/core/tests/telemetry-uid.test.ts packages/core/tests/telemetry-sink.test.ts packages/core/tests/telemetry-tracker.test.ts packages/core/tests/telemetry-send.test.ts packages/runtime/tests/telemetry-stage.test.ts packages/commands/tests/e2e/auth.e2e.test.ts
194200
```
195201

196202
## 常见漏点

‎packages/core/src/telemetry/sink.ts‎

Lines changed: 71 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join } from "path";
33
import { getConfigDir, ensureConfigDir } from "../config/paths.ts";
44
import { buildRemoteAemOptions, type TrackingEvent } from "./event.ts";
55
import { detectEnv } from "./env.ts";
6+
import { ensureUid, loadUid } from "./uid.ts";
67
import Tracker from "../../lib/remote-telemetry/tracker.cjs";
78
import EventPlugin from "../../lib/remote-telemetry/event-plugin.cjs";
89

@@ -12,74 +13,90 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
1213

1314
let remoteSendEvent: ((name: string, opts: Record<string, unknown>) => void) | undefined;
1415

15-
// 追踪在途(in-flight)的远端发送请求,以便在进程退出前等待它们完成。
16-
// 底层 tracker 是 fire-and-forget 模式(发起 fetch 后不再持有 promise);
17-
// 如果不追踪,CLI 在短命令或 SIGINT 场景下会在请求真正落网络前就退出。
18-
// 配合 tracker.js 中的 `keepalive: true`,await 这些 promise 可以在不阻塞
19-
// 主流程的前提下,尽最大努力把埋点送达。
16+
// Track in-flight remote sends so we can wait for them before process exit.
17+
// The underlying tracker is fire-and-forget (fetch starts without holding the promise);
18+
// without tracking, short commands or SIGINT can exit before the request hits the network.
19+
// With `keepalive: true` in tracker.js, awaiting these promises best-effort delivers
20+
// telemetry without blocking the main command path.
2021
const SEND_TIMEOUT_MS = 1000;
2122
interface PendingSend {
2223
controller: AbortController;
2324
promise: Promise<void>;
2425
}
2526
const inflightSends = new Set<PendingSend>();
2627
let remoteClient: any = undefined;
28+
let remoteInitialized = false;
2729

28-
try {
29-
// env 传给 Tracker 顶层 config 后会进 URL 查询参数, AEM 后台 "环境" 列
30-
// (原始字段 env) 直接读它, 值域 prod/pre/daily/dev。我们目前只用 prod/dev。
31-
const client = new Tracker({ pid: "bailian-cli-node", env: detectEnv() });
32-
// 仅对当前实例的 `send` 做猴补丁,把每次 fetch 的 promise 收集起来。
33-
// 不修改 prototype,避免污染其他可能存在的 Tracker 使用方。
34-
const originalSend = client.send.bind(client);
35-
client.send = function (payload: string) {
36-
const controller = new AbortController();
37-
const deadline = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
38-
deadline.unref?.();
39-
const pending: PendingSend = {
40-
controller,
41-
promise: Promise.resolve()
42-
.then(() => originalSend(payload, { signal: controller.signal }))
43-
.catch(() => {})
44-
.finally(() => {
45-
// Bun may resolve body.cancel() before stopping its underlying fetch.
46-
// Finish this send's private scope before removing its deadline.
47-
controller.abort();
48-
clearTimeout(deadline);
49-
inflightSends.delete(pending);
50-
}),
30+
/**
31+
* Construct the tracker on first remote report so disabled telemetry neither
32+
* constructs the tracker nor creates the uid file. Fully synchronous; see ensureUid.
33+
*/
34+
function initRemote(): void {
35+
if (remoteInitialized) return;
36+
remoteInitialized = true;
37+
try {
38+
const uid = ensureUid();
39+
// `env` on the Tracker top-level config becomes a URL query param; AEM's
40+
// "environment" column (raw field `env`) reads it. Allowed values:
41+
// prod/pre/daily/dev. We currently use only prod/dev.
42+
// anonymous install id as `uid`, which AEM exposes as a dashboard field.
43+
const client = new Tracker({
44+
pid: "bailian-cli-node",
45+
env: detectEnv(),
46+
...(uid ? { uid } : {}),
47+
});
48+
// Monkey-patch only this instance's `send` to collect each fetch promise.
49+
// Do not touch the prototype so other Tracker consumers stay unaffected.
50+
const originalSend = client.send.bind(client);
51+
client.send = function (payload: string) {
52+
const controller = new AbortController();
53+
const deadline = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
54+
deadline.unref?.();
55+
const pending: PendingSend = {
56+
controller,
57+
promise: Promise.resolve()
58+
.then(() => originalSend(payload, { signal: controller.signal }))
59+
.catch(() => {})
60+
.finally(() => {
61+
// Bun may resolve body.cancel() before stopping its underlying fetch.
62+
// Finish this send's private scope before removing its deadline.
63+
controller.abort();
64+
clearTimeout(deadline);
65+
inflightSends.delete(pending);
66+
}),
67+
};
68+
inflightSends.add(pending);
69+
return pending.promise;
5170
};
52-
inflightSends.add(pending);
53-
return pending.promise;
54-
};
55-
remoteClient = client;
56-
remoteSendEvent = client.use(EventPlugin);
57-
} catch {
58-
// 埋点逻辑任何异常都不能影响 CLI 主流程
71+
remoteClient = client;
72+
remoteSendEvent = client.use(EventPlugin);
73+
} catch {
74+
// Telemetry must never affect the CLI main flow
75+
}
5976
}
6077

6178
/**
62-
* 尽力等待所有在途的埋点发送完成(best-effort)。
79+
* Best-effort wait for all in-flight telemetry sends.
6380
*
64-
* 1. 先调用 `_sendAll` 排空 tracker 内部的去抖队列,把还卡在 500ms 合并窗口里
65-
* 的事件立刻推上网络。
66-
* 2. 在预算内等待发送,预算耗尽时真正取消 DNS/HTTP/body 工作。
81+
* 1. Call `_sendAll` to drain the tracker's debounce queue so events still in
82+
* the 500ms merge window are pushed to the network immediately.
83+
* 2. Wait for sends within the budget; on timeout, cancel DNS/HTTP/body work.
6784
*
68-
* 埋点永远不应阻塞 CLI:调用方应传入较短的超时(例如 1000ms),并始终与超时
69-
* race。错误与超时一律静默吞掉。
85+
* Telemetry must never block the CLI: callers should pass a short timeout
86+
* (e.g. 1000ms) and always race against it. Errors and timeouts are swallowed.
7087
*/
7188
export async function flushTelemetry(timeoutMs = 1000): Promise<void> {
7289
let deadline: ReturnType<typeof setTimeout> | undefined;
7390
try {
7491
if (remoteClient) {
7592
try {
76-
// 排空去抖队列:把卡在 500ms 合并窗口里的事件立刻发出去
77-
// (每次发出都会被追加到 inflightSends 里)。
93+
// Drain the debounce queue so events stuck in the 500ms merge window
94+
// are sent immediately (each send is appended to inflightSends).
7895
if (typeof remoteClient._sendAll === "function") {
7996
remoteClient._sendAll();
8097
}
8198
} catch {
82-
// flush 自身绝不抛出
99+
// flush itself must never throw
83100
}
84101
}
85102
if (inflightSends.size === 0) return;
@@ -94,7 +111,7 @@ export async function flushTelemetry(timeoutMs = 1000): Promise<void> {
94111
}),
95112
]);
96113
} catch {
97-
// 埋点逻辑任何异常都不能影响 CLI 主流程
114+
// Telemetry must never affect the CLI main flow
98115
} finally {
99116
clearTimeout(deadline);
100117
}
@@ -111,20 +128,25 @@ export async function localSink(event: TrackingEvent): Promise<void> {
111128
unlinkSync(path);
112129
}
113130
} catch {
114-
// 文件还不存在,忽略
131+
// File does not exist yet; ignore
115132
}
116133

117-
appendFileSync(path, JSON.stringify(event) + "\n", { mode: 0o600 });
134+
// Read-only; remoteSink creates the file on first report.
135+
const uid = loadUid();
136+
appendFileSync(path, JSON.stringify(uid ? { ...event, uid } : event) + "\n", {
137+
mode: 0o600,
138+
});
118139
} catch {
119-
// 埋点逻辑任何异常都不能影响 CLI 主流程
140+
// Telemetry must never affect the CLI main flow
120141
}
121142
}
122143

123144
export async function remoteSink(event: TrackingEvent): Promise<void> {
124145
try {
146+
initRemote();
125147
if (!remoteSendEvent) return;
126148
remoteSendEvent(event.command, buildRemoteAemOptions(event));
127149
} catch {
128-
// 埋点逻辑任何异常都不能影响 CLI 主流程
150+
// Telemetry must never affect the CLI main flow
129151
}
130152
}

‎packages/core/src/telemetry/uid.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { randomBytes, randomUUID } from "crypto";
2+
import {
3+
closeSync,
4+
mkdirSync,
5+
openSync,
6+
readFileSync,
7+
renameSync,
8+
unlinkSync,
9+
writeFileSync,
10+
} from "fs";
11+
import { join } from "path";
12+
import { getConfigDir } from "../config/paths.ts";
13+
14+
/** On-disk name for the anonymous install id reported to AEM as `uid`. */
15+
export const UID_FILE_NAME = "telemetry-uid";
16+
17+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
18+
19+
type ReadResult =
20+
| { kind: "ok"; id: string }
21+
| { kind: "missing" }
22+
| { kind: "invalid" }
23+
| { kind: "unreadable" };
24+
25+
export function getUidPath(): string {
26+
return join(getConfigDir(), UID_FILE_NAME);
27+
}
28+
29+
function readUidFile(path: string): ReadResult {
30+
let raw: string;
31+
try {
32+
raw = readFileSync(path, "utf8");
33+
} catch (error) {
34+
return (error as NodeJS.ErrnoException).code === "ENOENT"
35+
? { kind: "missing" }
36+
: { kind: "unreadable" };
37+
}
38+
const id = raw.trim().toLowerCase();
39+
return UUID_V4.test(id) ? { kind: "ok", id } : { kind: "invalid" };
40+
}
41+
42+
/** Read-only: returns undefined when missing or invalid; never creates the file. */
43+
export function loadUid(): string | undefined {
44+
const result = readUidFile(getUidPath());
45+
return result.kind === "ok" ? result.id : undefined;
46+
}
47+
48+
/** Same-directory temp + rename publish (atomic content swap; may overwrite). */
49+
function publishUid(path: string, id: string): void {
50+
const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
51+
try {
52+
const fd = openSync(tmp, "wx", 0o600);
53+
try {
54+
// writeFileSync(fd) loops until the full buffer is written; writeSync can short-write.
55+
writeFileSync(fd, `${id}\n`);
56+
} finally {
57+
closeSync(fd);
58+
}
59+
renameSync(tmp, path);
60+
} finally {
61+
try {
62+
unlinkSync(tmp);
63+
} catch {
64+
/* rename removed tmp; ignore ENOENT */
65+
}
66+
}
67+
}
68+
69+
/**
70+
* Read or create the persistent anonymous install id (AEM `uid`).
71+
*
72+
* Must stay synchronous: remoteSink relies on no await between the call and
73+
* event enqueue, otherwise flushTelemetry may return before the event is queued.
74+
*
75+
* Lock-free like qwen-code InstallationManager: steady-state is a plain read;
76+
* missing/invalid content is published with temp+rename (last writer wins under
77+
* concurrent first runs). Any failure returns undefined so remoteSink omits
78+
* `uid`; built-in Tracker `device_id` is unchanged.
79+
*/
80+
export function ensureUid(): string | undefined {
81+
const path = getUidPath();
82+
const current = readUidFile(path);
83+
if (current.kind === "ok") return current.id;
84+
// Do not overwrite unreadable paths (permission errors, a directory in the way).
85+
if (current.kind === "unreadable") return undefined;
86+
87+
try {
88+
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
89+
const id = randomUUID();
90+
publishUid(path, id);
91+
return id;
92+
} catch {
93+
// Read-only FS, disk full, etc.: stay silent and try reading once more.
94+
return loadUid();
95+
}
96+
}

‎packages/core/tests/telemetry-send.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ beforeEach(() => {
1818
});
1919
afterEach(() => vi.restoreAllMocks());
2020

21+
test("constructor uid reaches send config without replacing built-in device_id", () => {
22+
const id = "0f8fad5b-d9cb-469f-a165-70867728950e";
23+
const tracker = new Tracker({ pid: "fixture-pid", uid: id }) as unknown as {
24+
getConfig(key: string): unknown;
25+
_getSendConfig(): Record<string, unknown>;
26+
};
27+
expect(tracker.getConfig("uid")).toBe(id);
28+
expect(tracker._getSendConfig().uid).toBe(id);
29+
expect(tracker.getConfig("device_id")).toMatch(/^[0-9a-f]{32}$/);
30+
expect(tracker._getSendConfig().device_id).toBe(tracker.getConfig("device_id"));
31+
});
32+
2133
test("telemetry uses the bounded transport without a separate DNS preflight", async () => {
2234
const controller = new AbortController();
2335
const tracker = new Tracker({ pid: "fixture-pid", env: "dev" });

0 commit comments

Comments
 (0)